JSON format#
This page describes how Python types are represented in JSON by
zodb-json-codec.
All representations are round-trip safe: encoding to JSON and decoding
back yields a pickle that unpickles to an equal object. The bytes are not
identical to the input: memo puts (BINPUT, LONG_BINPUT, MEMOIZE) for
values that are never read back are dropped, the protocol header is
rewritten, a ZODB class pickle is written in the ((module, name), None)
form, and shared sub-objects come back as equal copies. The string API
pickle_to_json sorts dict keys and json_to_pickle writes protocol 3; the
record functions (decode_zodb_record, decode_zodb_record_for_pg_json,
encode_zodb_record) and dict_to_pickle keep key order and write
protocol 2.
Native JSON types#
These Python types map directly to JSON without any markers:
Python Type |
JSON Type |
Example |
|---|---|---|
|
string |
|
|
number |
|
|
number |
|
|
boolean |
|
|
null |
|
|
array |
|
|
object |
|
Structural markers#
These markers preserve Python types that have no direct JSON equivalent.
Each uses a single-key dict with a @-prefixed key.
@t – tuple#
{"@t": [1, 2, 3]}
Python: (1, 2, 3)
@b – Bytes#
Base64-encoded binary data.
{"@b": "AQID/w=="}
Python: b'\x01\x02\x03\xff'
@bi – BigInt#
Integers that exceed JSON’s safe integer range, stored as strings.
{"@bi": "123456789012345678901234567890"}
Python: 123456789012345678901234567890
@d – Dict with Non-String Keys#
Array-of-pairs representation for dicts whose keys are not all strings.
{"@d": [[1, "a"], [2, "b"]]}
Python: {1: "a," 2: "b"}
@set – Set#
{"@set": [1, 2, 3]}
Python: {1, 2, 3}
In ZODB’s pickle protocol 3, sets are serialized via the REDUCE opcode.
The codec recognizes this pattern and produces the @set marker.
@fset – Frozenset#
{"@fset": [1, 2, 3]}
Python: frozenset([1, 2, 3])
Known Type Markers#
These markers provide human-readable, JSONB-queryable representations
for common Python types that are stored via pickle’s REDUCE opcode.
Instead of the generic @reduce format, each gets a compact,
purpose-built marker.
@dt – datetime.datetime#
ISO 8601 format. Naive datetimes have no offset; timezone-aware datetimes include the UTC offset.
{"@dt": "2025-06-15T12:30:45"}
{"@dt": "2025-06-15T12:30:45.123456"}
{"@dt": "2025-06-15T12:00:00+00:00"}
{"@dt": "2025-06-15T12:00:00+05:30"}
{"@dt": "2025-06-15T12:00:00-05:00"}
Python: datetime(2025, 6, 15, 12, 30, 45)
Timezone handling:
Fixed-offset timezones are embedded directly in the ISO 8601 string.
Named timezones (pytz, zoneinfo) use a separate @tz key to preserve
the zone name for exact roundtrip fidelity.
These two forms are
mutually exclusive:
Timezone Source |
JSON Form |
|---|---|
Naive (no tz) |
|
|
|
|
|
|
|
|
|
|
|
For pytz named timezones, the @tz.pytz array preserves the full
constructor arguments (name, UTC offset in seconds, DST offset,
abbreviation) for exact roundtrip fidelity.
For zoneinfo, only the zone
key is needed.
@date – datetime.date#
ISO 8601 date format.
{"@date": "2025-06-15"}
Python: date(2025, 6, 15)
@time – datetime.time#
ISO 8601 time format. Microseconds are included only when non-zero.
{"@time": "12:30:45"}
{"@time": "12:30:45.123456"}
Python: time(12, 30, 45)
@td – datetime.timedelta#
Array of [days, seconds, microseconds].
{"@td": [7, 3600, 500000]}
Python: timedelta(days=7, seconds=3600, microseconds=500000)
@dec – decimal.Decimal#
String representation preserving exact decimal value.
{"@dec": "3.14159"}
{"@dec": "Infinity"}
{"@dec": "NaN"}
Python: Decimal("3.14159")
@uuid – uuid.UUID#
Standard UUID string format (8-4-4-4-12 hex digits).
{"@uuid": "12345678-1234-5678-1234-567812345678"}
Python: uuid.UUID("12345678-1234-5678-1234-567812345678")
ZODB-Specific Markers#
@cls – Class Reference#
Module and class name pair, used for ZODB class pickle and the GLOBAL opcode.
{"@cls": ["myapp.models", "Document"]}
@s – Object State#
The state value from __getstate__().
Always paired with @cls in a
ZODB record.
{
"@cls": ["myapp.models", "Document"],
"@s": {"title": "Hello", "count": 42}
}
@newobj – Construction Kind#
Written as false, next to @cls/@s, when the pickle created the instance
by calling the callable named by @cls, usually the class (REDUCE with
empty arguments, the shape of a __reduce__ that returns (cls, (), state),
for example OrderedDict subclasses with a __dict__). Absent means the
instance was created with NEWOBJ (cls.__new__(cls)), the common case. The
encoders emit the same opcode again, so __init__ runs on unpickling exactly
when it did for the original pickle. Note the opposite default of the
newobj key inside @reduce below: there, absent means REDUCE; each
default is the common case of its form. @newobj, like @items and
@appends, is only valid next to @s; readers reject it on a bare class
reference.
{
"@cls": ["myapp.models", "Registry"],
"@s": {"entries": 3},
"@newobj": false
}
@ref – Persistent Reference#
ZODB persistent object reference, using hex OID format (16 hex digits, zero-padded).
{"@ref": "0000000000000003"}
{"@ref": ["0000000000000003", "myapp.models.Document"]}
The first form is an OID-only reference (class resolved at load time). The second form includes the class path for direct resolution.
Fallback Markers#
@reduce – Generic REDUCE#
For REDUCE operations not handled by a known type handler. Preserves the callable and arguments for roundtripping.
{
"@reduce": {
"callable": {"@cls": ["some.module", "SomeClass"]},
"args": {"@t": ["arg1", "arg2"]}
}
}
Optional keys inside @reduce:
items,appendsContents of dict/list subclasses set through
SETITEMS/APPENDSafter the call (OrderedDict,defaultdict,deque, user subclasses).newobjtruewhen the object was created withNEWOBJ(cls.__new__(cls, *args)) instead of calling the callable (REDUCE). Absent meansREDUCE.stateBUILDstate that followed the call: after aREDUCEwith non-empty arguments, or after aREDUCE/NEWOBJwhose callable is not a global (the result of anotherREDUCE, for example afunctools.partial), with any arguments.
{
"@reduce": {
"callable": {"@cls": ["some.module", "SomeClass"]},
"args": {"@t": ["arg1"]},
"state": {"extra": 1}
}
}
A protocol 4 NEWOBJ_EX (cls.__new__(cls, *args, **kwargs), from
__getnewargs_ex__ with keyword arguments) is written as the reduce form
CPython’s own __reduce_ex__ returns for it: callable
copyreg.__newobj_ex__, args (cls, args, kwargs), and state for a
following BUILD. The encoders write it as a REDUCE, which loads to the same
object on any Python 3.4 or newer, and they translate the shape earlier
releases wrote for these objects (a dict args holding @args and
@kwargs, with or without newobj, also nested in an instance state) into
the same call.
Instances created with NEWOBJ and non-empty constructor arguments
(__getnewargs__) followed by BUILD keep the @cls/@s form; their state
holds both parts:
{
"@cls": ["some.module", "SomeClass"],
"@s": {"@args": {"@t": ["arg1"]}, "@state": {"token": "arg1"}}
}
@items and @appends next to @cls/@s carry dict/list subclass contents of
such instances.
Keys starting with @ inside an instance state are reserved for the codec:
a state that is exactly {"@args": ..., "@state": ...} is always read as
constructor arguments plus state. A @reduce with both newobj and state
is written only when the class itself is not a global (NEWOBJ on a class
pickled by value, then BUILD); for a global class the @cls/@s form above
is used, with @newobj marking the REDUCE kind. Readers accept both.
@ns – String with NUL bytes (PostgreSQL storage)#
PostgreSQL JSONB cannot store U+0000. The PG decode functions
(decode_zodb_record_for_pg_json, decode_zodb_record_for_pg) replace a
string value containing NUL bytes with a marker holding the base64 of its
UTF-8 bytes, and a dict key containing NUL bytes with a string key carrying
the same payload behind an @ns: prefix:
{"@ns": "YQBi"}
{"@ns:awBleQ==": 1}
Python: "a\x00b" and {"k\x00ey": 1}
The encoders (encode_zodb_record, json_to_pickle) restore both forms.
Keys that genuinely start with @ns: are escaped the same way by every
decode path, so they survive a round trip too. Single-key dicts {"@ns": str}
are reserved for the marker, like every other @-prefixed single-key dict.
@inst – BUILD on a non-instance#
BUILD applied to a value that is not a class instance (a corrupt or hand-made
pickle) is kept as an anonymous instance and re-encoded as value state BUILD:
{"@inst": {"@obj": {}, "@state": {}}}
Releases before 1.7.0 also wrote {"@inst": {"@callable": ..., "@args": ..., "@state": ...}} for BUILD after a REDUCE whose callable is not a global;
that stored form is still read and encodes as callable args REDUCE state BUILD. New output uses @reduce with the state key instead. Any other
@inst content is rejected by the encoders.
@pkl – Raw Pickle Escape Hatch#
Base64-encoded pickle fragment for types that cannot be represented in JSON. This is the “never fails” fallback – any pickle data can roundtrip through this marker.
{"@pkl": "gAJjc29tZS5tb2R1bGUKU29tZUNsYXNzCnEAKVxxAX0="}
Marker Priority#
When decoding JSON back to pickle, markers are checked in a specific order:
Single-key markers (checked first):
@t, @b, @bi, @d, @set, @fset, @ref, @pkl, @ns,
@dt, @date, @time, @td, @dec, @uuid, @reduce
Multi-key markers:
@cls + @s (instance with BTree detection; @items, @appends and
@newobj ride along), @dt + @tz (timezone-aware datetime)
Fallback: Plain JSON object becomes a Python dict.
Backward Compatibility#
If JSON data was stored using the generic @reduce format for types
that now have dedicated markers (for example, datetime stored as @reduce
before the known type handlers were added), the decoder still handles
@reduce correctly.
The new markers only affect the forward direction
(pickle to JSON).