Avro
Row-oriented binary records with the schema travelling alongside the data — built for exchange and evolution rather than for scanning one column across a billion rows.
Who needs this, what one row is, and why the obvious build breaks
Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.
When is storing whole records together the right answer, and what does carrying the schema with the data actually buy?
A consumer that needs whole records: a stream processor reading events one at a time, a sink writing them into a database, a replay job reconstructing state. None of them benefit from column pruning because they need every field of every record they touch (Stream Processing).
One record, serialised contiguously. In an Avro object container file, records are grouped into blocks with a sync marker between them; in a message on a broker, one record is one message payload with a reference to its schema.
Use JSON for events. It is human-readable, every language reads it, and no schema registry is needed. This is the right call for a first integration and for low-volume webhooks, and a large number of production event pipelines never need more.
Field names are repeated in every message. On a high-volume topic the key strings are a substantial share of the bytes, and they are the same strings every time (Payload Size: 20KB, 200KB, 5MB).
- Field names are repeated in every message. On a high-volume topic the key strings are a substantial share of the bytes, and they are the same strings every time (Payload Size: 20KB, 200KB, 5MB).
- There are no types. A consumer that expects a number gets
"41.90"from one producer and41.9from another, and both are valid JSON (CSV, JSON and Their Limits). - A producer adds a field. Nothing tells consumers, nothing validates it, and a consumer written to reject unknown fields starts failing on a change that was intended to be additive (Forward Compatibility).
- A producer renames a field. Every consumer breaks at once, and the only discovery mechanism is production (Breaking Schema Changes).
- A consumer needs to replay a year of history and finds that the meaning of
amountchanged six months ago, with nothing in the data recording which version produced which message (Semantic Changes).
What is actually happening
- Avro serialises a record as a compact binary encoding with no field names in the payload. Fields are written in schema order, so the reader needs the schema to interpret the bytes — and in exchange the bytes carry no key strings and no delimiters (Serialization: Objects to Bytes).
- In an object container file, the schema is written once in the file header as JSON, followed by blocks of records with a sync marker between blocks. The sync marker is what makes the file splittable: a reader can seek to an arbitrary offset, scan forward to the next marker, and start reading whole records from there.
- On a message broker, embedding the full schema in every message would defeat the purpose, so the standard arrangement is a schema registry: the payload carries a small schema identifier, and consumers resolve the id to a schema and cache it (Schema Registry).
- The property Avro is genuinely built around is schema resolution. A reader supplies its own expected schema, the data carries the writer's, and the library reconciles them: fields present in the writer's schema and absent from the reader's are skipped, and fields absent from the writer's but present in the reader's with a default are filled with that default (Schema Evolution).
- That reconciliation is what makes the compatibility rules concrete rather than aspirational. Adding a field with a default is backward compatible because old data reads under a new schema. Removing a field that had a default is forward compatible because new data reads under an old schema. Renaming is neither, because nothing connects the two names (Backward Compatibility).
- It is row-oriented, which is the whole trade. A record is contiguous, so reading one record is one contiguous read — and reading one field across a million records means touching all million records (Row vs Column Storage).
What is in the bytes
An Avro object container file is simpler than a Parquet file by a wide margin, and the simplicity is the point: there is a header with the schema, then blocks of records, with a sync marker between them so a reader can find a record boundary from an arbitrary offset.
Compare that with what a JSON Lines file of the same records contains. Every record repeats every field name as a string. On a topic emitting a small event millions of times, the field names are a meaningful fraction of the total bytes and they are byte-identical every time (Payload Size: 20KB, 200KB, 5MB).
The binary encoding has a consequence people meet immediately: you cannot read it. A team adopting Avro needs tooling to inspect messages from day one, and the teams that skip that step end up debugging a production incident by deploying a consumer that prints things (Debugging a Data Incident).
AVRO OBJECT CONTAINER FILE
┌─────────────────────────────────────────────────────────┐
│ HEADER │
│ magic "Obj\x01" │
│ metadata: │
│ avro.schema = { "type":"record","name":"OrderPlaced",│
│ "fields":[ ... ] } <- JSON, once │
│ avro.codec = "deflate" | "snappy" | "null" │
│ sync marker (16 random bytes) │
├─────────────────────────────────────────────────────────┤
│ BLOCK: record count, byte size, compressed records │
│ sync marker <- a reader can seek here │
├─────────────────────────────────────────────────────────┤
│ BLOCK: record count, byte size, compressed records │
│ sync marker │
└─────────────────────────────────────────────────────────┘
ONE RECORD, THREE WAYS
JSON {"order_id":1001,"country":"DE","revenue":41.90}
-> field names present, per record, every record
AVRO <varint 1001><len 2>"DE"<double 41.90>
-> values only, in schema order; the schema is elsewhere
ON KAFKA <magic 0><schema id: 4 bytes><avro-encoded body>
-> a schema IDENTIFIER travels with the data,
not the schema. The registry is now a dependency.Schema resolution, which is the actual product
Compact encoding is a nice property. Schema resolution is the reason Avro is chosen. A reader declares the schema it wants; the data carries the schema it was written with; the library reconciles them field by field, using names and defaults.
This turns "is this change safe?" from a judgement call into a mechanical question with a mechanical answer, and a registry can then enforce it in CI. That is a genuinely different situation from a JSON pipeline where safety is whatever the most fragile consumer happens to tolerate (Data Contracts).
The diff below is the everyday case and the reason defaults matter so much. Notice which of the impacts is marked silent: a consumer reading old data under a new schema gets the default, which is *correct behaviour* and also a value that was never in the source. If the default is 0 and the field is a monetary amount, a downstream sum is now confidently wrong (Nullability & Defaults).
- order_id: long
- country: string
- revenue: double
- placed_at: long (timestamp-millis)
- order_id: long
- country: string
- revenue: double
- placed_at: long (timestamp-millis)
- discount: double = 0.0
change Add discount with a default of 0.0. Backward compatible: a reader on the new schema can read data written under the old one, filling 0.0. Forward compatible: a reader on the old schema reads new data and skips the field it does not know.
| Consumer | Effect | How it shows up |
|---|---|---|
| Stream processor upgraded to the new schema | Reads new records with a real discount and old records with 0.0, exactly as designed. This is the intended behaviour. | Loudly — it raises |
| Analytics model summing `revenue - discount` | Historical periods report a discount of zero because that is the default, not because no discount was given. The trend line has a step in it that no test will flag. | Silently — no error, wrong result |
| Old consumer not yet redeployed | Skips the unknown field and continues. The intended outcome of forward compatibility. | Loudly — it raises |
| A consumer that validates strictly against a hand-copied schema | Rejects records containing an unknown field, because it never used resolution in the first place. | Loudly — it raises |
| A replay job re-reading a year of history under the new schema | Reconstructs a year of orders with discount = 0.0 throughout, producing a self-consistent and historically inaccurate dataset. | Silently — no error, wrong result |
Where Avro sits in a real pipeline
The productive framing is not "Avro or Parquet" but "where in the chain does each one belong". Row-oriented, schema-carrying records are what a transport wants; column-oriented, statistics-carrying files are what an analytical scan wants; and a pipeline that moves data from the first to the second is doing the normal thing rather than hedging.
The lineage below traces one event through that chain, with what each node holds and what it can corrupt. The interesting nodes are the registry — a metadata service that can invalidate data it does not store — and the conversion step, which is where a row format becomes a columnar one and where a schema decision becomes a table decision.
- Producer service
holds The in-memory object and the schema its code was compiled against.
could corrupt Emitting a semantically changed field under an unchanged schema — the change every compatibility check is blind to (Semantic Changes).
↑ reads from - Schema registry
holds Every registered version per subject and the compatibility policy that gates new ones.
could corrupt Accepting a breaking change because the mode was set to none; or losing history, after which landed schema ids are uninterpretable.
↑ reads from - Broker topic
holds The encoded payload plus a schema id, durably, partitioned by key.
could corrupt Ordering across partitions; retention expiring before a consumer or a replay needed it (Retention and Replay).
↑ reads from - Raw landing zone
holds Avro container files, exactly as consumed, including the schema id per record.
could corrupt Duplicates from at-least-once delivery; a gap where the sink was down (The Raw Landing Zone).
↑ reads from - Conversion to columnar
holds Parquet files, partitioned by date, typed from the resolved schema.
could corrupt Flattening a nested field wrongly; a default filling a value that was never emitted; a type promotion changing precision (Parquet).
↑ reads from - `fct_orders`
holds One row per order at a declared grain, with dimension keys.
could corrupt A deduplication that picks the wrong record among duplicates; a join that fans out (Deduplication).
Two nodes here hold no data and can still ruin it: the registry, whose policy decides what reaches consumers intact, and the conversion step, where a default silently becomes a value in a fact table.
What does the consumer of this hop actually do with a record?
when Events on a broker, records replayed whole, many independent producers evolving schemas over time.
cost A registry to operate and a hard dependency on it; binary payloads that need tooling to inspect; no column pruning if anyone queries it directly.
when Low volume, few producers, human inspection matters, or the integration is new and the schema is still moving.
cost Field names repeated per record, no types, no enforcement. Works well until volume or producer count grows (CSV, JSON and Their Limits).
when The consumer is an analytical query that reads a few columns across many rows.
cost Batch-shaped writes and a buffering delay; poor fit for record-at-a-time access (Parquet).
when Service-to-service RPC where the same definitions generate client and server code, and field-number-based evolution suits the team.
cost Evolution by field number rather than by name, no standard container file, and a different tooling ecosystem from the analytical one (gRPC: Schema, Codegen and Streams).
How to build it
Most important first.
- Use Avro (or an equivalent schema-carrying binary format) for the transport and landing of event data, and convert to a columnar format for the analytical layer. The two formats are not competing for the same job (The Raw Landing Zone).
- Give every field a default from the day the schema is created. Defaults are the mechanism that makes both directions of compatibility possible, and adding them retroactively does not help data already written (Nullability & Defaults).
- Run a schema registry with compatibility enforcement in CI, so an incompatible schema is rejected before a producer deploys rather than after (Contract Enforcement).
- Use logical types — date, timestamp-millis, decimal — rather than encoding those as strings or floats. A monetary amount as a float is a correctness problem that no format will save you from (Data Contracts).
- Keep the schema and the event's meaning versioned together, and record the schema id on every landed record so a replay can tell which version wrote each row (Replay from the Log).
- Do not reach for Avro because a diagram had it. If your events land straight into a warehouse and are never replayed, JSON with a contract check may be the whole answer (ELT: Load First, Transform Where the Data Lives).
What this actually promises
Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.
- The data is self-describing in a container file: the writer schema is in the header, so a file is interpretable with nothing external. On a broker it is *not* self-describing — it is registry-describing, and a lost registry is lost data.
- Schema resolution guarantees a deterministic reconciliation of writer and reader schemas by field name and by default, and it will refuse rather than guess when the two are irreconcilable.
- Container files are splittable at sync markers, so parallel reads are possible without a footer or an index.
- Nothing is guaranteed about column-level access. Reading one field means deserialising the record that contains it, which is the defining cost of a row format.
- Compatibility rules are enforced by the registry, not by the format. A registry configured for no compatibility checking will happily accept a breaking change (Schema Registry).
Can I trust it?
A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.
- The check that belongs here is compatibility enforcement in CI: register the candidate schema against the subject's compatibility mode and fail the build if it is rejected. It catches renames, type narrowing and removed fields without defaults, before deployment.
- Add a decode-failure counter per consumer per schema version, because a compatibility mode that only checks one direction will still let something through that a specific consumer cannot handle (The Data Quality Dashboard).
- Both miss semantics. A field that keeps its name, type and default while changing from gross to net revenue passes every compatibility check ever written (Semantic Changes).
- Row-oriented serialisation has no buffering requirement beyond a single record, so a producer can emit immediately. This is the structural reason event pipelines are row-shaped at the edge and columnar in the warehouse (Streaming Ingestion).
- Container files still block a little: records are written in blocks, so a file being appended to is readable up to its last complete block.
- The registry is on the write path for a producer that has not cached its schema id. A registry outage is therefore a producer availability concern, which is worth knowing before it happens (Schema Registry).
- Adding a field with a default is safe in both directions and is the change that should account for most of your schema history (Backward Compatibility).
- Removing a field that has a default is safe for readers on the old schema, because resolution fills the default. Removing one without a default breaks them (Forward Compatibility).
- Renaming is a breaking change dressed as a cosmetic one. Aliases exist in Avro for exactly this case and they must be added at the time of the rename, not afterwards (Breaking Schema Changes).
- Type changes follow promotion rules —
inttolongandfloattodoubleare permitted, the reverse is not — which is narrower than most teams assume and worth reading rather than guessing (Enum Evolution: The New Value That Broke Old Clients).
- Replay is Avro's natural strength: records are whole and self-contained, and a container file or a log segment can be re-read from a position with no reconstruction (Replay from the Log).
- Replaying old data under a new reader schema is exactly what schema resolution is for, and it works — provided the old writer schemas are still in the registry. Registry retention is therefore a recovery-window decision (Retention and Replay).
- A corrupt block in a container file costs that block, not the file, because the sync markers let a reader resynchronise. That is a meaningfully better failure mode than a lost Parquet footer.
What can go wrong
- A registry outage blocking producers that have not cached their schema id, turning a metadata service into a availability dependency of the write path.
- Compatibility mode set to none — often during an incident, often never set back — after which the registry records history and enforces nothing.
- A schema id landing in storage with no corresponding registry entry, because the registry was reset or migrated. The bytes are then uninterpretable.
- A consumer written against a hand-copied schema rather than a resolved one, which drifts from the registry and fails on the first change.
- Avro chosen for the analytical layer, where every query deserialises whole records to read two fields (Parquet vs Avro).
- "Avro is the streaming format and Parquet is the batch format." Closer to right than most slogans and still wrong. The distinction is whole-record access versus column scans; a batch job that reads every field of every row is an Avro-shaped workload (Parquet vs Avro).
- "The schema travels with the data." In a container file, yes. On a broker, a schema *identifier* travels with the data and the schema lives in a registry — a materially different operational commitment.
- "Avro gives us schema evolution." It gives resolution rules and a place to enforce them. Whether your changes are compatible is a decision your team makes and a registry checks, not a property you receive.
- "Avro is compressed, so it is fine for analytics." Compression is orthogonal. The cost of analytics over a row format is reading fields you did not ask for, and no codec addresses that (Why Analytical Data Compresses).
- A schema registry is a catalogue of every field your organisation emits, which makes it one of the better places to attach classification: marking a field as personal data at the schema level puts the label where producers and consumers both see it (Data Classification).
- Records landed for replay retain personal data for the length of the retention window, and a deletion request against the source does not touch them. Retention on the log and on the raw landing zone is therefore a privacy decision, not only a recovery one (Data Retention).
Operating it
- Schema versions in use per topic and per consumer group — the clearest picture of who is behind and what a breaking change would actually break (Impact Analysis).
- Deserialisation error rate per consumer, labelled by writer schema version.
- Registry request rate and cache hit rate from producers, so a registry becoming a hot path is visible before it becomes an outage.
- Schema id distribution in the landed raw data, which is what lets a replay reason about which version wrote which period (The Raw Landing Zone).
- At 10x events the format is unchanged and the registry cache absorbs it; producers resolve a schema once and reuse it.
- At 100x, the difference between row and columnar in the analytical layer becomes decisive — a query over a year of Avro reads everything, and the conversion step to columnar stops being optional (ETL: Transform Before the Data Lands).
- At 100x *schema versions* — many producers evolving independently — the registry's compatibility policy becomes the main governance surface, and per-subject configuration matters more than any format detail (Data Contracts).
- Bytes on the wire are far below a text encoding of the same records, because field names appear once in a schema instead of once per record (Payload Size: 20KB, 200KB, 5MB).
- Analytical scan cost over Avro is high by construction: no column pruning, no per-chunk statistics, so a query reads every field of every record in range.
- Registry operation is a small fixed cost and a real operational commitment — it is a service on the critical path with its own availability story.
- CPU on both sides is modest; the encoding is simple and there is no parsing in the text sense (What Serialization Costs).
- Compact bytes and clean evolution are bought with an external dependency: on a broker, the data is not interpretable without the registry. That is a real coupling and it is frequently underestimated.
- Row orientation makes whole-record access cheap and column access expensive. There is no configuration that changes this; it is what the layout is.
- Binary means not human-readable. Debugging requires tooling, and teams that skip that tooling debug by guessing (CSV, JSON and Their Limits).
Where this applies
Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.
- FORMAT-SPECIFICContainer-file self-description, sync-marker splittability and writer/reader schema resolution are Avro's specific design. Protobuf achieves evolution through field numbers rather than names and has no equivalent container file; JSON has neither mechanism.
- BROKER-SPECIFICThe schema-id-plus-registry arrangement is a convention of the Kafka ecosystem rather than part of Avro. On other transports the schema may be sent inline, negotiated once per connection, or shipped out of band, which changes the failure modes entirely.
- TOOL-SPECIFICCompatibility modes — backward, forward, full, transitive variants, none — are registry features and their exact semantics differ between registry implementations. The direction each mode protects is the part worth knowing; the naming is not portable.
Where the depth lives
This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.
- — Distributed Systems owns the delivery and ordering semantics of the broker this format usually rides on — at-least-once, per-partition ordering, and what a consumer group rebalance does to both.
- — DevOps / Production Engineering owns the CI step that registers a schema and fails the build on an incompatible change; schema compatibility is a delivery gate before it is a data concern.