Part II FlatBuffers

Book contents · Previous: Part I Bytes and Ownership · Next: Part III FlexBuffers

Chapters 4–9 of FlatBuffers & FlexBuffers: From Bytes to Mutable Memory. Tables, vtables, structs, vectors, builders, schema evolution, verification, and constrained mutation.

This is the supplied first edition, pinned to its September 2026 source baseline. The laboratories and LiveFlex capstone are educational implementations and design proposals. The book overview explains the evidence limits; Reaktor’s performance review documents the current framework.

In this part

Chapter 4: FlatBuffers: the root, the table, and the vtable

The first reference points forward

In the ordinary 32-bit FlatBuffers layout, the buffer begins with an unsigned 32-bit offset to the root object. If the first four bytes encode 12, the root begins at position 12. The offset is measured from the position of the offset itself. For the first word that position happens to be zero; elsewhere it is not. [2,4]

For an ordinary offset slot at position p, the rule is:

target = p + U32(p)

The root word does not generally identify the root's schema. The reader is expected to know which root type to use. An optional four-byte file identifier can help recognize an application format, but it is not a complete schema or a security guarantee. A size-prefixed buffer adds framing before the ordinary buffer; the consumer must know which framing convention it receives. [6,8]

A table begins with a different kind of offset

A table's first word is a signed displacement to a vtable. Its rule is subtraction:

vtable = table_position - S32(table_position)

This is deliberately not the rule for an ordinary forward offset. The signedness matters because a vtable may be located before or after a table, particularly when layouts are shared. A positive displacement points backward. A negative displacement makes the subtraction move forward. [2,3]

A FlatBuffers vtable is a small metadata array, not a C++ virtual-function table. Its first 16-bit value is the vtable's byte length. Its second is the table's inline byte size. Subsequent 16-bit values are field displacements measured from the beginning of the table. A zero displacement means the field is absent. The table's inline byte size does not include all the out-of-line strings, vectors, or child tables reachable from it. [2,4]

Finding a field slowly

Suppose a schema assigns field ID 2 to a signed integer. Its vtable entry is at byte offset 4 + 2 * 2 = 8 within the vtable. A generated accessor performs the conceptual steps below:

T = table position
V = T - signed32(T)
L = unsigned16(V)
E = 4 + 2 * field_id

if E is outside the vtable:
    return schema default
D = unsigned16(V + E)
if D == 0:
    return schema default
return signed32(T + D)

The real implementation must validate the vtable and field ranges before trusting them. Once verification has established the necessary invariants, a fast accessor can perform just the small number of loads needed for the requested field. The pinned Table::GetOptionalFieldOffset explicitly handles a field beyond the stored vtable as absent. [2]

This is how a newer reader can ask an older buffer for a field that was not present when the buffer was written. The absence behavior is not a vague compatibility feature; it comes directly from the vtable-length check and the schema's default.

Why not store fields in schema order?

A fixed field order would make some offsets predictable, but it would make omission and evolution less flexible. FlatBuffers lets the writer choose physical field placement while the vtable tells the reader where each present field ended up. Fields with different alignment requirements can be arranged efficiently. Fields equal to their default can be omitted. Tables with identical layouts can share a vtable. [3,4]

The cost is an extra dependency when locating a field: read the table's vtable displacement, then the relevant field displacement, then the field itself. If you repeatedly access many fields in a hot loop, the compiler and cache may make this cheap, but the logical dependency remains. A struct trades away this flexibility to remove the per-object vtable.

A vtable also makes bytewise equality a poor default definition of value equality. Two valid writers may choose different field order, omit different explicitly written defaults, use different sharing, or place padding differently while representing equivalent values. Canonical bytes require an additional deterministic encoding policy.

Why 16-bit field displacements?

A table's inline body is normally small: scalar fields and references, not the complete nested document. Sixteen-bit vtable entries keep its metadata compact. Large child vectors remain out of line and are reached through their own offsets. The pinned builder checks that the inline table body fits the representable vtable displacement range. [3] This is a useful example of a local limit that should not be confused with a global one. The limit on an inline table body, the width of an ordinary reference, and a particular implementation's maximum total buffer size are three separate constraints.

Checkpoint

Exercise 5. A table starts at position 64 and its first signed word is -16. Where is its vtable? If field ID 3 has displacement 12, where is that field? Which of those two calculations uses the table position as its base?

Chapter 5: A complete 32-byte FlatBuffer

The schema and the values

Consider this small schema:

table Sample {
  count:int = 0;
  name:string;
}
root_type Sample;

The logical value is count = 13, name = "Ada". The companion's flat_table_lab.hpp constructs a valid planned layout for this one schema. It is not the official backward-growing builder, and it is not a schema compiler. Its purpose is to expose every byte in a complete table example. The governing layout rules come from the FlatBuffers table and offset representation. [2,4]

The complete buffer is:

00: 0c 00 00 00 08 00 0c 00
08: 04 00 08 00 08 00 00 00
10: 0d 00 00 00 04 00 00 00
18: 03 00 00 00 41 64 61 00

The left column is a hexadecimal byte position. Thus 10 means decimal 16, not decimal 10. Every other number in the walkthrough below is decimal unless explicitly prefixed with 0x.

PositionSizeMeaningDecoded value
04Root offset, measured from position 012
42Vtable byte length8
62Table inline byte size12
82Field 0 displacement4
102Field 1 displacement8
124Signed vtable displacement8
164count13
204Forward reference to the string4
244String byte length3
PositionSizeMeaningDecoded value
284A, d, a, terminating zero—

Find the count

Read the unsigned word at position 0: 12. The root table is therefore at position 12. Read the signed word there: 8. The vtable is at 12 - 8 = 4.

Field 0 uses the vtable entry at 4 + 4 + 2 * 0 = 8. Its value is 4. The field itself is at 12 + 4 = 16. Reading a signed 32-bit integer at position 16 gives 13.

Notice the two different bases. The vtable entry's location is measured from the vtable. The entry's contents are measured from the table. Confusing these bases is a classic way to write an accessor that passes one carefully aligned example and fails on ordinary data.

Find the name

Field 1 uses the entry at 4 + 4 + 2 * 1 = 10. Its displacement is 8. The field's offset slot is therefore at 12 + 8 = 20.

The word at position 20 is 4. This is an ordinary forward reference, so the string object begins at 20 + 4 = 24. The length at position 24 is 3. The three content bytes begin at position 28, followed by a zero terminator.

A FlatBuffers string reference points to its length field. Later, a FlexBuffers string reference will point to the content bytes, with its length immediately before them. Remember that contrast now; it will prevent a deceptively plausible four-byte error later. [1,4]

Make the field absent without moving anything

In the laboratory test, replacing the field-0 vtable entry with zero makes count() return its schema default, 0. The old bytes holding 13 may still be present in the allocation. They are no longer the value of the field because the metadata no longer identifies them as such.

This illustrates an important separation between physical bytes and reachable semantic data. A buffer can contain padding or unreachable bytes. A decoder follows the format's references, not every byte that happens to resemble a value. Conversely, leaving a secret in unreachable storage is not the same as erasing it before publishing the allocation.

Changing a vtable can affect multiple tables if that vtable is shared. The demonstration modifies a purpose-built unshared example. It is not a recommendation to patch arbitrary production vtables in place.

A negative displacement is valid

The test suite also places the table at position 4 and its vtable at position 16. The table's signed displacement is then -12. Its bytes are f4 ff ff ff. The reader computes 4 - (-12) = 16 and finds the vtable correctly.

A reader that treats that word as an unsigned backward distance will fail. A verifier that assumes every vtable must precede its table will reject a representation the signed-displacement design permits. The pinned reader uses a signed scalar for exactly this calculation. [2]

From one table to a real reader

The laboratory accessor checks the ranges needed for this schema and exposes only count() and name(). A general generated reader additionally needs accessors for all declared fields and compound types. A schema-aware verifier must validate those types, required fields, nested objects, and resource limits.

Do not expand this example by simply casting the whole buffer to a C++ struct. Expand it by preserving the explicit navigation rules and generating the schema-specific parts. The smallness of the example is meant to isolate the mechanism, not to bypass it.

Checkpoint

Exercise 6. In the example, what must change to replace "Ada" with the same-length string "Lin"? What additional problems appear when replacing it with "A longer name"? Consider both storage space and references.

Chapter 6: Structs, vectors, strings, unions, and framing

Structs exchange flexibility for predictable placement

A FlatBuffers struct is an inline, fixed-layout aggregate. Its fields are present according to its schema-defined layout, with alignment and padding determined by the format's struct rules. It does not carry a per-instance vtable. This makes it suitable for small, stable records such as coordinates or numeric samples. A table can contain a struct inline; a vector of structs stores their fixed-size records consecutively. [4,6]

For a conceptual struct containing a 32-bit float followed by a 16-bit integer, the fields begin at offsets 0 and 4. With four-byte aggregate alignment, the total stride is rounded to 8, leaving tail padding. That tail padding matters in a vector: the next record begins at offset 8, not 6. The actual generated definition is the authority for a particular schema.

Adding a field changes the stride and often the alignment. That is why a struct is not the same evolution mechanism as a table. A reader that assumes the old stride cannot simply discover the new field from a vtable, because there is no vtable to consult.

Vectors are contiguous, but their elements may be references

An ordinary FlatBuffers vector begins with a count and then its elements. Numeric elements are stored consecutively at their schema-defined width. A vector of structs has a fixed struct stride. A vector of tables or strings contains offsets, one per element, with each offset measured from its own slot. [4,6]

Suppose two string-reference slots begin at positions 40 and 44. If their words are 20 and 24, their targets are 60 and 68 respectively. The second target is not 40 + 24. Every relative reference has its own origin.

This arrangement makes the vector itself compact and indexable while allowing variable-size children. It also distinguishes an array scan from a pointer chase. Scanning a million inline floats can be a regular memory stream. Scanning a million references to scattered strings includes the cost of resolving and visiting those strings.

FlatBuffers fixed-length arrays are associated with struct layouts, rather than being a drop-in evolvable table-field mechanism. Multidimensional or nested collection needs are commonly represented through an explicit table or struct layer according to the schema's supported constructs. Check the chosen language generator's support before relying on less common schema features. [6,8]

Strings have both a length and a terminator

The length permits bounded access and allows embedded zero bytes to be distinguished from the terminator. The terminator supports C-string-style interoperability where appropriate. A consumer that calls strlen discards the advantage of the stored length and will stop at an embedded zero. A consumer that constructs an owning string may allocate even though the wire access itself was direct. [3,4]

The byte count is not a count of Unicode characters or grapheme clusters. The schema's string convention and an application's text-validation policy should be treated explicitly. A binary payload that is not intended to be text is better modeled as a byte vector than silently treated as a user-visible string.

A union is a discriminator plus a payload

A union field needs information about which alternative is present. In the normal generated model, a discriminator accompanies the payload reference. The payload is interpreted according to that discriminator. A discriminator for one table paired with a reference to another table is not made correct merely because both references point inside the buffer. A verifier must validate the selected alternative. [6,8]

This is a reminder that bounds safety is not complete type safety. A range can exist and still be interpreted incorrectly. Schema evolution involving unions requires careful handling of new alternatives and the behavior of older readers. Union-vector support and more advanced alternatives should be checked for every target language, rather than inferred from one C++ API.

Size prefixes and identifiers solve different problems

A file identifier helps identify an application format. A size prefix helps determine a message's extent in a stream or container. Neither replaces structural verification. A four-byte identifier is easy for an attacker to copy. A declared length can be wrong. Framing code must ensure the complete declared range is available before constructing a format view. [6,8]

This distinction matters when comparing FlatBuffers and FlexBuffers roots. Ordinary FlatBuffers has a front root offset. FlexBuffers discovers its root from the end of a known buffer extent. A FlexBuffers receiver therefore needs an external boundary: a transport frame, a file size, or a containing field. Its trailer is not a universal stream delimiter.

Nested buffers are opaque until validated

A byte vector can contain another serialized format. FlatBuffers schemas and tooling support conventions for nested FlatBuffers and FlexBuffers payloads. The outer verifier establishes that the byte vector is in range; it does not automatically establish every semantic property your application needs from the inner format. Treat the inner buffer's parser and lifetime as a second boundary. [6,8] Embedding is often cleaner than forcing one schema to encode every dynamic value. A mostly stable record can use FlatBuffers fields for known data and a FlexBuffers blob for a genuinely dynamic extension. The cost is an additional validation and access path. That cost is explicit and often preferable to turning the whole record into a generic map.

Checkpoint

Exercise 7. Design a schema for a stable header, a million 3D points, and an optional dynamic metadata map. Explain which parts should be structs, tables, vectors, and an embedded FlexBuffer, and identify every validation boundary.

Chapter 7: Reconstructing the FlatBuffers builder

Why build backward?

In the usual FlatBuffers builder, children are constructed before parents, while the allocation grows toward lower addresses. When the final buffer is viewed from beginning to end, parents can point forward to children that were already built. This avoids leaving ordinary child references unresolved. [3,4]

The builder does not need to know the final base address to retain a child location. It records a construction offset measured from the end of the active region. If the backing allocation grows and existing bytes move together, those end-relative positions remain meaningful. Raw pointers into the old allocation would not.

This is a construction technique, not the definition of every valid FlatBuffer. Chapter 5 used a forward planned layout because it knew all positions in advance. Both approaches can produce the same kind of wire references.

Derive the offset conversion

Let S be the current used size after alignment. Let D be the child's construction offset from the end. Prepending an ordinary four-byte offset slot changes that position. After that prepend, the slot is S + 4 bytes from the end. The child is D bytes from the end. The forward distance from the new slot to the child is therefore:

relative_distance = S + 4 - D

For example, if the current used size is 20 and the child is 12 bytes from the end, the new slot stores 20 + 4 - 12 = 12. Its target is twelve bytes forward from its final position. The pinned builder's ReferTo implements this relationship, with separate handling for its 32-bit and 64-bit regions. [3]

Alignment must be completed before calculating S. Otherwise the formula uses a position that is no longer where the slot will be written. This is the same problem as FlexBuffers width selection: layout decisions and offset arithmetic cannot be separated casually.

Building a table

A table construction scope records the positions of fields that are actually emitted. A scalar equal to its default may be omitted unless the builder is configured to force defaults. An offset field whose child is absent can be omitted. Inline structs are copied into the table body with their required alignment. [3] When the table ends, the builder prepends a placeholder signed vtable displacement. That position becomes the beginning of the table. It can now compute each recorded field's displacement from the table beginning. It emits the vtable's byte length, the inline table byte size, and the field displacement entries, then fills in the table's signed displacement to the selected vtable.

Conceptually:

begin table
    remember start size
    write selected fields; record (field_id, construction_position)
end table
    prepend signed displacement placeholder
    compute table start and inline size
    build vtable entries from recorded positions
    reuse an identical vtable or emit a new one
    patch the signed displacement

The builder's temporary field-location data is not serialized as application data. It is construction state. Confusing scratch metadata with wire metadata is another common mistake when reading a compact builder implementation.

Why constructing children inside the parent scope is discouraged

A child string or vector is out-of-line relative to its parent table. Constructing such objects in the middle of the parent's inline construction can inflate the apparent table body and complicate field displacements and vtable reuse. The pinned builder's nesting checks explicitly guide callers to construct children before opening the parent table scope. [3]

Generated convenience functions make this discipline less intrusive. They accept already-created child offsets or create appropriately ordered code around the builder. A handwritten replacement should enforce a clear construction state machine rather than relying on users to infer when nested operations happen to work.

Vtable deduplication

Two tables can have the same presence pattern, inline size, and field displacements. They can share one vtable even when their actual field values differ. The pinned implementation compares candidate vtable bytes against previously remembered layouts and reuses an equal one. [3]

The shared object is the layout description, not the table's value data. This is a small form of structure sharing. It saves bytes at the cost of builder bookkeeping and comparison. It also explains why signed vtable displacements are necessary: a reused vtable's position need not be on the same side of every table.

The comparison strategy is an implementation choice. A different builder could index vtables by a hash, use a size bucket first, or decline to deduplicate for workloads where the extra work is not worthwhile. It must still emit valid displacement arithmetic.

Finishing and ownership

Finishing the buffer writes the root information and any requested identifier or size framing, with the alignment needed by the contained objects. The final byte range is then exposed to the caller. The builder's storage ownership must remain clear: a borrowed pointer into the builder is invalid if the builder is destroyed or reuses its allocation. [3,8]

The natural optimization is to transfer ownership of the finished allocation rather than copy it unnecessarily. That is an API and allocator design issue, not a change to the wire format. Be careful not to market a no-copy ownership transfer as evidence that earlier construction allocated nothing.

Checkpoint

Exercise 8. Derive the ReferTo formula for an eight-byte offset slot. Then explain why a construction offset must not be confused with the offset eventually stored in the wire buffer.

Chapter 8: Schema evolution, generated code, and reflection

Evolution works because absence is meaningful

A table field has an identity that survives changes in physical layout. A reader looks up that field's vtable entry; absent entries yield the schema's defined absence or default behavior. This permits a newer table schema to add fields while retaining access to older buffers. The supported evolution rules still need to be followed. [2,7]

Appending fields is the straightforward pattern. Explicit field IDs can make identity more visible, but they introduce their own consistency rules and should be checked by the schema compiler. Reusing a field's identity for a different meaning defeats the mechanism. A field that once meant meters does not safely become milliseconds merely because both are stored as integers.

Deprecation is different from deletion and reuse. Keeping the historical identity reserved prevents old data from being interpreted as a new field. The wire format solves location compatibility; the schema author remains responsible for semantic compatibility.

Defaults are part of the stored meaning

A writer can omit a scalar whose value equals its schema default. An older buffer containing no physical slot for that field is therefore interpreted through the reader's default. Changing the default can change the meaning of existing bytes without changing those bytes. This is why defaults belong in evolution review, not just API ergonomics. [3,7]

Optional scalar support distinguishes absence from an ordinary numeric default where the schema and target language support it. Applications should decide whether they need “not provided,” “provided as zero,” and “provided as a nonzero value” to be distinct. A serialization format cannot recover a distinction that the writer intentionally erased.

Required fields make another trade-off. They can express an invariant, but introducing a newly required field into a population of older buffers can cause rejection. Compatibility plans must include the verifier, not just accessor behavior.

What generated code actually supplies

A schema compiler parses declarations, resolves types and identifiers, validates rules, assigns layout information, and emits language-specific accessors and builder helpers. The runtime supplies common primitives such as scalar loads, vector views, table field lookup, and verification support. [8,9]

The generated accessor for a field often contains very little logic because the schema has already supplied its type, default, and vtable entry. Reimplementing “FlatBuffers in C++” can therefore mean several different projects: a runtime for generated code, a schema compiler, a compatible generator for a subset, or a handwritten reader for one schema. State which project you are undertaking.

The companion's table laboratory is the last of these. The FlexBuffers companion is broader because FlexBuffers carries its supported structural types in the buffer and does not require an application schema compiler.

Reflection moves schema information to runtime

A schema-less-looking FlatBuffers tool still needs schema information from somewhere. Reflection can supply a binary schema describing tables, fields, and types. Runtime code can then navigate a table using that schema rather than a statically generated accessor. The C++ documentation describes reflection and object-oriented access paths with different trade-offs from the direct generated API. [8]

Reflection is not evidence that an arbitrary FlatBuffer is self-describing by itself. A raw table's vtable gives locations, not enough information to infer whether a four-byte field is an integer, a float, or an offset. FlexBuffers makes a different trade-off by including its type information with each relevant value or container.

An object API can materialize a conventional owning object graph, making complex edits more convenient. Rebuilding a serialized buffer afterward is often a sensible engineering choice. It gives up some direct-access advantages during editing, but it avoids pretending that a compact immutable layout is a general-purpose mutable heap.

Test evolution in both directions

A useful compatibility matrix includes old writer to new reader and new writer to old reader. It should test missing fields, changed presence patterns, default values, deprecated fields, union alternatives, and verification outcomes. Parsing successfully is not the same as preserving meaning.

Keep byte fixtures produced by historical versions. A schema change that passes tests generated only from the new schema may still reinterpret old data. Test the actual historical bytes, especially when defaults, field IDs, or requiredness are involved.

For reproducible artifacts, pin the schema, generator version, runtime version, and relevant builder settings. Two valid implementations need not produce byte-identical output. Use semantic comparisons for interoperability and byte comparisons only where the encoding policy is deliberately fixed.

Checkpoint

Exercise 9. A field retries:int = 3 was usually omitted by old writers. A new schema changes the default to 5. What do new readers see in old buffers? Propose an evolution strategy that preserves historical meaning.

Chapter 9: FlatBuffers verification and the boundary of mutation

Verify before trusting navigation

A generated accessor can be extremely small because it assumes the buffer already satisfies the conditions required for its loads. A verifier checks those conditions using the schema: root range, vtable structure, field extents and alignments, referenced objects, vector counts, string boundaries, union alternatives, and resource limits. The exact checks and options depend on the runtime version. [2,8]

The critical arithmetic is familiar. A vector count must not overflow when multiplied by element size. A table field must fit within the permitted buffer region. A signed vtable displacement must be resolved without forming an invalid pointer first. A recursive structure needs depth and work limits even when individual offsets are in bounds.

Verification should be connected to ownership. If another thread or a caller can mutate the bytes after verification, the proof no longer necessarily applies to later access. A verified immutable snapshot is a much simpler contract than a writable shared allocation whose metadata can change at any time.

Mutation of an existing scalar

Suppose a present field contains a 32-bit integer. Replacing its value with another 32-bit integer does not change any field location or child extent. The existing slot is sufficient. A generated mutable accessor or an appropriate runtime operation can perform that update, provided the underlying allocation is actually writable and no conflicting access occurs. [2,8]

An absent scalar is different. There may be no slot to write. The pinned Table::SetField overload that also receives the default can report success for an absent field when the requested value equals that default; it has not allocated a new field. The overload without that default check fails when the slot is absent. Read return values according to their contract rather than assuming “true” always means bytes changed. [2]

Length-preserving edits are a narrow case

A present string's content can sometimes be replaced within existing storage. A vector element can be updated when its representation does not require a different layout. But inserting fields, increasing vector length, or growing a child object generally needs storage and metadata changes beyond a simple scalar write.

Patching an offset is not by itself a general allocator. The new destination must exist, be correctly aligned and encoded, lie in the reference's permitted direction and range, and remain alive as part of the buffer. Other aliases and verifiers must still observe a coherent object graph. An official mutable accessor should not be mistaken for an arbitrary editing engine.

The most robust editing strategy for infrequent structural changes is often materialize, edit, rebuild. The capstone later develops a native representation for workloads where that repeated rebuilding becomes expensive.

The modern 64-bit nuance

The ordinary layout taught in Chapters 4–7 uses 32-bit offsets and counts and 16-bit vtable entries. The pinned C++ source also contains 64-bit offset accessors and builder machinery that distinguishes 32-bit and 64-bit regions. It would be incorrect to claim that all modern FlatBuffers facilities are permanently restricted to a single uniform 32-bit reference representation. [2,3]

It would be equally incorrect to infer universal large-buffer support across every language and tool from those C++ symbols. A production design must pin the exact schema features, builder type, verifier, size limits, and consumer implementations it intends to use. Practical implementation limits may be stricter than the unsigned width of an individual field.

The companion deliberately implements the classic small-buffer case only. Its examples are intended to teach the mechanisms, not to silently advertise support for the 64-bit extensions.

When FlatBuffers is the right execution representation

Known schemas, mostly immutable records, sparse field access, memory-mapped data, and compact transport are strong reasons to preserve the serialized representation. Repeated mutation, dynamic field insertion, unknown keys, and heavy aggregate computation can favor a native layer or a different layout.

The correct decision can differ within one application. A received message can remain a verified FlatBuffer. A mutable working set can use native arrays and maps. A completed result can be serialized again. Trying to force every phase into one representation is an architectural choice with a measurable cost, not an automatic simplification.

Checkpoint

Exercise 10. A table's integer field is absent because its value equaled the default. Why is reserving extra bytes at the end of the whole buffer insufficient to make a normal scalar setter create that field? Identify the metadata that would also have to change.


Book contents · Previous: Part I Bytes and Ownership · Next: Part III FlexBuffers