Binary parsing and encoding
Reading and writing bytes laid out in a fixed format, such as packet headers, file headers and firmware messages, without trusting what those bytes claim about themselves.
5 technologies on this page ↓How it works
A binary format fixes where every field sits and how it is stored. Multi-byte numbers have a byte order: big-endian puts the most significant byte first, as the IPv4 header does (hence “network byte order”), while little-endian puts the least significant byte first, as x86 and Apple M1 processors do in memory. Read the same two bytes 00 07 in the wrong order and sensor 7 becomes sensor 1792. Small values share a byte as bit fields: IPv4 packs a 4-bit version and a 4-bit header length into its first byte, and a parser pulls them apart with shifts and masks. Variable-length data needs framing. A length prefix says how many items or bytes follow; tag-length-value (TLV) adds a type tag in front, so an old reader can skip a field it doesn’t know, as Protocol Buffers does. Every length, count and offset in the input is a claim made by whoever sent it, so a parser checks it against the bytes it actually has before reading. Libraries take three broad approaches: annotate a struct and let the library derive the reader and writer (declarative layouts), compose small parsing functions (parser combinators), or write a schema and let a compiler generate the encoder and decoder.
A customs form with numbered boxes. Box 1 holds a four-letter code, box 2 says how many item lines follow, and each line has the same three columns. A careful clerk counts the attached lines before stamping anything, rather than taking box 2 at its word.
Reach for it when
- Speaking a format someone else defined: network packets, file headers, device and firmware protocols
- Microcontrollers and radio links, where each byte on the wire and each kilobyte of code matters
- Large files or high message rates, where fixed offsets beat scanning text
Think twice when
- You control both ends and only need to move data: use an existing format such as Protocol Buffers, CBOR or JSON instead of inventing a layout
- Copying an in-memory C struct onto the wire: padding, alignment and byte order then depend on the compiler and the CPU
- Hand-writing parsers for untrusted input in a memory-unsafe language when a generated or memory-safe parser is available
A real example
Temperature sensors send 10-byte packets over a radio link: the magic bytes SN, one byte holding a 4-bit version and 4-bit flags, a big-endian 16-bit sensor id, a count, then that many signed 16-bit readings in hundredths of a degree. The same data as compact JSON, {"sensor":7,"temps":[2100,-100]}, is 32 bytes. The gateway reads the count, checks that exactly two bytes per reading follow, and drops packets cut short in transit instead of reading past the end of its buffer.
Kinds of binary parsing and encoding
Specific products, and what each does differently.
Declarative binary layouts →
Describe the layout once, on a struct or as a schema object, and the library derives both the reader and the writer from that description.
Parser combinators →
Small parsing functions, each returning the value it read plus the rest of the input, composed into a parser for the whole format.
Code generated from a schema →
A schema compiler reads a message definition, such as a `.proto` file, and generates the types, encoder and decoder for your language.
| binrw | Declarative binary layouts | Open source | Open-source project by jam1garner, Colin Snover, Kitlith and roblabla | MIT | 2026-10-09 |
| Construct | Declarative binary layouts | Open source | Open-source project by Tomer Filiba, Corbin Simpson and Arkadiusz Bulski | MIT | 2026-10-09 |
| deku | Declarative binary layouts | Open source | Open-source project started by Emmanuel Thompson (sharksforarms) | MIT or Apache 2.0 | 2026-10-09 |
| nom | Parser combinators | Open source | Open-source project created by Geoffroy Couprie, now in the rust-bakery organisation | MIT | 2026-10-09 |
nanopb | Code generated from a schema | Open source | Open-source project by Petteri Aimonen | zlib | 2026-10-09 |
Three ways to write the parser#
All three read the same bytes. They differ in where the description of the format lives and whether you also get a writer for free.
| Declarative layouts | Parser combinators | Code from a schema | |
|---|---|---|---|
| You write | A struct or schema object with attributes: sizes, byte order, counts, magic bytes | Small functions (“take 2 bytes”, “match SN”) combined into bigger ones | A message definition, such as a .proto file |
| Who decides the bytes | The format’s author; you describe it as it is | The format’s author; you describe it as it is | The schema compiler’s own encoding |
| Writing as well as reading | Yes, from the same description | No, the encoder is separate code | Yes, both are generated |
| Strong at | Fixed headers, bit fields, counted arrays | Streams that arrive in chunks, formats where later fields depend on earlier ones, text as well as binary | Formats you own and evolve; many languages at once |
| Examples | deku and binrw (Rust), Construct (Python), Kaitai Struct (YAML, many languages) | nom (Rust), Hammer (C) | nanopb and the official Protocol Buffers compilers |
Treat every length as a claim#
Most binary formats carry their own lengths, and the bug that keeps recurring is believing them. The TLS heartbeat extension (RFC 6520) sends a uint16 payload_length followed by the payload, and the RFC says to discard a message whose length is too large. OpenSSL 1.0.1 before 1.0.1g did not handle these packets properly, and crafted heartbeats made it over-read its buffer and send back process memory, private keys included: Heartbleed, CVE-2014-0160.
The language-theoretic security (LangSec) community argues for a stricter habit: treat the set of valid inputs as a formal language and recognize the whole input before acting on any of it, instead of scattering checks through the program. Libraries help by making the checks structural. nanopb promises it will never read more than the stream’s remaining bytes or write outside the message struct, whatever the input; nom returns an error or “need more data” rather than reading past its slice. Speed shortcuts can quietly drop those checks: Construct’s compiled parsers omit some of them, and its docs say not to use them on corrupted or untrusted data.
In code · python
import struct
# "SN" | version: 4 bits | flags: 4 bits | sensor id: u16 | count: u8 | count × i16
packet = bytes.fromhex("534e 12 0007 02 0834 ff9c")
def parse(buf: bytes) -> dict:
if len(buf) < 6 or buf[:2] != b"SN":
raise ValueError("not a reading")
vf, sensor_id, count = struct.unpack_from(">BHB", buf, 2) # > = big-endian
version, flags = vf >> 4, vf & 0x0F # two 4-bit fields
if version != 1:
raise ValueError(f"unsupported version {version}")
if len(buf) != 6 + 2 * count: # never trust a length
raise ValueError(f"expected {6 + 2 * count} bytes, got {len(buf)}")
temps = struct.unpack_from(f">{count}h", buf, 6)
return {"version": version, "flags": flags, "sensor": sensor_id, "temps": list(temps)}
print(parse(packet))
# {'version': 1, 'flags': 2, 'sensor': 7, 'temps': [2100, -100]}
print(struct.unpack("<H", packet[3:5])[0]) # same two bytes read little-endian: 1792, not 7
try:
parse(packet[:8]) # truncated in transit
except ValueError as e:
print(e) # expected 10 bytes, got 8Often confused with
Where it came from1980–2020
Byte order was argued over before most of today’s formats existed. In April 1980 Danny Cohen’s note “On Holy Wars and a Plea for Peace” borrowed the Big-Endians and Little-Endians from Gulliver’s Travels to name the two camps. The IPv4 specification, RFC 791 of September 1981, chose big-endian for the internet protocol: the most significant octet of a field goes first, and small fields like the 4-bit version share a byte. Programs read such headers with hand-written code that copied bytes, shifted and masked.
That hand-written code, mostly in C, became a leading source of security bugs. A 2017 paper by Pierre Chifflier and Geoffroy Couprie counted 95 vulnerabilities in the Wireshark protocol analyser in 2016 alone, and Heartbleed in 2014 was a buffer over-read in OpenSSL’s handling of TLS heartbeat messages, which carry their own length field. The LangSec movement, from Len Sassaman and Meredith L. Patterson’s 2010 Black Hat talk onwards, argued that input handling should be a recognizer for a precisely specified language rather than checks scattered through a program.
Libraries took the ad hoc code away in three ways. For declarative layouts, Tomer Filiba’s Construct reached version 2.0 in Python around 2007, and Rust’s deku and binrw (2020) moved the idea into derive macros. Parser combinators, which Graham Hutton described in 1992, reached binary formats through the C library Hammer and through nom (2015) in Rust, which drew on Haskell’s Parsec. Schema compilers such as Protocol Buffers chose their own bytes, and nanopb (2012) fitted that approach onto microcontrollers.
Why it’s built this way
- Describe the layout once, derive both directions
- A reader and a writer written separately drift apart. Construct’s docs put it plainly: the description is a data structure, not code, so it can parse in one direction and build in the other.
- Parsers as pure functions that return the rest of the input
- With no mutable state, small parsers can be tested alone and combined freely, and in Rust a parser that returns slices of its input can’t read past the end of them.
- Check everything before using anything
- LangSec’s argument: when validation is scattered through a program, what it actually accepts no longer matches the programmer’s assumptions, which leaves room for exploits.
- Let a compiler choose the bytes when you own the format
- If nobody else defined the layout, a schema compiler can pick one that old readers can skip through, as Protocol Buffers’ field tags allow, and generate code for every language in use.
Timeline
- 1980
Danny Cohen names the big-endian and little-endian camps
- 1981
RFC 791 fixes the IPv4 header: bit fields, most significant octet first
- 1992
Graham Hutton’s “Higher-order functions for parsing”
- 2007
Construct 2.0, a declarative binary parser and builder for Python
- 2014
Heartbleed; the first LangSec workshop at IEEE Security & Privacy
- 2015
nom 1.0 brings parser combinators for binary data to Rust
- 2020
deku and binrw derive binary readers and writers from Rust structs
- Danny Cohen: On Holy Wars and a Plea for Peace (IEN 137, 1 April 1980)
- RFC 791: Internet Protocol (September 1981), header format and Appendix B
- Chifflier and Couprie: Writing parsers like it is 2017 (IEEE S&P Workshops 2017)
- NVD: CVE-2014-0160 (Heartbleed, published April 2014)
- LangSec: Language-theoretic Security (principles, workshops since 2014, talks since 2010)
- Graham Hutton: Higher-order functions for parsing (Journal of Functional Programming, July 1992)
- Tomer Filiba: The Future of Construct (May 2012)
- Hammer README: bit-oriented parser combinators in C
- nom CHANGELOG: 1.0.0 (November 2015)
- crates.io API: deku versions (0.1.1, March 2020)
- crates.io API: binrw versions (0.1.0, September 2020)
- nanopb CHANGELOG: 0.1.0, first stable release (January 2012)
Sources10
- Python docs: struct, byte order, size and alignment
- RFC 791: Internet Protocol (header bit fields, transmission order)
- Protocol Buffers docs: Encoding (varints, tag-length-value)
- RFC 6520: TLS and DTLS Heartbeat Extension (payload_length)
- NVD: CVE-2014-0160 (Heartbleed)
- LangSec: Language-theoretic Security
- nanopb docs: Security model (invariants for untrusted input)
- nom README
- Construct docs: Compilation (do not use compiled parsers on untrusted data)
- Chifflier and Couprie: Writing parsers like it is 2017
