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!

CRUD Operations

Every document type must have a public Id property of type Guid, int, long, or string. The Id is stored in both the database Id column and inside the JSON blob, so query results always include it.

public class User
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public int Age { get; set; }
public string? Email { get; set; }
}
Id CLR TypeDefault ValueAuto-Gen Strategy
GuidGuid.EmptyGuid.NewGuid()
stringnull or ""Throws InvalidOperationException
int0MAX(CAST(Id AS INTEGER)) + 1 per TypeName
long0MAX(CAST(Id AS INTEGER)) + 1 per TypeName

When Insert is called with a default Id, the store auto-generates one for Guid, int, and long types and writes it back to the object. For string Ids, a default value throws an InvalidOperationException — you must always supply an explicit Id. When a non-default Id is provided, it is used as-is.

To use an Id type beyond the four built-ins — a Ulid, or a strongly-typed wrapper such as record struct OrderId(Guid Value) — register a converter with MapIdType on the store options. The Id is still stored as a string in every provider, so there is no schema or on-disk change; the converter just defines how that string round-trips.

public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
// Inline delegates:
options.MapIdType(
toString: (OrderId id) => id.Value.ToString("N"),
parse: s => new OrderId(Guid.ParseExact(s, "N")),
isDefault: id => id.Value == Guid.Empty, // when to auto-generate on Insert
generate: OrderId.New); // optional; omit to require explicit Ids
// …or a reusable class deriving from DocumentIdConverter<TId>:
public sealed class OrderIdConverter : DocumentIdConverter<OrderId>
{
public override string ToStorageString(OrderId id) => id.Value.ToString("N");
public override OrderId FromStorageString(string s) => new(Guid.ParseExact(s, "N"));
public override bool IsDefault(OrderId id) => id.Value == Guid.Empty;
public override bool TryGenerate(out OrderId id) { id = OrderId.New(); return true; }
}
options.MapIdType(new OrderIdConverter());
public class Order
{
public OrderId Id { get; set; } // default-named "Id" — no MapIdProperty needed
public string Customer { get; set; } = "";
}
var order = new Order { Customer = "Alice" };
await store.Insert(order); // Id auto-generated via TryGenerate/generate
var fetched = await store.Get<Order>(order.Id); // Get/Update/Remove accept the typed Id

MapIdType is available on every provider’s options class. The built-in Guid/int/long/string types need no registration and are unchanged.

For time-ordered, append-friendly Guid Ids, call UseGuidV7Ids() to auto-generate version 7 GUIDs (Guid.CreateVersion7()) instead of the default random version 4. No extra dependency — it’s BCL — and the storage format is unchanged, so it is a drop-in for existing Guid-keyed data (only newly generated Ids differ).

options.UseGuidV7Ids(); // every `Guid Id` now auto-generates a sortable v7 GUID

If you only want a non-Guid integral key, note long is already a built-in Id type (auto-generated as MAX(id) + 1 per type).

// Auto-generated ID (Guid) — written back to the object
public class UserGuid
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public int Age { get; set; }
}
var user = new UserGuid { Name = "Alice", Age = 25 };
await store.Insert(user);
// user.Id is now populated
// Explicit ID (string)
await store.Insert(new User { Id = "user-1", Name = "Alice", Age = 25 });

BatchInsert inserts multiple documents in a single transaction with prepared command reuse for optimal performance. Returns the count inserted. If any document fails (e.g. duplicate Id), the entire batch is rolled back. Auto-generates IDs for Guid, int, and long Id types.

var users = Enumerable.Range(1, 1000).Select(i => new User
{
Id = $"user-{i}", Name = $"User {i}", Age = 20 + (i % 50)
});
var count = await store.BatchInsert(users); // single transaction, prepared command reused

BatchUpsert, BatchUpdate, and BatchRemove apply many writes as one set operation instead of one round-trip per document, and they are all-or-nothing: on a versioned type the first version conflict throws ConcurrencyException and the whole batch rolls back.

await store.BatchUpsert(users); // RFC 7396 merge-or-insert, many at once
await store.BatchUpdate(users); // full replace, every doc must exist
int removed = await store.BatchRemove<User>(new object[] { "u1", "u2", "u3" }); // ids that don't exist are ignored

How much you save depends on the backend (the provider tier):

BackendBatchUpsertBatchUpdateBatchRemove
SQLite, DuckDBsingle multi-row INSERT … ON CONFLICT (deep merge)per-row in one transactionsingle DELETE … IN (…)
SQL Server, PostgreSQL, MySQL, Oracleper-doc loop in one transaction¹per-row in one transactionsingle DELETE … IN (…)
MongoDBone BulkWriteone BulkWriteone DeleteMany
Cosmosbounded-concurrency waves²bounded-concurrency waves²bounded-concurrency waves²

¹ These providers lack a native deep-merge function, so BatchUpsert reuses the per-document read-merge-write inside one transaction. ² Cosmos has no cross-document transaction; batches run as parallel request waves (best-effort, matching the provider’s unit-of-work behaviour).

A type that has a version mapping, temporal history, a spatial/vector sidecar, multi-tenancy, a query filter, or a per-document interceptor always takes the per-document loop inside one transaction — correct and atomic, just not the multi-row/bulk fast path.

Unit of work — IDocumentSession (store.OpenSession())

Section titled “Unit of work — IDocumentSession (store.OpenSession())”

The document store is a connection: open a short-lived IDocumentSession (the EF-DbContext analogue), queue operations, then call SaveChanges. Everything commits together or rolls back together. Contiguous same-type runs of inserts, upserts, updates, and removes are each coalesced into the matching batch method, so grouping like operations in a session costs nothing versus calling those methods directly.

await using var session = store.OpenSession();
session.Add(order)
.AddRange(orderLines) // coalesced into one batch insert
.Update(customer)
.Remove<Cart>(cartId);
await session.SaveChanges(); // one transaction; rolls back entirely on failure

Open a session from the singleton IDocumentStore (store.OpenSession()), inject IDocumentSession (scoped) in ASP.NET via AddScopedDocumentSession(), or open one from the singleton IDocumentSessionFactory where there’s no ambient scope (MAUI/desktop/background — mirrors EF’s IDbContextFactory). A session is a write buffer, not a tracking context: reads don’t see operations still buffered before SaveChanges. For read-and-lock or multiple set-based ExecuteUpdate/ExecuteDelete in one transaction, open an explicit await using var tx = await session.BeginTransaction(); (relational providers) and use session.Get(id, LockMode.Update); SaveChanges joins the active transaction. See the FAQ for the full write-API decision tree.

Upsert uses JSON merge patch (RFC 7396) to deep-merge a partial patch into an existing document. If the document doesn’t exist, it is inserted as-is. Unlike Update, which replaces the entire document, Upsert only overwrites the fields present in the patch. The document must have a non-default Id.

// Insert a full document
await store.Insert(new User { Id = "user-1", Name = "Alice", Age = 25, Email = "alice@test.com" });
// Merge patch — only update Name and Age, preserve Email
await store.Upsert(new User { Id = "user-1", Name = "Alice", Age = 30 });
var user = await store.Get<User>("user-1");
// user.Name == "Alice", user.Age == 30, user.Email == "alice@test.com" (preserved)

How it works:

  • On insert (new ID): the patch is stored as the full document.
  • On conflict (existing ID): the provider’s JSON merge function merges the patch into the stored JSON. Scalars and arrays are replaced.
  • Null properties are excluded from the patch automatically. In C#, unset nullable properties (e.g. string? Email) serialize as null, which would remove the key under RFC 7396. The library strips these so that unset fields are preserved rather than deleted.

Update and Upsert each take an optional boolean so you can pick merge or replace explicitly, without switching methods:

await store.Update(doc, patch: false); // full replace (default) — the whole body is overwritten
await store.Update(doc, patch: true); // RFC 7396 merge into the existing doc (must already exist)
await store.Upsert(doc, patchIfUpdate: true); // merge on update, insert if absent (the default behaviour)
await store.Upsert(doc, patchIfUpdate: false); // replace the body wholesale on update, insert if absent

The merge modes strip null properties (so an unset nullable field is left unchanged, never deleted) and require a non-default Id. Update(patch: true) throws if the document doesn’t exist; Upsert inserts instead.

SetProperty updates a single scalar field in-place using the provider’s JSON set function — no deserialization, no full document replacement. Returns true if the document was found and updated, false if not found. The id parameter accepts Guid, int, long, or string.

// Update a scalar field
await store.SetProperty<User>("user-1", u => u.Age, 31);
// Update a string field
await store.SetProperty<User>("user-1", u => u.Email, "newemail@test.com");
// Set a field to null
await store.SetProperty<User>("user-1", u => u.Email, null);
// Nested property
await store.SetProperty<Order>("order-1", o => o.ShippingAddress.City, "Portland");
// Check if document existed
bool updated = await store.SetProperty<User>("user-1", u => u.Age, 31);

How it works: The expression u => u.Age is resolved to the JSON path $.age (respecting [JsonPropertyName] attributes and naming policies). The generated SQL varies by provider — for example, SQLite uses json_set(), SQL Server uses JSON_MODIFY(), etc.

Supported value types: string, int, long, double, float, decimal, bool, and null. To replace a collection or nested object, use Update (full replacement) or Upsert (merge patch).

RemoveProperty strips a field from the stored JSON using the provider’s JSON remove function. Returns true if the document was found and updated, false if not found. The removed field will have its C# default value on next read. The id parameter accepts Guid, int, long, or string.

// Remove a nullable field
await store.RemoveProperty<User>("user-1", u => u.Email);
// Remove a nested property
await store.RemoveProperty<Order>("order-1", o => o.ShippingAddress.City);
// Remove a collection property (removes the entire array)
await store.RemoveProperty<Order>("order-1", o => o.Tags);

Unlike SetProperty, RemoveProperty works on any property type — scalar, nested object, or collection — because it simply removes the key from the JSON.

OperationUse whenScopeCollections
SetPropertyChanging one scalar fieldSingle field via JSON setScalar values only
RemovePropertyStripping a field from the documentSingle field via JSON removeAny property type
UpsertPatching multiple fields at onceDeep merge via JSON merge patchReplaces arrays (RFC 7396)
UpdateReplacing the entire documentFull replacementFull control
InsertInserting a new documentStrict insert, throws on duplicateThrows for string default Ids
BatchInsertInserting many documents efficientlySingle transaction, prepared commandAuto-generates IDs; atomic rollback
BatchUpsert / BatchUpdate / BatchRemoveApplying many upserts / updates / removes at onceOne set operation (multi-row or bulk where supported); all-or-nothingPer-provider tier; ids missing on remove are ignored
GetDiffDiffing local changes vs stored stateRead-only; returns RFC 6902 patchDeep nested diff; arrays replaced as whole

Map a version property on your document type for automatic optimistic concurrency checks. The version is stored inside the JSON blob — no schema or table changes required. Works across all providers.

// Expression-based (reflection)
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}.MapVersionProperty<Order>(o => o.RowVersion));
// AOT-safe overload
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}.MapVersionProperty<Order>("RowVersion", o => o.RowVersion, (o, v) => o.RowVersion = v));

All provider options classes support MapVersionProperty: DocumentStoreOptions, LiteDbDocumentStoreOptions, CosmosDbDocumentStoreOptions, and IndexedDbDocumentStoreOptions.

OperationBehavior
InsertVersion is set to 1 before serialization
UpdateReads the expected version from the object, checks it against the stored version, then increments. Throws ConcurrencyException on mismatch
UpsertInsert path sets version to 1. Update path checks and increments (only when the existing version > 0)
BatchInsertVersion is set to 1 for each document
BatchUpsert / BatchUpdate / BatchRemoveSame per-document version rules as Upsert/Update; the batch is all-or-nothing — the first conflict throws ConcurrencyException and rolls the whole batch back
public class Order
{
public string Id { get; set; } = "";
public string Status { get; set; } = "";
public int RowVersion { get; set; }
}
// Insert — RowVersion is set to 1
var order = new Order { Id = "ord-1", Status = "Pending" };
await store.Insert(order);
// order.RowVersion == 1
// Update — RowVersion is checked and incremented
order.Status = "Shipped";
await store.Update(order);
// order.RowVersion == 2
// Concurrent update — throws ConcurrencyException
var staleOrder = new Order { Id = "ord-1", Status = "Cancelled", RowVersion = 1 };
await store.Update(staleOrder); // throws ConcurrencyException

ConcurrencyException provides diagnostic properties:

PropertyDescription
TypeNameThe document type name
DocumentIdThe document Id
ExpectedVersionThe version the caller expected
ActualVersionThe version found in the store (when available)
try
{
await store.Update(staleOrder);
}
catch (ConcurrencyException ex)
{
Console.WriteLine($"Conflict on {ex.TypeName} {ex.DocumentId}: expected v{ex.ExpectedVersion}, found v{ex.ActualVersion}");
}

The id parameter accepts Guid, int, long, or string. Passing an unsupported type throws ArgumentException.

var user = await store.Get<User>("user-1");
// Guid, int, and long Ids work directly — no ToString() needed
var item = await store.Get<GuidIdModel>(myGuid);
var order = await store.Get<IntIdModel>(42);

Compare a modified object against the stored document and get an RFC 6902 JsonPatchDocument<T> describing the differences. Returns null if no document with that ID exists.

var proposed = new Order
{
Id = "ord-1", CustomerName = "Alice", Status = "Delivered",
ShippingAddress = new() { City = "Seattle", State = "WA" },
Lines = [new() { ProductName = "Widget", Quantity = 10, UnitPrice = 8.99m }],
Tags = ["priority", "expedited"]
};
var patch = await store.GetDiff("ord-1", proposed);
// patch.Operations:
// Replace /status → Delivered
// Replace /shippingAddress/city → Seattle
// Replace /shippingAddress/state → WA
// Replace /lines → [...]
// Replace /tags → [...]
// Apply the patch to any instance of the same type
var current = await store.Get<Order>("ord-1");
patch!.ApplyTo(current!);

The diff is deep — nested objects produce individual property-level operations (e.g. /shippingAddress/city), while arrays and collections are replaced as a whole. Works with table-per-type, custom Id, and inside transactions.

var users = await store.Query<User>().ToList();

The id parameter accepts Guid, int, long, or string. Passing an unsupported type throws ArgumentException.

// By ID
bool deleted = await store.Remove<User>("user-1");
bool removed = await store.Remove<GuidIdModel>(myGuid);
// Returns number of deleted rows
int deleted = await store.Query<User>().Where(u => u.Age < 18).ExecuteDelete();
// Update a property on matching docs — returns number of updated rows
int updated = await store.Query<User>()
.Where(u => u.Age < 18)
.ExecuteUpdate(u => u.Age, 18);

See Querying for more examples of bulk delete and update with expressions.

int deletedCount = await store.Clear<User>();

When you have a registered document Type but not a CLR T at the call site — generic HTTP intake, message-bus payloads, ETL, gateways — write and read documents by handing the store the Type and the JSON body directly. This skips serialization: the supplied JSON is stored as-is.

using System.Text.Json.Nodes;
// One document
var body = JsonNode.Parse("""{ "customer": "acme", "total": 42.50 }""")!;
int n = await store.Insert(typeof(Order), body); // n == 1, Id auto-generated & injected into `body`
// Many at once — one atomic transaction (a mid-batch failure rolls the whole call back)
var batch = JsonNode.Parse("""[ { "customer": "a" }, { "customer": "b" } ]""")!;
await store.Insert(typeof(Order), batch); // returns 2
// Update (full replace) and Upsert (RFC 7396 merge patch) mirror the typed methods
await store.Update(typeof(Order), JsonNode.Parse("""{ "id": "ord-7", "status": "open" }""")!);
await store.Upsert(typeof(Order), JsonNode.Parse("""{ "id": "ord-7", "status": "shipped" }""")!);
// Read back as raw JSON — no deserialize to T (cheaper, and AOT-clean)
JsonNode? doc = await store.Get(typeof(Order), "ord-7");
IReadOnlyList<JsonNode> open = await store.Query(typeof(Order), "json_extract(Data, '$.status') = @s", new { s = "open" });
await foreach (var node in store.QueryStream(typeof(Order), "json_extract(Data, '$.status') = @s", new { s = "open" })) { /* … */ }

This lane rides the full normal write pipeline — tenancy, temporal history, versioning/optimistic-CAS, spatial + vector sidecars, JSON interceptors, and change notifications all apply (unlike IDocumentBackup/BulkImport, which deliberately bypasses them). The generated Id (and bumped version) is written back onto your node, exactly like the typed Insert<T>.

Key rules:

  • JSON stored AS-IS — you own the property shape. Member names must match what T serializes to (camelCase by default). Get/Query read that same shape back.
  • Object vs. array. A JsonObject writes one document; a JsonArray writes many atomically; a primitive JsonValue throws ArgumentException.
  • Mapped properties must be present. Because the body is verbatim, every registered spatial/vector mapping’s JSON path must be present, or Insert/Update throws InvalidOperationException naming it. An explicit JSON null is honored as a deliberate “no value” (the sidecar is skipped). Upsert runs this check only when the element has no Id (a guaranteed insert), so partial merge patches aren’t forced to re-send the location/vector.
  • Filtering reuses the string WHERE/OData surface — there is no “filter by JsonNode”.

Two documented limitations: object-mutating interceptors are a no-op on this lane (there is no T to mutate — JSON-shaped interceptors via ctx.GetJsonDocument() still work fully), and vector auto-embedding does not run (supply the embedding in the JSON).

Provider tier: supported on the relational providers (SQLite, SQLCipher, MySQL, SQL Server, PostgreSQL, Oracle, DuckDB) via the core store. The document-native (MongoDB, Cosmos, LiteDB, IndexedDB) and key-partitioned (Azure Table, DynamoDB) providers throw NotSupportedException until a later cut, and the lane is not available inside a session transaction.

Creates a hot backup of the database to a file. The store remains fully usable during the backup. Only available on concrete store types — not on the IDocumentStore interface.

StoreBehavior
SqliteDocumentStoreUses the SQLite Online Backup API
SqlCipherDocumentStoreBackup is automatically encrypted with the same password
LiteDbDocumentStoreRequires a file-based connection string with a Filename parameter
// SQLite
var sqliteStore = new SqliteDocumentStore("Data Source=mydata.db");
await sqliteStore.Backup("/path/to/backup.db");
// SQLCipher — backup encrypted with same password
var cipherStore = new SqlCipherDocumentStore("encrypted.db", "mySecretKey");
await cipherStore.Backup("/path/to/backup.db");
// LiteDB
var liteStore = new LiteDbDocumentStore(new LiteDbDocumentStoreOptions
{
ConnectionString = "Filename=mydata.db"
});
await liteStore.Backup("/path/to/backup.db");

IDocumentMaintenance.ClearAll() wipes every document type in the store — including temporal-history, spatial, and vector sidecars. It’s intended for test and dev resets, not production use. Unlike Clear<T>() it is neither type- nor tenant-scoped; on a shared-table multi-tenant store it clears all tenants. It targets only your own (user) tables in the current database — system catalog schemas are never touched.

It’s an optional capability — probe for it with is IDocumentMaintenance:

if (store is IDocumentMaintenance maintenance)
await maintenance.ClearAll();

Implemented on the relational DocumentStore (SQLite, SQL Server, PostgreSQL, MySQL, DuckDB, Oracle), MongoDB, and CosmosDB; verified across every provider suite.

Register IDocumentSeeders to populate initial data once. The store is schema-free, so seeding is just idempotent writes — there is no schema to reconcile, and the same seeder works against every provider.

Run-once is versioned: each seeder records a DocumentSeedMarker keyed on its Name, and runs only when it has never run or when its Version is greater than the recorded one. Bump the version to re-run after changing seed data.

public class CountrySeeder : IDocumentSeeder
{
public string Name => "countries";
public int Version => 1; // bump to re-run after editing the data below
public async Task SeedAsync(IDocumentStore store, CancellationToken ct)
{
// Upsert on a known Id keeps it idempotent across re-runs
await store.Upsert(new Country { Id = "CA", Name = "Canada" }, cancellationToken: ct);
await store.Upsert(new Country { Id = "US", Name = "United States" }, cancellationToken: ct);
}
}

Register it with DI — it runs once at host startup via a hosted service:

builder.Services.AddDocumentStore(o => o.DatabaseProvider = ...);
builder.Services.AddDocumentSeeder<CountrySeeder>();
// or inline, without a class:
builder.Services.AddDocumentSeeder("settings", version: 1, async (store, ct) =>
await store.Upsert(new AppSettings { Id = "global", Theme = "dark" }, cancellationToken: ct));

To seed a named (keyed) store registered with AddDocumentStore("reporting", ...), pass storeName — seeders are grouped by target store and run against the right one:

builder.Services.AddDocumentSeeder<CountrySeeder>(storeName: "reporting");

Where there’s no generic host (e.g. a MAUI app), run the seeders yourself:

await DocumentSeedRunner.RunAsync(store, new IDocumentSeeder[] { new CountrySeeder() });