Skip to content
Client v5: BLE, BLE Hosting, HTTP, Jobs - Linux, MacOS, & Blazor Support! Full AOT, RX on BLE only & MANY other features! Power up!

Blobs

A blob is a binary payload attached to a document — a PDF, an image, a signature — stored in a sidecar table rather than inside the document JSON, and loaded on demand. The document keeps only the blob’s metadata (size, content type, file name) in its body, so ordinary queries never drag the bytes along; you fetch a payload explicitly, when you actually need it.

This is the right tool when a byte[] would otherwise land in the document. A raw byte[] property is base64-encoded straight into the Data column, inflating it ~33% and — the real cost — materializing on every read (Get, every filtered query, the change feed, temporal history). A DocumentBlob keeps the body small and the payload out of the hot path.

Expose a DocumentBlob property (single) and/or a DocumentBlobCollection (many), then map them:

public class Invoice
{
public string Id { get; set; } = "";
public string Customer { get; set; } = "";
public DocumentBlob? Pdf { get; set; }
public DocumentBlobCollection Attachments { get; set; } = new();
}
services.AddDocumentStore(opts =>
{
opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db");
opts.MapBlob<Invoice>(i => i.Pdf);
opts.MapBlobCollection<Invoice>(i => i.Attachments);
});

Each mapping accepts options:

opts.MapBlob<Invoice>(i => i.Signature, o =>
{
o.Key = "sig"; // sidecar key; defaults to the property name
o.ComputeHash = true; // SHA-256 into metadata; OFF by default (real CPU per write)
o.MaxSize = 256 * 1024; // per-mapping ceiling, floored by the provider's MaxBlobSize
});

Payloads live in a {table}_blobs sidecar keyed (DocumentId, TypeName, BlobKey). The document body holds metadata only.

Blobs are written by assigning the member and saving the document — there is no separate “set blob” call. Routing every mutation through the document is what keeps the metadata in the body from ever disagreeing with the stored bytes.

var invoice = new Invoice
{
Customer = "Acme",
Pdf = DocumentBlob.FromBytes(pdfBytes, contentType: "application/pdf", fileName: "acme.pdf")
};
// the collection's Add(...) assigns the sidecar key up front and returns the blob
invoice.Attachments.Add(scanBytes, contentType: "image/png"); // file name is optional
invoice.Attachments.Add(noteBytes, contentType: "text/plain", fileName: "note.txt");
await store.Insert(invoice); // document row + N sidecar rows, one unit of work

Updating is the same object model:

invoice.Pdf = DocumentBlob.FromBytes(newPdf, "application/pdf", "acme-v2.pdf"); // replaces the row
invoice.Pdf = null; // drops the row
invoice.Attachments.RemoveAt(0); // prunes that item's row
await store.Upsert(invoice);

A GetUpsert round-trip that doesn’t touch the blobs issues zero blob writes — an unloaded blob is never resent, and never dropped.

Reading — metadata is free, bytes are not

Section titled “Reading — metadata is free, bytes are not”

A query returns the document with blob metadata populated and the payload unloaded:

var inv = await store.Get<Invoice>(id);
inv.Pdf!.Length; // 84213 — from the document body
inv.Pdf.FileName; // "acme.pdf" — or null
inv.Pdf.ContentType; // "application/pdf"
inv.Pdf.IsLoaded; // false
inv.Pdf.Bytes; // throws InvalidOperationException — not loaded

Because the metadata is real JSON in the body, you can filter and sort by it like any other property — the payload is never touched:

store.Query<Invoice>().Where(x => x.Pdf!.Length > 1_000_000);
store.Query<Invoice>().Where(x => x.Pdf!.ContentType == "application/pdf")
.OrderByDescending(x => x.Pdf!.Length);
// the string grammar lowers to the same IR
store.Query<Invoice>().Where("Pdf.Length > 1000000");
// Bytes is not a queryable path — throws at translation time
store.Query<Invoice>().Where(x => x.Pdf!.Bytes.Length > 0); // ✗

The store stamps each blob during hydration with the loader it needs, so a blob fetches itself — no separate store call, and you choose the granularity:

// one blob
await inv.Pdf!.LoadAsync();
var bytes = inv.Pdf.Bytes;
// load + read in a single call
var raw = await inv.Pdf.GetBytesAsync();
// one item of a collection (items are DocumentBlobs)
await inv.Attachments[0].LoadAsync();
// the whole collection, in ONE round trip
await inv.Attachments.LoadAllAsync();
foreach (var a in inv.Attachments)
Save(a.FileName, a.Bytes);

Bytes throws until the payload is loaded — there is no hidden I/O behind a property getter and no sync-over-async. A fetch happens only where you write await.

Self-loading is per-document. Looping a page and awaiting LoadAllAsync() per row is an N+1. When you know you want every blob across a page, use the store’s batch load — one round trip for the whole page:

var page = await store.Query<Invoice>().Where(x => x.Customer == "Acme").ToList();
await ((IBlobDocumentStore)store).BatchLoadBlobs(page); // one query for the page
foreach (var inv in page)
Process(inv.Pdf!.Bytes);

When you have an id and a key but no materialized document — a direct download endpoint, say — read the payload straight off the store:

var bytes = await ((IBlobDocumentStore)store).GetBlob<Invoice>(id, "Pdf");

ExportAsync includes blob payloads by default — base64, inline with each document — so a restore is self-contained and rebuilds the sidecar. Take a metadata-only snapshot with IncludeBlobs = false:

await ((IDocumentBackup)store).ExportAsync(stream); // payloads included
await ((IDocumentBackup)store).ExportAsync(stream, new BackupExportOptions { IncludeBlobs = false });
await ((IDocumentBackup)store).RestoreAsync(stream); // rebuilds sidecar rows

Blobs are not versioned — the history sidecar would multiply payload storage by the retention window. Restoring an old version restores the document’s fields but keeps the blobs exactly as they are now, so a restored document never carries metadata describing a superseded payload.

Deleting a document cascades to its blob rows in the same unit of work; ClearAll drops the blob tables too. Blobs are only written through their document, so on the relational providers there are no orphans. SweepOrphanedBlobs<T>() (on IDocumentMaintenance) is available as anti-entropy insurance — it deletes blob rows whose owning document no longer exists (e.g. after an out-of-band delete) and returns the count removed.

Every provider reports a real ceiling via store.MaxBlobSize (bytes). Pre-flight against it rather than discovering the limit as an opaque provider error; a per-mapping MaxSize can only make the ceiling smaller.

if (store.MaxBlobSize == 0)
// this provider does not support blobs
else if (bytes.Length > store.MaxBlobSize)
// too large — reject before writing

Blobs are supported on every server-side provider — the relational family and all the document/NoSQL backends. Only IndexedDB (Blazor WebAssembly) does not support them yet. Providers that do not support blobs report MaxBlobSize == 0 and throw NotSupportedException on any blob write.

ProviderBlobsMaxBlobSize
SQLite1 GB
PostgreSQL / CockroachDB1 GB
SQL Server2 GB
MySQL / MariaDB1 GB
Oracle2 GB
DuckDB1 GB
LiteDB16 MB
MongoDB / Amazon DocumentDB15 MB
Redis512 MB
Azure Table64 KB
DynamoDB390 KB
Azure Cosmos DB1.4 MB
Firestore1 MB
RavenDB✅ (native attachments)2 GB
IndexedDB (Blazor WASM)— not yet0