FlatBuffers Zero-Copy Serialization

Protocols & Interoperability intermediate Building with AI ~30 min read

Overview

Many serialization systems optimize the bytes sent over the wire but still require a second step before an application can use the data: parse the bytes, allocate native objects, copy strings and arrays, and then traverse the reconstructed object graph. That is a poor fit for latency-sensitive readers that need to inspect only a small part of a message, memory-map a file, or move records between languages without paying a full decode cost on every hop.

FlatBuffers solves this by making the serialized buffer itself the readable data structure. Code generated from a schema exposes typed accessors that compute offsets into the buffer and return scalars, strings, vectors, structs, or nested tables directly. Readers do not need to unpack the whole message into a separate heap representation before accessing fields.

The tradeoff is that schema and layout discipline move earlier in the design process. Tables, structs, vtables, offsets, defaults, and evolution rules become part of the compatibility contract. Engineers who understand those mechanics can use FlatBuffers for game assets, mobile payloads, embedded messages, inference metadata, cross-language RPC payloads, and memory-mapped datasets without treating the format as a black box.

Core Concepts

Schema-Generated Accessors

A FlatBuffers schema defines tables, structs, enums, unions, strings, vectors, defaults, and the root type. The flatc compiler turns that schema into language-specific code. Producers use generated builders to write buffers, while consumers use generated accessors to read fields by offset. The schema is not carried with every buffer; both sides must agree on the intended root type or wrap the buffer in a higher-level container that identifies it.

Tables Versus Structs

Tables are the normal object type. They are referenced by offsets, can omit fields, and support forward and backward compatibility through vtables and defaults. Structs are simpler: they are stored inline, have a fixed layout, and do not support adding or deprecating fields. Use structs for stable, compact numeric records such as vectors or coordinates; use tables whenever future schema evolution matters.

Vtables and Defaults

Each table starts with an offset to a vtable. The vtable stores field offsets for the table instance, so generated accessors can find a field even though the table’s physical layout is not fixed. If a field is absent, out of range for an older buffer, or has a zero vtable entry, the accessor returns the schema default. This is the mechanism that lets old data and new code coexist.

Key Concept: The Buffer Is the Object Graph

FlatBuffers does not deserialize into a second object graph by default. Generated readers interpret the original byte buffer through offsets, vtables, and typed accessors, so the lifetime, alignment, and compatibility of that buffer are central design concerns.

How It Works

Define the Wire Shape

The schema language looks like a compact IDL. Tables can grow by appending fields, defaults define what readers see when values are absent, vectors hold repeated values, strings are length-prefixed and null-terminated, and unions encode both a type tag and the selected object offset.

namespace Inventory;

enum Color : byte { Red = 1, Green, Blue }

struct Vec3 {
  x:float;
  y:float;
  z:float;
}

table Item {
  id:uint;
  name:string;
  position:Vec3;
  color:Color = Blue;
  tags:[string];
}

root_type Item;

Generate Code and Build Backwards

The compiler emits builders and readers for the selected languages. In the reference implementation, buffers are constructed backwards: child objects such as strings and vectors are created first, then their offsets are inserted into parent tables, and finally the root object offset is written at the start of the buffer. Building backwards reduces bookkeeping because offsets to already-written children are known when parent objects are assembled.

// C++ shape, simplified from the generated-builder workflow.
flatbuffers::FlatBufferBuilder builder;

auto name = builder.CreateString("laser");
std::vector<flatbuffers::Offset<flatbuffers::String>> tag_offsets = {
  builder.CreateString("tool"),
  builder.CreateString("portable")
};
auto tags = builder.CreateVector(tag_offsets);
Inventory::Vec3 pos(1.0f, 2.0f, 3.0f);

auto item = Inventory::CreateItem(
  builder,
  42,
  name,
  &pos,
  Inventory::Color_Blue,
  tags
);
builder.Finish(item);

uint8_t *bytes = builder.GetBufferPointer();
size_t size = builder.GetSize();

Read by Offset

Readers start from the root offset and call generated accessors. A scalar accessor reads the field from the table when present or returns the schema default. A string or vector accessor returns a pointer-like view into the buffer. That is why the byte buffer must remain alive for as long as any view or generated object pointer is used.

auto item = Inventory::GetItem(bytes);

uint32_t id = item->id();
auto name = item->name()->str();
auto tags = item->tags();

for (uint32_t i = 0; i < tags->size(); i++) {
  process_tag(tags->Get(i)->str());
}

Practical Applications

Memory-Mapped Assets and Datasets

Because a FlatBuffer can be read in place, it is useful for files that are mapped into a process and queried repeatedly. A game engine can load asset metadata without constructing a large heap graph, a search service can inspect only the needed record fields, and a local inference runtime can carry model or tensor metadata alongside binary payloads with predictable access costs.

Cross-Language Message Boundaries

Generated code exists for many languages, which makes FlatBuffers a practical interchange layer between C++, Rust, Go, Java, TypeScript, Python, Swift, and other runtimes. The binary buffer stores little-endian scalars, fixed-width offsets, strings, vectors, and tables rather than language-specific object representations, so a producer in one language can hand the same bytes to a reader in another.

Partial Reads on Large Records

The format is strongest when consumers often read a subset of fields. For example, a routing service might inspect an ID, type tag, and timestamp without touching a large vector or nested payload. With a parse-into-objects format, the skipped fields may still be decoded. With FlatBuffers, those fields can remain untouched bytes until an accessor is called.

Implementation Patterns

Separate Envelope Metadata from FlatBuffer Payloads

FlatBuffers intentionally does not put a universal format identifier or version number into every buffer. Production systems often wrap FlatBuffers in an envelope that names the message type, compression, checksum, tenant, encryption state, or transport version. The envelope routes the payload to the correct generated reader; the FlatBuffer schema handles the fields inside that payload.

struct FrameHeader {
  char magic[4];        // e.g. "FBIN"
  uint16_t schema_id;   // maps to an expected root type
  uint16_t flags;       // compression, encryption, checksum
  uint32_t payload_len;
};

bool read_frame(const uint8_t *frame, size_t len) {
  auto header = parse_header(frame, len);
  auto payload = frame + sizeof(FrameHeader);

  if (header.schema_id == ITEM_SCHEMA) {
    flatbuffers::Verifier verifier(payload, header.payload_len);
    if (!Inventory::VerifyItemBuffer(verifier)) return false;
    handle_item(Inventory::GetItem(payload));
  }
  return true;
}

Verify Before Trusting Untrusted Bytes

Zero-copy access is only safe when the buffer is structurally valid. For untrusted input, run the generated verifier before reading. Verification checks that offsets land inside the buffer, required fields are present, nested objects are reachable, and strings or vectors have valid bounds. Trusted local assets may skip verification after build-time validation, but network and user-supplied payloads should not.

Design Evolution as an Append-Only Contract

Add new table fields at the end unless every field uses explicit id attributes. Do not remove fields; deprecate them and stop writing them. Avoid changing default values after data has been produced, because absent fields rely on the old default. Type changes are risky even when widths match, and union variants should be appended rather than inserted in the middle.

Performance Considerations

Common Pitfalls

Further Reading