A .NET client for Databento market data — real-time streaming, historical data, and reference data, with a zero-copy DBN codec at its core.
Status: code complete, in beta at 0.10.0. The DBN codec, live streaming, the historical client, reference data and the hosting extensions are all merged to
masterand published to NuGet at0.10.0— five packages, 2,043 tests, zero warnings, a public API locked by an analyzer, and full method-level parity withdatabento-rs. It is0.xrather than 1.0.0 deliberately: 1.0.0 undertakes not to break a 3,805-member public surface, and that undertaking is cheap to make and expensive to withdraw. The beta is where that surface gets contested, and it works — designing the fifth package against the other four is what turned up the one gap0.10.0fills (#86). If something is awkward to use, an issue now is far cheaper than a major version later. Tracked as #74 and #102, with 1.0.0 as #68.The NuGet badges below read the live feed, so they are the authority on what is actually published.
- Documentation — the site: guides, API reference, and release notes
- ROADMAP.md — milestones, architecture, and design decisions
- PORTING.md — Rust→.NET mapping guide for the port
using DatabentoDotNet.Dbn;
using var decoder = new DbnDecoder(File.OpenRead("data.dbn.zst")); // zstd is detected, not declared
Metadata? metadata = decoder.Metadata;
while (decoder.TryNextRecord(out RecordRef record))
{
if (record.TryGet(out TradeMsg trade))
Console.WriteLine($"{DbnTime.ToInstant(trade.IndexTs)} {trade.Price} x {trade.Size}");
}IndexTs, not Header.TsEvent. Most schemas — trades included — index on ts_recv, and the
two can fall on opposite sides of UTC midnight, so keying a symbol lookup on ts_event silently
returns the previous day's symbol with nothing looking broken. RecordRef.IndexTs picks the
right field per record type.
Records are reinterpreted in place over the read buffer — no allocation per record. That is
why RecordRef is a ref struct and TryNextRecord is synchronous: neither can cross an
await, which is the boundary that keeps the zero-copy path sound. A record is valid only until
the next call on the decoder.
Prices are long at a fixed 1e-9 scale and timestamps are ulong nanoseconds, both deliberately:
decimal would cost throughput on the hot path, and a record field's type is its wire layout,
so nothing wider than the 8 bytes on the wire can go there.
Above the codec, dates and times are NodaTime — Instant and
LocalDate, never the BCL's DateTime family, whose 100 ns tick cannot represent a nanosecond
timestamp at all. DbnTime is the single conversion between the two, and it reports DBN's
undefined-timestamp sentinel as absent rather than as a time one nanosecond before the epoch.
using DatabentoDotNet;
using DatabentoDotNet.Dbn;
using DatabentoDotNet.Historical;
using NodaTime;
await using var client = new HistoricalClient
{
ApiKey = new ApiKey(Environment.GetEnvironmentVariable("DATABENTO_API_KEY")!),
};
// get_cost prices the exact request you're about to send, not an approximation of it. A
// timeseries.get_range call renders this same value with GetRangeParams.ToQuery().
var request = new MetadataQueryParams
{
Dataset = "XNAS.ITCH",
Symbols = Symbols.From(["AAPL", "MSFT"]),
Schema = Schema.Trades,
DateTimeRange = DateTimeRange.Between(
Instant.FromUtc(2023, 7, 1, 0, 0, 0), Instant.FromUtc(2023, 8, 1, 0, 0, 0)),
};
decimal cost = await client.Metadata.GetCostAsync(request);
if (cost > 5.00m)
{
Console.WriteLine($"${cost} for that range — narrowing it before pulling any data.");
return;
}get_cost answers, in dollars, what pulling this exact range would cost — before any data
moves. That is deliberately not a second parameter set assembled by hand: GetRangeParams.ToQuery()
renders the MetadataQueryParams for the very request you are about to send, so what was priced and
what is sent cannot drift apart. SubmitJobParams carries the same conversion. The cost comes back as
decimal, not double — the API's own f64 is a Rust standard-library limitation rather than
a choice, and a per-gigabyte unit price gets multiplied by a record count before a caller ever
sees a figure.
The same session as a BackgroundService: registered once, configured from appsettings.json, and
started, reconnected and shut down by the host rather than by your code.
dotnet add package DatabentoDotNet.Extensions.Hosting
dotnet add package Microsoft.Extensions.Hosting # only in a plain console app — the Worker and Web SDKs carry itusing DatabentoDotNet.Dbn;
using DatabentoDotNet.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddDatabento(); // the key: Databento:ApiKey, else DATABENTO_API_KEY
builder.Services.AddDatabentoLive().AddRecordHandler<TradePrinter>();
using var host = builder.Build();
await host.RunAsync(); // connect, authenticate, subscribe, start — billing begins inside here
// One singleton per session rather than an instance per record: the package exists because this
// path does not allocate, and a handler resolved per record would be the first thing that did.
internal sealed class TradePrinter : ILiveRecordHandler
{
public void OnRecord(scoped RecordRef record)
{
// Copy out what you need. The RecordRef points into the session's read buffer and is
// valid for this call only — which is why OnRecord is not async and cannot be.
if (record.TryGet(out TradeMsg trade))
{
Console.WriteLine($"{record.Header.InstrumentId} {trade.Price} x {trade.Size}");
}
}
public ValueTask OnFlushAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
}{
"Databento": {
"Live": {
"Default": {
"Dataset": "EQUS.MINI",
"Subscriptions": [{ "Schema": "trades", "Symbols": ["AAPL", "MSFT"] }],
"Reconnect": { "MaxAttempts": 10, "MaxDelay": "PT30S" }
}
}
}
}No key in that file. The precedence chain is the session's own ApiKey, then Databento:ApiKey,
then DATABENTO_API_KEY — so a key can stay in the environment while everything else stays in
configuration. A missing dataset, a schema that is not a schema, a subscription with no symbols or
a duration that is not ISO-8601 fails at startup, naming its configuration path — startup
validation converts these strings to the library's types, so what it catches is everything that
conversion can see. A dataset you are simply not entitled to is not one of those; the gateway says
so, at start_session.
Reconnection is bounded and off the caller's hands: LiveClient itself never reconnects, because a
client that silently re-subscribes can hand you a gap it did not mention. The hosted session does
reconnect, with capped exponential backoff and a MaxAttempts ceiling, and it re-subscribes from
the configuration it was given. AddHealthCheck() on the same builder reports the session's state
to an ASP.NET Core /health endpoint, and four instruments publish on the
DatabentoDotNet.Extensions.Hosting meter.
Hosting and Dependency Injection is the full guide — two sessions in one host, the transport seam, and what startup validation does and does not cover.
Five runnable console programs live under samples/ — a live stream, a historical
range, a batch download, symbol resolution applied to decoded records, and the same live stream
again as a hosted BackgroundService configured from appsettings.json. Each takes its key from
DATABENTO_API_KEY, each runs with no arguments beyond what HostedLive's configuration file
supplies, and each says what it costs before it spends anything.
export DATABENTO_API_KEY=db-...
dotnet run --project samples/DatabentoDotNet.Samples.HistoricalRangeSee samples/README.md for what each one shows and what it costs to run.
jerbersoft.github.io/databentodotnet is the documentation — the guides, the API reference and the release notes, on one searchable site. Start at Getting Started.
The two pages worth reading before writing anything real:
Zero-Copy and Allocation
(a RecordRef is valid until the next decoder call, and breaking that reads stale bytes rather than
throwing) and
Timestamps and Prices
(nanoseconds, NodaTime, and the three sentinels).
The API reference is generated from the XML
documentation comments, so it cannot drift from the code it describes. The same comments ship
inside the NuGet package, which means every member's docs — worked <example> blocks included —
also reach IntelliSense in your editor, at the call site, without opening a browser.
That site replaced the wiki in #82. The guides live in docs/ now, which means a change
in behaviour and the change to the page describing it land in the same pull request and are
reviewed together — and the site is built with --warningsAsErrors, so a cross-reference that
stops resolving fails the build instead of becoming a dead link nobody reports.
For contributing: CLAUDE.md · PORTING.md · ROADMAP.md
Databento maintains official clients for Python, C++, and Rust — but not .NET. This fills that
gap, with the wire format ported from the normative
databento/dbn Rust implementation and struct layouts
pinned against the static_asserts in
databento-cpp.
DatabentoDotNet.* is used consistently for package IDs, assemblies, and namespaces. This is a
third-party client, so it stays out of Databento.* — that is the vendor's namespace, and an
unreserved NuGet prefix they could claim at any time.
All five badges read the live feed, so they are the authority on what is actually published — not this page. The fifth package is newer than the others and carries no SemVer promise yet; see ROADMAP.md §8.
All five packages are published to nuget.org:
dotnet add package DatabentoDotNet.Dbn
dotnet add package DatabentoDotNet.Live
dotnet add package DatabentoDotNet.Historical
dotnet add package DatabentoDotNet.Reference
dotnet add package DatabentoDotNet.Extensions.Hosting # ASP.NET Core / generic hostusing DatabentoDotNet.Dbn;| Status | |
|---|---|
| CI | |
| Native AOT | |
| Publish | |
| License | |
| Latest |
net10.0, with three public dependencies across the four client packages, each a deliberate cost
rather than an accident: NodaTime for all date and time handling,
ZstdSharp.Port for DBN's Zstandard transport compression, and
Microsoft.Extensions.Logging.Abstractions for the two HTTP clients' optional LoggerFactory.
DatabentoDotNet.Extensions.Hosting adds the Microsoft.Extensions.* packages it exists to
integrate with — options and configuration binding, hosting abstractions, IHttpClientFactory and
health checks — which is what a hosting package is, not an accident either.
ZstdSharp.Port is pure managed — no P/Invoke, no native asset, no per-RID build — so the
packages stay trim- and AOT-friendly, which is verified by publishing and running a Native AOT
binary rather than by the analyzers alone.
A net11.0 target existed briefly, to pick up System.IO.Compression.ZstandardStream from the
BCL and ship dependency-free. It was removed in #16 while .NET 11 is still preview: the
preview SDK is not installed on dev machines, so that code path was compiled nowhere, and CI
inferred the target from the installed SDK — meaning a failed SDK resolution silently dropped it
and the build still passed. An unverifiable branch is worse than one dependency.
Every zstd call routes through a single internal seam, so restoring the target at GA is a one-file change.
dotnet build
dotnet testRequires the .NET 10 SDK or newer. CI builds and tests on Linux, macOS, and Windows, and a separate workflow publishes and runs a Native AOT binary on every push.