Mert Özen Further With Every Line
Database MongoDB — #11

The MongoDB EF Core Provider: When to Use It and What It Does Not Support

Mert Özen Nov 14, 2026 14 min 11 views
The MongoDB EF Core Provider: When to Use It and What It Does Not Support

The MongoDB EF Core provider is a good choice where the work is mostly CRUD and the team is used to EF Core; aggregation, change streams, bulk writes and field-level atomic updates call for the native driver. With real command output: SaveChanges rewriting owned fields every time and overwriting the driver's $inc, row versions, implicit transactions, Select and Include working in 10.0.5, and GroupBy not working.

Version note: The code in this post was built with .NET 10, MongoDB.EntityFrameworkCore 10.0.5 (on EF Core 10.0.11) and MongoDB.Driver 3.14 and run against MongoDB 9.0.8 Community Server. On MongoDB 8.0.38 the same commands produced exactly the same server commands and output; everything in this post happens on the provider side.

Guess First

Imagine you wrote the admin screen of Bookstore, the online bookstore used as the shared example across this site's series, with Entity Framework Core, while the order side works with the native driver and a conditional $inc, as in posts 3 and 10. An editor opens Dune's page. Meanwhile two copies of Dune are sold. The editor changes only the price and saves:

// An editor opens Dune in the admin screen (EF Core)
Book dune = await db.Books.SingleAsync(b => b.Id == 26);

// Meanwhile an order is placed through the driver (post 3): conditional $inc on the stock
UpdateDefinition<BsonDocument> sell = Builders<BsonDocument>.Update.Inc("stock.onHand", -2);
await rawBooks.UpdateOneAsync(new BsonDocument { { "_id", 26 }, { "stock.onHand", new BsonDocument("$gte", 2) } }, sell);

// The editor changes only the price and saves
dune.ListPrice = 399.90m;
await db.SaveChangesAsync();

The stock was 51, 2 were sold, and the editor never touched the stock. What is the stock in the database?

editor loads Dune: price 390.50, stock 51, version 0
order sells 2 copies
editor saved the new price
in the database: price 399.90, stock 51, version 1

51. The two sold copies silently came back to the stock; no error, no exception. You would expect EF Core to write only the changed property, but during SaveChanges the MongoDB provider sends all of the entity's owned parts along with the changed fields: the stock subdocument was rewritten as the editor had loaded it, as 51. This post covers MongoDB's Entity Framework Core provider through details of this kind: setup and mapping, what SaveChanges sends to the server, optimistic concurrency, how far LINQ is translated, the official limitations and the choice between the native driver and EF Core.

The commands are in the console application from post 9: ef-concurrency, ef-save and ef-query. 02-load.sh, 06-bookstore-indexes.js and this post's 11-version-field.js script must have run first; we will see why below.

UseMongoDB, the DbContext and Mapping

The provider is the MongoDB.EntityFrameworkCore package. The 10.0.x line is for EF Core 10 and .NET 10; as the 9.1.5 and 8.4.5 releases published on the same day show, the 9.x and 8.x lines are maintained for EF Core 9 and 8 as well. According to the project README, it supports MongoDB 5.0 and later and wants a deployment that supports transactions; our single-node replica set meets that. Setup has two parts: the singleton IMongoClient from the driver stays as it is, and the DbContext is added on top of it as a scoped service.

public static class BookstoreEfCore
{
    // The DbContext is scoped (one change tracker per request); the MongoClient underneath stays a singleton
    public static IServiceCollection AddBookstoreEfCore(this IServiceCollection services) =>
        services.AddDbContext<BookstoreDbContext>((sp, options) =>
            options.UseMongoDB(sp.GetRequiredService<IMongoClient>(), "bookstore"));
}

public sealed class BookstoreDbContext(DbContextOptions<BookstoreDbContext> options) : DbContext(options)
{
    public DbSet<Book> Books => Set<Book>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Review> Reviews => Set<Review>();

    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
    {
        // Field names in our documents are camelCase (post 2); without this EF Core writes "ListPrice"
        configurationBuilder.Conventions.Add(_ => new CamelCaseElementNameConvention());
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>(book =>
        {
            book.ToCollection("books");
            book.OwnsMany(b => b.Authors);
            book.OwnsOne(b => b.Category);
            book.OwnsOne(b => b.Stock);
            // A navigation to another collection: Include becomes a $lookup (provider 10.0.3+)
            book.HasMany(b => b.Reviews).WithOne().HasForeignKey(r => r.BookId);
        });

        modelBuilder.Entity<Order>(order =>
        {
            order.ToCollection("orders");
            order.OwnsOne(o => o.Customer);
            order.OwnsMany(o => o.Items);
            order.OwnsMany(o => o.Payments);
            order.Property(o => o.Status).HasConversion<string>();
        });
    }
}

The logic of the mapping is the same as the model in post 4: everything embedded (authors, category, stock, the items of an order) is an owned type, and everything in a separate collection is a separate entity. The driver conventions from post 9 do not affect EF Core; the provider builds its own model, so camelCase field names need the provider's own CamelCaseElementNameConvention. The Id property maps to _id by itself. The enum line is the fix for a pitfall, covered in "Common Pitfalls".

The entities are not the records from post 9 but separate, mutable classes. Change tracking expects you to modify the properties of an entity it loaded and to find the difference in SaveChanges; with an init record you cannot write dune.ListPrice = 399.90m, and with produces a new instance that EF Core does not track.

// EF Core entities are mutable classes: change tracking compares property values, records with init cannot be edited in place
public sealed class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public List<AuthorRef> Authors { get; set; } = [];
    public CategoryRef? Category { get; set; }
    public decimal ListPrice { get; set; }
    public Stock? Stock { get; set; }
    public List<Review> Reviews { get; set; } = [];

    // Row version: EF Core adds it to the update filter and increments it on every save
    [Timestamp]
    public long Version { get; set; }
}

(Other properties such as Isbn and Language are shortened to save space.) When I added the Version field and ran ef-concurrency before 11-version-field.js, the first line failed with this error:

Unhandled exception. System.InvalidOperationException: Document element is missing for required non-nullable property 'Version'.

The driver skipped a missing field with the property's default value; the provider requires the field to be present in the document for a non-nullable property. The usual way to add a field to the model in EF Core is a migration, but the MongoDB provider has no migrations. You update the existing documents yourself; a small instance of the schema versioning approach from post 5:

// 11 - EF Core row version: existing books get version 0 (there are no EF Core migrations for MongoDB)
const result = db.books.updateMany(
    { version: { $exists: false } },
    { $set: { version: NumberLong("0") } }
);
print("books updated:", result.modifiedCount);

What Does SaveChanges Send to the Server?

I added a CommandStartedEvent subscription to the lab's MongoClient (the command events from post 9); the ef-save command prints every update, insert and commitTransaction the provider sends. First only the price changes, then within one SaveChanges a review is added and the price is set back:

loaded Dune: price 390.50, stock 51, version 0
change only the price:
  > { "update" : "books", "ordered" : true, "updates" : [{ "q" : { "_id" : 26, "version" : 0 }, "u" : { "$set" : { "_id" : 26, "listPrice" : { "$numberDecimal" : "399.90" }, "version" : 1, "authors" : [{ "authorId" : 18, "name" : "Frank Herbert" }], "category" : { "categoryId" : 3, "name" : "Science Fiction" }, "stock" : { "onHand" : 51, "reorderLevel" : 5, "updatedAt" : { "$date" : "2026-06-14T17:53:00Z" } } } } }] }
a new review and the price back, in one SaveChanges:
  > { "insert" : "reviews", "ordered" : true, "startTransaction" : true, "autocommit" : false, "documents" : [{ "_id" : { "$oid" : "6af86c965e79f63dee3bc706" }, "body" : null, "bookId" : 26, "rating" : 5, "reviewedAt" : { "$date" : "2026-11-14T14:13:42.782Z" }, "customer" : { "customerId" : 3, "name" : "Grace Hopper" } }] }
  > { "update" : "books", "ordered" : true, "autocommit" : false, "updates" : [{ "q" : { "_id" : 26, "version" : 1 }, "u" : { "$set" : { "_id" : 26, "listPrice" : { "$numberDecimal" : "390.50" }, "version" : 2, "authors" : [{ "authorId" : 18, "name" : "Frank Herbert" }], "category" : { "categoryId" : 3, "name" : "Science Fiction" }, "stock" : { "onHand" : 51, "reorderLevel" : 5, "updatedAt" : { "$date" : "2026-06-14T17:53:00Z" } } } } }] }
  > { "commitTransaction" : 1, "autocommit" : false }

The first update explains "Guess First". The only scalar field that changed, listPrice, is in the $set; unchanged scalars such as title and isbn are not sent. But the owned parts authors, category and stock are rewritten in full, even though they did not change. The provider documentation says that when a property of nested owned entities changes, the root document is rewritten; the observed behavior is a little narrower than that, but the result is the same: the owned parts go to the server with the values they had when they were loaded, on every save. A $inc or $push written through the driver in between is overwritten. In post 4, one gain of embedding was atomic updates at field level; through EF Core that gain is gone.

In the second save two root entities (a review and a book) changed, and the provider put both in a transaction that starts with startTransaction and ends with commitTransaction. This is an implicit transaction; with its default value WhenNeeded, Database.AutoTransactionBehavior kicks in when more than one root entity is affected, with Always on every save, with Never not at all. Because a standalone server cannot open a transaction, the documentation says the provider throws NotSupportedException; one more benefit of building the lab as a replica set in post 2. An important difference from post 10: WithTransactionAsync reran the body on an error labelled TransientTransactionError, while according to its documentation the EF Core provider does not retry on this error. Where write conflicts are frequent, you need to wrap SaveChanges in your own retry. A detail: the review's body field was written as null instead of being left out; the difference between "field missing" and "field null" that we saw in post 3 has to be taken into account in your queries.

Optimistic Concurrency: the Row Version

The filter above has "version" : 0 and the $set has "version" : 1: a long property marked with [Timestamp] becomes the row version. The provider adds it to the update filter and increments it by one on every save; if the filter finds no document, it throws DbUpdateConcurrencyException. So why did it not catch anything in the guessing round? Because the $inc on the driver side did not touch version; in EF Core's eyes the document had not changed. In the second round of ef-concurrency the order side plays by the rule as well:

UpdateDefinition<BsonDocument> sell = Builders<BsonDocument>.Update.Inc("stock.onHand", -2);
if (bumpVersion)
{
    sell = sell.Inc("version", 1L);   // the driver side plays by the row version rule too
}
editor loads Dune: price 390.50, stock 51, version 0
order sells 2 copies and increments version
DbUpdateConcurrencyException: Conflicts were detected when performing updates to 'bookstore.books'. Did not perform 1 modifications, 0 insertions, and 0 deletions.
in the database: price 390.50, stock 49, version 1

This time the editor's save was rejected and the stock stayed at 49. The application's job is to catch the exception, reload the entity and tell the user "this record changed while you were editing it". The limitations note in the documentation reads like a summary of this post: a row version only works if every application writing to the same document increments it. The same gap exists inside EF Core itself: according to the provider's README, ExecuteUpdate bypasses the change tracker and does not check concurrency tokens; in the output below its $set has no version either. If you want to protect a single field, you can make that field a concurrency token with [ConcurrencyCheck] instead of a row version; then only that field's old value goes into the filter. Since 10.0.3 the provider rejects concurrency tokens on owned entities, so you cannot protect Stock.OnHand directly.

LINQ: How Far Is It Translated?

ef-query runs five queries. The provider translates LINQ into an aggregation pipeline through the driver's LINQ3 translator:

List<Book> scienceFiction = await db.Books
    .Where(b => b.Category!.Name == "Science Fiction" && b.ListPrice < 500m)
    .OrderByDescending(b => b.ListPrice)
    .Take(3)
    .ToListAsync();

var prices = await db.Books.Where(b => b.Id == 26).Select(b => new { b.Title, b.ListPrice }).ToListAsync();

Book dune = await db.Books.Include(b => b.Reviews).SingleAsync(b => b.Id == 26);

var byStatus = await db.Orders.GroupBy(o => o.Status).Select(g => new { Status = g.Key, Count = g.Count() }).ToListAsync();

int updated = await db.Books.Where(b => b.Id == 26).ExecuteUpdateAsync(s => s.SetProperty(b => b.ListPrice, 390.50m));
Where + OrderBy + Take:
  > { "aggregate" : "books", "pipeline" : [{ "$match" : { "category.name" : "Science Fiction", "listPrice" : { "$lt" : { "$numberDecimal" : "500" } } } }, { "$sort" : { "listPrice" : -1 } }, { "$limit" : 3 }] }
  Neuromancer 480.50, The War of the Worlds 450.50, Dune 390.50
Select into an anonymous type:
  > { "aggregate" : "books", "pipeline" : [{ "$match" : { "_id" : 26 } }, { "$project" : { "Title" : "$title", "ListPrice" : "$listPrice", "_id" : 0 } }] }
  { Title = Dune, ListPrice = 390.50 }
Include across collections:
  > { "aggregate" : "books", "pipeline" : [{ "$match" : { "_id" : 26 } }, { "$lookup" : { "from" : "reviews", "localField" : "_id", "foreignField" : "bookId", "as" : "_lookup_Reviews" } }, { "$limit" : 2 }] }
  Dune: 5 reviews
GroupBy:
  InvalidOperationException: The LINQ expression 'DbSet<Order>() ...
ExecuteUpdate:
  > { "update" : "books", "ordered" : true, "updates" : [{ "q" : { "_id" : 26 }, "u" : { "$set" : { "listPrice" : { "$numberDecimal" : "390.50" } } }, "multi" : true }] }
  1 book(s) updated

The first query produced the same pipeline as the LINQ query in post 9 and returned the same three books. The second and third lines are surprising: the provider's "Feature Limitations" page says that projection into an anonymous type with Select is not supported, yet in 10.0.5 the query ran on the server as a $project. The release notes explain this: 10.0.2 extended Select() projections, and 10.0.3 brought cross-collection Include and navigation support together with ExecuteUpdate/ExecuteDelete. Include became a $lookup; the rule from post 7 applies, there must be an index on reviews.bookId (the bookId_1_reviewedAt_-1 index from post 6 covers it). $limit: 2 is how SingleAsync checks whether there is a second result. GroupBy could not be translated and EF Core's familiar "could not be translated" error came back. Lesson: this provider changes quickly and the documentation's limitations page can lag behind the release notes; check whether a query is translated in the version you use, with a command log as in this post.

The Official Limitations

The documentation's list falls into two groups. Unsupported on the EF Core side: migrations, generating a model from an existing database (database-first), foreign keys, table splitting, temporal tables and spatial data. Most of these are relational database concepts; in the document model they either have no counterpart or make no sense. No foreign keys means that the HasForeignKey above sets up no constraint on the server: the database does not stop you from writing a review for a book that does not exist, and Include only matches them when reading. Unsupported on the MongoDB side: time series collections and Atlas Search. To these I add two from today's output: GroupBy, and aggregation in general, and field-level updates on owned fields.

The release notes carry one more warning: because the version numbers are aligned with EF Core, the provider does not limit breaking changes to major versions and can make them in minor versions too. The BsonRepresentation fix for nullable fields in 8.3.0 is one example. Treat a package update not as a patch update but as something you do after reading the release notes and running the tests.

The Choice: Native Driver or EF Core?

I make the choice by the kind of work. At one end are screens that are mostly CRUD, at the other end jobs that need field-level atomic updates and pipelines:

NeedMy recommendationWhy
Admin screens, CRUD through formsEF Core providerChange tracking, DbContext and an API the team knows
Stock decrements, counters, adding to an array with $pushNative driverField-level atomic updates; EF Core rewrites owned parts
Reports, $group, $setWindowFields, $mergeNative driverGroupBy is not translated; the whole pipeline is in the driver
Change streams, bulk writes, transactions with retriesNative driverThe provider does not offer these or does not retry
Time series, Atlas SearchNative driverAn official limitation of the provider
Both worlds write to the same collectionBoth, with rulesBoth sides must increment the row version

Using both in the same project is natural: in the setup above the DbContext and the driver's collections share the same IMongoClient, and so the same connection pool. My preference in Bookstore is to write the Catalog admin screens with EF Core and the hot paths of Ordering and Inventory (stock, orders, change streams) with the native driver, and to look for the row version rule in code review for both sides writing to the same collection. The "thin tool for reads, EF Core for writes" split from the "EF Core and Architecture: Repositories, the Read Side of CQRS and Moving to Dapper" post in the Entity Framework Core series is reversed here: in MongoDB the hot write path is in the native driver.

In Production

Keep the DbContext scoped and the MongoClient a singleton. The provider documentation says the same: a singleton DbContext accumulates the entities of all requests in the change tracker, which means stale data and growing memory. A separate MongoClient per DbContext is the EF Core version of the "new client on every request" mistake from post 9.

Set up a place where you can keep the command log on. All the findings in this post came from CommandStartedEvent; in production the same subscription can be switched on and off, for example by log level. The provider has its own logs too, but you see behaviors such as the rewriting of owned parts most clearly in the command sent to the server. Watch document size as well: large owned arrays (an items array with hundreds of entries, for example) are sent in full on every save.

Common Pitfalls

Breaking the document model with relational EF Core habits. Because Include works now, splitting the author and category data we embedded in post 4 into separate collections and building Include chains on every screen is exactly the $lookup-on-every-read mistake from post 4's "Common Pitfalls". Include is for relationships that are read now and then and grow without limit (a book and its reviews); data read together should still stay embedded.

Leaving enums without a conversion. When I removed the HasConversion<string>() line and changed the status of order 1, the provider had no problem reading the string "Delivered" but wrote the new value as a number: the document was left with status: 2. No error, but mongosh queries looking for status: "Shipped", the partial index on status from post 6 and the driver code from post 9 that works with the convention no longer see this order.

Incrementing counters through EF Core. book.Stock.OnHand -= 1; SaveChanges() is a read-then-write race (50 sales from a stock of 10 in post 3); on top of that, because it is an owned field, the whole stock subdocument is written. Fields such as counters, stock and balances should be updated with the driver's conditional $inc.

A Small Experiment

★☆☆ Set Database.AutoTransactionBehavior to Never and run ef-save. What changes in the command log? Then point the lab at a standalone container as in post 1 and try the same command with the default setting.

★★☆ Write an UpdatePriceAsync method that catches DbUpdateConcurrencyException, refreshes the entity with ReloadAsync and applies the price change once more on top of the new values. Try it with the two rounds from "Guess First".

★★★ Try to write the "orders per customer" report from post 7 through EF Core. Find the part that does not translate, write it with the driver's Aggregate() through the IMongoClient under the same DbContext, and compare the commands each solution sends to the server.

Block B ends here. In the next post we move on to consistency: write concern, read concern and read preference. We turn the lab into a three-member replica set and see how reading an order from a secondary right after payment returns "no such order".