Streaming large objects (BLOB/CLOB)
Read a single large binary or text column as a
Stream/TextReaderwithout materialising the whole value in managed memory.
Prerequisites: Querying and projections · Projections · PostgreSQL · SQL Server · Limitations
Overview
By default a byte[]/string projection is materialised whole: the row reader calls GetValue/GetString and the entire value lands in a managed array or string. For a file, image or document column of a few megabytes that is an avoidable allocation and can exhaust memory. The LOB terminals open the provider's streaming accessors instead (DbDataReader.GetStream/GetTextReader) and hand back an object you read incrementally.
For a whole result set rather than a single LOB column, the JSON streaming terminal applies the same idea to rows: WriteJson / WriteJsonAsync write the query's Select projection to a caller-owned Stream with O(buffer) live memory and never close the destination. See Streaming data to a Stream.
Terminals
Sync overloads take the parameters as a params ReadOnlySpan<object?>:
public static Stream ToStream(this QueryCommand<byte[]> command, params ReadOnlySpan<object?> parameters);
public static Stream ToStream(this QueryCommand<byte[]> command, CancellationToken cancellationToken, params ReadOnlySpan<object?> parameters);
public static TextReader ToTextReader(this QueryCommand<string> command, params ReadOnlySpan<object?> parameters);
public static TextReader ToTextReader(this QueryCommand<string> command, CancellationToken cancellationToken, params ReadOnlySpan<object?> parameters);
Async overloads take the parameters as a params object?[] (opening is asynchronous; the CancellationToken overload also cancels the open):
public static Task<Stream> ToStreamAsync(this QueryCommand<byte[]> command, params object?[] parameters);
public static Task<Stream> ToStreamAsync(this QueryCommand<byte[]> command, CancellationToken cancellationToken, params object?[] parameters);
public static Task<TextReader> ToTextReaderAsync(this QueryCommand<string> command, params object?[] parameters);
public static Task<TextReader> ToTextReaderAsync(this QueryCommand<string> command, CancellationToken cancellationToken, params object?[] parameters);
The terminals are extension methods on QueryCommand<TResult> — the type Select returns — so the query must first project a single byte[] or string column. They are created with storeInCache: false: the LOB command is per-call and never reuses the shared, buffered command of the same query shape.
Usage
using var ctx = new DataContextBuilder().UsePostgres(connectionString).CreateDataContext();
// BLOB
await using var stream = ctx.From<BinaryEntity>()
.Where(x => x.Id == 1)
.Select(x => x.Payload)
.ToStream(); // reader opened with CommandBehavior.SequentialAccess
var buffer = new byte[81920];
int read;
while ((read = await stream.ReadAsync(buffer)) > 0)
await destination.WriteAsync(buffer.AsMemory(0, read));
// CLOB
using var reader = ctx.From<Document>()
.Where(x => x.Id == 1)
.Select(x => x.Body)
.ToTextReader();
var chars = new char[8192];
int n;
while ((n = await reader.ReadAsync(chars, 0, chars.Length)) > 0)
_ = chars[n];
The async form opens the command against the database; bring your own parameters through the trailing params list:
Stream stream = await ctx.From<BinaryEntity>()
.Where(x => x.Id == id)
.Select(x => x.Payload)
.ToStreamAsync(cancellationToken, id);
Named columns and row streaming
The streaming accessors are also available on the named-column (From("table")) surface and can be
read per row of an IAsyncEnumerable projection. A Stream/TextReader member in the projection is
opened lazily from the sequential-access reader and is valid only until the enumerator advances to
the next row (MoveNext): the underlying reader is owned by the enumerator and its per-call
command, so the value must be fully consumed inside the loop body and never stored past the step.
Dispose each per-row stream/reader before the next iteration; this releases the provider's per-row
handle (required by SQLite, whose rowid locator keeps the statement pinned until the value is
released).
After MoveNext the handle must not be used: its behavior is undefined and provider-specific.
PostgreSQL may hand back the next row's bytes, SQL Server throws ObjectDisposedException, and
SQLite returns end-of-stream at the blob's end. The only guarantee is a safety property — a stale
handle never yields the watched row's data — so treat the value as invalid the moment the enumerator
advances and do not depend on any particular failure mode.
The named-column accessors are
TableAlias.GetStream /
TableAlias.GetTextReader and the
indexer members TableColumn.AsStream /
TableColumn.AsTextReader. The streaming member must be
the last member of the projection, so the sequential-access reader reaches the LOB column after
every scalar column:
await foreach (var row in ctx.From("documents")
.Where(t => t.GetInt32("id") == id)
.Select(t => new { Id = t.GetInt32("id"), Data = t.GetStream("data") })
.ToAsyncEnumerable(cancellationToken))
{
// The stream is valid only until the next MoveNext: read and release it before iterating.
await using var data = row.Data;
await data.CopyToAsync(destination, cancellationToken);
}
A row projection that contains a streaming member is prepared fresh with
CommandBehavior.SequentialAccess and is never written to the plan cache, so an ordinary buffered
projection of the same shape keeps its plan. On SQLite the rowid locator is appended to these rows
too, so the source must be a rowid-bearing table. MySQL/MariaDB and ClickHouse do not provide
memory-bounded LOB streaming: their drivers buffer the whole value even under
CommandBehavior.SequentialAccess, so those dialects deliberately leave
ISqlDialect.SupportsSequentialAccess at
its false default and the terminal throws NotSupportedException when the query is executed
(MySqlConnector's GetStream/GetTextReader both allocate with the value — measured ratio ≈ 4–8,
and ClickHouse.Driver's GetStream is not implemented at all). There is no streaming benefit to
unlock, so use the buffered byte[]/string projection there instead.
Providers
| Provider | ToStream / ToTextReader |
|---|---|
| PostgreSQL | supported (bytea / text) |
| SQL Server | supported (varbinary(max) / nvarchar(max); the streaming mode is CommandBehavior.SequentialAccess) |
| SQLite | supported (blob / text; the source must expose rowid) |
| MySQL / MariaDB | NotSupportedException (the driver buffers; not memory-bounded) |
| ClickHouse | NotSupportedException (GetStream not implemented; GetTextReader buffers) |
| In-memory | supported (MemoryStream/StringReader over the single materialized value; see In-memory) |
Streaming is implemented for PostgreSQL, SQL Server and SQLite in this release. The streaming terminal is opt-in: the ordinary buffered byte[]/string projection keeps working on every provider, and DML of a LOB is out of scope. MySQL/MariaDB and ClickHouse reject the terminal with a NotSupportedException whose message names the provider, because their drivers do not offer memory-bounded LOB reads (they buffer the whole value; SequentialAccess does not change the allocations, and ClickHouse's GetStream is not implemented), so SupportsSequentialAccess is deliberately false for them — the buffered projection is the supported path. The in-memory provider supports the scalar terminals as well, but with no DbDataReader to stream from it returns a plain BCL object over the materialized value — see In-memory.
The multi-column ToDataReader terminal is available on PostgreSQL, SQL Server and SQLite. PostgreSQL and SQL Server hand the sequential-access command over; SQLite returns a buffered, locator-free reader (the query is not opened with SequentialAccess and no rowid is appended, so FieldCount equals the projection column count and the ordinals match Select). MySQL/MariaDB and ClickHouse reject it because they have no sequential-access support; the in-memory provider rejects it because it has no DbDataReader at all:
| Provider | ToDataReader / ToDataReaderAsync |
|---|---|
| PostgreSQL | supported (sequential-access) |
| SQL Server | supported (sequential-access) |
| SQLite | supported (buffered, locator-free; no chunked LOB) |
| MySQL / MariaDB | NotSupportedException |
| ClickHouse | NotSupportedException |
| In-memory | NotSupportedException |
On the single-column streaming terminals and named-column row streaming, SQLite has no row locator of its own, so Microsoft.Data.Sqlite returns a true streaming SqliteBlob only when the query also selects rowid. The dialect appends its own trailing locator column — ISqlDialect.LobLocatorColumn, "rowid" on SQLite and null on PostgreSQL/SQL Server — so the payload stays at ordinal 0 and is not part of the user projection. The query source must therefore be a normal rowid table: on a view or a WITHOUT ROWID table the single-column streaming command fails closed with the raw Microsoft.Data.Sqlite.SqliteException: no such column: rowid — there is no buffered fallback on that path. The multi-column ToDataReader does not use it: it prepares a locator-free buffered command (no SequentialAccess, no rowid), so it is not tied to a rowid-bearing source.
In-memory
InMemoryDataContext has no DbDataReader, so the scalar LOB terminals do not open a provider reader. They query the in-memory source as usual and wrap the single projected value in a plain BCL object owned by the caller: ToStream/ToStreamAsync return a MemoryStream over the byte[], and ToTextReader/ToTextReaderAsync return a StringReader over the string. The projection must still be exactly one byte[]/string column.
Only the first row is read. An empty result and a first value that is NULL both return Stream.Null/TextReader.Null — the in-memory scalar API does not distinguish "no row" from "row with a NULL LOB". Nothing on the context needs disposing and the context need not stay alive while the returned object is read: it is an ordinary MemoryStream/StringReader that owns no reader or command. Because the value is already materialized, there is no O(buffer) streaming benefit in memory — the whole value is in managed memory either way.
ToDataReader/ToDataReaderAsync remain unsupported on the in-memory provider (NotSupportedException): there is no DbDataReader to hand back.
SQLite query shapes
The SQLite locator can only be appended to a single-source, rowid-bearing projection. Queries that join several sources, or that carry DISTINCT/DISTINCT ON, UNION, GROUP BY or an aggregate, are not supported; they may surface a SQLite driver error instead of a clean early failure. On a single-column projection a raw WithSql override is rejected with NotSupportedException, because a rowid locator cannot be added to caller-supplied SQL safely; a raw projection of two or more columns is rejected with the usual InvalidOperationException.
Exactly one column
The terminal reads column ordinal 0 and requires exactly one column: zero or several columns throw InvalidOperationException. The single column must also be a valid byte[]/string; when it is of the wrong type the error surfaces from the provider's GetStream/GetTextReader (the exact exception type depends on the driver). To read several columns — or several rows — through a DbDataReader, use the ToDataReader escape hatch below.
Multiple columns: ToDataReader
When the projection has more than one column, or you need every row without materialising the result, use the ToDataReader/ToDataReaderAsync terminals. They return a caller-owned DbDataReader over the provider's command — a sequential-access command on PostgreSQL/SQL Server, a buffered locator-free command on SQLite:
public static DbDataReader ToDataReader<TResult>(this QueryCommand<TResult> command, params ReadOnlySpan<object?> parameters);
public static DbDataReader ToDataReader<TResult>(this QueryCommand<TResult> command, CancellationToken cancellationToken, params ReadOnlySpan<object?> parameters);
public static Task<DbDataReader> ToDataReaderAsync<TResult>(this QueryCommand<TResult> command, params object?[] parameters);
public static Task<DbDataReader> ToDataReaderAsync<TResult>(this QueryCommand<TResult> command, CancellationToken cancellationToken, params object?[] parameters);
using var reader = ctx.From<Document>()
.Where(x => x.Id == id)
.Select(x => new { x.Id, x.Body })
.ToDataReader(id);
while (reader.Read())
{
var id2 = reader.GetInt64(0);
var body = reader.IsDBNull(1) ? null : reader.GetString(1);
}
The terminal does not apply the single-column guard: it hands the provider reader over as-is, so the streaming projection contract still applies:
- Forward-only, sequential access (PostgreSQL, SQL Server). Read columns in ascending ordinal order and do not read a column twice; a LOB column must be read before any later column. On SQLite the reader is buffered instead: it is not opened with
SequentialAccess, ordinals may be read in any order, andFieldCountequals the projection column count (no appendedrowidlocator). The reader is forward-only on every provider. - SQLite is buffered, not chunked. A
byte[]/stringcolumn in a multi-column SQLite projection is read whole into managed memory (Select(x => new { x.Id, x.Body })round-tripsBodyviaGetString/GetValue); there is noSqliteBlobchunking and therefore no O(buffer) benefit for that column. For a single large column on SQLite useToStream/ToTextReader, which do stream. - Ownership. The returned reader owns the underlying
DbDataReaderand the per-callDbCommand; dispose it (await usingon the async path) to release both. The context stays alive and usable. - Cancellation. The token cancels opening the reader; it is also linked into
Read/ReadAsyncandNextResult/NextResultAsync.
ToDataReader is supported on SQLite for non-LOB projections and, with the buffered caveat above, for a LOB column inside a multi-column projection. MySQL/MariaDB and ClickHouse reject the terminal because they have no sequential-access support, and the in-memory provider rejects it because it has no DbDataReader.
SequentialAccess and the plan cache
The command is opened with CommandBehavior.SequentialAccess, which tells the provider to hand the LOB bytes over as they are read rather than buffering the value. The streaming terminal prepares a fresh per-call command with storeInCache: false, so it never touches the plan cache — it neither reads nor writes an entry. A buffered query of the same SQL shape keeps its ordinary plan (without SequentialAccess) and does not reuse the LOB command. The observable guarantee is the same — the buffered and streaming forms never share a plan — but the mechanism is the per-call preparation, not a discriminator in the plan-cache key.
Ownership and disposal
The returned Stream/TextReader owns the underlying DbDataReader and a per-call DbCommand. Disposing the stream (preferably with await using on the async path) releases both and returns the connection to the pool; the DataContext itself is not closed and can serve further queries. The context must stay alive while the stream is read: opening a terminal when the context is already disposed throws ObjectDisposedException.
On the in-memory provider none of this applies: the returned MemoryStream/StringReader owns no reader or command, so disposing it releases nothing on the context, and the context need not stay alive while the object is read (opening the terminal on an already-disposed context still throws ObjectDisposedException).
Memory and async
Memory use is O(buffer): only the chunk you request is materialised, never the whole value. ToStreamAsync/ToTextReaderAsync open the reader and command asynchronously, but the provider getter itself (GetStream/GetTextReader) is synchronous — so the asynchronous part is the reading you drive through Stream.ReadAsync/TextReader.ReadAsync on the returned object. Cancellation is honoured while opening and surfaces as an exception without leaking the reader or the command.
On the in-memory provider the value is materialised before the terminal wraps it, so memory is O(value), not O(buffer), and ReadAsync on the returned MemoryStream/StringReader is the ordinary BCL async surface with no provider round-trip.
See also
- Projections
- Streaming data to a Stream
- Raw SQL
- PostgreSQL provider · SQL Server provider
- Limitations and out-of-scope features
Source: src/nextorm.core/Query/Commands/QueryCommandExtensions.cs, src/nextorm.core/DataContext/Results/CommandReaderOwner.cs,
src/nextorm.core/DataContext/Lob/LobDataReader.cs,
tests/nextorm.integration.tests/CommonTestSuite/Lob/CommonTestSuite.Lob.cs and the SQL-generation/dialect tests for SupportsSequentialAccess.