Table of Contents

Zero-Copy and Allocation

Records are reinterpreted in place over the read buffer — decoding one allocates nothing. That guarantee is the reason this library exists, and it is measured by a test rather than claimed in a README.

The cost is a lifetime rule: a RecordRef is valid only until the next call on the decoder. This page explains what the compiler enforces for you, what it cannot, and what to do when you need a record to outlive its buffer.


What "zero-copy" means here

while (decoder.TryNextRecord(out RecordRef record))
{
    if (record.TryGet(out TradeMsg trade)) { Handle(trade); }
}

No byte is copied between the socket and record. TryNextRecord finds a complete record in the buffer and hands back a ReadOnlySpan<byte> over it; Get<T> reinterprets those same bytes as a struct with MemoryMarshal.AsRef<T>. The struct's field layout is the wire layout — which is also why record fields stay ulong and long rather than becoming Instant and decimal.

The buffer itself is a ulong[], not a byte[], so the bytes start 8-byte aligned no matter what the runtime decides to do. Reinterpreting a misaligned buffer is undefined behaviour on some architectures and merely slow on x86, which is the worst combination: it would pass every test on a developer machine.

The lifetime rule, and who enforces it

RecordRef is a readonly ref struct. That is not decoration — it is the enforcement mechanism. The compiler will refuse to let you:

You try to Compiler says
Store it in a field or a List<RecordRef> CS8345 — a ref struct cannot be a member
Hold it across an await CS4007 — cannot be in scope across an await
yield return it CS4013 — cannot appear in an iterator
Box it, or use it as a generic argument CS0029 / CS0306
Capture it in a lambda or local function CS4013 — cannot be used inside a nested function
Return it from a method outliving the buffer CS8352 — may expose referenced variables

Every one of those errors is the lifetime rule stated in a place a human cannot forget it. If your code compiles, the record does not outlive its buffer.

This is why there is no Task<RecordRef>, and there never can be. An async method cannot return a ref struct. Upstream's single-call next_record() therefore does not port; its fill_buf() / try_next_record() pair does, which is why the live loop is two calls:

while (true)
{
    while (client.TryNextRecord(out var record)) { /* synchronous — no await in here */ }

    if (await client.FillBufferAsync(ct) == 0) { break; }
}

A RecordRef local inside an async method is fine. Only one that survives an await is rejected.

What the compiler does not catch

One thing: the buffer's contents change on the next call, and Bytes is a span the compiler believes is still valid within the same statement sequence. Do not do this:

decoder.TryNextRecord(out var a);
decoder.TryNextRecord(out var b);
Compare(a, b);                       // compiles. `a` now points at whatever `b` displaced.

Two records at once means copying one of them out. Which brings us to the escape hatches.

When a record has to outlive its buffer

The cheapest: copy the struct

TryGet<T> copies the record into an ordinary readonly struct, and an ordinary struct has no lifetime restrictions at all. It can be stored, awaited across, put in a list, and returned:

var trades = new List<TradeMsg>();

while (decoder.TryNextRecord(out var record))
{
    if (record.TryGet(out TradeMsg trade))
    {
        trades.Add(trade);           // fine — `trade` is a value, not a view
    }
}

A TradeMsg is 48 bytes and the copy is a register-width move or two. When you know the record type you want, this is almost always the right answer.

One caveat for records with text fields: def.RawSymbol.AsSpan() returns a span into the struct you are holding. Copy the struct to a local, and the span points at that local — correct, but only for as long as the local lives. Call ToString() if the text must outlive it.

The general one: OwnedRecord

When you do not know the type, or want the whole record regardless:

OwnedRecord owned = OwnedRecord.CopyOf(record);
// … much later, on another thread if you like …
if (owned.TryGet(out TradeMsg trade)) { /* … */ }

OwnedRecord holds its own ulong[] and exposes the same Has / TryGet / Get / IndexTs surface as RecordRef, plus AsRef() to get a RecordRef back over its own storage. It costs two allocations — the object and its array — around 110 bytes for a typical trade.

This is what LiveClient.RecordsAsync() yields, and why:

await foreach (OwnedRecord record in client.RecordsAsync(ct)) { /* … */ }

yield return carries the same restriction await does, so a ref struct cannot leave an iterator at all. The copy is necessary rather than a convenience that could be optimised away — which is why OwnedRecord states the cost on its own type rather than hiding it behind the pleasant await foreach.

Choosing a loop

FillBufferAsync + TryNextRecord RecordsAsync
Per record 0 bytes ~110 bytes, 2 allocations
Shape Nested while, manual drain await foreach
Records may cross await No Yes

RecordsAsync is written in terms of the other two and does not bypass them, so the surfaces cannot diverge. Use it when records are individually interesting and the rate is modest; use the zero-copy loop for full-depth MBO at market open, where the difference between 0 and 110 bytes per record is the difference between a steady heap and a GC pause in the middle of the session.

What the tests actually assert

Two files — AllocationTests (codec) and LiveAllocationTests (over the mock gateway's socket) — measure GC.GetAllocatedBytesForCurrentThread() around a steady-state loop and require exactly zero. Not "small". Zero.

The codec test runs over the whole vendored corpus, so a regression on any record type is caught, not just on the one the test happened to pick.

Both files also contain a test that the measurement itself notices a deliberate allocation on the same path. A broken instrument reporting zero would pass every other assertion in them, so the instrument is verified too.

Anything added to the FillBufferAsync / TryNextRecord path has to keep those green. The benchmark project reports the same numbers but enforces nothing — a benchmark someone has to remember to run cannot hold a guarantee.

What the zero does and does not cover

It is a per-record guarantee. Decoding records from bytes already buffered allocates nothing, and that is what the assertion pins.

A call to FillBufferAsync that actually suspends — a quiet feed waiting on the next heartbeat — has the ordinary per-call cost of an async socket read: a state machine box, the timeout machinery, the cancellation registration. That is per fill, not per record, so it is amortised across every record the fill delivers, and on a busy stream where reads are satisfied from bytes the kernel already holds it does not arise at all. The live allocation test is deliberately sized so that every measured read is of that second kind, which is both the case worth measuring and the one where nothing suspends.

If you are counting bytes at that level, run the benchmarks:

dotnet run -c Release --framework net10.0 \
  --project benchmarks/DatabentoDotNet.Benchmarks -- --filter '*'

Release only — BenchmarkDotNet refuses a Debug build.

What it costs in time, and what it does not

Zero allocation is the property the tests pin; the time it takes is measured separately, against a real gateway. Over EQUS.MINI trades on 2026-08-31, decoding a record and handing it to the caller took 7.7 µs at p50 and 27 µs at p99 — the interval from the buffer read that produced a record to the moment TryNextRecord returned it. That figure reproduced to 0.1 µs across two runs on different samples, and it is the only part of end-to-end latency this library controls.

It is also a small part of what you will observe. The time from the gateway's send stamp to your callback was around 40 ms in the same runs — from a machine in London to a US gateway. That is the distance to the venue, it dominates the decode cost by four orders of magnitude, and it would read the same for Databento's Python, C++ and Rust clients. Moving closer to the gateway changes it; tuning your read loop does not.

One consequence worth knowing before you try to measure this yourself: a gateway-to-caller figure subtracts your machine's wall clock from the gateway's, so it carries whatever offset separates them — on the day of these runs, 63 ms, enough to make the median negative. One-way delay between unsynchronised clocks is not observable. If you need a number for the network path, time a round trip on your own clock instead; RealGatewaySmokeTests does exactly that, for free, without starting a session.

ROADMAP.md §7 records both tables with their dates and datasets.

Things that do allocate, and are meant to

  • CStr71.ToString() and friends — the reason AsTextSpan() exists. Never called during decoding.
  • PitSymbolMap.OnRecord — exactly one string per genuinely new mapping, and nothing else.
  • TsSymbolMap.FromMetadata — one dictionary entry per instrument-day, built once.
  • The whole control plane: connecting, the CRAM handshake, subscription lines. Once per session, or once per subscription. string.Join and a StringBuilder there cost nothing that matters.

Why not System.IO.Pipelines

It is the reflexive .NET answer for a socket read loop and it is wrong here, for two reasons.

ReadOnlySequence<byte> may be non-contiguous, and MemoryMarshal.AsRef<T> needs contiguous bytes — a record split across two segments would have to be copied to be read, which is exactly the copy this library is built to avoid. And it adds a second buffering layer over the state machine's own, which already tracks where the complete records are.

Async reads go through DbnFsm.SpaceMemory() instead, a Memory<byte> projected over the buffer's ulong[] by a MemoryManager<byte> — there is no ReadAsync(Span<byte>) and no Memory<T> reinterpret cast, so the projection is the seam. Space() is derived from SpaceMemory() so the two views cannot drift.

The alternatives that were considered and rejected are in PORTING.md §1 and §3.

See also