How to Work with FlatBuffers in Rust

Use FlatBuffers in Rust by defining a schema, compiling with flatc, and accessing data directly from the buffer without deserialization.

When copying is the enemy

You are building a game engine or a high-frequency trading bot. You have a struct representing a game entity or a market order. It's 4KB of data. You serialize it to JSON to send over the network. On the receiving end, you deserialize it. The deserializer allocates a new struct, copies every field from the JSON string, and drops the JSON. You just wasted CPU cycles and memory bandwidth on data that was already in memory.

Or you are parsing a configuration file that loads once and stays in memory for hours. You deserialize it into a Rust struct. Now you have two copies of the data: the raw bytes in the file buffer and the Rust struct in heap memory. You need the struct to access fields, but the duplication hurts cache locality and increases RSS.

FlatBuffers solves this by eliminating the deserialization step. You read fields directly from the serialized byte buffer. No allocation. No copy. Just pointer arithmetic and type casting. The data lives where it was born.

Zero-copy in plain words

Most serialization formats like JSON, YAML, or MessagePack work by parsing text or binary into a new object in memory. The format is a description of data. You read the description and build the data.

FlatBuffers treats the serialized buffer as the data itself. The buffer is structured so that you can jump directly to any field using offsets. You don't build a new object. You wrap the buffer in a type-safe accessor and read values in place.

Think of JSON like photocopying a document and then reading the photocopy. The photocopy is your usable object. The original document is discarded. FlatBuffers is like reading the document directly from the file cabinet. You point to the data where it lives. The "accessor" is just a bookmark that tells you where to look.

The trade-off is rigidity. You must define a schema ahead of time. The schema describes the layout. A code generator creates Rust types that know exactly how to interpret the bytes. If the schema changes, you regenerate the code. This is a contract between the writer and the reader.

The schema and the generator

FlatBuffers uses a schema language to define tables, vectors, and enums. You write a .fbs file. You run the flatc compiler to generate Rust code. The generated code contains the accessor types and builder helpers.

Convention aside: The generated code is ephemeral. Never edit it. Treat the .fbs file as the single source of truth. If you need to change the structure, edit the schema and regenerate. The Rust community convention is to run flatc as part of your build script or CI pipeline to keep generated code in sync.

/// Schema: player.fbs
///
/// table Player {
///     id: uint;
///     name: string;
///     score: int;
///     inventory: [Item];
/// }
///
/// table Item {
///     item_id: uint;
///     count: short;
/// }

The schema defines a Player table with fields. id, name, and score are scalars. inventory is a vector of Item tables. The flatc compiler reads this and generates Rust structs with methods like Player::create and player.name().

Minimal example: Building a buffer

You build a FlatBuffer using a FlatBufferBuilder. The builder manages a contiguous byte buffer. You add fields in reverse order because FlatBuffers builds from the end of the buffer backwards. This allows efficient appending without shifting data.

use flatbuffers::FlatBufferBuilder;
// Import generated code. In a real project, this comes from flatc.
// use generated::player::{Player, Item, ItemArgs, PlayerArgs};

fn main() {
    // Create a builder. It allocates a small buffer and grows as needed.
    let mut builder = FlatBufferBuilder::new();

    // Build the inventory vector.
    // Vectors must be created before the table that contains them.
    let sword = builder.create_string("Sword");
    let shield = builder.create_string("Shield");
    
    // Create Item offsets.
    // Each create call returns an Offset pointing into the buffer.
    let sword_offset = Item::create(
        &mut builder,
        &ItemArgs {
            item_id: 101,
            count: 1,
        },
    );
    
    let shield_offset = Item::create(
        &mut builder,
        &ItemArgs {
            item_id: 102,
            count: 2,
        },
    );

    // Create the vector of items.
    // The builder stores the vector and returns an Offset.
    let inventory = builder.create_vector(&[sword_offset, shield_offset]);

    // Create the player name string.
    let name = builder.create_string("Rustacean");

    // Create the Player table.
    // Pass all offsets and scalars in the Args struct.
    let player_offset = Player::create(
        &mut builder,
        &PlayerArgs {
            id: 42,
            name: Some(name),
            score: 1500,
            inventory: Some(inventory),
        },
    );

    // Finish the buffer.
    // This writes the root table offset and returns the raw bytes.
    builder.finish(player_offset, None);

    // Get the finished data.
    // This is a &[u8] containing the entire FlatBuffer.
    let bytes = builder.finished_data();
    
    // bytes can now be sent over a socket, written to disk, or passed to C.
}

The builder pattern ensures you don't accidentally create dangling offsets. Every create call validates inputs and returns an offset. The finish call seals the buffer. Once finished, the buffer is immutable. You cannot mutate fields in place. You must rebuild the buffer to change data. This immutability is a feature. It guarantees the buffer is safe to share across threads without locks.

How the buffer is laid out

FlatBuffers uses a virtual table mechanism to handle optional fields and schema evolution. Every table in the buffer has a vtable. The vtable is a small index that maps field IDs to offsets within the table.

When you read a field, the accessor reads the vtable to find the offset. If the field is missing, the vtable entry is zero, and the accessor returns the default value. This allows you to add new fields to the schema without breaking old readers. Old readers simply ignore fields they don't know about. New readers use defaults for fields that old writers didn't include.

The buffer structure looks like this:

  1. Root offset: A 4-byte integer at the start pointing to the root table.
  2. Tables: Each table has a vtable pointer, followed by the field data.
  3. Vectors: A length followed by elements.
  4. Strings: A length followed by UTF-8 bytes.

The flatc generated code knows this layout. When you call player.name(), the code reads the vtable, calculates the offset, and returns a &str that points directly into the buffer. No allocation happens. The &str borrows the buffer. Rust's borrow checker enforces that the buffer lives as long as the reference. This is where Rust shines. Zero-copy usually leads to dangling pointers in C++. Rust makes it safe.

Realistic example: Reading without allocating

Reading a FlatBuffer is straightforward. You wrap the byte slice in a root accessor. The accessor borrows the buffer. You can then read fields.

use flatbuffers::InvalidFlatbuffer;
// use generated::player::{Player, Item};

/// Reads a player score from a FlatBuffer byte slice.
///
/// Returns the score if the buffer is valid and contains a Player.
/// Returns an error if the buffer is malformed or the root is not a Player.
fn read_score(bytes: &[u8]) -> Result<i32, InvalidFlatbuffer> {
    // root_as_player validates the buffer and returns a Player accessor.
    // The accessor borrows `bytes`. It cannot outlive the slice.
    let player = Player::root_as_player(bytes)?;

    // Access fields directly.
    // player.name() returns &str.
    // player.score() returns i32.
    // player.inventory() returns Option<&[Item]>.
    
    let score = player.score();
    let name = player.name().unwrap_or("Unknown");
    
    println!("Player {} has score {}", name, score);
    
    // Iterate over inventory.
    // Each Item is accessed via a reference into the buffer.
    if let Some(items) = player.inventory() {
        for item in items {
            println!("  Item {}: count {}", item.item_id(), item.count());
        }
    }

    Ok(score)
}

fn main() {
    // Simulate receiving bytes from network.
    let buffer: &[u8] = &[/* ... flatbuffer bytes ... */];
    
    match read_score(buffer) {
        Ok(score) => println!("Score: {}", score),
        Err(e) => eprintln!("Invalid buffer: {:?}", e),
    }
}

The root_as_player function checks the buffer integrity. It verifies the root offset points to a valid table and that the vtable is consistent. If the buffer is corrupted, it returns an error. This validation is cheap. It checks a few integers. It does not parse the entire buffer.

Convention aside: Always check the result of root_as. Never unwrap it blindly. Network buffers can be truncated or corrupted. The InvalidFlatbuffer error covers these cases.

Pitfalls and constraints

FlatBuffers is powerful but has constraints. Understanding these prevents headaches.

Schema drift: If you change the schema, you must regenerate code. If the reader and writer use different schema versions, you rely on vtable defaults. If you change a field type or remove a field, you break compatibility. Treat the schema as a versioned contract. Use semantic versioning for your .fbs files.

Immutability: You cannot mutate a buffer. If you need to update a field, you must rebuild the buffer. This is efficient for small updates. You can use builder.reset() to reuse the builder's internal allocation, but you still have to write all fields again. If you need frequent mutations, FlatBuffers is the wrong tool. Use a mutable struct in memory and serialize only when sending.

Endianness: FlatBuffers handles endianness automatically. The generated code swaps bytes if the host architecture differs from the buffer. You don't need to worry about this.

Error E0277: If you try to use a type that doesn't implement the required traits, you get E0277 (trait bound not satisfied). This usually happens if you mix generated code from different flatc versions or if you forget to import the generated module. Ensure your Cargo.toml and build.rs are in sync.

Error E0308: If you pass an Offset where a scalar is expected, or vice versa, you get E0308 (mismatched types). The generated Args structs enforce this at compile time. You cannot accidentally pass a string offset to an integer field.

Treat the schema as the contract. If the schema changes, the binary changes. Version carefully.

Decision: FlatBuffers vs the rest

Choose FlatBuffers based on your performance needs and schema control.

Use FlatBuffers when you need zero-copy deserialization and performance is paramount. Use FlatBuffers when you control the schema and can update clients with new code. Use FlatBuffers when you need schema evolution with vtable-based defaults.

Use serde with JSON or MessagePack when you need human-readable debugging or dynamic schema support. Use serde when you are building a public API where clients cannot recompile code.

Use bincode when you need a simple binary format for Rust-to-Rust communication without schema generation. Use bincode when you are serializing Rust structs directly and don't need cross-language compatibility.

FlatBuffers trades flexibility for speed. Pick the tool that matches your bottleneck.

Where to go next