encoding.cbor is an RFC 8949 Concise Binary Object Representation codec.
CBOR is a compact, schema-free binary format that supports the same value
model as JSON (numbers, strings, arrays, maps) plus byte strings, tagged
items, IEEE 754 floats at three widths, and a small set of "simple"
values (true, false, null, undefined). It is used by COSE/CWT
(IETF security stack), WebAuthn/FIDO2, the Matter smart-home protocol,
and many IoT stacks because messages are typically 30–60 % smaller than
JSON and parse without quoting/escaping.
Three layers of API are available:
encode[T] / decode[T] — comptime-driven generic API. Works on
primitives, strings, arrays, maps, structs (with @[cbor: 'name'],
@[skip], @[cbor_rename_all: 'snake_case']), enums, time.Time
(auto-tagged), and any type implementing Marshaler / Unmarshaler.Packer / Unpacker — manual streaming API. Use when the schema
isn't known at compile time, or when you need full control over tags,
indefinite-length items and simple values.Value sumtype — dynamic representation for round-tripping unknown
payloads or inspecting tagged data.Defaults follow RFC 8949 preferred serialisation (§4.2.2): floats
shrink to the shortest IEEE 754 width that preserves their value, and
every length argument uses the shortest encoding. Set
EncodeOpts.canonical = true to additionally sort map keys for
hash/signature stability (§4.2.1, deterministic encoding). Set
EncodeOpts.validate_utf8 = true if callers may build strings from raw
bytes (e.g. bytestr()) — the streaming pack_text trusts its input
for performance, but encode[T] will then refuse to emit non-UTF-8
text strings the strict-by-default decoder would reject on the way back.import encoding.cbor
import time
struct Person {
name string
age int
email ?string
birthday time.Time
}
fn main() {
bob := Person{
name: 'Bob'
age: 30
birthday: time.now()
}
bytes := cbor.encode[Person](bob, cbor.EncodeOpts{})!
// bytes is []u8 — wire-ready CBOR
back := cbor.decode[Person](bytes, cbor.DecodeOpts{})!
assert back.name == 'Bob'
}
Optional fields (?T) encode as CBOR null when set to none. Enums
encode as their underlying integer.
@[cbor_rename_all: 'kebab-case']
struct Login {
user_name string @[cbor: 'u'] // emit/read key "u" (overrides rename_all)
password string @[skip] // never serialise
remember bool // becomes "remember"
is_admin bool // becomes "is-admin"
}
The @[cbor_rename_all: '...'] attribute on a struct applies a global
rename strategy to every field that doesn't have an explicit
@[cbor: '...'] override — supported strategies: snake_case,
camelCase, PascalCase, kebab-case, SCREAMING_SNAKE_CASE. Use
@[cbor: '-'] as an alternative to @[skip].
Use this when the schema is dynamic or when you need access to CBOR features that don't map directly to V types (tags, indefinite-length strings, custom simple values):
import encoding.cbor
fn main() {
mut p := cbor.new_packer(cbor.EncodeOpts{})
p.pack_array_header(3)
p.pack_uint(42)
p.pack_text('hello')
p.pack_bool(true)
bytes := p.bytes()
mut u := cbor.new_unpacker(bytes, cbor.DecodeOpts{})
n := u.unpack_array_header()! // 3
a := u.unpack_uint()! // 42
b := u.unpack_text()! // 'hello'
c := u.unpack_bool()! // true
_ = n
_ = a
_ = b
_ = c
}
ValueWhen the payload schema is unknown at compile time, decode into
cbor.Value and walk the sumtype:
import encoding.cbor
fn main() {
bytes := cbor.encode[map[string]int]({
'a': 1
'b': 2
}, cbor.EncodeOpts{})!
v := cbor.decode[cbor.Value](bytes, cbor.DecodeOpts{})!
if val := v.get('a') {
if i := val.as_int() {
assert i == 1
}
}
}
Value covers every CBOR type: IntNum, FloatNum, Text, Bytes,
Array, Map, Tag, Bool, Null, Undefined, Simple. Re-encoding
a Value round-trips bit-for-bit when the source was already in
preferred form.
For types that need a custom on-wire representation, implement either side of the interface:
import encoding.cbor
struct Color {
mut:
r u8
g u8
b u8
}
pub fn (c Color) to_cbor() []u8 {
mut p := cbor.new_packer(cbor.EncodeOpts{})
p.pack_array_header(3)
p.pack_uint(c.r)
p.pack_uint(c.g)
p.pack_uint(c.b)
return p.bytes().clone()
}
pub fn (mut c Color) from_cbor(data []u8) ! {
mut u := cbor.new_unpacker(data, cbor.DecodeOpts{})
n := u.unpack_array_header()!
if n != 3 {
return error('Color expects 3 elements')
}
c.r = u8(u.unpack_uint()!)
c.g = u8(u.unpack_uint()!)
c.b = u8(u.unpack_uint()!)
}
to_cbor must return exactly one well-formed CBOR data item — the
generic encoder copies the bytes verbatim. from_cbor receives a slice
already trimmed to one item.
For hashing or signing, set canonical: true so that map keys are
sorted by length-then-lexicographic order (RFC 8949 §4.2.1):
import encoding.cbor
bytes := cbor.encode[map[string]int]({
'b': 2
'a': 1
}, cbor.EncodeOpts{ canonical: true })!
// keys are emitted in the order "a", "b" regardless of input order
time.TimeValues of type time.Time round-trip losslessly: whole-second values
use tag 1 (epoch seconds, integer) for the smallest canonical wire,
and sub-second values use tag 0 (RFC 3339 string with nanosecond
precision) — necessary because a tag-1 float can't carry both a
10-digit unix epoch and 9 fractional digits. Decode accepts tag 0
(RFC 3339 text, any sub-second precision) or tag 1 (integer or float).
Custom tags can be emitted/read via pack_tag / unpack_tag or by
constructing a Value with cbor.new_tag(number, content).
The test suite (vlib/encoding/cbor/tests/) covers every vector from
RFC 8949 Appendix A, plus indefinite-length strings, depth limits,
malformed-input rejection, UTF-8 validation, canonical ordering, and
tagged time round-trips.
v test vlib/encoding/cbor/tests/