
MongoDB in .NET: Getting Started with the C# Driver
.NET developers tend to arrive at MongoDB with Entity Framework habits: a DbContext, change tracking, migrations, and LINQ everywhere. The official C# driver takes a different path. There's no change tracker and no unit of work. Instead you get strongly typed collections, expression-based builders that compile to MongoDB queries, and a LINQ provider that translates to aggregation pipelines. Once it clicks, it's a very pleasant way to work.
The driver has also matured significantly. Version 3.x removed the old LINQ2 provider, made LINQ3 the only implementation, cleaned up GUID handling, and dropped a lot of legacy APIs. If you're reading older tutorials, some of what they show will no longer compile, and some of it will compile but store data in a format you didn't intend.
This guide covers setting up the driver in an ASP.NET Core app, mapping classes to documents, CRUD with builders and LINQ, updates, indexes, transactions, error handling, and the serialization settings you should decide on before your first document hits production.
Installing and Registering the Client
Add the driver package:
dotnet new webapi -n Catalog
cd Catalog
dotnet add package MongoDB.Driver
The MongoClient is thread-safe and holds the connection pool, so register it as a singleton. Registering it as scoped or transient is one of the most common and expensive mistakes in .NET MongoDB apps: every request creates a new pool.
// Program.cs
using Microsoft.Extensions.Options;
using MongoDB.Driver;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<MongoSettings>(builder.Configuration.GetSection("Mongo"));
builder.Services.AddSingleton<IMongoClient>(sp =>
{
var cfg = sp.GetRequiredService<IOptions<MongoSettings>>().Value;
var settings = MongoClientSettings.FromConnectionString(cfg.ConnectionString);
settings.ApplicationName = "catalog-api";
settings.MaxConnectionPoolSize = 100;
return new MongoClient(settings);
});
builder.Services.AddSingleton(sp =>
{
var cfg = sp.GetRequiredService<IOptions<MongoSettings>>().Value;
return sp.GetRequiredService<IMongoClient>().GetDatabase(cfg.Database);
});
builder.Services.AddSingleton<ProductRepository>();
var app = builder.Build();
With a matching settings class and configuration:
public sealed class MongoSettings
{
public string ConnectionString { get; set; } = "mongodb://localhost:27017";
public string Database { get; set; } = "catalog";
}
{
"Mongo": {
"ConnectionString": "mongodb+srv://app:secret@cluster0.example.mongodb.net/",
"Database": "catalog"
}
}
In real deployments, keep the connection string in user secrets locally and in environment variables or a secret store in production (Mongo__ConnectionString maps to the nested key).
IMongoDatabase and IMongoCollection<T> are also thread-safe and cheap to obtain, so it's fine to register them as singletons or fetch them in a repository constructor.
Serialization Settings to Decide Up Front
Before you write data, configure a few global serialization conventions. Changing them later means migrating existing documents, so do it on day one. These registrations must run once, before the first use of any mapped class, so put them at the very top of Program.cs:
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Conventions;
using MongoDB.Bson.Serialization.Serializers;
var pack = new ConventionPack
{
new CamelCaseElementNameConvention(),
new IgnoreExtraElementsConvention(true),
new EnumRepresentationConvention(BsonType.String)
};
ConventionRegistry.Register("app-conventions", pack, _ => true);
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
What these do:
CamelCaseElementNameConventionstoresUnitPriceasunitPrice, matching the naming used by JavaScript, Python, and most MongoDB tooling.IgnoreExtraElementsConventionstops deserialization from throwing when a document contains a field your class doesn't have. Without it, adding a field from another service breaks your app with aFormatException.- Enums as strings keep documents readable and survive reordering of enum members.
GuidRepresentation.StandardstoresGuidvalues as the standard UUID binary subtype (4). In driver 3.x, the old global GUID mode is gone and there's no implicit default forGuidproperties, so configure it explicitly if you use GUIDs anywhere.
Mapping Classes to Documents
A typical document class:
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
public sealed class Product
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public required string Sku { get; set; }
public required string Name { get; set; }
[BsonRepresentation(BsonType.Decimal128)]
public decimal Price { get; set; }
public List<string> Tags { get; set; } = [];
public int Stock { get; set; }
public Dimensions? Size { get; set; }
public ProductStatus Status { get; set; } = ProductStatus.Active;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public sealed record Dimensions(double Width, double Height, double Depth);
public enum ProductStatus { Active, Discontinued }
The key decisions:
stringId withBsonRepresentation(BsonType.ObjectId)gives you a properObjectIdin the database and a plain string in your API. LeavingIdnull on insert lets the driver generate it.decimalwithBsonRepresentation(BsonType.Decimal128)stores money exactly and keeps it queryable as a number. Be explicit here rather than relying on the default decimal serialization, which has historically been a string.DateTimevalues are stored as UTC BSON dates. When they're read back, the driver returns them withDateTimeKind.Utc. If you pass a local time, it's converted to UTC on the way in, which surprises people displaying values without converting back. UseDateTime.UtcNoworDateTimeOffsetconsistently.- Nested types and records like
Dimensionsbecome embedded documents. Records with positional constructors work because the driver maps constructor parameters to properties.
CRUD with Builders
Builders give you type-safe filters, projections, sorts, and updates using lambda expressions, so a renamed property is a compile error instead of a silent bug. Here's a repository covering the basics:
using MongoDB.Driver;
public sealed class ProductRepository
{
private readonly IMongoCollection<Product> _products;
public ProductRepository(IMongoDatabase db)
{
_products = db.GetCollection<Product>("products");
}
public Task CreateAsync(Product product, CancellationToken ct = default) =>
_products.InsertOneAsync(product, cancellationToken: ct);
public async Task<Product?> GetBySkuAsync(string sku, CancellationToken ct = default) =>
await _products.Find(p => p.Sku == sku).FirstOrDefaultAsync(ct);
public Task<List<Product>> ListByTagAsync(string tag, int limit, CancellationToken ct = default)
{
var filter = Builders<Product>.Filter.AnyEq(p => p.Tags, tag)
& Builders<Product>.Filter.Gt(p => p.Stock, 0);
return _products.Find(filter)
.SortByDescending(p => p.CreatedAt)
.Limit(limit)
.ToListAsync(ct);
}
public async Task<bool> DeleteAsync(string id, CancellationToken ct = default)
{
var result = await _products.DeleteOneAsync(p => p.Id == id, ct);
return result.DeletedCount == 1;
}
}
Filters combine with & and | operators, which produce $and and $or. AnyEq matches when an array contains the value, which is how MongoDB naturally queries arrays.
After InsertOneAsync, the driver fills in product.Id with the generated ObjectId, so you can return the object directly from your create endpoint.
Projections
For list views, fetch only the fields you need and project them into a DTO:
public sealed record ProductSummary(string Id, string Name, decimal Price);
public Task<List<ProductSummary>> SummariesAsync(CancellationToken ct = default) =>
_products.Find(p => p.Status == ProductStatus.Active)
.Project(p => new ProductSummary(p.Id!, p.Name, p.Price))
.Limit(50)
.ToListAsync(ct);
The driver translates the lambda into a server-side projection, so only _id, name, and price travel over the network.
Updates
Never load a document, change a property, and call ReplaceOneAsync for something like a stock change. Two concurrent requests will overwrite each other. Use update builders so the change happens atomically on the server:
public async Task<bool> ReserveAsync(string sku, int qty, CancellationToken ct = default)
{
var filter = Builders<Product>.Filter.Eq(p => p.Sku, sku)
& Builders<Product>.Filter.Gte(p => p.Stock, qty);
var update = Builders<Product>.Update
.Inc(p => p.Stock, -qty)
.CurrentDate("updatedAt");
var result = await _products.UpdateOneAsync(filter, update, cancellationToken: ct);
return result.ModifiedCount == 1;
}
Other common update operations:
// Add a tag only if it's not already there
Builders<Product>.Update.AddToSet(p => p.Tags, "sale");
// Remove a tag
Builders<Product>.Update.Pull(p => p.Tags, "sale");
// Upsert: insert if missing
await _products.UpdateOneAsync(
p => p.Sku == "LAMP-002",
Builders<Product>.Update
.SetOnInsert(p => p.CreatedAt, DateTime.UtcNow)
.Set(p => p.Name, "Floor Lamp")
.Set(p => p.Price, 89.00m),
new UpdateOptions { IsUpsert = true });
When you need the updated document back, use FindOneAndUpdateAsync with ReturnDocument = ReturnDocument.After.
LINQ Queries
The driver's LINQ provider translates queries into aggregation pipelines. Since version 3.0, LINQ3 is the only provider, and it supports far more expressions than the old one did:
using MongoDB.Driver.Linq;
var stats = await _products.AsQueryable()
.Where(p => p.Status == ProductStatus.Active)
.SelectMany(p => p.Tags, (p, tag) => new { tag, p.Stock })
.GroupBy(x => x.tag)
.Select(g => new { Tag = g.Key, Products = g.Count(), Units = g.Sum(x => x.Stock) })
.OrderByDescending(x => x.Units)
.Take(5)
.ToListAsync();
That becomes a pipeline with $match, $unwind, $group, $sort, and $limit. Use the async extension methods from MongoDB.Driver.Linq (ToListAsync, FirstOrDefaultAsync), not the synchronous LINQ ones, so you don't block threads.
If you want to see what the driver generates, call ToString() on the queryable before executing it; it prints the translated pipeline. When LINQ can't translate an expression, it throws ExpressionNotSupportedException at runtime rather than silently evaluating in memory. That's a good thing, but it means you should test your queries rather than assume every C# method translates.
For pipelines that LINQ doesn't express well, use the fluent Aggregate() API or pass raw BsonDocument stages.
Indexes
Create indexes with typed key definitions. CreateOneAsync is idempotent when the definition hasn't changed:
public async Task EnsureIndexesAsync()
{
var keys = Builders<Product>.IndexKeys;
await _products.Indexes.CreateManyAsync(
[
new CreateIndexModel<Product>(keys.Ascending(p => p.Sku),
new CreateIndexOptions { Unique = true }),
new CreateIndexModel<Product>(keys.Ascending(p => p.Tags).Descending(p => p.CreatedAt)),
]);
}
Call it from a hosted service at startup in development. For large production collections, run index creation as a separate deployment step so an app restart never triggers a long index build.
Transactions
Multi-document transactions use a client session. WithTransactionAsync handles commit, abort, and retrying on transient errors:
using var session = await client.StartSessionAsync();
await session.WithTransactionAsync(async (s, ct) =>
{
var reserved = await products.UpdateOneAsync(s,
p => p.Sku == sku && p.Stock >= qty,
Builders<Product>.Update.Inc(p => p.Stock, -qty),
cancellationToken: ct);
if (reserved.ModifiedCount == 0)
throw new InvalidOperationException("Out of stock");
await orders.InsertOneAsync(s, new Order(sku, qty, DateTime.UtcNow), cancellationToken: ct);
return true;
});
Every operation inside takes the session as its first argument. Leave it off and that operation runs outside the transaction. Transactions need a replica set or sharded cluster, which every Atlas cluster is.
Handling Errors
Write errors surface as MongoWriteException (single operations) or MongoBulkWriteException (bulk and InsertMany). Filter by category rather than parsing messages:
app.MapPost("/products", async (Product product, ProductRepository repo) =>
{
try
{
await repo.CreateAsync(product);
return Results.Created($"/products/{product.Sku}", product);
}
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return Results.Conflict(new { error = $"SKU {product.Sku} already exists" });
}
});
Connection problems appear as TimeoutException (typically "A timeout occurred after 30000ms selecting a server") or MongoConnectionException. The server selection timeout almost always means a wrong connection string, a missing IP access list entry in Atlas, or a TLS issue, not a slow database.
What About EF Core?
MongoDB also publishes an official EF Core provider (MongoDB.EntityFrameworkCore). It lets you use DbContext, change tracking, and SaveChangesAsync against MongoDB, which can ease adoption for teams deeply invested in EF patterns.
It's a reasonable choice for straightforward CRUD apps. The trade-off is that EF's abstractions were designed around relational databases, so MongoDB-specific operations (atomic $inc, array operators, complex aggregations) are either limited or require dropping down to the driver anyway. Many teams use the driver directly for anything performance-sensitive. Check the provider's documentation for its current feature coverage before committing to it.
Common Mistakes
Registering MongoClient as scoped or transient. Every request gets a new client and a new pool, and you'll exhaust connections quickly under load. Register it as a singleton.
Skipping IgnoreExtraElements. A single new field written by another service or a migration script breaks deserialization for your whole app. Configure it globally.
Relying on default decimal and GUID serialization. Be explicit with Decimal128 and GuidRepresentation.Standard, or you'll end up with strings and legacy binary formats that are painful to migrate.
Using synchronous methods in ASP.NET Core. Find(...).ToList() blocks a thread-pool thread for the whole round trip. Use the async versions and pass the request's CancellationToken.
Load, modify, replace. It's the EF instinct, and it creates lost updates. Use update builders for targeted changes.
Conclusion
The MongoDB C# driver rewards you for leaning into its model: one singleton client, typed collections, builders for filters and updates, LINQ for readable aggregations, and explicit serialization conventions set on day one. Get those basics right and you'll have compile-time safety for queries with none of the impedance mismatch of forcing documents through a relational ORM.
Start by adding the convention pack and GUID serializer registration to the top of your Program.cs, then write your first repository with builder-based updates. If you're coming from a SQL background and want a refresher on the operations themselves, MongoDB CRUD Operations Explained with Practical Examples maps each one to its shell equivalent.


