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!

Azure Table Storage

The Shiny.DocumentDb.AzureTable package provides a document store over Azure Table Storage using Azure.Data.Tables. It also targets the Cosmos DB Table API (the same SDK/protocol). Every document type lives in one table: PartitionKey = typeName, RowKey = id.

NuGet package Shiny.DocumentDb.AzureTable

Azure Table is a NoSQL key-partitioned store. Rich LINQ queries evaluate client-side after a single-partition scan (the LiteDB model); optimistic concurrency is backed by the native Table ETag. There is no spatial / vector / full-text / temporal support on this provider.

  • Cheap, massively scalable key/value + JSON document storage on Azure
  • Existing Azure Storage accounts (or a Cosmos DB Table API account) you already pay for
  • Workloads whose reads are almost always “all documents of a type” or point reads by id
  • You don’t need server-side rich querying, spatial, vector, full-text, or temporal history
Terminal window
dotnet add package Shiny.DocumentDb.AzureTable
  1. Direct instantiation

    using Shiny.DocumentDb.AzureTable;
    var store = new AzureTableDocumentStore(new AzureTableDocumentStoreOptions
    {
    ConnectionString = "UseDevelopmentStorage=true", // or a real account / SAS connection string
    TableName = "Documents" // one table; PartitionKey=typeName, RowKey=id
    });
  2. Dependency injection

    using Shiny.DocumentDb;
    builder.Services.AddAzureTableDocumentStore(o =>
    {
    o.ConnectionString = "UseDevelopmentStorage=true";
    o.TableName = "Documents";
    o.MapVersionProperty<Order>(x => x.Version); // opt-in optimistic concurrency (ETag-backed)
    });

    AddAzureTableDocumentStore registers IDocumentStore and IDocumentMaintenance as singletons.

Set exactly one of the following on the options (checked in this order): TableServiceClient (a pre-built client), ConnectionString, or ServiceUri + one credential.

// Connection string (account key, SAS, or the emulator)
o.ConnectionString = "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...";
// Managed identity / DefaultAzureCredential
o.ServiceUri = new Uri("https://myaccount.table.core.windows.net");
o.TokenCredential = new Azure.Identity.DefaultAzureCredential();
// Shared key or SAS
o.SharedKeyCredential = new TableSharedKeyCredential("myaccount", "<key>");
o.SasCredential = new AzureSasCredential("<sas>");

The same provider works against the Cosmos DB Table API — point the connection string / endpoint at your Cosmos Table account.

PropertyTypeDefaultDescription
ConnectionStringstring?nullAccount / SAS / emulator connection string
ServiceUriUri?nullTable endpoint (used with a credential)
TokenCredentialTokenCredential?nullDefaultAzureCredential / managed identity
SharedKeyCredentialTableSharedKeyCredential?nullAccount name + key
SasCredentialAzureSasCredential?nullSAS credential
TableServiceClientTableServiceClient?nullPre-built client (wins over all other options)
TableNamestring"Documents"The single table holding every type
AutoCreateTablebooltrueCreate the table if it doesn’t exist
TypeNameResolutionTypeNameResolutionShortNameHow type names (partition keys) are derived
JsonSerializerOptionsJsonSerializerOptions?nullJSON serialization settings
UseReflectionFallbackbooltrueSet false for AOT safety
LoggingAction<string>?nullDiagnostic callback

Additional mapping methods: MapTypeToPartition<T>(pk), MapIdProperty<T>(...), MapIdType<TId>(...), AddQueryFilter<T>(...), AddInterceptor / OnBeforeWrite<T> / OnAfterWrite<T>, and MapVersionProperty<T>(...).

ConceptAzure Table
PartitionPartitionKey = typeName
Document keyRowKey = id.ToString()
PayloadData string column (+ CreatedAt / UpdatedAt)
Point readGetEntity(pk, rk)
Type-scoped queryPartitionKey eq '<typeName>'
Concurrency (CAS)ETag If-Match on UpdateEntity
BatchSubmitTransaction ≤ 100 per PartitionKey

Because Query<T>() is always type-scoped, the common read path is a single-partition query — no cross-partition scan.

There is no server-side query translation. Query<T>() runs a single-partition query to load every document of the type, then evaluates Where / OrderBy / Paginate / Select / aggregates in memory via the shared ExpressionInterpreter:

var open = await store.Query<Order>()
.Where(o => o.Status == "Open") // evaluated client-side
.OrderByDescending(o => o.CreatedAt)
.Paginate(0, 25)
.ToList();

Promote a scalar property to a native top-level Table column so predicates over it are pushed into a server-side OData $filter (the full predicate still re-runs client-side, so results are always exact):

var opts = new AzureTableDocumentStoreOptions { ConnectionString = "…", TableName = "Documents" }
.MapIndexedProperty<Order>(o => o.Status)
.MapIndexedProperty<Order>(o => o.Total);
// Status/Total are written as native columns → this predicate is pushed down, not scanned:
var open = await store.Query<Order>().Where(o => o.Status == "Open" && o.Total > 100).ToList();

Inspect the query the builder would run with ToQueryString().

The string Query/QueryStream/Count overloads take a raw OData $filter fragment (Azure Table’s native query language) scoped to the type’s partition. It targets native columns — the promoted columns (reference them by their CLR/JSON property name) and the built-in PartitionKey/RowKey/Timestamp — not fields inside the opaque JSON body. parameters supplies @name token substitutions.

var open = await store.Query<Order>("Status eq @s and Total gt @min",
parameters: new { s = "Open", min = 100 });

Project(string) is not supported.

Azure Table implements IObservableDocumentStore — subscribe to in-process changes made through this store instance:

await foreach (var change in ((IObservableDocumentStore)store).NotifyOnChange<Order>(ct))
Console.WriteLine($"{change.ChangeType} {change.Id}");
// or scoped to a query's predicate
await foreach (var change in store.Query<Order>().Where(o => o.Status == "Open").NotifyOnChange(ct))

Notifications are in-process (this instance’s writes), buffered inside a UnitOfWork until it commits. For a cross-writer feed use DynamoDB (Streams) or Cosmos.

  • Guid / string Ids auto-generate on Insert when default. Explicit int/long Ids round-trip fine.
  • Int/Long Id auto-generation is unsupported — inserting a default int/long Id throws NotSupportedException (there is no cheap MAX). Use Guid/string Ids, or assign the int/long Id yourself.
  • Optimistic concurrency: MapVersionProperty<T>(x => x.Version) seeds the version to 1 on insert, checks & increments on update/upsert, and guards the write with the Table ETag (If-Match). A stale write throws ConcurrencyException. Blind (unversioned) upsert is last-write-wins.

Each document is a TableEntity:

ColumnValue
PartitionKeythe type name
RowKeythe document id (string form)
Datathe serialized JSON body
CreatedAt / UpdatedAtISO-8601 timestamps
ETag / TimestampSDK-managed (physical CAS token)
  • 64 KB per-property / 1 MB per-entity cap — the JSON body is a single Data string column, so a document larger than ~64 KB throws a clear NotSupportedException (not a raw storage 413). Store large fields externally or use Cosmos/MongoDB.
  • Client-side queries by default — an unindexed Query<T>() is a full type scan; promote the filtered property with MapIndexedProperty<T> to push the filter server-side.
  • Int/Long Id auto-generation unsupported — use Guid/string.
  • No spatial / vector / full-text / temporalSupportsSpatial / SupportsVector / SupportsFullText are false and there is no ITemporalDocumentStore.
  • In-process change observation onlyIObservableDocumentStore observes this instance’s writes; Azure Table has no cross-writer change feed (use DynamoDB Streams or Cosmos for that).
  • Compensating unit of workCreateUnitOfWork() tracks inserts and rolls them back on failure; there is no cross-partition transaction (same model as Cosmos).
  • IDocumentMaintenance.ClearAll() is supported (scan-deletes every partition) — handy for tests/dev resets.
  • BatchInsert / BatchRemove use native SubmitTransaction in ≤ 100-item waves per PartitionKey.
  • Upsert deep-merges in C# with recursive null stripping (RFC 7396 semantics); when versioned, the merge write is the ETag-guarded write.