What is Apache Avro?
Apache Avro is a data serialization system developed within the Hadoop ecosystem. It uses a compact binary encoding where field names are not stored in the binary data — the schema carries all structural information, making payloads significantly smaller than JSON or XML.
The avro-c library is the reference C implementation. It supports both binary and JSON encoding from the same schema, dynamic typing (no code generation required), and the full Avro type system including records, enums, arrays, maps, unions, and fixed-width fields.
The defining feature of Avro is schema evolution: producers and consumers can use different versions of a schema as long as evolution rules are respected. This makes Avro the dominant serialization format in the Kafka ecosystem, where producers and consumers are deployed independently.
Schema Definition
Avro schemas are written in JSON. Each schema defines a type, and record schemas list their fields with names, types, and optional defaults.
// SensorReading.avsc
{
"type": "record",
"name": "SensorReading",
"fields": [
{"name": "sensor_id", "type": "string"},
{"name": "value", "type": "double"},
{"name": "timestamp", "type": "long"},
{"name": "unit", "type": ["null", "string"], "default": null}
]
}
The unit field uses a union type ["null", "string"] with a default of null, making it optional. This is the standard Avro pattern for nullable fields.
Schema Evolution Rules
Avro resolves schemas at read time by matching the writer schema (used to serialize) against the reader schema (used to deserialize). The following rules govern compatibility:
| Change | Notes | Compatible? |
| Add field with default value |
Old readers ignore new field; new readers use the default when reading old data |
Backward ✅ |
| Remove field with default value |
New readers ignore removed field; old readers use the field default |
Forward ✅ |
| Add field without default |
Old readers cannot supply a value for the missing field — deserialization fails |
NOT compatible ❌ |
| Rename a field |
Add the old name to the "aliases" array in the field definition; reader resolves by alias |
With aliases ✅ |
Best practice: always provide a "default" for every field you may want to add or remove later. Use the ["null", "type"] union pattern for optional fields.
Avro vs Protobuf vs Parquet
Avro
- JSON schema, no compile step
- Schema evolution built-in
- Row-oriented binary
- Kafka & Hadoop native
- Dynamic typing
Protobuf
.proto schema file
- Code generation required
- Faster encode/decode
- Wider language support
- Field numbers, not names
Parquet
- Columnar storage format
- Analytics & OLAP workloads
- Not suitable for streaming
- Excellent compression
- Arrow / Spark / DuckDB
Kafka Schema Registry Integration
Confluent Schema Registry stores Avro schemas centrally. Producers serialize a magic byte + 4-byte schema ID prefix before the Avro binary payload. Consumers read the ID from the first five bytes, fetch the schema from the registry, and deserialize the remainder. This is the standard pattern in production Kafka data pipelines.
The avro-c library handles the Avro binary encoding. Combine it with librdkafka for the Kafka transport layer and implement the 5-byte schema ID prefix in your producer/consumer glue code.
Types
Handle Types
| Type | Description |
| TAvroSchema | Compiled Avro schema handle. Created by ParseSchema or LoadSchema; freed with FreeSchema. |
| TAvroValue | Avro data value instance. Created by NewValue; freed with FreeValue. |
| TAvroReader | Binary decoder handle for streaming deserialization. |
| TAvroWriter | Binary encoder handle for streaming serialization. |
| TAvroFile | Avro Object Container File handle. Opened with OpenFileWrite / OpenFileRead; closed with CloseFile. |
TAvroType Enum
| Value | Ordinal | Description |
| atString | 0 | UTF-8 string |
| atBytes | 1 | Raw byte sequence |
| atInt32 | 2 | 32-bit signed integer |
| atInt64 | 3 | 64-bit signed integer (long) |
| atFloat | 4 | 32-bit IEEE 754 float |
| atDouble | 5 | 64-bit IEEE 754 double |
| atBoolean | 6 | Boolean true/false |
| atNull | 7 | Null value |
| atRecord | 8 | Named record with fields |
| atEnum | 9 | Named enumeration |
| atArray | 10 | Ordered sequence of values |
| atMap | 11 | String-keyed map |
| atUnion | 12 | Union of two or more types |
| atFixed | 13 | Fixed-width byte array |
TAvroCodec Enum
| Value | Description |
| acNull | No compression. Fastest write/read; largest files. |
| acDeflate | zlib deflate compression. Good ratio, universally supported. |
| acSnappy | Snappy compression. Faster than deflate, slightly lower ratio. Common in Kafka pipelines. |
| acBzip2 | Bzip2 compression. Best ratio; slowest. |
| acZstd | Zstandard compression. Best balance of speed and ratio. |
API Reference
Schema
| Function | Signature | Description |
| ParseSchema | ParseSchema(const JSON: string): TAvroSchema | Parse and compile a schema from a JSON string. Raises on malformed JSON or invalid schema. |
| LoadSchema | LoadSchema(const Path: string): TAvroSchema | Load and compile a schema from a .avsc file on disk. |
| LookupSchema | LookupSchema(Schema: TAvroSchema; const Name: string): TAvroSchema | Get the schema for a named type (record, enum, fixed) by name within a compiled schema. |
| FreeSchema | FreeSchema(Schema: TAvroSchema) | Release all memory held by a compiled schema handle. |
| SchemaType | SchemaType(Schema: TAvroSchema): TAvroType | Return the top-level Avro type of a schema handle. |
| SchemaToJSON | SchemaToJSON(Schema: TAvroSchema): string | Serialize a compiled schema back to its canonical JSON string representation. |
Value Building
| Function | Signature | Description |
| NewValue | NewValue(Schema: TAvroSchema): TAvroValue | Allocate a new value instance conforming to the given schema. All fields are set to their defaults. |
| FreeValue | FreeValue(V: TAvroValue) | Release all memory held by a value handle. |
| ResetValue | ResetValue(V: TAvroValue) | Reset all fields of a value to their schema defaults, allowing the handle to be reused. |
Field Setters
| Procedure | Signature | Description |
| SetString | SetString(V: TAvroValue; const Field, Value: string) | Set a string field by name. |
| SetInt | SetInt(V: TAvroValue; const Field: string; Value: Integer) | Set a 32-bit integer field by name. |
| SetLong | SetLong(V: TAvroValue; const Field: string; Value: Int64) | Set a 64-bit long field by name. |
| SetFloat | SetFloat(V: TAvroValue; const Field: string; Value: Single) | Set a 32-bit float field by name. |
| SetDouble | SetDouble(V: TAvroValue; const Field: string; Value: Double) | Set a 64-bit double field by name. |
| SetBoolean | SetBoolean(V: TAvroValue; const Field: string; Value: Boolean) | Set a boolean field by name. |
| SetNull | SetNull(V: TAvroValue; const Field: string) | Set a field to null (for union fields with null branch). |
| SetBytes | SetBytes(V: TAvroValue; const Field: string; const Data: string) | Set a bytes field by name (data is treated as a raw byte sequence). |
Field Getters
| Function | Signature | Description |
| GetString | GetString(V: TAvroValue; const Field: string): string | Read a string field by name. |
| GetInt | GetInt(V: TAvroValue; const Field: string): Integer | Read a 32-bit integer field by name. |
| GetLong | GetLong(V: TAvroValue; const Field: string): Int64 | Read a 64-bit long field by name. |
| GetFloat | GetFloat(V: TAvroValue; const Field: string): Single | Read a 32-bit float field by name. |
| GetDouble | GetDouble(V: TAvroValue; const Field: string): Double | Read a 64-bit double field by name. |
| GetBoolean | GetBoolean(V: TAvroValue; const Field: string): Boolean | Read a boolean field by name. |
| GetBytes | GetBytes(V: TAvroValue; const Field: string): string | Read a bytes field by name. |
Serialization
| Function | Signature | Description |
| Serialize | Serialize(V: TAvroValue): string | Encode a value to raw Avro binary (no file framing). Returns a byte string. |
| Deserialize | Deserialize(Schema: TAvroSchema; const Data: string): TAvroValue | Decode raw Avro binary using the given schema. Returns a new value handle. |
| ToJSON | ToJSON(V: TAvroValue): string | Serialize a value to its Avro JSON encoding. |
| FromJSON | FromJSON(Schema: TAvroSchema; const JSON: string): TAvroValue | Deserialize a value from Avro JSON encoding. |
Object Container Files
Avro Object Container Files (.avro) embed the schema and support block-level compression. They are the standard file format for Avro data at rest.
| Function | Signature | Description |
| OpenFileWrite | OpenFileWrite(const Path: string; Schema: TAvroSchema; Codec: TAvroCodec): TAvroFile | Open an .avro file for writing. Embeds the schema and configures block compression. |
| OpenFileRead | OpenFileRead(const Path: string): TAvroFile | Open an existing .avro file for reading. The schema is read from the file header. |
| FileAppend | FileAppend(F: TAvroFile; V: TAvroValue) | Append a value record to an open write file. |
| FileRead | FileRead(F: TAvroFile; Schema: TAvroSchema): TAvroValue | Read the next record from an open read file. Returns 0 (nil handle) at end of file. |
| FileSchema | FileSchema(F: TAvroFile): TAvroSchema | Return the schema embedded in an open file. Use after OpenFileRead to obtain the writer schema. |
| CloseFile | CloseFile(F: TAvroFile) | Flush buffers and close the file handle. Must be called on both read and write files. |
Code Example — Write and Read an .avro File
uses avro_c;
var
Schema : TAvroSchema;
V : TAvroValue;
F : TAvroFile;
Read : TAvroValue;
begin
{ Parse schema inline }
Schema := ParseSchema('{"type":"record","name":"Sensor",' +
'"fields":[{"name":"id","type":"string"},' +
'{"name":"temp","type":"double"}]}');
{ Open file for writing with Snappy compression }
F := OpenFileWrite('/data/sensors.avro', Schema, acSnappy);
{ Write first record }
V := NewValue(Schema);
SetString(V, 'id', 'sensor-001');
SetDouble(V, 'temp', 23.7);
FileAppend(F, V);
{ Reuse value for second record }
SetString(V, 'id', 'sensor-002');
SetDouble(V, 'temp', 24.1);
FileAppend(F, V);
FreeValue(V);
CloseFile(F);
{ Read back }
F := OpenFileRead('/data/sensors.avro');
Schema := FileSchema(F);
repeat
Read := FileRead(F, Schema);
if Read <> 0 then begin
WriteLn(GetString(Read, 'id'), ': ', GetDouble(Read, 'temp'):5:1, ' C');
FreeValue(Read);
end;
until Read = 0;
CloseFile(F);
end.
Tip: Call ResetValue instead of FreeValue + NewValue when writing many records in a loop to avoid repeated allocations. The handle is reused and all fields revert to their schema defaults.