JSON and encoding¶
Veloce serialises responses with orjson and a
small conversion layer that turns rich Python objects — Pydantic models,
dataclasses, datetime, UUID, Decimal, set, Path — into JSON-safe
values. jsonable_encoder exposes
that conversion directly, register_encoder
extends it for your own types, and the
JSONProvider interface lets you replace
the serialiser entirely. orjson ships as a Veloce dependency, so there is
nothing extra to install.
How a return value becomes JSON¶
When a handler returns a dict, list, or Pydantic model, Veloce wraps it in a
JSONResponse and encodes it with orjson.
orjson handles the common leaf types (str, int, float, bool, None,
datetime, UUID, Enum, dataclass) at C speed; anything it cannot encode
natively — set, Path, Decimal, bytes, or a registered custom type — is
routed through Veloce's fallback so the encode succeeds instead of raising.
from datetime import datetime
from uuid import UUID
from veloce import TestClient, Veloce
app = Veloce()
@app.get("/event")
async def event():
return {
"id": UUID("12345678-1234-5678-1234-567812345678"),
"at": datetime(2026, 1, 1, 12, 0, 0),
"tags": {"a", "b"},
}
client = TestClient(app)
resp = client.get("/event")
assert resp.status_code == 200
assert resp.json() == {
"id": "12345678-1234-5678-1234-567812345678",
"at": "2026-01-01T12:00:00",
"tags": ["a", "b"], # sets serialise as a sorted list
}
A bare str return is sent as text/html, not JSON; return a dict/list or
a JSONResponse when you want a JSON body.
See Requests and responses for the full return-value
rules.
NaN and Infinity become null¶
JSON has no representation for NaN, Infinity or -Infinity — RFC 8259
forbids all three — so an encoder has to choose. Veloce's default serialiser
writes null:
The response stays valid JSON and the request succeeds. The trade-off is that a
non-finite value is indistinguishable from a genuine null at the client, and
that is usually a bug upstream — a division by zero or an empty aggregate.
Guard it where the value is produced:
import math
@app.get("/stats")
async def stats() -> dict:
mean = compute_mean()
return {"mean": mean if math.isfinite(mean) else None}
If you would rather a non-finite value fail loudly, give the app a provider that refuses one and return through it — see Choosing the serialiser with JSONProvider:
import json
from veloce import JSONProvider, Veloce, jsonify
class StrictJSONProvider(JSONProvider):
def dumps(self, obj, **kwargs) -> bytes:
return json.dumps(obj, allow_nan=False, separators=(",", ":")).encode()
def loads(self, data):
return json.loads(data)
app = Veloce()
app.json_provider_class = StrictJSONProvider
@app.get("/stats")
async def stats():
return jsonify({"mean": float("nan")}) # -> 500, and logged
The jsonify(...) is optional here: a bare dict return goes through the
provider too, so both forms get the same treatment.
Why this is not the default
The null is not a preference so much as the shape of the problem. An
encoder has three choices and each gives something up: write NaN and
Infinity literals (what the standard library does by default — a strict
client cannot parse the result), refuse and fail the request, or write
null. Veloce's default is the only one of the three that always produces
parseable JSON.
Refusing by default is not cheaply available either: the underlying
serialiser has no option for non-finite floats and never consults a
default= hook for a native float, so detecting one would mean walking
every response payload on every request.
jsonable_encoder¶
Use jsonable_encoder when you need
the JSON-safe form of an object before it reaches a response — for example to
store it, log it, or pass it to a non-orjson serialiser. Unlike the response
path, it walks the whole object graph and returns plain dict/list/scalar
values.
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel
from veloce import jsonable_encoder
class Item(BaseModel):
name: str
price: Decimal
created: datetime
data = jsonable_encoder(Item(name="book", price=Decimal("9.99"), created=datetime(2026, 1, 1)))
# {"name": "book", "price": 9.99, "created": "2026-01-01T00:00:00"}
The built-in conversions:
| Input type | JSON output |
|---|---|
datetime / date / time |
ISO 8601 string (.isoformat()). |
timedelta |
Total seconds as a float. |
UUID, Path, IPv4Address, re.Pattern |
Their str() form. |
Decimal |
int when integer-valued, else float (lossless). |
Enum |
The member's .value. |
bytes / bytearray |
Base64 string (lossless, JSON Schema format: byte). |
set / frozenset |
A list, sorted for deterministic output. |
BaseModel / dataclass |
Its fields as a dict. |
Note
bytes encode as base64, not a UTF-8 decode. A UTF-8 decode would
substitute U+FFFD for any non-text byte and corrupt image headers, hash
digests, or gzip blobs; base64 round-trips every byte exactly.
Filtering fields¶
Every filter applies at every depth, not just the top level.
include and exclude are sets of key names. Because they filter keys at all
depths, a nesting key must itself be listed in include or the branch holding
the value is dropped — include={"name"} applied to {"user": {"name": "x"}}
yields {}.
exclude_unset, exclude_defaults, and exclude_none forward to Pydantic's
model_dump, and reach a model nested inside a dict or list as well as one
passed in directly. exclude_none additionally drops None-valued keys from
plain dicts and from an arbitrary object's attributes.
Changed in version 0.18
exclude_unset and exclude_defaults previously applied only to a model
passed in directly and were ignored for a nested one; exclude_none was
ignored for an arbitrary object's attributes.
from pydantic import BaseModel
from veloce import jsonable_encoder
class User(BaseModel):
name: str
password: str
bio: str | None = None
user = User(name="ada", password="secret")
jsonable_encoder(user, exclude={"password"}, exclude_none=True)
# {"name": "ada"}
Per-call custom encoders¶
Pass custom_encoder={type: fn} to override a conversion for a single call. It
is consulted before every built-in rule, at every depth: the exact type(obj)
wins, otherwise entries are scanned in insertion order for the first
isinstance match.
from datetime import datetime
from veloce import jsonable_encoder
jsonable_encoder(
{"at": datetime(2026, 1, 1, 12, 0, 0)},
custom_encoder={datetime: lambda dt: int(dt.timestamp())},
)
# {"at": 1767268800}
Circular references raise
jsonable_encoder raises ValueError on a self-referential object graph
(a container that transitively contains itself) rather than recursing until
the stack overflows.
Secrets are refused
A Secret reaching either JSON path raises
TypeError. Call .reveal() explicitly at the point you intend to expose
the value, so a secret never leaks into a response by accident.
The same encoder serves both doors
A route exposed as an MCP tool is the same handler either way, so its return
value encodes identically for an agent and for a browser — a set is a list,
a Decimal keeps its numeric form, a registered encoder applies, and a
Secret is refused. The one deliberate difference is bytes: a tool result
carries text, so a tool returning bytes reports the decoded text (or base64
when the bytes are not text), where an HTTP response reports base64 always.
Registering an encoder for a custom type¶
register_encoder installs a
process-wide encoder for a type and its subclasses. It applies to both
jsonable_encoder and the response path, so a registered type serialises the
same way everywhere. Registering a type that already has a built-in handler
overrides it.
from veloce import TestClient, Veloce, jsonable_encoder, register_encoder
class Color:
def __init__(self, r: int, g: int, b: int) -> None:
self.r, self.g, self.b = r, g, b
register_encoder(Color, lambda c: f"#{c.r:02x}{c.g:02x}{c.b:02x}")
app = Veloce()
@app.get("/brand")
async def brand():
return {"primary": Color(255, 0, 0)}
assert jsonable_encoder(Color(0, 128, 255)) == "#0080ff"
client = TestClient(app)
assert client.get("/brand").json() == {"primary": "#ff0000"}
The encoder receives one instance and must return a JSON-able value (a scalar,
or a list/dict of such). Resolution walks the type's MRO, so subclasses of a
registered type are covered too. Remove a registration with
unregister_encoder; it is a no-op
if the type was never registered.
Note
Registration is global to the process and persists across requests. Register your encoders once at startup, not per request.
Choosing the serialiser with JSONProvider¶
app.json is the active JSONProvider.
Veloce's default is DefaultJSONProvider,
an orjson-backed provider, instantiated lazily on first access. A provider
exposes three methods:
| Method | Purpose |
|---|---|
dumps(obj, **kwargs) |
Serialise obj to JSON bytes. |
loads(data) |
Parse JSON bytes/str into Python objects. |
response(value, **kwargs) |
Build a Response carrying value as JSON. |
Subclass JSONProvider to plug in a different serialiser, then point the app at
it. Set app.json_provider_class to a class (instantiated lazily) or assign
app.json an instance directly.
Which responses the provider reaches
Every value a handler returns — a dict, a list, a model, a msgspec
struct, a (body, status) tuple, jsonify(...), or a JSONResponse
subclass named by response_class — is serialised by the active provider,
as are ws.send_json(...) frames and ServerSentEvent.json(...) events, so
one dialect covers the whole application.
An app that configures nothing keeps the direct orjson path, so the default pays nothing for the indirection.
What the provider does not reach is the framework's own wire formats:
cache keys (which always sort, so equal mappings hash alike), signed
cookies, JWTs, and protocol frames. Those are not the application's to
restyle. A JSONResponse you construct yourself outside a request has no
application to consult and stays on the direct path.
import json
from typing import Any
from veloce import JSONProvider, TestClient, Veloce
class StdlibJSONProvider(JSONProvider):
def dumps(self, obj: Any, **kwargs: Any) -> bytes:
return json.dumps(obj).encode()
def loads(self, data: bytes | str) -> Any:
return json.loads(data)
app = Veloce()
app.json_provider_class = StdlibJSONProvider
@app.get("/ping")
async def ping():
return app.json.response({"pong": True})
client = TestClient(app)
assert client.get("/ping").json() == {"pong": True}
Note
dumps returns bytes (not str) so callers can write straight to a
response body without re-encoding. The base response() calls dumps and
wraps the result in a JSONResponse via from_bytes, so your dump options
survive without a re-encode.
Tuning the default provider¶
DefaultJSONProvider reads two
config flags into an orjson option bitmask when it is first
instantiated. config_orjson_options
builds that bitmask and is shared with the jsonify helper so the two paths
cannot drift.
| Config key | orjson option | Effect |
|---|---|---|
JSON_SORT_KEYS |
OPT_SORT_KEYS |
Sort object keys for deterministic output. Off by default. |
JSONIFY_PRETTYPRINT_REGULAR |
OPT_INDENT_2 |
Indent output by two spaces. |
from veloce import TestClient, Veloce
app = Veloce()
app.config["JSON_SORT_KEYS"] = True
@app.get("/")
async def index():
return {"b": 2, "a": 1}
client = TestClient(app)
assert client.get("/").body == b'{"a":1,"b":2}'
Set the flags before the first app.json access
The default provider caches its option bitmask the first time app.json is
read. Set these flags during application setup; mutating them after the
first response does not retroactively change the cached provider. Per-call
sort_keys / indent keyword arguments to dumps are still applied on top.
Next steps¶
- Return JSON, HTML, and streaming bodies from handlers — see Requests and responses.
- Validate and shape request/response bodies with models — see Request models.
- Swap in a Struct-based encoder for hot endpoints — see msgspec.
- Full signatures are in the API reference.