boring emits standard CBOR. Any conformant decoder in any language parses its output without error — that is what "self-describing" means, and it is the whole reason for choosing CBOR.
What this document answers is the next question: once it parses, what do you actually get, and how do you turn it into something idiomatic?
Everything here is executed in CI. interop/test_read_boring.py runs on
every push against a committed fixture, so if boring's wire format changes and
this document stops being true, the build breaks. A snippet pasted into a
markdown file rots silently; this one cannot.
| tag | meaning | what a generic decoder gives you | idiomatic form |
|---|---|---|---|
| — | ints, floats, strings, bytes, arrays, maps, booleans, null | the obvious thing | — |
| 0, 1 | date/time | handled natively by most libraries | timestamp |
| 2, 3 | bignum | handled natively | big integer |
| 4 | decimal fraction | handled natively | decimal, scale preserved |
| 25, 256 | stringref | handled natively by cbor2; a compression detail | transparent |
| 27 | generic object | [type-name, field-map] | your record type |
| 30 | rational | handled natively | fraction |
| 32 | URI | a tagged string | java.net.URI |
| 35 | regular expression | a tagged string | regex (source only, no flags) |
| 37 | UUID | handled natively | UUID |
| 40 | multi-dimensional array | [dims, flat-array] | row-major matrix |
| 39 | identifier | a tagged string | keyword or symbol |
| 77–86 | RFC 8746 typed array | often unimplemented — raw bytes | array of numbers |
| 258 | set | handled natively by cbor2 | set |
| 1002 | duration (RFC 9581) | a keyed map | java.time.Duration |
| 1004 | full-date (RFC 8943) | a date string | java.time.LocalDate |
| 39649 | shaped array — boring's one extension | [keys, [row-values…]] | array of maps |
Three rows deserve attention.
Tag 39 is where Clojure shows through. A keyword is a string with a leading
:; a symbol is the same tag without one. Distinguishing them matters: if you
flatten both to plain strings, :a and "a" become the same map key and you
have silently merged two entries.
Tags 77–86 are registered, standard, and frequently unimplemented. Python's
cbor2 hands back a raw CBORTag(79, b"…") rather than a list. This is worth
handling rather than avoiding — the payload is a plain little-endian memory
image, so a homogeneous numeric column costs one bulk read instead of one
decode per element. It is the fastest thing boring emits, and unpacking it is
one struct call.
Tag 39649 is the only thing here that is not a registered CBOR tag. See below.
Tag 27 is "serialised language-independent object with type name and constructor arguments". boring uses it for your records — the type name is the record's class name — and also for a handful of types CBOR has no tag for, under names carrying a slash, which a JVM class name never does:
| name | argument | why it is not just the bare value |
|---|---|---|
clojure/sorted-map | a map | a sorted map is a CBOR map; unmarked it returns unsorted |
clojure/sorted-set | an array | same, for sets |
clojure/queue | an array | a queue is a CBOR array; unmarked it returns a vector |
clojure/with-meta | [meta, value] | Clojure metadata, which has nowhere else to go |
clojure/char | a 1-character string | (= \a "a") is false in Clojure |
java/period | an ISO-8601 string, e.g. "P1Y1M1D" | a date amount; RFC 9581's tag 1002 carries seconds |
The prefix names the runtime that owns the type. These names are frozen — they are pinned in the golden corpus, because renaming one is a silent break: a reader that does not recognise a name yields an ordinary value rather than raising.
For a foreign reader, ignoring them entirely is safe and lossy in a predictable
direction — a sorted map arrives as a map, a queue as an array, a char as a
one-character string. Handling clojure/with-meta is worth the four lines, since
otherwise every annotated value arrives wrapped:
def tag27(name, arg):
if name == "clojure/with-meta":
return tag27_value(arg[1]) # or keep arg[0] if you want the meta
if name in ("clojure/queue", "clojure/sorted-set"):
return arg
...
Only clojure/with-meta changes the shape of what you get; the rest change
only the type, which a language without sorted maps or characters was going to
lose anyway.
An array whose elements are all maps sharing one key set is written with the keys once:
value [{:e 1 :a :x} {:e 2 :a :y}]
wire 39649([ [":e", ":a"], <- the key set, once
[ [1, ":x"], <- row 1, values only
[2, ":y"] ] ]) <- row 2
Reconstructing it is a zip, and it needs no state outside the tag — that is the property that makes it easy to support elsewhere, and the reason it was designed this way rather than as a stream-scoped structure table:
def shaped_array(payload):
keys, rows = payload
return [dict(zip(keys, row)) for row in rows]
It is off by default (:shapes true opts in), and a decoder that ignores it
gets an inert tagged value — never a misreading. Use {:profile :interop} to
guarantee no extension appears at all.
It is not yet registered with IANA, so treat the number as provisional. The registration is tracked in COMPATIBILITY.md.
interop/read_boring.py is a complete reader in about 60 lines of logic. It is
the file CI runs.
from read_boring import loads
value = loads(open("data.cbor", "rb").read())
cbor2 already handles tags 0–4, 25/256, 30, 37 and 258 natively. What
read_boring adds:
def _tag_hook(decoder, tag):
if tag.tag == 39: # identifier
s = tag.value
return Keyword(s) if s.startswith(":") else Symbol(s)
if tag.tag == 27: # record
type_name, fields = tag.value
return Record(type_name, fields)
if tag.tag == 39649: # shaped array
keys, rows = tag.value
return [dict(zip(keys, row)) for row in rows]
if tag.tag in TYPED_ARRAYS: # RFC 8746
fmt, width = TYPED_ARRAYS[tag.tag]
return list(struct.unpack(f"<{len(tag.value) // width}{fmt}", tag.value))
return tag
def loads(data):
return cbor2.loads(data, tag_hook=_tag_hook)
Run it yourself:
pip install cbor2
python3 interop/test_read_boring.py
The test asserts 24 values against literal expectations, including that a
decimal keeps its scale — Decimal("1.50") == Decimal("1.5") is True in
Python, so equality alone would not catch that loss, and the test checks
as_tuple().exponent instead.
interop/rust/ is a complete reader in ~350 lines of ciborium::value::Value
walking — no boring-specific crate, because there isn't one and none is needed.
It reads the same committed fixture the Python reader does and checks the same
values, so the two languages agree or CI says so:
cargo run --manifest-path interop/rust/Cargo.toml -- interop/fixture.cbor
Two things it has to do that a naive reader will not, and both are worth knowing before you write your own:
{:stringref false} if you would rather not implement it.1, 3, 2. Comparing set contents positionally passes on some values
and fails on others, which is worse than failing consistently.The same four hooks, against these libraries:
fxamacker/cbor, which is the most rigorous Go
implementation; register a TagSet.cbor-x or cbor; both expose a tag
hook.Neither is executed in CI, unlike the Python and Rust readers above. Treat them as guidance — the shape of the problem is identical, but I would rather say plainly that they are untested than imply a coverage that does not exist.
(Rust used to be in this list. It is not any more: interop/rust runs in CI
and bin/ci FAILS rather than skips it when CI is set, because a reference
reader that silently does not run is worse than one that is absent.)
If you are writing data for a consumer you do not control:
(boring/encode value {:profile :interop})
That disables stringref and shapes. Every byte is then a registered CBOR construct, and any conformant decoder produces the right structure without a single hook — at the cost of a larger document.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |