Reading and Writing CBOR and DAG-CBOR
CBOR lets you exchange a structured value as a compact binary block without converting numbers and bytes to text. This page shows how to turn a complete block into a value tree, build and edit that tree, and write it back. It also explains the types Core understands, the limits you can place on input, and how to select DAG-CBOR when a peer requires its deterministic encoding.
What Is CBOR?
The Concise Binary Object Representation is a binary format for individual data items. An item may be a number, text or byte string, array, map, or tagged value; the same encoding can nest these items. Unlike a text document, a byte string stays distinct from text, and a number carries a numeric representation on the wire. CBOR is useful in binary protocols, stored records, and exchanges where both sides agree on the meaning of the data. For example, a sculpture catalog can keep a title as text, a year as a number, and a preview as bytes inside the same record. The full format is defined by RFC 8949.
Core reads one complete CBOR item per ByteBlock.
That root can be a scalar, array, or map; it does not need a document wrapper.
Ordinary CBOR permits indefinite-length byte strings, text strings, arrays, and maps, where a break marker ends the
item.
Core accepts those forms but writes definite lengths, so reading and writing the same value need not reproduce the
original bytes.
Its CborValue model covers the types in Supported Values and Format
Boundaries rather than every extension defined for CBOR.
Parsing a Complete Item
Use CborValue::fromByteBlock when invalid input is an
expected validation result.
It returns an optional value, empty if parsing fails.
If you need a diagnostic exception, use
CborValue::fromByteBlockOrThrow; malformed or
unsupported input raises ParseError.
Both forms require exactly one item and reject truncated input, a second trailing item, duplicate map keys, and
unsupported types.
This block holds a small Portuguese sculpture record, {"obra": "Lua"}.
The example passes its complete bytes to the optional parser, then shows the throwing form on a break marker that cannot
stand alone.
It also reads an indefinite array and shows that writing it again gives it a definite length.
data/DataFormats/CborExamples.cpp
void parseCborBlock() {
// This block contains the map {"obra": "Lua"}.
const auto bytes =
el::ByteBlock::fromVector(std::vector<uint8_t>{0xa1, 0x64, 'o', 'b', 'r', 'a', 0x63, 'L', 'u', 'a'});
if (const auto parsed = CborValue::fromByteBlock(bytes)) {
el::io::printLine("Work: "_el, parsed->getOrThrow("obra"_el).getText().value());
}
// Ordinary CBOR may use an indefinite array; the writer gives it a definite length.
const auto indefinite = el::ByteBlock::fromVector(std::vector<uint8_t>{0x9f, 0x01, 0x02, 0xff});
const auto array = CborValue::fromByteBlockOrThrow(indefinite);
el::io::printLine("Array items: "_el, array.itemCount().toSizeT());
el::io::printLine("Rewritten bytes: "_el, array.toByteBlock().length().toRawValue());
try {
(void)CborValue::fromByteBlockOrThrow(el::ByteBlock{el::Byte{0xffU}});
} catch (const el::err::ParseError &) {
el::io::printLine("Invalid CBOR item"_el);
}
}
Output
Work: Lua
Array items: 2
Rewritten bytes: 3
Invalid CBOR item
Building a Value Tree
A default CborValue is null.
Construct other scalar values directly, or begin with CborMap and
CborArray for containers.
set(key, value) adds or replaces a map member, while append(value) adds an array item.
set(index, value) replaces an existing array item or appends at exactly the current size; a larger index throws an
out-of-range error.
Calling either mutator on the wrong container type throws a logic error.
The exhibition below has a title, a year, and an ordered list of works. It shows how you can construct a nested tree without first creating a CBOR byte sequence.
data/DataFormats/CborExamples.cpp
auto makeExhibition() -> CborValue {
auto works = CborValue{CborArray{}};
works.append(CborValue{"Maré"_el}).append(CborValue{"Horizonte"_el});
auto exhibition = CborValue{CborMap{}};
exhibition.set("mostra"_el, CborValue{"Esculturas do Mar"_el});
exhibition.set("obras"_el, std::move(works));
exhibition.set("ano"_el, CborValue{2026});
return exhibition;
}
void buildCborValue() {
const auto exhibition = makeExhibition();
el::io::printLine("Fields: "_el, exhibition.itemCount().toSizeT());
el::io::printLine("Works: "_el, exhibition.getOrThrow("obras"_el).itemCount().toSizeT());
}
Output
Fields: 3
Works: 2
Serializing a Value
toByteBlock serializes any supported root value as one
complete CBOR item.
The result is binary data; store or transmit it as bytes, not as a string.
The default CborFormatOptions writes ordinary CBOR with definite
containers and strings, shortest integer and length headers, and 64-bit floating-point numbers.
If a value cannot be represented, such as an invalid date or CID link, serialization raises a parameter error.
The example writes the exhibition, reads its block, and retrieves the title from the received tree.
Notice what happens to a positive value constructed as int64_t: CBOR encodes it as an unsigned integer, so its
semantic type is Unsigned after parsing.
The numeric value is unchanged, and getSigned() can still read it while it fits in int64_t.
data/DataFormats/CborExamples.cpp
void serializeCborValue() {
const auto original = makeExhibition();
const auto bytes = original.toByteBlock();
const auto received = CborValue::fromByteBlockOrThrow(bytes);
el::io::printLine("Encoded bytes: "_el, bytes.length().toRawValue());
el::io::printLine("Exhibition: "_el, received.getOrThrow("mostra"_el).getText().value());
const auto positiveSigned = CborValue{int64_t{2026}};
const auto roundTrip = CborValue::fromByteBlockOrThrow(positiveSigned.toByteBlock());
el::io::printLine("Positive integer reads as unsigned: "_el, roundTrip.is(CborType::Unsigned));
}
Output
Encoded bytes: 56
Exhibition: Esculturas do Mar
Positive integer reads as unsigned: true
Finding and Editing Children
get(key) and get(index) return a null CborValue if the requested child is absent.
Since a real CBOR null looks the same, use getOrThrow when absence must be an error.
It raises an out-of-range error for a missing key or index and a logic error when the parent is the wrong container
type.
itemCount() returns a map’s field count or an array’s element count, and zero for a scalar.
Check type or is(type) before interpreting a value.
Typed getters such as getText(), getBytes(), and getFloat() return an optional result.
getSigned() can also read an unsigned value through INT64_MAX; getUnsigned() can read a nonnegative signed
value.
Other getters need their matching semantic type.
getArray() and getMap() return copies of the containers.
Child access returns a value, not an editor into the parent.
To change a nested child, edit the copy and set it back.
CborValue uses copy-on-write storage, so editing a copy of the parsed exhibition leaves the original alone.
data/DataFormats/CborExamples.cpp
void inspectCborValue() {
const auto original = CborValue::fromByteBlockOrThrow(makeExhibition().toByteBlock());
const auto works = original.getOrThrow("obras"_el);
el::io::printLine("First work: "_el, works.getOrThrow(el::ItemIndex{0U}).getText().value());
el::io::printLine("Missing is null: "_el, original.get("artista"_el).is(CborType::Null));
auto revised = original;
auto revisedWorks = revised.getOrThrow("obras"_el);
revisedWorks.append(CborValue{"Vento"_el});
revised.set("obras"_el, revisedWorks);
el::io::printLine("Original works: "_el, original.getOrThrow("obras"_el).itemCount().toSizeT());
el::io::printLine("Revised works: "_el, revised.getOrThrow("obras"_el).itemCount().toSizeT());
}
Output
First work: Maré
Missing is null: true
Original works: 2
Revised works: 3
Supported Values and Format Boundaries
CborType identifies the semantic type stored by Core.
This table shows how it relates to the CBOR item you exchange.
CBOR wire item |
Core type and getter |
Notes |
|---|---|---|
Null and Boolean |
|
A default value is null. |
Unsigned integer |
|
Full CBOR |
Negative integer |
|
Limited to the |
Half-, single-, or double-precision float |
|
Read as |
Text and byte string |
|
DAG-CBOR validates UTF-8 text; binary bytes remain distinct. |
Array and map |
|
Maps require unique text keys. |
Date/time tags 0 and 1 |
|
Ordinary CBOR only; written with tag 0 as UTC text. |
CID link tag 42 |
|
The getter returns binary CID bytes without the wire prefix. |
CBOR itself allows more map-key types, simple values, and application tags.
Core rejects nontext map keys, unsupported simple values, and tags other than 0, 1, and 42. It also rejects a negative
CBOR integer below INT64_MIN.
The in-memory map is keyed by Core strings, so map order from an ordinary CBOR input is not retained through a round
trip.
Ordinary parsing does not validate every text string as UTF-8; use DAG-CBOR parsing when that validation is required by
the exchange profile.
Dates and CID Links
In ordinary CBOR, tag 0 carries a date/time as text and tag 1 carries seconds relative to the Unix epoch.
Core reads either as Timestamp and writes a Timestamp with tag 0 in UTC.
Malformed or unrepresentable dates fail parsing; an invalid date fails serialization.
Date/time tags are excluded from DAG-CBOR.
Tag 42 carries a CID link in both modes.
On the wire, its byte string begins with a zero byte followed by a valid binary CID.
Core exposes only the CID bytes as CborLink or through
getLinkBytes(); you do not include the zero prefix when constructing a link.
Parsing and serialization validate the CID structure.
The next demo recognizes a tagged date and a CID link without interpreting the link as text.
It then writes the date as tag 0 and constructs a new link value from the CID bytes for a DAG-CBOR round trip.
data/DataFormats/CborExamples.cpp
void readCborTags() {
// Tag 1 wraps the number 1.5: one and a half seconds after the Unix epoch.
const auto timeBytes = el::ByteBlock::fromVector(std::vector<uint8_t>{0xc1, 0xfb, 0x3f, 0xf8, 0, 0, 0, 0, 0, 0});
const auto date = CborValue::fromByteBlockOrThrow(timeBytes);
el::io::printLine("Date/time value: "_el, date.is(CborType::DateTime));
el::io::printLine(
"Written date/time: "_el, CborValue::fromByteBlockOrThrow(date.toByteBlock()).is(CborType::DateTime));
// Tag 42 wraps a zero prefix and a binary CIDv0 (SHA-256 multihash).
auto linkWire = std::vector<uint8_t>{0xd8, 0x2a, 0x58, 0x23, 0x00, 0x12, 0x20};
linkWire.insert(linkWire.end(), 32U, 0U);
const auto link =
CborValue::fromByteBlockOrThrow(el::ByteBlock::fromVector(linkWire), CborParseOptions{}.setDagCbor(true));
el::io::printLine("CID bytes: "_el, link.getLinkBytes()->length().toRawValue());
const auto rewritten =
CborValue{el::cbor::CborLink{link.getLinkBytes().value()}}.toByteBlock(CborFormatOptions{}.setDagCbor(true));
el::io::printLine("Link round trip: "_el, rewritten == el::ByteBlock::fromVector(linkWire));
}
Output
Date/time value: true
Written date/time: true
CID bytes: 34
Link round trip: true
Exchanging DAG-CBOR
DAG-CBOR is a restricted CBOR profile for content-addressed data, where equivalent values must have a predictable byte
representation.
Select it on both sides: pass CborFormatOptions{}.setDagCbor(true) to toByteBlock() and
CborParseOptions{}.setDagCbor(true) to the parser.
The DAG-CBOR specification defines the profile.
Core writes definite forms, shortest integer and length headers, text keys ordered by their encoded bytes, and 64-bit floats. The DAG-CBOR parser checks those wire rules; it also rejects invalid UTF-8, indefinite forms, nonfinite floats, negative zero, and tags other than the CID link tag 42. The writer rejects invalid UTF-8, nonfinite floats, and date/time values, and normalizes negative zero to positive zero. An ordinary CBOR decoder may accept a different encoding for the same logical value, so use the DAG-CBOR option when validating bytes received under this profile.
data/DataFormats/CborExamples.cpp
void useDagCbor() {
const auto exhibition = makeExhibition();
const auto bytes = exhibition.toByteBlock(CborFormatOptions{}.setDagCbor(true));
const auto parsed = CborValue::fromByteBlockOrThrow(bytes, CborParseOptions{}.setDagCbor(true));
el::io::printLine("DAG-CBOR works: "_el, parsed.getOrThrow("obras"_el).itemCount().toSizeT());
// The integer 1 encoded in two bytes is valid ordinary CBOR, but not DAG-CBOR.
const auto longOne = el::ByteBlock::fromVector(std::vector<uint8_t>{0x18, 0x01});
el::io::printLine("Ordinary accepts: "_el, CborValue::fromByteBlock(longOne).has_value());
el::io::printLine(
"DAG-CBOR accepts: "_el, CborValue::fromByteBlock(longOne, CborParseOptions{}.setDagCbor(true)).has_value());
}
Output
DAG-CBOR works: 2
Ordinary accepts: true
DAG-CBOR accepts: false
Limiting Untrusted Input
CborParseOptions combines the profile switch with four
independent input limits.
An exceeded limit is a parse failure in both parser forms.
Defaults are useful for ordinary records, but a known schema can often use smaller limits.
This demo applies each one to the same exhibition block so you can see what it protects.
data/DataFormats/CborExamples.cpp
void limitCborInput() {
const auto bytes = makeExhibition().toByteBlock();
const auto input = CborParseOptions{}.setMaximumInputLength(el::ByteCount{10U});
const auto nesting = CborParseOptions{}.setMaximumNesting(el::ItemCount{1U});
const auto values = CborParseOptions{}.setMaximumValueCount(el::ItemCount{3U});
const auto strings = CborParseOptions{}.setMaximumStringLength(el::ByteCount{4U});
el::io::printLine("Input accepted: "_el, CborValue::fromByteBlock(bytes, input).has_value());
el::io::printLine("Nesting accepted: "_el, CborValue::fromByteBlock(bytes, nesting).has_value());
el::io::printLine("Values accepted: "_el, CborValue::fromByteBlock(bytes, values).has_value());
el::io::printLine("Text accepted: "_el, CborValue::fromByteBlock(bytes, strings).has_value());
}
Output
Input accepted: false
Nesting accepted: false
Values accepted: false
Text accepted: false
Maximum Input Length
setMaximumInputLength(ByteCount{...}) bounds the complete byte block before parsing begins.
The default is 16 MiB.
In the demo, a ten-byte limit rejects the whole exhibition block even though its individual strings are small.
Maximum Nesting
setMaximumNesting(ItemCount{...}) bounds array and map depth; the default is 64. The root container counts as one
level, so the demo’s limit of one permits a flat map but rejects its nested obras array.
This limit is useful when a peer can supply deeply nested containers.
Maximum Value Count
setMaximumValueCount(ItemCount{...}) bounds the total number of parsed values, including the root, map keys, and
nested items.
The default is 1,000,000. The demo sets a limit of three, which the exhibition exceeds before the parser reaches the
end.
Maximum String Length
setMaximumStringLength(ByteCount{...}) bounds the byte length of each text string, including map keys.
The default is 8 MiB.
It is a per-string limit, rather than a total text budget; the complete block still obeys the input-length limit.
The demo’s four-byte setting rejects longer text in the exhibition.
The DAG-CBOR switch, setDagCbor(bool), is also a parse option.
It changes which wire forms are accepted as shown in Exchanging DAG-CBOR; it does not replace any of these four
resource limits.
Complete demo sources
data/DataFormats/CborExamples.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "DataFormatsDemos.hpp"
#include <DemoCommon.hpp>
#include <erbsland/Byte.hpp>
#include <erbsland/ByteBlock.hpp>
#include <erbsland/ByteCount.hpp>
#include <erbsland/cbor/CborFormatOptions.hpp>
#include <erbsland/cbor/CborLink.hpp>
#include <erbsland/cbor/CborParseOptions.hpp>
#include <erbsland/cbor/CborType.hpp>
#include <erbsland/cbor/CborValue.hpp>
#include <erbsland/ItemCount.hpp>
#include <erbsland/ItemIndex.hpp>
#include <erbsland/ParseError.hpp>
#include <erbsland/StandardStreams.hpp>
#include <vector>
namespace demo {
using namespace el::text::literals;
using el::cbor::CborArray;
using el::cbor::CborFormatOptions;
using el::cbor::CborMap;
using el::cbor::CborParseOptions;
using el::cbor::CborType;
using el::cbor::CborValue;
auto makeExhibition() -> CborValue {
auto works = CborValue{CborArray{}};
works.append(CborValue{"Maré"_el}).append(CborValue{"Horizonte"_el});
auto exhibition = CborValue{CborMap{}};
exhibition.set("mostra"_el, CborValue{"Esculturas do Mar"_el});
exhibition.set("obras"_el, std::move(works));
exhibition.set("ano"_el, CborValue{2026});
return exhibition;
}
/// Parse a complete CBOR data item and choose how malformed input is reported.
void parseCborBlock() {
// This block contains the map {"obra": "Lua"}.
const auto bytes =
el::ByteBlock::fromVector(std::vector<uint8_t>{0xa1, 0x64, 'o', 'b', 'r', 'a', 0x63, 'L', 'u', 'a'});
if (const auto parsed = CborValue::fromByteBlock(bytes)) {
el::io::printLine("Work: "_el, parsed->getOrThrow("obra"_el).getText().value());
}
// Ordinary CBOR may use an indefinite array; the writer gives it a definite length.
const auto indefinite = el::ByteBlock::fromVector(std::vector<uint8_t>{0x9f, 0x01, 0x02, 0xff});
const auto array = CborValue::fromByteBlockOrThrow(indefinite);
el::io::printLine("Array items: "_el, array.itemCount().toSizeT());
el::io::printLine("Rewritten bytes: "_el, array.toByteBlock().length().toRawValue());
try {
(void)CborValue::fromByteBlockOrThrow(el::ByteBlock{el::Byte{0xffU}});
} catch (const el::err::ParseError &) {
el::io::printLine("Invalid CBOR item"_el);
}
}
/// Build a CBOR map with an ordered array and native values.
void buildCborValue() {
const auto exhibition = makeExhibition();
el::io::printLine("Fields: "_el, exhibition.itemCount().toSizeT());
el::io::printLine("Works: "_el, exhibition.getOrThrow("obras"_el).itemCount().toSizeT());
}
/// Serialize one value to a binary CBOR block and parse it again.
void serializeCborValue() {
const auto original = makeExhibition();
const auto bytes = original.toByteBlock();
const auto received = CborValue::fromByteBlockOrThrow(bytes);
el::io::printLine("Encoded bytes: "_el, bytes.length().toRawValue());
el::io::printLine("Exhibition: "_el, received.getOrThrow("mostra"_el).getText().value());
const auto positiveSigned = CborValue{int64_t{2026}};
const auto roundTrip = CborValue::fromByteBlockOrThrow(positiveSigned.toByteBlock());
el::io::printLine("Positive integer reads as unsigned: "_el, roundTrip.is(CborType::Unsigned));
}
/// Navigate a parsed CBOR tree and replace a nested value without changing the original.
void inspectCborValue() {
const auto original = CborValue::fromByteBlockOrThrow(makeExhibition().toByteBlock());
const auto works = original.getOrThrow("obras"_el);
el::io::printLine("First work: "_el, works.getOrThrow(el::ItemIndex{0U}).getText().value());
el::io::printLine("Missing is null: "_el, original.get("artista"_el).is(CborType::Null));
auto revised = original;
auto revisedWorks = revised.getOrThrow("obras"_el);
revisedWorks.append(CborValue{"Vento"_el});
revised.set("obras"_el, revisedWorks);
el::io::printLine("Original works: "_el, original.getOrThrow("obras"_el).itemCount().toSizeT());
el::io::printLine("Revised works: "_el, revised.getOrThrow("obras"_el).itemCount().toSizeT());
}
/// Limit the accepted bytes, nesting, values, and text length independently.
void limitCborInput() {
const auto bytes = makeExhibition().toByteBlock();
const auto input = CborParseOptions{}.setMaximumInputLength(el::ByteCount{10U});
const auto nesting = CborParseOptions{}.setMaximumNesting(el::ItemCount{1U});
const auto values = CborParseOptions{}.setMaximumValueCount(el::ItemCount{3U});
const auto strings = CborParseOptions{}.setMaximumStringLength(el::ByteCount{4U});
el::io::printLine("Input accepted: "_el, CborValue::fromByteBlock(bytes, input).has_value());
el::io::printLine("Nesting accepted: "_el, CborValue::fromByteBlock(bytes, nesting).has_value());
el::io::printLine("Values accepted: "_el, CborValue::fromByteBlock(bytes, values).has_value());
el::io::printLine("Text accepted: "_el, CborValue::fromByteBlock(bytes, strings).has_value());
}
/// Write a deterministic DAG-CBOR block and validate incoming bytes with the same profile.
void useDagCbor() {
const auto exhibition = makeExhibition();
const auto bytes = exhibition.toByteBlock(CborFormatOptions{}.setDagCbor(true));
const auto parsed = CborValue::fromByteBlockOrThrow(bytes, CborParseOptions{}.setDagCbor(true));
el::io::printLine("DAG-CBOR works: "_el, parsed.getOrThrow("obras"_el).itemCount().toSizeT());
// The integer 1 encoded in two bytes is valid ordinary CBOR, but not DAG-CBOR.
const auto longOne = el::ByteBlock::fromVector(std::vector<uint8_t>{0x18, 0x01});
el::io::printLine("Ordinary accepts: "_el, CborValue::fromByteBlock(longOne).has_value());
el::io::printLine(
"DAG-CBOR accepts: "_el, CborValue::fromByteBlock(longOne, CborParseOptions{}.setDagCbor(true)).has_value());
}
/// Read CBOR's date/time tag and DAG-CBOR's CID link tag as semantic values.
void readCborTags() {
// Tag 1 wraps the number 1.5: one and a half seconds after the Unix epoch.
const auto timeBytes = el::ByteBlock::fromVector(std::vector<uint8_t>{0xc1, 0xfb, 0x3f, 0xf8, 0, 0, 0, 0, 0, 0});
const auto date = CborValue::fromByteBlockOrThrow(timeBytes);
el::io::printLine("Date/time value: "_el, date.is(CborType::DateTime));
el::io::printLine(
"Written date/time: "_el, CborValue::fromByteBlockOrThrow(date.toByteBlock()).is(CborType::DateTime));
// Tag 42 wraps a zero prefix and a binary CIDv0 (SHA-256 multihash).
auto linkWire = std::vector<uint8_t>{0xd8, 0x2a, 0x58, 0x23, 0x00, 0x12, 0x20};
linkWire.insert(linkWire.end(), 32U, 0U);
const auto link =
CborValue::fromByteBlockOrThrow(el::ByteBlock::fromVector(linkWire), CborParseOptions{}.setDagCbor(true));
el::io::printLine("CID bytes: "_el, link.getLinkBytes()->length().toRawValue());
const auto rewritten =
CborValue{el::cbor::CborLink{link.getLinkBytes().value()}}.toByteBlock(CborFormatOptions{}.setDagCbor(true));
el::io::printLine("Link round trip: "_el, rewritten == el::ByteBlock::fromVector(linkWire));
}
}