TSON — Terse JSON Binary Format
A compact, schema-deduplicated binary format for JSON data, built for microcontrollers and constrained environments.
Core idea: in repetitive JSON (API payloads, telemetry, config), field names appear thousands of times. TSON stores them once in a definition block. Repeated strings are stored once in a dict block. The data stream is pure typed values, no key repetition, no duplicate strings.
JSON (890 bytes) TSON binary (~374 bytes)
[{ ┌── Header (13 B)
"id": 1, │ version=1, def_off=13,
"name": "Alice", │ dict_off=110, data_off=122
"age": 30, ├── Definition block (97 B)
"address": { │ #0 Null #1 Bool #2 Int #3 UInt
"street": "123…", │ #4 Float #5 String
"city": "Anytown", │ #6 Array<String>
"state": "CA", │ #7 Object fields:
"zip": "12345" │ street:String city:String
}, │ state:String zip:String
"hobbies": ["reading", │ #8 Object fields:
"hiking", "cooking"] │ id:Int name:String age:Int
}, │ address:#7 hobbies:#6
… │ #9 Array<Object>
] ├── Dict block (12 B, only
│ repeated strings)
├── Data block (252 B)
│ Entry #9: 3 elements
│ [0]: #8 -> 1, "Alice", 30…
│ [1]: #8 -> 2, "Bob", 25…
│ [2]: #8 -> 3, "Charlie",35…
└── (end)
Features
- Zero-dependency core: encode/decode/stream on
&[u8]slices, only needsalloc. - Streaming reader: loads the tiny definition + dict blocks into memory, then yields data entries one-at-a-time -
O(1)memory per entry. - Schema deduplication: identical object shapes share one definition. Field names stored once.
- String interning (
dictfeature): repeated strings stored once in a dict block.StrRefpoints to them instead of repeating inline. Only strings that appear ≥2 times are included - no waste. - Hybrid string encoding: short strings (≤127 B) use 1-byte length, medium strings 2 bytes, long strings 4 bytes - saves space over flat u32.
no_stdcapable: disable thestdfeature for embedded targets - the core builds againstalloconly (verified:cargo build --no-default-features).- Optional JSON bridge:
serde_json-based compile/decompile behind thejsonfeature. - Self-describing wire format: every compound value carries its definition index, enabling forward compatibility and partial decoding.
Install
# Rust (crates.io)
# Python (PyPI) — distribution is `tson-bin`; you still `import tson`
# Node.js (npm) — scoped package, ships a prebuilt addon per platform
Quick Start
// Round-trip a JSON string through TSON binary
let json = r#"{"name":"Alice","age":30}"#;
// JSON -> TSON document -> binary
let doc = compile_json.unwrap;
let bytes = to_bytes.unwrap;
// Binary -> TSON document -> JSON
let restored = from_bytes.unwrap;
let value = decompile_to_value.unwrap;
assert_eq!;
Emit Mode (Bypass JSON)
Need TSON binary directly from structured data without parsing JSON? tson::emit() takes a TsonData tree and produces a complete TSON document.
use ;
// Build a sensor reading value tree directly
let reading = Object;
// Emit as TSON binary - no JSON parse step
let bytes = emit.unwrap;
// Decode back
let doc = from_bytes.unwrap;
let value = decompile_to_value.unwrap;
// value = {"f0": 22.5, "f1": 61, "f2": "nominal"}
Field names are synthetic ("f0", "f1", …) since TsonData values don't carry names. Definitions and the string dict are discovered automatically from the value tree.
Server Response Path - emit_with_context()
Reuse an incoming document's definitions and dict to emit a response - no schema re-discovery, no dict rebuild.
use ;
let response = Object;
let bytes = emit_with_context.unwrap;
Field values must be in definition field order (alphabetical).
Direct Field Access - doc.get(), doc.index(), doc.get_by_index()
Extract values without decompiling to JSON. O(1) access when you pre-resolve field indices:
let doc = compile_json.unwrap;
// By name (linear scan)
let name = doc.get.unwrap;
let age = doc.get.unwrap;
// Or pre-resolve index for O(1) repeated access
let name_idx = doc.index.unwrap;
for _ in 0..1000
Multi-Document Stream - TsonDocReader
Read length-prefixed TSON documents from any byte source (archives, TCP streams).
use TsonDocReader;
use Cursor;
for doc in new
Each document is prefixed by a 4-byte LE length u32 followed by the TSON binary.
Command-Line Tool
# Build
# Compile JSON -> TSON binary
# Decompile TSON -> pretty JSON
# Stream-debug (inspect header, defs, dict, entries)
Feature Flags
| Feature | Default | Description |
|---|---|---|
std |
on | Enables std::io::Read helpers and the IoError variant. Off -> no_std + alloc. |
json |
on | Enables serde_json-based compile_json / decompile_to_value. Off -> pure core. |
dict |
on | Enables string interning (dict block). Strings appearing ≥2 times get StrRef instead of inline copies. When off, all strings are emitted inline - reduces compile memory at the cost of larger output. |
# All features (default)
# Core only (no serde, no std, no dict)
# Core + std (no JSON bridge, no dict)
# Without dict (all strings inline - less compile memory)
Architecture
┌──────────────────────────────────────────────────────┐
│ Public API (tson.rs) │
│ to_bytes / from_bytes / compile_json / stream … │
├──────────────────────────────────────────────────────┤
│ Encode Decode Stream │
│ (encode.rs) (decode.rs) (stream.rs) │
│ 13B header 13B header TsonStreamReader │
│ hybrid strings sentinel+StrRef dict available │
├──────────────────────────────────────────────────────┤
│ Type System (structure.rs, error.rs) │
│ TsonType, TsonData::StrRef, TsonDocument::dict │
├──────────────────────────────────────────────────────┤
│ JSON Bridge (compile.rs, decompile.rs) │
│ lazy-promotion dict, inline↔StrRef resolution │
└──────────────────────────────────────────────────────┘
All core modules (structure, encode, decode, stream) operate on &[u8] slices with zero system dependencies beyond alloc. The JSON bridge (compile, decompile) is feature-gated behind #[cfg(feature = "json")].
Benchmark
The project includes two human-readable benchmark tools plus a Criterion harness.
tson-bench - Compression Summary
Scans examples/ for .json files, compiles each to TSON, reports compression ratios with dict size and leaf entry counts.
╔══════════════════════╤══════════╤══════════╤══════════╤══════════╤══════════╤═════════╗
║ File │ JSON (B) │ TSON (B) │ Ratio │ Defs │ Dict │ Entries ║
╠══════════════════════╪══════════╪══════════╪══════════╪══════════╪══════════╪═════════╣
║ telemetry.json │ 54.4K │ 16.2K │ 29.8% │ 11 │ 63 │ 500 ║
║ config.json │ 27.9K │ 8.4K │ 30.3% │ 16 │ 20 │ 1 ║
║ 128KB.json │ 249.2K │ 104.3K │ 41.9% │ 8 │ 601 │ 788 ║
║ users-t1.json │ 890 B │ 374 B │ 42.0% │ 10 │ 1 │ 3 ║
╟──────────────────────┼──────────┼──────────┼──────────┼──────────┼──────────┼─────────╢
║ TOTAL │ 331.0K │ 129.2K │ 39.0% │ │ │ ║
╚══════════════════════╧══════════╧══════════╧══════════╧══════════╧══════════╧═════════╝
comp-bench - Detailed Performance Breakdown
Measures 7 independent workloads: JSON parse, compile, encode, decode, decompile, streaming read, and full round-trip.
╔══════════════════════╤══════════════╤══════════════════╗
║ Operation │ avg / iter│ % of round-trip ║
╠══════════════════════╪══════════════╪══════════════════╣
║ serde_json parse │ 2641 ns │ 15% (baseline) ║
║ TSON compile │ 8098 ns │ 46% ║
║ TSON encode │ 453 ns │ 3% (cheapest!) ║
║ TSON decode │ 2178 ns │ 12% ║
║ TSON decompile │ 2035 ns │ 12% ║
║ TSON stream (full) │ 2088 ns │ 12% ║
╟──────────────────────┼──────────────┼──────────────────╢
║ Full round-trip │ 11987 ns │ summed ║
╚══════════════════════╧══════════════════════════════════╝
cargo bench - Criterion Micro-benchmarks
For statistically rigorous numbers (warmup, outlier detection), benches/core.rs
measures compile/encode/decode/decompile/round-trip over examples/telemetry.json
and examples/128KB.json:
Observations
- Compile dominates (~46% of per-op time) - schema discovery + string interning + definition building.
- Encode is the cheapest stage (~0.45µs) - values are appended directly into one shared output buffer, with no per-node allocation or copy.
- Decode is competitive with JSON parse - cached definitions and O(1) index lookups.
- Streaming loads defs+dict once, then yields entries without re-parsing.
- Dict is empty for unique-only documents - lazy-promotion ensures no waste. Only strings appearing ≥2 times are included.
- 70%+ savings on large repetitive telemetry (500 sensor readings with 6 repeated field names per reading).
Why TSON? Comparison with Other Formats
TSON occupies a unique position in the binary JSON landscape - it is neither a general-purpose serializer nor a schema-first code generator. It compiles JSON into a self-describing, compressed binary that is optimized for decoding on constrained devices.
Size Comparison
| File | JSON | TSON | Savings |
|---|---|---|---|
telemetry.json (500 sensor readings) |
54.4 KB | 16.2 KB | 70.2% |
config.json (200 routing rules) |
27.9 KB | 8.4 KB | 69.7% |
128KB.json (mixed documents) |
249.2 KB | 104.3 KB | 58.1% |
iot-t2.json |
1.3 KB | 0.6 KB | 49.1% |
users-t1.json |
890 B | 374 B | 58.0% |
For repetitive structured data, TSON achieves 60-70% compression by deduplicating field names and interned strings. The larger and more repetitive the input, the better the ratio.
Format Comparison
| Feature | TSON | MessagePack | CBOR | serde_json | Protobuf | FlatBuffers |
|---|---|---|---|---|---|---|
| Self-describing | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| Schema discovery | ✅ auto | ❌ | ❌ | ❌ | ❌ hardcoded | ❌ |
| String interning | ✅ per-doc | ❌ | ❌ | ❌ | ❌ | ❌ |
| Field-name dedup | ✅ auto | ❌ repeats keys | ❌ | ❌ | ❌ | ❌ |
| Streaming decode | ✅ O(1) mem | ❌ | ❌ | ❌ | ❌ | ✅ |
| no_std + alloc | ✅ | ❌ std | ❌ std | ❌ std | ❌ | ❌ |
| Zero-copy strings | ✅ StrRef | ❌ | ❌ | ❌ | ❌ | ✅ |
| Security caps | ✅ built-in | ❌ | ❌ | ❌ | ❌ | ❌ |
| Hybrid str lengths | ✅ 1/2/4 B | ❌ | ❌ | ❌ | ❌ | ❌ |
| Human-readable | ❌ binary | ❌ binary | ❌ binary | ✅ text | ❌ | ❌ |
When to Use Each Format
| Scenario | Best Choice | Why |
|---|---|---|
| Browser ↔ server REST API | JSON | Native support everywhere |
| General-purpose binary packing | MessagePack | Good libraries, no schema needed |
| IoT with constrained nodes | CBOR | RFC standard, concise encoding |
| High-performance RPC | Protobuf | Schema-first, fast, compact |
| Microcontroller receiving structured telemetry | TSON | No schema file, streaming, zero-copy strings |
| Embedded device with limited RAM | TSON | no_std + alloc, O(1) per-entry memory |
| Config files needing human readability | JSON | Text is still the universal interface |
Key Insight
TSON trades compile time for decode efficiency. The compiler does the heavy lifting - discovering schemas, interning strings, building definitions - so that the decoder on a microcontroller can process data without allocating field names and strings. For a server compiling millions of telemetry packets, the compile cost is amortized. For the microcontroller decoding thousands of entries, the memory savings and allocation-free path are transformative.
Security
TSON prioritizes safe decoding of untrusted input. The reference implementation includes:
- Bounds-checked reads: every byte access is guarded, no panics on malformed input.
- OOM caps: entry count (1M max), definition count (2048 max), fields per object (256 max).
- Recursion guard: nesting depth limited to 128 - prevents stack overflow from circular definitions.
- UTF-8 validation: all string data is validated; invalid sequences are rejected.
- Header validation: offsets checked for consistency before use (def ≥ 13, dict ≥ def, data ≥ dict).
See the Security Considerations section in TSON-FORMAT.md for full details.
Testing
Three language bindings, one make target each.
| Language | Command | Tests |
|---|---|---|
| Rust | make test-rust |
48 unit + 3 doctests |
| Python | make test-python |
9 tests (round-trip, file I/O, emit, compression) |
| Node.js | make test-node |
8 tests (dumps/loads, file, emit, errors) |
| All | make test |
Full cross-language suite |
Quick Run
The Makefile builds the Python wheel (maturin) and the Node.js addon
(napi-rs v3, via cd js && npm run build) automatically. Full reference:
Full Format Specification
See TSON-FORMAT.md for the complete binary wire protocol with byte-level examples and BNF grammar.
License
MIT