
Learn Azure Cosmos DB with C# in this step-by-step tutorial. Master partition keys, RUs, LINQ queries, and .NET SDK best practices. Start building today!
If you have ever watched a SQL Server database buckle under traffic from three continents at once, you already understand why Azure Cosmos DB exists. Working with Azure Cosmos DB C# applications gives you a globally distributed, multi-model database with single-digit millisecond reads, 99.999% availability, and elastic scale — all reachable through a first-class .NET SDK. This tutorial walks you through everything from your first container to advanced patterns like bulk ingestion, optimistic concurrency, and change feed processing.
We will use the Microsoft.Azure.Cosmos v3 SDK on .NET 8/9, and every example is runnable. More importantly, we will explain why each decision matters — because in Cosmos DB, an innocent-looking design choice can multiply your monthly bill by ten.
What Is Azure Cosmos DB and Why Should C# Developers Care?
Azure Cosmos DB is Microsoft's globally distributed, horizontally partitioned NoSQL database. Unlike a traditional relational database where you scale up by buying a bigger machine, Cosmos DB scales out by spreading your data across physical partitions and, optionally, across Azure regions worldwide.
Three characteristics define it:
- Turnkey global distribution. Adding a read region in London, Sydney, or Toronto is a checkbox — no replication scripts, no failover tooling.
- Guaranteed latency and throughput. Microsoft backs P99 latency of <10 ms for point reads with a financial SLA.
- Tunable consistency. Five levels (Strong, Bounded Staleness, Session, Consistent Prefix, Eventual) let you trade consistency for latency and cost deliberately rather than accidentally.
The trade-off is that you pay for provisioned throughput measured in Request Units per second (RU/s), and you must choose a partition key up front. Get these two things right and Cosmos DB is superb. Get them wrong and you will be paying for a system that feels slower than a single SQL Server instance.
Cosmos DB APIs: Which One Do You Want?
Cosmos DB exposes several wire protocols: NoSQL (formerly SQL/Core), MongoDB, Cassandra, Gremlin, and Table. For greenfield .NET work, always pick the NoSQL API. It is the native API, it ships features first, and it has the richest C# SDK. The other APIs exist primarily as migration paths.
Setting Up the Azure Cosmos DB .NET SDK
Install the SDK from NuGet:
// dotnet add package Microsoft.Azure.Cosmos
// dotnet add package Azure.Identity
using Microsoft.Azure.Cosmos;
using Azure.Identity;
var options = new CosmosClientOptions
{
// Direct mode uses TCP and is significantly faster than Gateway mode.
ConnectionMode = ConnectionMode.Direct,
// Serializer that matches modern .NET conventions.
SerializerOptions = new CosmosSerializationOptions
{
PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
},
// Route reads to the nearest region automatically.
ApplicationPreferredRegions = new List<string>
{
Regions.EastUS, Regions.WestEurope, Regions.AustraliaEast
},
MaxRetryAttemptsOnRateLimitedRequests = 9,
MaxRetryWaitTimeOnRateLimitedRequests = TimeSpan.FromSeconds(30)
};
// Prefer Entra ID (managed identity) over connection strings in production.
var client = new CosmosClient(
accountEndpoint: "https://your-account.documents.azure.com:443/",
tokenCredential: new DefaultAzureCredential(),
clientOptions: options);
Critical pitfall: CosmosClient is thread-safe and expensive to construct — it caches routing tables, warms TCP connections, and maintains partition maps. Create one singleton per account for the lifetime of your application. Creating a client per request is the single most common cause of "why is Cosmos DB so slow?" support tickets.
// Program.cs — ASP.NET Core registration
builder.Services.AddSingleton(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
return new CosmosClient(
config["Cosmos:Endpoint"],
new DefaultAzureCredential(),
new CosmosClientOptions { ConnectionMode = ConnectionMode.Direct });
});
Creating Databases and Containers in C#
A Cosmos DB account holds databases; databases hold containers; containers hold items (JSON documents). Here is idempotent bootstrap code you can safely run at startup:
Database database = await client.CreateDatabaseIfNotExistsAsync(
id: "RetailStore",
throughput: null); // null = throughput lives at the container level
var containerProperties = new ContainerProperties(
id: "Orders",
partitionKeyPath: "/customerId")
{
DefaultTimeToLive = -1, // TTL enabled, but no automatic expiry unless set per item
IndexingPolicy = new IndexingPolicy
{
IndexingMode = IndexingMode.Consistent,
Automatic = true
}
};
// Autoscale scales between 10% and 100% of max RU/s and is ideal for spiky workloads.
Container container = await database.CreateContainerIfNotExistsAsync(
containerProperties,
ThroughputProperties.CreateAutoscaleThroughput(maxAutoscaleThroughput: 4000));
Choosing a Partition Key: The Decision That Matters Most
The partition key determines how Cosmos DB distributes your data across physical partitions (each capped at 50 GB and 10,000 RU/s). You cannot change it without migrating the container, so think carefully.
A good partition key has three properties:
- High cardinality — many distinct values, so data spreads evenly.
/customerIdor/tenantIdis good;/countryor/statusis usually terrible. - Even request distribution — no single value receives a disproportionate share of traffic. This avoids the dreaded "hot partition."
- Alignment with your most frequent query — queries that filter on the partition key are single-partition queries, which are dramatically cheaper than fan-out queries.
If no single field works, use a synthetic partition key by concatenating fields, or Cosmos DB's hierarchical partition keys (up to three levels), which let you query efficiently at any prefix level:
var hierarchical = new ContainerProperties(
id: "Telemetry",
partitionKeyPaths: new List<string> { "/tenantId", "/deviceId", "/date" });
await database.CreateContainerIfNotExistsAsync(hierarchical, throughput: 400);
// Query efficiently by tenant alone, or tenant + device.
var prefixKey = new PartitionKeyBuilder()
.Add("tenant-42")
.Add("device-7")
.Build();
CRUD Operations with the Azure Cosmos DB C# SDK
Define a POCO. Cosmos DB requires an id property (string) and the partition key property must be present on every item.
public record Order
{
public string Id { get; init; } = Guid.NewGuid().ToString();
public string CustomerId { get; init; } = default!;
public string Status { get; init; } = "Pending";
public decimal Total { get; init; }
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public List<OrderLine> Lines { get; init; } = new();
}
public record OrderLine(string Sku, int Quantity, decimal UnitPrice);
Create, Read, Replace, Delete
var order = new Order
{
CustomerId = "cust-1001",
Total = 149.97m,
Lines = { new OrderLine("KB-01", 3, 49.99m) }
};
var pk = new PartitionKey(order.CustomerId);
// CREATE — fails with 409 Conflict if the id already exists in this partition.
ItemResponse<Order> created = await container.CreateItemAsync(order, pk);
Console.WriteLine($"Create cost {created.RequestCharge} RU");
// POINT READ — the cheapest operation in Cosmos DB (~1 RU for a 1 KB item).
// Always prefer this over a query when you know id + partition key.
Order fetched = await container.ReadItemAsync<Order>(order.Id, pk);
// UPSERT — creates or replaces.
await container.UpsertItemAsync(fetched with { Status = "Shipped" }, pk);
// PATCH — sends only the delta; cheaper and avoids lost updates.
await container.PatchItemAsync<Order>(
id: order.Id,
partitionKey: pk,
patchOperations: new[]
{
PatchOperation.Set("/status", "Delivered"),
PatchOperation.Increment("/total", 5.00)
});
// DELETE
await container.DeleteItemAsync<Order>(order.Id, pk);
Why point reads matter: a point read costs roughly 1 RU. The equivalent SELECT * FROM c WHERE c.id = 'x' query costs 2.5–3 RU even on a single partition. At a million reads a day, that difference is real money.
Handling Errors Correctly
try
{
var response = await container.ReadItemAsync<Order>(id, pk);
return response.Resource;
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null; // Expected case — do not log this as an error.
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
// 429 = rate limited. The SDK retries automatically; reaching here means
// retries were exhausted. Scale up RU/s or add backpressure.
logger.LogWarning("Throttled. Retry after {Delay}", ex.RetryAfter);
throw;
}
Querying: SQL and LINQ in Azure Cosmos DB
Cosmos DB's NoSQL API speaks a SQL dialect over JSON. Use parameterised queries — string concatenation is both a security risk and a query-plan-cache killer.
var query = new QueryDefinition(
"SELECT * FROM c WHERE c.customerId = @customerId AND c.total > @min ORDER BY c.createdUtc DESC")
.WithParameter("@customerId", "cust-1001")
.WithParameter("@min", 100);
var results = new List<Order>();
double totalRu = 0;
using FeedIterator<Order> iterator = container.GetItemQueryIterator<Order>(
query,
requestOptions: new QueryRequestOptions
{
PartitionKey = new PartitionKey("cust-1001"), // single-partition = cheap
MaxItemCount = 100
});
while (iterator.HasMoreResults)
{
FeedResponse<Order> page = await iterator.ReadNextAsync();
totalRu += page.RequestCharge;
results.AddRange(page);
}
Console.WriteLine($"Returned {results.Count} orders for {totalRu:F2} RU");
Prefer LINQ when you want compile-time safety:
using Microsoft.Azure.Cosmos.Linq;
var linqFeed = container
.GetItemLinqQueryable<Order>(
requestOptions: new QueryRequestOptions { PartitionKey = pk })
.Where(o => o.Status == "Shipped" && o.Total > 100)
.OrderByDescending(o => o.CreatedUtc)
.Take(20)
.ToFeedIterator();
while (linqFeed.HasMoreResults)
{
foreach (var o in await linqFeed.ReadNextAsync())
Console.WriteLine($"{o.Id}: {o.Total:C}");
}
Never call .ToList() on a Cosmos LINQ queryable. Use ToFeedIterator(); otherwise the SDK cannot stream pages and you lose access to RequestCharge.
Continuation Tokens for Stateless Paging
public async Task<(List<Order> Items, string? Next)> GetPageAsync(
string customerId, string? continuationToken, int pageSize = 25)
{
using var iterator = container.GetItemQueryIterator<Order>(
new QueryDefinition("SELECT * FROM c WHERE c.customerId = @id")
.WithParameter("@id", customerId),
continuationToken,
new QueryRequestOptions
{
PartitionKey = new PartitionKey(customerId),
MaxItemCount = pageSize
});
var page = await iterator.ReadNextAsync();
return (page.ToList(), page.ContinuationToken);
}
Continuation tokens are the correct paging mechanism in Cosmos DB. There is no OFFSET-based paging that scales — OFFSET 10000 LIMIT 25 still reads and charges for all 10,025 documents.
Advanced Azure Cosmos DB C# Patterns
Optimistic Concurrency with ETags
Every Cosmos DB item carries an _etag that changes on every write. Use it to prevent lost updates:
ItemResponse<Order> current = await container.ReadItemAsync<Order>(id, pk);
var updated = current.Resource with { Status = "Cancelled" };
try
{
await container.ReplaceItemAsync(updated, id, pk,
new ItemRequestOptions { IfMatchEtag = current.ETag });
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
{
// Someone else wrote first — re-read and reapply your change.
}
Transactional Batch
Cosmos DB supports ACID transactions within a single logical partition:
TransactionalBatchResponse batch = await container
.CreateTransactionalBatch(new PartitionKey("cust-1001"))
.CreateItem(newOrder)
.PatchItem(cartId, new[] { PatchOperation.Set("/checkedOut", true) })
.ExecuteAsync();
if (!batch.IsSuccessStatusCode)
throw new InvalidOperationException($"Batch failed: {batch.StatusCode}");
Bulk Ingestion
For large imports, enable bulk mode and fan out with Task.WhenAll. The SDK groups operations by partition automatically and can improve throughput by an order of magnitude:
var bulkClient = new CosmosClient(endpoint, credential,
new CosmosClientOptions { AllowBulkExecution = true });
var bulkContainer = bulkClient.GetContainer("RetailStore", "Orders");
var tasks = orders.Select(o =>
bulkContainer.CreateItemAsync(o, new PartitionKey(o.CustomerId))
.ContinueWith(t =>
{
if (t.IsFaulted)
logger.LogError(t.Exception, "Failed {Id}", o.Id);
}));
await Task.WhenAll(tasks);
Change Feed: Event-Driven Architecture for Free
var processor = container
.GetChangeFeedProcessorBuilder<Order>("order-projector",
async (context, changes, ct) =>
{
foreach (var change in changes)
await searchIndex.UpsertAsync(change, ct);
})
.WithInstanceName(Environment.MachineName)
.WithLeaseContainer(leaseContainer)
.Build();
await processor.StartAsync();
The change feed is a persistent, ordered log of every create and update within a partition. It powers materialised views, cache invalidation, and event sourcing without adding Kafka to your stack.
Best Practices and Common Pitfalls
- Singleton client, Direct mode. Non-negotiable for latency.
- Always pass the partition key. Omitting it turns a 1 RU point read into a cross-partition fan-out.
- Log
RequestChargein development. RU consumption is your real performance metric, not milliseconds. - Trim your indexing policy. Every indexed path costs RUs on write. Exclude large blobs you never filter on: set
ExcludedPathsto/payload/*. - Use Session consistency by default. It gives read-your-own-writes at roughly half the RU cost of Strong, and Strong is unavailable across multiple write regions anyway.
- Denormalise deliberately. There are no joins across containers. Embed data you read together; reference data that is large or changes independently.
- Avoid unbounded arrays. An item has a 2 MB limit; an order with unlimited line items will eventually fail in production.
- Use autoscale for spiky traffic, manual RU/s for steady traffic. Autoscale costs 1.5× the manual rate per RU but only bills what you use.
- Test against the Cosmos DB Emulator (or its Linux container image) in CI rather than a shared cloud account.
Conclusion: Key Takeaways
Building with Azure Cosmos DB in C# is less about learning API surface and more about internalising a handful of design rules. The .NET SDK is excellent and largely gets out of your way — the difficulty lives in modelling.
- Pick the NoSQL API for new .NET projects.
- Register
CosmosClientas a singleton in Direct mode. - Your partition key is the highest-leverage decision you will make; choose for cardinality and query alignment.
- Prefer point reads and single-partition queries; measure everything in RUs.
- Use ETags for concurrency, TransactionalBatch for atomicity, bulk mode for ingestion, and the change feed for event-driven flows.
Start with a single region and 400 RU/s, instrument your request charges, and let real usage tell you where to scale. That approach keeps costs sane while giving you a database ready to serve users in New York, London, Toronto, Sydney, and Bangalore with the same millisecond latency.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment