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.
Mapping a blob
Section titled “Mapping a blob”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.
Writing
Section titled “Writing”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 blobinvoice.Attachments.Add(scanBytes, contentType: "image/png"); // file name is optionalinvoice.Attachments.Add(noteBytes, contentType: "text/plain", fileName: "note.txt");
await store.Insert(invoice); // document row + N sidecar rows, one unit of workUpdating is the same object model:
invoice.Pdf = DocumentBlob.FromBytes(newPdf, "application/pdf", "acme-v2.pdf"); // replaces the rowinvoice.Pdf = null; // drops the rowinvoice.Attachments.RemoveAt(0); // prunes that item's rowawait store.Upsert(invoice);A Get → Upsert 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 bodyinv.Pdf.FileName; // "acme.pdf" — or nullinv.Pdf.ContentType; // "application/pdf"inv.Pdf.IsLoaded; // falseinv.Pdf.Bytes; // throws InvalidOperationException — not loadedBecause 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 IRstore.Query<Invoice>().Where("Pdf.Length > 1000000");
// Bytes is not a queryable path — throws at translation timestore.Query<Invoice>().Where(x => x.Pdf!.Bytes.Length > 0); // ✗Loading payloads on demand
Section titled “Loading payloads on demand”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 blobawait inv.Pdf!.LoadAsync();var bytes = inv.Pdf.Bytes;
// load + read in a single callvar raw = await inv.Pdf.GetBytesAsync();
// one item of a collection (items are DocumentBlobs)await inv.Attachments[0].LoadAsync();
// the whole collection, in ONE round tripawait 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.
Across a page of results
Section titled “Across a page of results”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);Without a document instance
Section titled “Without a document instance”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");Backup
Section titled “Backup”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 includedawait ((IDocumentBackup)store).ExportAsync(stream, new BackupExportOptions { IncludeBlobs = false });await ((IDocumentBackup)store).RestoreAsync(stream); // rebuilds sidecar rowsTemporal
Section titled “Temporal”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.
Lifecycle
Section titled “Lifecycle”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.
Size limits
Section titled “Size limits”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 blobselse if (bytes.Length > store.MaxBlobSize) // too large — reject before writingProvider support
Section titled “Provider support”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.
| Provider | Blobs | MaxBlobSize |
|---|---|---|
| SQLite | ✅ | 1 GB |
| PostgreSQL / CockroachDB | ✅ | 1 GB |
| SQL Server | ✅ | 2 GB |
| MySQL / MariaDB | ✅ | 1 GB |
| Oracle | ✅ | 2 GB |
| DuckDB | ✅ | 1 GB |
| LiteDB | ✅ | 16 MB |
| MongoDB / Amazon DocumentDB | ✅ | 15 MB |
| Redis | ✅ | 512 MB |
| Azure Table | ✅ | 64 KB |
| DynamoDB | ✅ | 390 KB |
| Azure Cosmos DB | ✅ | 1.4 MB |
| Firestore | ✅ | 1 MB |
| RavenDB | ✅ (native attachments) | 2 GB |
| IndexedDB (Blazor WASM) | — not yet | 0 |