Good practice
Advice for designing schemas and using them well. None of it is required by the format. Each tip gives its reason, so you can tell when it does not apply.
Designing schemas
Make a field required only if it can never go away
A required field is a promise to every reader, including ones already deployed. Once it
exists, no writer can stop sending it without breaking those readers: the compiler stops
you with the required-reserved or required-relaxed warning until you confirm with
#[allow_breaking_readers].
Use required for what defines the record: an id, a timestamp, an amount. Make everything
else optional (?) or give it a default.
table Order {
id @0: common.Uuid; // the record is meaningless without it
total @1: common.Money;
coupon @2: string?; // may be dropped some day
priority @3: u8 = 1; // absent reads as 1
}
Pick a default only when you will never change it
Changing a default is refused (default-changed). Old messages that left the field out
would suddenly read a different value. If the "normal" value may change later, make the
field optional and apply the fallback in your code.
Never delete a field: reserve it
When a field is no longer used, replace it with @N: reserved;. Its ID and its bytes in
the layout stay taken, so a future field can never be misread as the old one. Renaming a
field is free: the name is not on the wire.
Make incompatible changes as a new type
If a change cannot be made compatibly, declare a new table (OrderV2) next to the old one.
It gets its own schema id, the two can coexist, and readers choose which one to accept. Do
not put versions in namespaces.
Use enums for closed sets of values
A field like "status": "Active" stored as a string costs 4 bytes of pointer, 4 of length
and the text. As an enum it costs 1 byte and a presence bit, and a typo cannot get in.
enum Status : u8 { Draft = 0, Active = 1, Archived = 2 }
Readers must handle values they do not know: a newer writer may add a variant, and the
generated code reports it as Unknown(n) instead of failing.
Use structs only for values that will never change
A struct is stored inline, with no pointer and no presence bits per member, which makes it
the most compact way to group fields. In exchange it is frozen: any change is refused
(struct-changed). Good structs: coordinates, a UUID, an amount with its currency. If you
can imagine adding a member, use a table.
Choose integer sizes for the range, not for the typical value
Integers are stored at their full width, with no variable-length encoding, so a u64
always takes 8 bytes. Use u8/u16 when the range is truly bounded (a percentage, a port).
Use u64 for identifiers and counters that grow. Changing a type later is refused
(type-changed), so leave room.
Store money as integers
Use an integer amount of minor units with a currency code, never f32/f64, which cannot
represent most decimal fractions exactly.
struct Money { cents: i64, currency: u16 } // currency: ISO 4217 numeric code
Share common types through imports
Put types used across schemas (Uuid, Money, Address) in one file and
import it. Copies in each file drift apart, and nothing
checks that they stay identical.
Document fields with ///
Doc comments are carried into the generated code. Units, valid ranges and the meaning of an absent field are worth writing down: the schema outlives the people who wrote it.
Lists and collections
Wrap a list that travels as a unit in a table
An API response, a configuration or a snapshot should be one message that holds a list.
The whole list then costs 12 bytes of structure plus about 8 bytes per string element,
with a single envelope if you use one. A top-level JSON array such as
[{"name": "product_a"}, …] with 100 elements takes 1712 bytes this way, against 2101 as
compact JSON.
Store growing collections as one message per record
For logs, events and exports, write one enveloped message per record. Records are appended
without rewriting the file, each one is checked on its own, and
gravenc scan tells a crash-truncated tail from real corruption. The
price is 24 bytes of envelope per record: for 100 tiny records, 4300 bytes against 1712 for
one wrapped list. Do not use this layout for small lists that are always read whole.
Prefer lists of structs for small fixed records
A [Point] stores its elements back to back with no pointers. A list of tables costs a
pointer per string or nested value inside each element, but its elements can evolve. Use
structs for numeric samples, coordinates and similar data that will never gain a field.
Give the length when you know it
Writing a list whose length you pass up front is faster than writing it as a stream (about 1.4–1.7× in the benchmarks). Stream only when the length is truly unknown, for example while reading from an iterator.
Reading and writing
Verify untrusted bytes once, then read freely
Bytes from the network, from disk or from another process are untrusted. Open them with
from_bytes (enveloped) or verified (bare). This walks the message once under fixed
depth and work limits, and gives you a verified view whose reads do not check again.
Plain read is also safe on any input, but it checks on every access, so prefer it only
when you read a field or two.
Read lazily
A message is not decoded up front: reading one field costs about a nanosecond, whatever the size of the message. Pass the view around and read fields where you need them instead of copying everything into your own structs.
Use the envelope at boundaries
Files, queues and network connections between services should carry enveloped messages: the schema id stops a message being read as the wrong type, and the CRC32C separates a cut write from corruption. Inside one process, or when a transport already frames and checks messages and the type is known, a bare message saves 24 bytes and the CRC (about 10 ns on a small message, about 1.6 µs on 64 KiB).
Reuse the builder
Keep one Builder per thread and reuse it between messages. After warm-up, writing a
message then allocates nothing.
Process
Commit the lockfiles and check them in CI
The lockfile is the memory of every layout you have shipped. Commit it next to the
schema, review its diff like code, and run gravenc check --locked in CI so that no build
uses a schema the lock has not accepted.
Change the lock deliberately
Only gravenc update writes the lock, and build.rs never does. When update refuses a
change, read the rule it names before reaching for #[allow_breaking_readers]. The
attribute exists for breaks you have planned, not to silence the compiler.