Serialization limits¶
eventsource.adapters.serialization.json_dumps/json_loads are backed by
orjson, a core dependency of this library (not
optional). The constraints below are the limits of orjson as this library's
encoder — not a general statement about JSON. They matter because every
value that reaches json_dumps is a candidate for persistence: this is the
encoding used to write event payloads and metadata into the event_outbox and
dead_letter_queue tables. A value that can't be encoded today can't be
written today; a value that encodes differently than you expect is a
correctness bug in whatever reads it back.
Integer range¶
Integers must be within [-2**63, 2**64-1] (-9223372036854775808 to
18446744073709551615). orjson only supports 64-bit integers; outside that
range, json_dumps raises:
Rationale: orjson itself raises a bare TypeError: Integer exceeds 64-bit
range for the same input, with no indication of which value or field caused
it, and no way to distinguish "an int was too big" from any other cause.
json_dumps catches that specific TypeError and re-raises ValueError with
the supported range stated, so the failure mode is at least documented rather
than an opaque orjson internal. This is done as error translation after the
fact, not a pre-emptive scan of the payload -- see "How this is enforced"
below for why that distinction is load-bearing. (The message does not name
the offending value: orjson's own exception doesn't carry it, so there is
nothing to recover and pass through.)
This check applies everywhere orjson itself traverses the structure -- top
level, nested inside dict/list (including subclasses of those, since
orjson serializes those subclasses natively too), and to int subclasses
holding an out-of-range value (e.g. an IntEnum member defined with too
large a value). bool is never treated as an out-of-range candidate: it's
technically an int subclass in Python, but it always serializes as
true/false, never as a number, so the question doesn't apply to it.
Non-finite floats: json_dumps does NOT reject these¶
This is the one constraint on this page that changed direction, and it is
easy to get wrong if you only skim. json_dumps itself does not check for
inf, -inf, or nan at all. Passed directly, they silently become JSON
null:
This is orjson's own behavior, unmodified. An earlier version of this
encoder pre-scanned every payload to catch and reject this before calling
orjson, but that scan made json_dumps 14.5x slower than raw
orjson.dumps on a realistic event payload -- slower, in fact, than the
stdlib json encoder this library replaced by taking on orjson as a core
dependency in the first place. The scan was deleted.
Protection moved upstream instead, to DomainEvent.
DomainEvent.model_config sets allow_inf_nan=False, so pydantic itself
rejects a non-finite float with ValidationError at event construction
time -- before serialization is ever reached:
>>> class OrderPlaced(DomainEvent):
... amount: float
...
>>> OrderPlaced(aggregate_id=..., aggregate_type="Order", amount=float("inf"))
ValidationError: ... Input should be a finite number [type=finite_number, ...]
This covers every event in the system -- strictly better than the old scan, since it fires at the earliest possible point instead of the latest, and applies uniformly regardless of which repository or serialization call site eventually handles the event.
Explicit residual risk, not a hidden one: a non-finite float in anything
that is NOT a DomainEvent is unprotected. A hand-built dict passed
straight to json_dumps, DLQ error metadata, or any other payload
constructed outside pydantic validation will silently serialize a
non-finite float as null, with no error anywhere. If you build payloads
by hand and they might contain a non-finite float, validate that yourself
before calling json_dumps -- the library will not catch it for you.
float subclasses (e.g. numpy.float64)¶
A float subclass -- finite or non-finite -- behaves exactly like a plain
float: converted to a plain float and serialized (finite), or silently
turned into null (non-finite). This needs calling out because it is not
orjson's default behavior for the type itself: orjson serializes
str/int/dict/list subclasses natively via their base-type behavior,
but not float subclasses -- those would otherwise raise TypeError: Type
is not JSON serializable: <ClassName> even for an ordinary finite value.
json_dumps handles the type-conversion case explicitly, but deliberately
does not try to special-case non-finite subclass values to raise:
verified by direct execution that orjson.dumps discards any exception
raised inside its default= callback and replaces it with a generic
message, so there was never a way to signal "this specific value is
non-finite" back through that hook. See the _orjson_default docstring in
eventsource/serialization/json.py for the full account of why that design
was tried and abandoned.
dict / list subclasses: supported. tuple subclasses: NOT supported¶
A dict or list subclass serializes natively via orjson, same as the
base type -- fully traversed, nothing skipped.
A tuple subclass, however, is not serializable at all, regardless of
its contents:
>>> class MyTuple(tuple): pass
>>> json_dumps({"a": MyTuple([1, 2, 3])})
TypeError: Object of type MyTuple is not JSON serializable
This is unrelated to any of the constraints above -- it's a general orjson
limitation on the tuple type specifically (list and dict subclasses
are fine; tuple subclasses are not). A plain tuple (not a subclass)
still serializes fine, as a JSON array.
How this is enforced (for anyone auditing this list against the code)¶
- Integer range is enforced by catching and translating orjson's own
TypeErrorinjson_dumps. This costs nothing on the success path -- it's atry/except, not a pre-scan. - Non-finite floats are enforced upstream, at
DomainEventconstruction (pydantic'sallow_inf_nan=False), not insidejson_dumpsat all. - There is no pre-serialization walk of the payload for either
constraint anymore. If you're reading older code, docs, or commit history
that describes a scan-based implementation (
_validate_json_safe_values/_reject_non_finite_floats), that was deleted for the performance reason described above.
Non-str dict keys¶
An int key stringifies ({1: "a"} -> {"1":"a"}), matching how a plain
int key behaves in ordinary dict-to-JSON conversion. A UUID key also
stringifies -- via orjson.OPT_NON_STR_KEYS -- to its string form
({uuid4(): "a"} -> {"<uuid-string>":"a"}). Any other non-str,
non-int, non-UUID key type is unsupported and raises.
Output format¶
- Compact. No whitespace between tokens (
{"a":1}, not{"a": 1}). - Raw UTF-8. Non-ASCII characters (accented Latin, CJK, Cyrillic, emoji
including those outside the Basic Multilingual Plane) are emitted as their
literal UTF-8 bytes, never
\uXXXX-escaped. boolastrue/false, never as0/1or any other numeric representation, regardless of the fact thatboolis anintsubclass in Python.
Unsupported types¶
Anything not covered above and not natively handled by orjson (UUID, datetime, the types listed above) raises:
This includes, for example, set, frozenset, Decimal, generators, and
arbitrary custom objects with no special handling. If you need one of these
supported, convert it to a plain dict/list/str/int/float before
calling json_dumps.
json_loads¶
json_loads is a thin wrapper over orjson.loads and performs no type
reconstruction: a UUID or datetime that was serialized comes back as a plain
str. Rehydrating those types, if needed, is the caller's responsibility.
See also¶
serialization/README (src/eventsource/serialization/README.md) — package overview and usage.eventsource.adapters.serialization.jsonmodule docstring — the same constraints, documented at the source.