cbor2
cbor2 encodes and decodes CBOR, the binary data format defined in RFC 8949. Think of CBOR as JSON with a byte-level encoding and a type system that actually matches what programs hold in memory: integers stay integers, binary blobs travel as bytes instead of base64, dict keys can be any type instead of only strings, and the result is usually smaller than the equivalent JSON. The API is deliberately the same shape as the stdlib json and pickle modules: dumps, loads, dump, load. Beyond the basics it knows the registered CBOR semantic tags, so datetimes, Decimals, Fractions, UUIDs, sets, IP addresses, and compiled regexes round-trip as themselves. Since version 6.0 the whole encoder and decoder is a Rust extension rather than the old C plus pure-Python pair.
The default CBOR library for Python, and correctly so: complete tag coverage, a stdlib-shaped API, and an unusually careful maintainer who keeps shipping decoder-correctness fixes. Reach for it when a spec or a binary-heavy payload calls for CBOR, not as a general replacement for JSON, and read the 6.0 migration notes before upgrading from 5.x.
Use it if
- You are implementing a protocol that specifies CBOR: COSE and CWT tokens, WebAuthn and FIDO2 attestation objects, CoAP payloads, or a firmware update manifest
- You are shipping binary fields (image bytes, hashes, signatures, sensor readings) and want to stop paying the 33 percent base64 tax that JSON forces on you
- Your dict keys are not strings: CBOR maps take integers, tuples, or bytes as keys and give them back with the same type, where json silently stringifies them
- You need values that JSON has no type for, such as timezone-aware datetimes, exact Decimals, sets, or UUIDs, and you would rather not invent a per-field encoding convention
- Your data crosses a boundary humans or curl will inspect: CBOR is opaque bytes, so every debugging session needs the cbor2 CLI tool or a hex viewer, and the built-in JSON conversion in that tool is explicitly lossy
- You are only trying to make JSON faster. orjson or msgspec will parse and emit JSON several times quicker than the stdlib and keep every existing consumer working, with none of the format-migration cost
- The other side of the wire is not yours to change. CBOR support outside Python and embedded firmware is thin, and the two space-saving options here (value_sharing and string_referencing) are rarely implemented anywhere else, so turning them on can produce payloads only cbor2 can read
- You are decoding attacker-controlled bytes and treating the result as trusted. The README says outright that the library has not been tested against malicious input; version 5.9.0 added a max_depth limit under CVE-2026-26209, and unbounded maps or bignums can still cost real memory
- You are on cbor2 5.x with custom hooks and cannot schedule a migration. Version 6.0 changed both the tag_hook and object_hook signatures, dropped the cbor2.encoder and cbor2.decoder submodules, removed CBORDecodeValueError, and renamed FrozenDict to frozendict
Setup reality
pip install cbor2 and you are done on any mainstream platform: version 6.1.4 publishes 49 wheels covering CPython 3.10 through 3.15 plus PyPy, on manylinux, musllinux, macOS arm64 and x86_64, and Windows. There are no Python dependencies at all. The trap is the source fallback. Since 6.0 the extension is written in Rust, so if pip cannot find a wheel for your interpreter or platform it will try to build from source and demand a full Rust toolchain, which surprises people on Alpine images with an unusual Python or on freshly released Python versions. Pin the version and use a wheel-only install in CI if you want that failure to be loud. The other setup cost is conceptual: you have to decide up front whether to enable canonical encoding, value sharing, and string referencing, because those flags change the bytes on the wire and other CBOR implementations may not read them.
Patterns
Round-trip a Python object to CBOR bytesencode-decode-bytes
import cbor2
payload = cbor2.dumps({"id": 7, "tags": ["a", "b"], "blob": b"\x00\x01"})
print(type(payload)) # <class 'bytes'>
back = cbor2.loads(payload)
print(back) # {'id': 7, 'tags': ['a', 'b'], 'blob': b'\x00\x01'}Same four function names as json and pickle. Two things json cannot do show up immediately: bytes survive as bytes with no base64 step, and a non-string dict key such as 7 comes back as the integer 7 rather than the string '7'.
Write to and read from a file without building the whole bufferfile-stream-roundtrip
import cbor2
with open("out.cbor", "wb") as fp:
cbor2.dump({"records": list(range(1000))}, fp)
with open("out.cbor", "rb") as fp:
data = cbor2.load(fp)dump and load take a binary file object, so open with 'wb' and 'rb'. Passing a text-mode file gives a TypeError about needing bytes. load reads exactly one CBOR item and leaves the stream positioned after it.
Encode datetimes without hitting the naive-datetime errordatetime-timezone
from datetime import datetime, timezone
import cbor2
naive = datetime(2026, 8, 1, 12, 0)
# cbor2.dumps(naive) raises a CBOREncodeError: no timezone
data = cbor2.dumps(naive, timezone=timezone.utc)
compact = cbor2.dumps(naive, timezone=timezone.utc, datetime_as_timestamp=True)
print(cbor2.loads(data)) # always timezone-aware on the way backCBOR has no representation for a naive datetime, so the encoder makes you supply a default timezone instead of guessing. datetime_as_timestamp=True writes a numeric epoch (tag 1) which is smaller but throws away the original offset, so everything decodes as UTC.
Teach the encoder about your own classcustom-type-encoder
import cbor2
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def encode_point(encoder: cbor2.CBOREncoder, value: Point) -> None:
encoder.encode_semantic(4000, [value.x, value.y])
data = cbor2.dumps(Point(4, 5), encoders={Point: encode_point})The encoders mapping matches the exact type, not subclasses. Use the default= keyword instead if you want one catch-all callback for every unrecognised object. Pick a tag number outside the IANA-assigned ranges so you do not collide with a registered meaning.
Decode an unregistered semantic tag back into your classtag-hook-catch-all
import cbor2
def tag_hook(tag: cbor2.CBORTag, immutable: bool):
if tag.tag == 4000:
return Point(*tag.value)
return tag
point = cbor2.loads(data, tag_hook=tag_hook)The signature changed in 6.0. It used to be (decoder, tag); it is now (tag, immutable). Code copied from a pre-2026 blog post will fail with a confusing argument error. Always return the tag unchanged for numbers you do not handle, or unknown tags disappear.
Override how a specific registered tag decodesoverride-semantic-decoder
from datetime import datetime, timezone
import cbor2
def decode_epoch(item: int, immutable: bool) -> datetime:
return datetime.fromtimestamp(item, timezone.utc)
payload = cbor2.dumps(cbor2.CBORTag(1, 1363896240))
print(cbor2.loads(payload, semantic_decoders={1: decode_epoch}))semantic_decoders is for tags cbor2 already handles and you want handled differently; tag_hook is for tags it does not know. Supplying any semantic_decoders slows down every tagged value slightly, because each one now needs a trip back into the interpreter.
Serialize a structure that points back at itselfcyclic-value-sharing
import cbor2
node = {"name": "root"}
node["self"] = node
# cbor2.dumps(node) raises: cyclic data structure detected
data = cbor2.dumps(node, value_sharing=True)
back = cbor2.loads(data)
assert back["self"] is backWithout value_sharing the encoder refuses rather than looping forever. Turning it on tags every potentially shared list and dict, which costs bytes, and very few non-Python CBOR implementations understand the shared-value tags, so keep it to internal formats.
Produce deterministic bytes for signing or hashingcanonical-encoding
import cbor2
a = cbor2.dumps({"b": 1, "a": 2}, canonical=True)
b = cbor2.dumps({"a": 2, "b": 1}, canonical=True)
assert a == bWithout canonical=True map keys are written in dict insertion order, so two equal dicts can serialize to different bytes and break a signature check. Canonical mode sorts keys and uses the shortest encoding for each number, which is what COSE, CWT, and content-addressed storage expect.
Put limits on a decoder fed by the networkharden-untrusted-input
import io
import cbor2
raw = untrusted_bytes[:1_000_000] # cap the size yourself first
decoder = cbor2.CBORDecoder(
io.BytesIO(raw),
max_depth=32,
allow_indefinite=False,
allow_duplicate_keys=False,
)
obj = decoder.decode()The README states the library has not been tested against malicious input. max_depth defaults to 400 (added in 5.9.0 under CVE-2026-26209); lower it to whatever your schema actually nests. Rejecting indefinite-length items and duplicate map keys closes off two classes of parser-mismatch bug between you and the sender.
Read many CBOR items concatenated in one streamdecode-sequence
import io
import cbor2
buf = io.BytesIO(cbor2.dumps([1, 2]) + cbor2.dumps([3, 4]))
decoder = cbor2.CBORDecoder(buf)
while True:
try:
print(decoder.decode())
except cbor2.CBORDecodeEOF:
breakcbor2.load reads exactly one item, so a log file of appended records needs a CBORDecoder held open across calls. CBORDecodeEOF is the clean end-of-stream signal; a plain CBORDecodeError means the bytes are actually malformed.
Look at CBOR bytes from the shellcli-inspect
# hex on stdin, pretty JSON out
echo a16568656c6c6f65776f726c64 | xxd -r -ps | cbor2 --pretty
# base64 input
echo ggEC | python -m cbor2.tool --decode
# a stream of concatenated items
echo ggECggMEggUG | cbor2 -d --sequenceThe cbor2 console script and python -m cbor2.tool are the same tool and ship with the package. The JSON it prints is explicitly lossy: bytes, non-string map keys, and tagged values all get flattened, so use it to eyeball a payload, never to convert one.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| msgpack | PyPI | Your peers already speak MessagePack; it is a similar compact binary format with wider cross-language library support but no registered tag registry for things like Decimal. |
| orjson | PyPI | You want speed and small code changes but the format must stay JSON so browsers, logs, and existing services keep working. |
| msgspec | PyPI | You want schema-typed structs validated on decode across JSON, MessagePack, YAML, and TOML, and you are willing to define the schema instead of decoding to plain dicts. |
| cbor | npm | You are writing the other end of the same protocol in Node and need a CBOR codec that interoperates with what cbor2 emits. |