Avro
The Data SDK supports Avro container files and raw binary records. Read files as Arrow tables for columnar processing, or generate typed record classes when working with individual events. Both paths execute natively and support writing: tables use an explicit Avro schema, while generated records encode raw messages or write container files.
Choose table batches for analytical extraction and generated records for typed field access. The API reference documents schema resolution, logical types, and resource limits for both approaches.
Before you begin: install the all-SDK distribution.
The sample inputs contain the container files, raw records,
and schemas used below. Extract the archive in your working directory before
running the examples. Each input contains 100 trades with ID 1, so the batch
example prints 100. The generated-record example also shows its schema inline.
Read Tables
import exaloop.data as data
with data.read_avro("events.avro") as events:
identifiers = events["id"].to_numpy(int)
Run the example:
exaloop --with data run -release program.codon
For an individual SDK, register it using the installation guide
or run codon run -release -plugin /path/to/sdk/data program.codon directly.
data.read_table("events.avro") infers Avro from the extension.
Path-based readers and writers also accept s3://bucket/key and an optional
filesystem=data.S3FileSystem(...); see S3 objects for
credentials, object replacement, and upload failure handling.
data.Table.from_bytes(encoded, "avro") reads an in-memory container file.
Both materialize the entire table. To process bounded batches and select columns:
import exaloop.data.avro as avro
for batch in avro.iter_batches("events.avro", columns=["id"], batch_size=65536):
with batch:
identifiers = batch["id"].to_numpy(int)
print(identifiers.sum())
This reads only id and prints a sum for each batch. Select the columns your
application needs to avoid constructing unused values. The with block releases
each batch after processing; a column you keep separately remains usable.
Projection skips unselected data, so it should not be used to validate every field in an input file.
Generated Records
Generate records when you know the schema before compiling and want ordinary
typed field access. Save this small schema as trade.avsc:
{
"type": "record",
"name": "Trade",
"fields": [{"name": "id", "type": "long"}]
}
Generate the Codon module:
exaloop avrogen trade.avsc trade.codon
The distribution registers the Data SDK automatically. Use
exaloop --with NAME avrogen ... for another registered SDK alias. Without the
CLI, run /path/to/sdk/data/bin/avrogen-codon trade.avsc trade.codon.
Place the generated module alongside your program:
from trade import Trade
from exaloop.data.avro import read_records
for record in read_records("trades.avro", Trade):
print(record.id)
Trade is a generated record with a typed id field. Compile it together with
your application; the deployed program does not need the generator.
For a raw binary message rather than a container file, use Trade.from_bytes().
When decoding many messages, prepare a decoder once and reuse it. For the schema
above, the one-byte payload below represents a trade with ID 1:
from trade import Trade
decoder = Trade.decoder()
record = decoder.decode(b"\x02")
assert record.id == 1
If the incoming writer schema differs, pass it to Trade.decoder(writer_schema).
See the generated-record reference for field types,
unions, and naming rules.
Construct Logical Values
Use logical-type helpers to construct timestamp, decimal, and UUID fields without calculating epoch counts or assembling bytes. Generated fields retain their physical Codon types; these helpers return values you can pass directly to them.
For example, generate Payment from this schema:
{
"type": "record",
"name": "Payment",
"fields": [
{"name": "created_at", "type": {"type": "long", "logicalType": "timestamp-micros"}},
{"name": "amount", "type": {"type": "bytes", "logicalType": "decimal", "precision": 12, "scale": 2}},
{"name": "id", "type": {"type": "string", "logicalType": "uuid"}}
]
}
exaloop avrogen payment.avsc payment.codon
from datetime import datetime
from payment import Payment
from exaloop.data.avro import timestamp, timestamp_datetime, decimal_bytes, decimal_string, uuid_string
payment = Payment(
timestamp(datetime(2026, 10, 11, 9, 30), offset_minutes=330),
decimal_bytes("123.45", precision=12, scale=2),
uuid_string("00112233445566778899AABBCCDDEEFF"),
)
restored = Payment.from_bytes(payment.to_bytes())
assert timestamp_datetime(restored.created_at) == datetime(2026, 10, 11, 4, 0)
assert decimal_string(restored.amount, precision=12, scale=2) == "123.45"
assert restored.id == "00112233-4455-6677-8899-aabbccddeeff"
Here, offset_minutes=330 means the supplied clock time is UTC+05:30.
The default is UTC, never the machine's local timezone. Match unit to the
schema: "millis", "micros" (default), or "nanos". For an Avro
local-timestamp-*, use an offset of zero to retain the wall-clock value.
Conversion rejects precision loss instead of rounding. Codon datetime has
microsecond precision and no timezone metadata; use physical integer values
directly when sub-microsecond timestamps are required.
Decimals accept exact decimal text, including scientific notation, or whole
integers. Floats are not accepted. Match precision and scale to the schema;
for a decimal stored as fixed, also pass its byte width as fixed_size.
For a UUID stored as fixed(16), use uuid_bytes(text) instead of
uuid_string(text). UUID helpers validate and normalize identifiers; they do
not generate new IDs. See the logical helper reference
for limits and inverse conversions.
Encode and Write Records
Use to_bytes() to encode a generated record as one raw Avro datum:
from trade import Trade
payload = Trade(42).to_bytes()
assert Trade.from_bytes(payload).id == 42
These bytes do not include a schema, container header, or schema-registry
envelope. The receiving application needs the matching writer schema. Regenerate
older modules with exaloop avrogen to add encoding methods; generated names
such as a schema field named to_bytes may receive a _field suffix.
For a file, use Writer[Record]. It embeds the generated record's schema and
writes bounded blocks as records arrive:
from trade import Trade
from exaloop.data.avro import Writer, read_records
with Writer[Trade]("written-trades.avro", codec="deflate") as output:
for identifier in range(3):
output.write(Trade(identifier))
assert [record.id for record in read_records("written-trades.avro", Trade)] == [0, 1, 2]
Opening a writer creates or truncates the destination. The context manager
flushes pending records and closes it. Call flush() when another reader needs
to see accepted records before the writer closes; this does not guarantee
durability after a crash.
Writers support null, deflate, snappy, and zstandard compression without
additional runtime dependencies. The default block target is
64 KiB, with at most 65,536 records per block and a 64 MiB hard limit on encoded
records and compressed or uncompressed blocks. A record larger than the target
can occupy its own block within that hard limit. These limits do not describe
total process memory. See the writer reference
for configuration and failure behavior.
Write Tables
Use Table.write_avro() to export an Arrow table without constructing generated
records. Supply the writer schema explicitly; Arrow types alone do not identify
Avro enums, unions, or named records unambiguously.
import exaloop.data as data
from exaloop.data.avro import Schema
schema = Schema('{"type":"record","name":"Trade","fields":[{"name":"id","type":"long"}]}')
with data.table({"id": data.array([10, 20, 30])}) as trades:
trades.write_avro("table-trades.avro", schema, codec="deflate")
with data.read_avro("table-trades.avro") as restored:
assert restored["id"].to_list(int) == [10, 20, 30]
The equivalent function is avro.write_table(table, path, writer_schema, ...).
Field names select columns and nested struct fields; schema order determines
output order. Extra columns are ignored, but every schema field must exist,
even if it has a default. Values are range-checked rather than silently truncated.
See table export types for nested and logical values.
Export creates or truncates the file and uses the same block limits and codecs
as generated-record writing. Schema/layout errors are checked before opening
the destination; a bad value later in the table can leave earlier records in
the file. For atomic replacement, write a temporary file and rename it only
after export succeeds. Calling write_avro() again replaces the file; it does
not append another table or batch.
The input table stays in memory. Export retains its Arrow buffers and encodes records incrementally, but combining chunked columns can allocate additional memory. No Python runtime or generated record module is required.
Runtime Schemas
Use a runtime schema when it arrives with the data or changes independently of your application. A writer schema describes the bytes being read; a reader schema describes the result your application expects.
This example reads 100 raw records, selecting their id field:
import exaloop.data.avro as avro
with open("writer.avsc") as stream:
writer = avro.Schema(stream.read())
with open("reader.avsc") as stream:
reader = avro.Schema(stream.read())
decoder = avro.Decoder(writer, reader, columns=["id"])
with open("records.bin", "rb") as stream:
with decoder.decode(stream.read(), count=100) as records:
print(records.num_rows)
Reuse the decoder for later batches with the same schemas. It handles compatible changes such as reordered fields, added reader fields with defaults, and numeric promotions. Keep both schemas unchanged while the decoder is in use.
API
The Avro API reference lists reader and decoder signatures, schema evolution rules, and supported formats.
Arrow Mapping
Table readers preserve Avro's structure and logical types in Arrow. For example,
Avro long becomes Arrow int64 and a nullable field becomes a nullable column.
See the type mapping when working with nested values,
decimals, or timestamps.
Limits and Ownership
Use batches when a file is too large to load entirely into memory. The default limits allow 64 MiB per compressed or decompressed block and 256 MiB of Arrow buffers per batch; these are not limits on total process memory. Keeping generated records can also retain their input blocks. Adjust limits only after considering the size and trustworthiness of your input.
Malformed or unsupported input raises ValueError. With an iterator, earlier
batches may already have been processed before a later error occurs. The
limits reference covers configurable bounds and
restrictions.
Choosing a Reader
Tables are a natural fit for column-oriented analysis, while generated records provide typed access for event-by-event processing. The workload determines which is more efficient: representative files and the application's actual projection and decoding requirements provide a useful basis for comparison.
Next Steps
- Use the generated-record reference for unions and nested fields.
- Review decoder limits before accepting external input.
- Consume Kafka records to apply decoding in a streaming application.