NoSQL in .NET usually means a document database: Azure Cosmos DB for NoSQL when you want a fully managed, globally distributed service on Azure, or MongoDB when you want its query language and ecosystem on Atlas or your own servers. This guide is for .NET developers who know relational databases and need to design, build and operate document workloads well. You will learn how to model documents, choose partition keys, reason about consistency and request units, use the Cosmos DB SDK's bulk, patch, batch and change feed features, work with the MongoDB C# driver, and decide when EF Core providers or NoSQL itself are the wrong choice.
What Is NoSQL in .NET? Document Databases Explained#
A document database stores self-contained JSON or BSON documents instead of rows spread across normalized tables. A document holds an aggregate, such as an order with its lines and shipping address, so the most common read becomes a single lookup with no joins. The price is that you design for known access patterns up front, and relationships and cross-document consistency become your responsibility.
The .NET building blocks in September 2026:
- Azure Cosmos DB for NoSQL through
Microsoft.Azure.Cosmos(version 3.63 at the time of writing). Cosmos DB also offers MongoDB, Cassandra, Gremlin and Table APIs; this guide covers the native NoSQL API. - MongoDB through
MongoDB.Driver(version 3.12), which includes a LINQ provider, builders and Atlas Search and Vector Search support. - EF Core providers:
Microsoft.EntityFrameworkCore.Cosmosfrom Microsoft andMongoDB.EntityFrameworkCorefrom MongoDB, with 10.x releases for EF Core 10 on .NET 10.
How Cosmos DB and MongoDB Scale: Partitions, Request Units and Consistency#
Both databases scale horizontally by splitting data on a key. In Cosmos DB, every item has a partition key value. Items with the same value form a logical partition, capped at 20 GB, and the service spreads logical partitions across physical partitions that each have fixed throughput and storage ceilings. Queries that supply the partition key go to one partition; queries without it fan out to all of them. MongoDB uses replica sets for availability and, at scale, sharding by a shard key, with the same trade-off: targeted queries are cheap, scatter-gather queries are not.
Cosmos DB bills in request units (RUs), a normalized cost of CPU, memory and I/O. A point read by id and partition key of a small item costs about 1 RU. Writes cost more, and cost rises with document size, the number of indexed properties and query complexity. You buy RUs as provisioned throughput, autoscale throughput, or serverless consumption for spiky and intermittent workloads. When you exceed your budget, the service answers with HTTP 429, and the SDK retries up to 9 times within 30 seconds by default before surfacing the error.
Cosmos DB offers five consistency levels, set on the account and optionally relaxed per request:
| Level | Guarantee | Typical use |
|---|---|---|
| Strong | Reads always return the latest committed write | Rare; strict correctness with a single write region |
| Bounded staleness | Reads lag writes by at most a configured number of versions or time | Multi-region reads that need a known staleness bound |
| Session | Read-your-writes and monotonic reads within a client session | The default and the right choice for most applications |
| Consistent prefix | Reads never see writes out of order | Feeds and timelines |
| Eventual | No ordering guarantees; lowest latency | Counters, telemetry, non-critical reads |
Stronger levels use quorum reads, which cost more RUs and add latency. MongoDB exposes the same trade-off through write concern (for example majority), read concern and read preference, set per client, database, collection or operation.
Getting Started with the Azure Cosmos DB .NET SDK#
CosmosClient is thread-safe and should live for the whole application, because it owns connections and routing metadata. Register it once, use Microsoft Entra ID instead of keys, and tune a few options:
using System.Text.Json;
using Azure.Identity;
using Microsoft.Azure.Cosmos;
var builder = WebApplication.CreateBuilder(args);
var endpoint = builder.Configuration["Cosmos:Endpoint"]
?? throw new InvalidOperationException("Cosmos:Endpoint is not configured.");
// One client per application: it is thread-safe and caches connections and routing
builder.Services.AddSingleton(_ => new CosmosClient(endpoint, new DefaultAzureCredential(),
new CosmosClientOptions
{
ApplicationName = "orders-api",
ApplicationPreferredRegions = ["West Europe", "North Europe"],
UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
},
EnableContentResponseOnWrite = false // don't send documents back on writes
}));
builder.Services.AddSingleton(sp =>
sp.GetRequiredService<CosmosClient>().GetContainer("shop", "orders"));The default connection mode is Direct, which talks TCP to the replicas and gives the lowest latency. The two operations you use most are a point read and a single-partition query. Log RequestCharge from day one; it is the cost signal for every design decision in this guide.
using System.Net;
using Microsoft.Azure.Cosmos;
public sealed class OrderRepository(Container container, ILogger<OrderRepository> logger)
{
// Point read: id plus partition key, the cheapest operation Cosmos DB offers
public async Task<Order?> GetAsync(string customerId, string orderId, CancellationToken ct)
{
try
{
var response = await container.ReadItemAsync<Order>(
orderId, new PartitionKey(customerId), cancellationToken: ct);
logger.LogDebug("Point read cost {Charge} RU", response.RequestCharge);
return response.Resource;
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
}
// Single-partition query, parameterized, paged with continuation tokens
public async Task<(List<Order> Items, string? Continuation)> GetRecentAsync(
string customerId, string? continuation, CancellationToken ct)
{
var query = new QueryDefinition(
"""
SELECT * FROM c
WHERE c.customerId = @customerId AND c.type = 'order'
ORDER BY c.createdAt DESC
""")
.WithParameter("@customerId", customerId);
using var iterator = container.GetItemQueryIterator<Order>(
query,
continuationToken: continuation,
requestOptions: new QueryRequestOptions
{
PartitionKey = new PartitionKey(customerId),
MaxItemCount = 20
});
var page = await iterator.ReadNextAsync(ct);
logger.LogDebug("Query page cost {Charge} RU", page.RequestCharge);
return (page.ToList(), page.ContinuationToken);
}
}Partition Key Design for Azure Cosmos DB#
The partition key is the most consequential and hardest-to-change decision in a Cosmos DB design, because changing it means migrating data to a new container. A good key has:
- High cardinality, with many distinct values so data spreads across physical partitions.
- Even load, so no single value receives a disproportionate share of reads or writes.
- Presence in your hottest queries, so they stay single-partition.
- Alignment with transactions, because atomic operations are scoped to one logical partition.
customerId for orders or tenantId for a SaaS app often works. Creation date is a classic mistake, because all of today's writes hit one partition. Status fields have too few values. In multitenant systems, a single large tenant can outgrow the 20 GB logical partition limit or overload one partition. Hierarchical partition keys solve this with up to three levels, such as tenant, then user, then session. Queries that supply a prefix, like only the tenant, are routed to that tenant's partitions instead of fanning out everywhere.
var database = (await client.CreateDatabaseIfNotExistsAsync("shop", cancellationToken: ct))
.Database;
// Hierarchical partition key: tenant, then user, then session
var properties = new ContainerProperties(
id: "sessions",
partitionKeyPaths: ["/tenantId", "/userId", "/sessionId"])
{
DefaultTimeToLive = (int)TimeSpan.FromDays(30).TotalSeconds // expire idle sessions
};
// Every path is indexed by default; exclude large blobs you never filter on
properties.IndexingPolicy.ExcludedPaths.Add(new ExcludedPath { Path = "/payload/*" });
var sessions = (await database.CreateContainerIfNotExistsAsync(
properties, ThroughputProperties.CreateAutoscaleThroughput(4000), cancellationToken: ct))
.Container;
// A point read needs the full hierarchical key
var key = new PartitionKeyBuilder().Add(tenantId).Add(userId).Add(sessionId).Build();
var session = await sessions.ReadItemAsync<UserSession>(sessionId, key, cancellationToken: ct);Excluding unused paths from the indexing policy is one of the simplest RU optimizations, because every indexed property adds to the cost of every write.
Cosmos DB SDK Patterns: Patch, Transactional Batch, Bulk and Change Feed#
Partial Updates with Patch and Optimistic Concurrency#
A read-modify-write cycle costs two operations and races with concurrent writers. PatchItemAsync applies operations such as Set, Add, Remove, Replace and Increment on the server, and a filter predicate makes the patch conditional:
// Reserve two units atomically, only if enough stock is available
await products.PatchItemAsync<Product>(
id: productId,
partitionKey: new PartitionKey(categoryId),
patchOperations:
[
PatchOperation.Increment("/stock/available", -2),
PatchOperation.Increment("/stock/reserved", 2),
PatchOperation.Set("/updatedAt", DateTimeOffset.UtcNow)
],
requestOptions: new PatchItemRequestOptions
{
FilterPredicate = "FROM p WHERE p.stock.available >= 2" // else 412
},
cancellationToken: ct);When you must replace a whole document, pass the ETag you read in ItemRequestOptions.IfMatchEtag. If someone else changed the item first, the service returns 412 Precondition Failed, and you reload and reapply your change.
Transactional Batch#
Operations on items that share a logical partition can run as one ACID transaction, up to 100 operations per batch. That makes the transactional outbox pattern straightforward: write the business document and the event to publish in the same batch, then let a change feed processor deliver the event.
var pk = new PartitionKey(order.CustomerId);
var message = new OutboxMessage(
Id: Guid.NewGuid().ToString(), CustomerId: order.CustomerId, Type: "OrderPlaced",
OrderId: order.Id);
using TransactionalBatchResponse result = await orders
.CreateTransactionalBatch(pk)
.CreateItem(order)
.CreateItem(message)
.PatchItem($"summary-{order.CustomerId}", [PatchOperation.Increment("/orderCount", 1)])
.ExecuteAsync(ct);
if (!result.IsSuccessStatusCode)
{
// All or nothing: no operation was applied. 409 means a duplicate id, for example.
throw new InvalidOperationException($"Order batch failed with {result.StatusCode}.");
}Bulk Import#
For imports and backfills, set AllowBulkExecution = true. The SDK then groups concurrent point operations into batches per partition, trading per-operation latency for throughput. Because of that latency cost, use a separate long-lived client for bulk jobs rather than the one serving user requests.
var bulkClient = new CosmosClient(endpoint, new DefaultAzureCredential(),
new CosmosClientOptions { AllowBulkExecution = true });
var target = bulkClient.GetContainer("shop", "products");
// Bulk mode is not transactional: capture failures per item instead of failing the run
var results = await Task.WhenAll(batch.Select(async product =>
{
try
{
var r = await target.UpsertItemAsync(
product, new PartitionKey(product.CategoryId), cancellationToken: ct);
return (product.Id, Charge: r.RequestCharge, Error: (string?)null);
}
catch (CosmosException ex)
{
return (product.Id, Charge: ex.RequestCharge, Error: ex.StatusCode.ToString());
}
}));
var failed = results.Where(r => r.Error is not null).ToList();
Console.WriteLine($"{results.Length - failed.Count} upserted, {failed.Count} failed, " +
$"{results.Sum(r => r.Charge):N0} RU");Feed the method in chunks of a few thousand items to bound memory, and scale out across processes for very large loads.
Change Feed Processor#
The change feed is a persistent, ordered-per-partition log of changes to a container. The change feed processor distributes partitions across running instances using a lease container, checkpoints progress, and rebalances when instances come and go. It powers read models, search indexing, cache invalidation and the outbox pattern above. The default mode delivers the latest version of each changed item; the all-versions-and-deletes mode, generally available in SDK 3.60 and later, also delivers intermediate versions and deletes.
public sealed class OrderProjectionService(
CosmosClient client, IReadModelStore readModels, ILogger<OrderProjectionService> logger)
: IHostedService
{
private ChangeFeedProcessor? _processor;
public async Task StartAsync(CancellationToken cancellationToken)
{
var orders = client.GetContainer("shop", "orders");
var leases = client.GetContainer("shop", "leases"); // partitioned on /id
_processor = orders
.GetChangeFeedProcessorBuilder<Order>("order-projections", HandleChangesAsync)
.WithInstanceName(Environment.MachineName) // unique per running instance
.WithLeaseContainer(leases)
.Build();
await _processor.StartAsync();
}
public Task StopAsync(CancellationToken cancellationToken) =>
_processor?.StopAsync() ?? Task.CompletedTask;
private async Task HandleChangesAsync(
ChangeFeedProcessorContext context, IReadOnlyCollection<Order> changes,
CancellationToken ct)
{
foreach (var order in changes)
{
// Delivery is at least once, so every handler must be idempotent
await readModels.UpsertOrderSummaryAsync(order, ct);
}
logger.LogInformation("Lease {Lease}: {Count} changes, {Charge} RU",
context.LeaseToken, changes.Count, context.Headers.RequestCharge);
}
}MongoDB C# Driver Basics#
The MongoDB driver follows the same rules as Cosmos DB: one MongoClient for the application, since it owns the connection pool, and serialization conventions configured once at startup. In driver 3.x, GUIDs have no default binary representation, so serializing a Guid without configuration throws. Register the standard representation globally:
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Conventions;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
// Configure serialization once, before the first MongoClient is created
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
ConventionRegistry.Register("app-conventions",
new ConventionPack
{
new CamelCaseElementNameConvention(),
new IgnoreExtraElementsConvention(true) // tolerate fields added by newer versions
},
_ => true);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IMongoClient>(
new MongoClient(builder.Configuration.GetConnectionString("Mongo")!));
builder.Services.AddSingleton(sp => sp.GetRequiredService<IMongoClient>()
.GetDatabase("shop").GetCollection<Order>("orders"));
public sealed class OrderStore(IMongoCollection<Order> orders)
{
public Task CreateIndexesAsync(CancellationToken ct) =>
orders.Indexes.CreateOneAsync(new CreateIndexModel<Order>(
Builders<Order>.IndexKeys
.Ascending(o => o.CustomerId)
.Descending(o => o.CreatedAt)), cancellationToken: ct);
public Task<List<Order>> GetRecentAsync(Guid customerId, CancellationToken ct) =>
orders.Find(o => o.CustomerId == customerId)
.SortByDescending(o => o.CreatedAt)
.Limit(20)
.ToListAsync(ct);
// Conditional update: only paid orders can move to shipped
public async Task<bool> MarkShippedAsync(Guid orderId, CancellationToken ct)
{
var result = await orders.UpdateOneAsync(
o => o.Id == orderId && o.Status == OrderStatus.Paid,
Builders<Order>.Update
.Set(o => o.Status, OrderStatus.Shipped)
.CurrentDate(o => o.UpdatedAt),
cancellationToken: ct);
return result.ModifiedCount == 1;
}
}Unlike Cosmos DB, MongoDB supports multi-document ACID transactions across collections, provided the deployment is a replica set or sharded cluster. The convenient WithTransactionAsync API commits for you and retries transient transaction errors:
public async Task PlaceOrderAsync(Order order, CancellationToken ct)
{
using var session = await client.StartSessionAsync(cancellationToken: ct);
await session.WithTransactionAsync(async (s, token) =>
{
await orders.InsertOneAsync(s, order, cancellationToken: token);
foreach (var line in order.Lines)
{
var reserved = await inventory.UpdateOneAsync(s,
i => i.ProductId == line.ProductId && i.Available >= line.Quantity,
Builders<InventoryItem>.Update.Inc(i => i.Available, -line.Quantity),
cancellationToken: token);
if (reserved.ModifiedCount == 0)
{
// Throwing aborts the transaction, including the order insert
throw new InvalidOperationException($"Out of stock: {line.ProductId}");
}
}
return true;
}, cancellationToken: ct);
}For high-volume writes, BulkWriteAsync sends a list of insert, update and delete models in one call, and with MongoDB 8.0 or later a client-level bulk write can span collections. Transactions still cost latency and hold resources, so embedded documents with single-document atomicity remain the first design choice. For semantic search, the driver's VectorSearch aggregation stage queries Atlas Vector Search indexes, which is covered in the vector search guide.
EF Core Providers for Cosmos DB and MongoDB#
Both providers let you use DbContext, LINQ and change tracking against documents, but they are not relational providers in disguise.
The Cosmos DB provider from Microsoft supports only the NoSQL API. EF Core 9 reworked it substantially: synchronous APIs now throw, hierarchical partition keys are supported, and ToPageAsync pages with continuation tokens. EF Core 10 added full-text search, hybrid ranking with EF.Functions.Rrf, generally available vector search, and default values for required properties missing from older documents. EF Core 11, due with .NET 11 in November 2026, adds complex types, automatic transactional batches per partition, bulk execution and session token management. There are no migrations, and cross-document navigations and joins are not supported.
The MongoDB provider is built and supported by MongoDB. Current 10.x releases target EF Core 10 on .NET 10, with 9.x and 8.x lines for earlier EF versions. It requires MongoDB 5.0 or later, ideally a transaction-capable deployment, because SaveChanges commits all changes or none by default. Recent releases added cross-collection Include and joins, bulk ExecuteUpdate and ExecuteDelete, and Atlas Search index creation. Migrations are out of scope, and LINQ coverage remains narrower than a relational provider's.
// Azure Cosmos DB: Microsoft.EntityFrameworkCore.Cosmos
builder.Services.AddDbContext<CatalogContext>(options =>
options.UseCosmos(endpoint, new DefaultAzureCredential(), databaseName: "catalog"));
// MongoDB: MongoDB.EntityFrameworkCore, reusing the singleton client
builder.Services.AddDbContext<OrdersContext>((sp, options) =>
options.UseMongoDB(sp.GetRequiredService<IMongoClient>(), "shop"));
public sealed class CatalogContext(DbContextOptions<CatalogContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultContainer("catalog");
modelBuilder.Entity<Product>(product =>
{
product.HasPartitionKey(p => p.CategoryId);
product.UseETagConcurrency();
});
}
}
public sealed class OrdersContext(DbContextOptions<OrdersContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Order>().ToCollection("orders");
}Use a provider when your model is aggregate-shaped and your team values EF conventions. Drop to the native SDK for change feed processing, bulk loads, fine-grained RU and consistency control, and features the provider has not caught up with. Mixing both in one application is common and perfectly reasonable. The EF Core guide covers the shared concepts.
When Not to Choose NoSQL#
A document database is the wrong default in several situations:
- Highly relational domains. If most screens join five entities and relationships are many-to-many, you will rebuild joins in application code.
- Ad hoc querying and reporting. Document stores reward known access patterns. Analysts running arbitrary queries belong on a relational database or an analytical copy.
- Invariants across aggregates. Cosmos DB transactions stop at the logical partition, and MongoDB transactions add latency and operational requirements.
- Unclear access patterns. Partition keys and document shapes encode your queries; if you cannot list them yet, a relational schema is easier to evolve.
- Modest scale with flexible data. PostgreSQL
jsonbor the SQL Server 2025jsontype give schemaless columns inside a transactional relational database; see the PostgreSQL guide.
Choose NoSQL when you need elastic horizontal scale, global distribution with tunable consistency, aggregate-shaped data with predictable access paths, or a change feed as an integration backbone.
Best Practices#
- Model from queries. Rank access patterns and make the hottest ones single-document or single-partition.
- Treat the partition key as permanent. Load-test it with realistic skew before going live.
- Prefer point reads. A read by id and partition key is the cheapest operation; design ids you can compute.
- Log request charges. Record
RequestChargein telemetry and review expensive operations regularly. - Trim the indexing policy. Exclude large or never-queried paths to reduce write costs.
- Use singletons. One
CosmosClientorMongoClientper configuration for the application's lifetime. - Make consumers idempotent. Change feed and change stream delivery is at least once.
Common Pitfalls#
- Unbounded arrays. Embedding every comment or event eventually hits document size limits and inflates RU costs.
- Low-cardinality or time-based partition keys. They create hot partitions that throttle long before total throughput is used.
- Cross-partition queries on hot paths. Fan-out multiplies latency and RUs; add the partition key or a lookup document.
- A new client per request. It exhausts sockets and discards routing caches.
- Unconfigured GUIDs in MongoDB 3.x. Serialization throws until you register a
GuidSerializer. - Assuming relational transactions. Cross-partition writes in Cosmos DB need sagas or change feed workflows.
Azure Cosmos DB vs MongoDB vs Relational JSON: Comparison#
| Aspect | Azure Cosmos DB for NoSQL | MongoDB (Atlas or self-managed) | Relational with JSON columns |
|---|---|---|---|
| Data model | JSON items in containers | BSON documents in collections | Tables plus json or jsonb columns |
| Scale-out unit | Partition key, managed automatically | Shard key on sharded clusters | Mostly scale-up plus read replicas |
| Transactions | Within one logical partition | Multi-document on replica sets and sharded clusters | Full ACID across tables |
| Consistency controls | Five levels, relaxed per request | Read and write concerns, read preference | Isolation levels |
| Cost model | Request units: provisioned, autoscale or serverless | Cluster size or your own hardware | Instance or vCore size |
| .NET client | Microsoft.Azure.Cosmos | MongoDB.Driver | Npgsql or Microsoft.Data.SqlClient |
| EF Core provider | Microsoft, NoSQL API only | MongoDB, no migrations | Mature relational providers |
| Change notifications | Change feed | Change streams | Change data capture, logical replication |
| Vector search | Vector indexes, full-text and hybrid search | Atlas Vector Search | pgvector, SQL Server 2025 vector |
Frequently Asked Questions#
Should I use the Cosmos DB SDK or the EF Core provider?#
Use the SDK when you need change feed processing, bulk operations, precise control over request options, or the newest service features. The EF Core provider suits aggregate-shaped CRUD code where LINQ and change tracking improve productivity. Many applications use the provider for request handling and the SDK for background processing.
How do I choose a partition key for a multitenant application?#
Start with the tenant identifier if tenants are numerous and similar in size. If a few tenants dominate, use hierarchical partition keys, such as tenant, then user or entity id, so large tenants spread across physical partitions while tenant-scoped queries stay targeted.
Is Azure Cosmos DB for MongoDB the same as MongoDB?#
No. Azure's MongoDB-compatible services implement the MongoDB wire protocol, so the MongoDB C# driver works, but they are not MongoDB itself. Check supported server versions and features, such as aggregation stages and search, before assuming parity with MongoDB Atlas.
Do document databases support transactions?#
Yes, within limits. MongoDB supports multi-document ACID transactions on replica sets and sharded clusters. Cosmos DB supports transactions within a single logical partition through transactional batch and stored procedures. Design so that most business operations touch one document or one partition.
How can I estimate Azure Cosmos DB costs?#
Measure RequestCharge for each operation type with realistic documents, multiply by expected volumes, and add headroom for peaks. Point reads, lean indexing policies and single-partition queries keep costs down, and autoscale or serverless capacity helps with uneven traffic.
Summary#
- Document databases trade joins and ad hoc queries for aggregate-shaped reads and horizontal scale.
- Embed what is read together and bounded; reference what grows or is shared; denormalize deliberately.
- The Cosmos DB partition key drives scale, cost and transaction scope; hierarchical keys handle large tenants.
- Session consistency and point reads are the defaults to beat; log request charges everywhere.
- Patch, transactional batch, bulk and the change feed cover most advanced Cosmos DB needs.
- The MongoDB driver needs one client and explicit GUID configuration, and offers multi-document transactions.
- EF Core providers exist for both databases, without migrations and with narrower LINQ support.