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 values in Erbsland Core

CBOR wire item

Core type and getter

Notes

Null and Boolean

Null; Bool / getBool()

A default value is null.

Unsigned integer

Unsigned / getUnsigned()

Full CBOR uint64_t range is accepted.

Negative integer

Signed / getSigned()

Limited to the int64_t range.

Half-, single-, or double-precision float

Float / getFloat()

Read as double; written as 64-bit float.

Text and byte string

Text / getText(); Bytes / getBytes()

DAG-CBOR validates UTF-8 text; binary bytes remain distinct.

Array and map

Array / getArray(); Map / getMap()

Maps require unique text keys.

Date/time tags 0 and 1

DateTime / getTimestamp()

Ordinary CBOR only; written with tag 0 as UTC text.

CID link tag 42

Link / getLinkBytes()

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.

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));
}

}