# binrw turns a Rust struct into a binary parser with attribute directives

> binrw is a Rust crate for writing declarative readers and writers for binary data, where the format is described with attributes on a struct and the parser is generated by a derive macro. The directives handle the tedious parts of a format, magic numbers, byte order, padding, alignment and validation, and the crate supports no_std and does not require repr(C) layouts on the types it reads.

**jam1garner/binrw** — A Rust crate for helping parse and rebuild binary data using ✨macro magic✨.

- Repository: https://github.com/jam1garner/binrw
- Website: https://binrw.rs
- Stars: 858 · Forks: 59
- Language: Rust
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/jam1garner-binrw

## The format is the struct definition

The README describes binrw as a crate for helping parse and rebuild binary data using macro magic, and it says the crate helps you write maintainable and easy-to-read declarative binary data readers and writers.

The shape of it is familiar to anyone who has used a serialization derive. You annotate a struct, and a derive macro generates the parser and the serialiser.

```rust
#[derive(BinRead)]
#[br(magic = b"DOG", assert(name.len() != 0))]
struct Dog {
    bone_pile_count: u8,

    #[br(big, count = bone_pile_count)]
    bone_piles: Vec<u16>,

    #[br(align_before = 0xA)]
    name: NullString
}
```

Everything in that block is layout, not logic. The `magic` directive says the data starts with those three bytes. The `assert` directive says the name must not be empty. The `big` directive says those integers are big endian rather than native. The `count` directive says how many of them there are, and it reads the count from another field of the same struct. The `align_before` directive says the name starts at the next offset that is a multiple of ten.

That is the whole idea. A binary format is a sequence of constraints, and the crate's job is to let you write those constraints down in the place where the data is described, rather than in a separate hand-written function where the two can drift apart.

## Directives cover the parts of a format that are easy to get wrong

The feature list is the best catalogue of what the crate covers, and the README links each item to the relevant documentation page.

It generates efficient data parsers and serialisers for structs and enums using `#[derive]`, and it reads and writes data from any source using the standard `io::Read` and `io::Write` streams. That last point is the one that makes the crate usable outside a file, because a reader can be anything that implements `Read`, including a slice, a cursor, or a stream over the network.

The directives in attributes handle the common binary parsing tasks, and the README names them: matching magic numbers, byte ordering, padding and alignment, data validation, and more. Byte ordering in particular is not a detail you want to re-derive every time, because a wrong endianness produces plausible-looking garbage rather than an error, and the `big` directive in the example is the difference between a working parser and a subtly broken one.

Data validation is the other one worth calling out. The `assert` in the example is a check on the parsed data rather than on the bytes, and the README lists data validation among the things the attribute directives handle, which means a parser can refuse bad input at the point it reads it rather than leaving a half-valid struct for the caller to discover.

The one ergonomic claim that matters for editors is that code in attributes is written as code, not as strings, for improved ergonomics and first-class IDE support. A directive like `count = bone_pile_count` is a Rust expression, so a rename refactor reaches into the attribute, and a typo is a compile error rather than a runtime surprise. That is a small design decision with a large effect on how the crate feels to work with.

## NullString and FilePtr cover the two classic indirections

Two reusable types are shipped with the crate, and between them they remove the two patterns that show up in almost every binary format.

The first is the null-terminated string. The README links `NullString` as a reusable type for common data structures, and the example uses it for a field whose type in the file is a run of bytes ending in a zero. Writing that by hand means reading bytes until you find the terminator, and then either losing the terminator or accidentally keeping it, and the type exists so that the terminator handling is a solved problem rather than a decision you make in every parser.

The second is data indirection using offsets, which the README links as `FilePtr`. Formats that point at data elsewhere in the file, rather than storing it inline, are common, and `FilePtr` expresses the pointer and the pointed-at value in the same struct definition, so the field's type says what it actually is.

Both are ordinary structs with their own derives, which is the point. They are not a special layer of the library but examples of how a type is written so that the crate can read it, and the README's separate documentation links for them are the same documentation every other type would get.

The example also shows the read side, which is short enough to take in whole.

```rust
let mut reader = Cursor::new(b"DOG\x02\x00\x01\x00\x12\0\0Rudy\0");
let dog: Dog = reader.read_ne().unwrap();
assert_eq!(dog.bone_piles, &[0x1, 0x12]);
assert_eq!(dog.name.to_string(), "Rudy")
```

The `read_ne` call asks for native endian reading, which is the default and is the counterpart to the `big` directive in the struct. The cursor is a slice of bytes, so there is no file involved at all.

## Foreign types come in through custom parsers and value maps

The interesting constraint in practice is that a file format rarely contains only your own types. It contains timestamps, and string tables, and lengths that are defined elsewhere in the specification, and each of those comes from somewhere else.

The crate's answer has two parts, and the README links both to the attribute documentation. One is parsing types from third-party crates using free functions, documented as custom parsers, which is how you attach a parser to a type you do not own, such as a `std::time::SystemTime` or a foreign struct that happens to have the right layout. The other is value maps, which is how you map a parsed value onto a type you want, for instance when the file stores an enum discriminant as an integer and you want a Rust enum in memory.

The distinction between the two is worth holding on to, because they solve different problems. A custom parser produces a value of a type you did not write. A value map produces a value of a type you did write, from a representation that is not the one you want. A format with its own enum encodings wants the second, and a format that stores a standard type wants the first.

The crate also states that it uses efficient in-memory representations, and does not require `#[repr(C)]` or `#[repr(packed)]`. That is a real difference from a hand-rolled parser, where the easiest path is to mirror the file layout in a struct and read into it, which ties your in-memory type to the on-disk layout forever, including its padding and its endianness. Here the struct is a description of the format, and the crate is free to store the fields in whatever layout is efficient, because it is generating the read code rather than casting a pointer.

## The workspace, the pre-release version and the release script

The repository is a Cargo workspace with two members, `binrw` and `binrw_derive`, and the split is the one you would expect from a derive-based crate, with the macro implementation kept separate from the runtime library it generates calls into. The workspace uses the second resolver, edition 2024, and the package metadata points the documentation at docs.rs and the homepage at binrw.rs.

Two fields in that metadata are worth a second look. The version is `0.16.0-pre`, and `publish = false` with a comment saying to use `release.sh`. So the published crate on crates.io is not the version in the repository's manifest, and the release path runs through the script in the root of the tree rather than through a manual `cargo publish`. The `release.sh` file, the `rustfmt.toml` and the `codecov.yml` are the visible pieces of that automation, along with the GitHub Actions workflow whose badge is in the README.

The minimum Rust version is declared as 1.88, with a comment to update the workflow file at the same time, which is the kind of detail that saves a contributor an afternoon when their toolchain is slightly older than the one the maintainer uses.

The recent release history is consistent with that: v0.15.0 on 2025-05-05, then v0.15.1 and v0.15.2 on consecutive days in July 2026, the last of them on the same day as the last push. The project is not archived, and it is being maintained, though the gap between the 0.15.0 release and the pair of patch releases suggests a burst of work rather than a steady drip.

For anyone pinning a dependency, the practical note is that the manifest version is a pre-release line, so a plain version requirement in a `Cargo.toml` will resolve to the last published release rather than to what is in the repository.

## Where this approach stops being the right one

The design has clear edges, and it is worth naming them before you commit a parser to it.

The first edge is that the format must be describable as directives. A format that depends on runtime context, where the meaning of a field is decided by something read later in the file, will not fit cleanly into a struct description, and you will end up writing custom parsers for the awkward parts, which is exactly the escape hatch the crate provides but also the point at which you stop using the declarative model.

The second is that a derive macro generates code, and generated code has to be readable when something goes wrong. The crate's answer to that is that attribute code is real Rust rather than strings, so the compiler and the IDE can help, but the generated parser is still not something you read in a debugger the way you read your own function.

The third is the one that decides it for many teams. The in-memory representation is the struct you wrote, not a mirror of the file, which means that a struct designed for parsing is not automatically a good domain type. If your application wants a validated, owned, meaningful value, you will be converting between the two anyway, and the crate's saving is smaller than it first appears.

What the crate is really for is the file and protocol layer, where a layout has to be described somewhere and the description has to stay in step with the code that uses it. The alternative, and the honest comparison, is to write the reader by hand, which for a simple format with two fields is a perfectly reasonable thing to do, and which for a format with padding, mixed endianness and a length field is where the mistakes accumulate. The crate's bet is that the description is the hard part, and that the same declaration should produce both the reader and the writer, so that a format with a serialiser cannot drift from the format with a parser.

## Conclusion

binrw fits a Rust developer writing a parser for a file format, a save game, a model file or a protocol, who would rather describe the layout than hand-roll a byte cursor. Skip it if your format needs runtime schema, or if you need to read a type whose memory layout you do not control, since the crate's own types are used for the in-memory representation. Verify first that the format's fixed fields, padding and endianness can all be expressed as directives, and that the version you depend on is a released one rather than the pre-release line the workspace currently carries.

## FAQ

### What is the binrw crate?

It is a Rust crate for writing declarative binary data readers and writers, where the format is described with attributes on a struct and the parser and serialiser are generated by `#[derive]`. The crate reads and writes from any source using the standard `io::Read` and `io::Write` streams.

### Which binary format details does binrw handle with directives?

The README lists matching magic numbers, byte ordering, padding and alignment, and data validation among the tasks handled by directives in attributes. The example in the README uses `magic`, `assert`, `big`, `count` and `align_before`.

### Does binrw require repr(C) on the structs it parses?

No. The README states that it uses efficient in-memory representations and does not require `#[repr(C)]` or `#[repr(packed)]`, and that code in attributes is written as code rather than as strings for better ergonomics and first-class IDE support.

### How do I parse a type from another crate with binrw?

The README gives two mechanisms: parsing types from third-party crates using free functions, documented as custom parsers, and value maps, which map a parsed value onto a type you want. It also ships reusable types such as `NullString` for null-terminated strings and `FilePtr` for data indirection using offsets.

### Does binrw support no_std?

Yes, the feature list states that the crate supports no_std. The repository is a Cargo workspace with the `binrw` and `binrw_derive` members, uses edition 2024 and declares a minimum Rust version of 1.88.

## Sources

- [jam1garner/binrw on GitHub](https://github.com/jam1garner/binrw)
- [License: MIT](https://github.com/jam1garner/binrw/blob/master/LICENSE)
- [Project website](https://binrw.rs)
- [README](https://github.com/jam1garner/binrw/blob/master/README.md)
- [Releases](https://github.com/jam1garner/binrw/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/jam1garner-binrw
