Marten as a document database stores one JSON document per identity in PostgreSQL. I use it when that document is the consistency boundary, and EF Core or MongoDB would be carrying the wrong kind of model.
This guide is the data layer: identity, the write, the query, and what happens when two requests save the same document. It is the current state. It is not a history of how the state was reached.
A stream of facts, aggregates, and projections is Marten Event Sourcing in Production. A handler that loads this document and decides is in CQRS in .NET with Wolverine. A save that must also emit a message is Transactional Outbox with Wolverine. The design of that context is Marten, Polecat, and Wolverine architecture. The hands-on version, on your domain, is the Marten and Wolverine workshop.
The document is the record
A document is the thing you load and save as one unit. An order with its lines. A user profile. A piece of configuration that changes together. The identity is a public Id. Guid, string, int, and long all work. I set the id in the application when the caller already knows it. Marten assigns a Guid when the value is empty, and the default algorithm is a sequential Comb guid so the primary key does not fragment.
public enum OrderStatus { Open, Shipped, Cancelled }
public sealed record OrderLine(string Sku, int Quantity);
public sealed class Order
{
public Guid Id { get; set; }
public string CustomerId { get; set; }
public OrderStatus Status { get; set; }
public DateTimeOffset PlacedAt { get; set; }
public List<OrderLine> Lines { get; set; } = [];
}Marten stores that type as JSONB in a table named mt_doc_order. The id is the primary key. The document is the data column. There is no line-item table unless you create one. A property that must be constrained, queried by SQL, or shown to a reporting tool as a column is a decision you make on purpose. The default is the document.
If the id on an existing type is not called Id, map it once with [Identity] or Schema.For<Order>().Identity(x => x.Number). Do that when you are moving a document that already has a key. Keep the key the business already uses.
Write the document you mean
A lightweight session does not watch the objects you loaded. Nothing is saved because you mutated a property. Insert stages a new document. Update stages a replacement of one that already exists. Store stages an upsert. All three wait for SaveChangesAsync. That call is the commit, and that call is where Marten throws.
Insert of an id that exists fails the commit with DocumentAlreadyExistsException. Update of an id that does not exist fails the commit with NonExistentDocumentException. A catch around Insert or Update never sees those exceptions. The same timing is why the outbox guide discards a duplicate insert on the handler chain, after the method has returned.
await using var session = store.LightweightSession();
var order = await session.LoadAsync<Order>(orderId);
if (order is null)
throw new InvalidOperationException($"Order {orderId} does not exist.");
order.Status = OrderStatus.Shipped;
session.Update(order);
await session.SaveChangesAsync();I use Insert for the first write and Update after a load. Store is for the case where replace-or-create is actually the rule, such as a settings document keyed by a known id. Using Store for every save hides a lost update: the last writer replaces the document, including fields they never read.
DirtyTrackedSession is the session that saves a mutation by itself. I keep it off the request path. A document loaded for a read, then touched while building a response, gets written back. The lightweight session makes the write a line I can see.
Load with LoadAsync<Order>(id) when you have the id. A missing id returns null. Use a query session for a request that is only allowed to read. A query session cannot commit. That is the constraint I want on a GET.
Query a field, then index it
LINQ queries one document type. Marten writes SQL against the JSONB, or against an index you declared.
opts.Schema.For<Order>().Index(x => x.CustomerId);
opts.Schema.For<Order>().Duplicate(x => x.PlacedAt);
var open = await session.Query<Order>()
.Where(x => x.CustomerId == customerId && x.Status == OrderStatus.Open)
.ToListAsync();Index is a computed index on the JSON path. PostgreSQL can use it, and you do not add a second column that has to stay in sync. I add an index for a predicate the application actually runs. I leave the other properties inside the document.
Marten's Index does not emit a computed index for DateTime or DateTimeOffset. PostgreSQL accepts an expression index only when the expression is immutable, and a cast of that JSON value to timestamptz is not: it depends on the session time zone. A hot filter on PlacedAt is a Duplicate: a real column Marten keeps beside data, and a migration when you add it to a large table. A column a report already queries with SQL is the other reason I duplicate. Everything else stays in the JSON.
A value that must be unique, such as an order number the customer can see, is an index with IsUnique = true. A duplicate insert then fails the commit. I use that for a business key. I do not unique the customer id, because a customer has many orders.
A query that wants five joins is telling you the data is relational. Marten can include another document type. I treat that include as a sign the included data belongs inside this document, or the screen is a reporting query and belongs in SQL. ToListAsync on an unfiltered table loads every document into the process. That works once.
Two updates at once
Two requests will load the same order, change it, and save. Without a revision check, the second save wins and the first change disappears. I turn on numeric revisions for any document that more than one actor writes.
public sealed class Order : IRevisioned
{
public Guid Id { get; set; }
public int Version { get; set; }
public OrderStatus Status { get; set; }
public List<OrderLine> Lines { get; set; } = [];
}IRevisioned stores an integer in mt_version and copies it onto Version. The load brings that number back. The save I send is the next one.
session.UpdateRevision(order, order.Version + 1);
await session.SaveChangesAsync();Marten rejects the commit when the database revision is already that number or higher. The exception is ConcurrencyException, and it comes from SaveChangesAsync. The request fails. The caller reloads and decides again. Swallowing the exception and retrying the same in-memory order writes the older intent over the winner.
UseOptimisticConcurrency(true) is the other mechanism. It stores a Guid in the same mt_version column. A type that has both, including an IRevisioned document with that call added later, fails at startup with InvalidDocumentException. I use the integer. A support query can read it. Aggregate projections are already revisioned; that path stays in the event sourcing guide.
If conflicts are constant, the document is too wide. An order that also contains the customer's profile, the invoice, and the shipment will collide all day. Split it until a conflict means two decisions about the same thing. A document nobody else writes does not need the revision.
Calling Store on the same revisioned instance twice in one session does not keep producing the next number. One decision, one UpdateRevision. A loop that saves the same order again in that session passes the next number itself.
Where EF Core still fits
EF Core remains the tool when the model is rows. Foreign keys, a line that is queried without its parent, a report that joins five tables, a schema other services already read with SQL. Mapping that to documents means copying the same fact into several JSON blobs and hoping they stay aligned.
I leave an existing EF model on EF. The document table is a new schema, mt_doc_*, and the application has to mean the document. Pointing EF at those tables to get a change tracker back gives you two models of one JSON column. The Marten configuration is the one that knows the identity, the revision, and the indexes.
The split I use in one database: Marten for the aggregates that are loaded and saved whole, EF Core or SQL for the relational remainder. One PostgreSQL transaction can cover both when a single request truly writes both. That case is rarer than the diagram suggests. If every command needs both, the boundary is wrong.
Where MongoDB still fits
The document shape is the same idea. One aggregate, one JSON document, load by id, query by fields. The operational home is different. Marten is the PostgreSQL you already back up, restore, and connect to from the rest of the application. A save of several documents in one session is one PostgreSQL transaction. There is no second cluster to upgrade because one service wanted documents.
MongoDB still fits when the access pattern is a large document collection with an aggregation pipeline, sharding, and operators who already run it. Moving that workload onto Marten to "be on Postgres" trades a database your team knows for a JSONB schema that will not behave like the aggregation framework. I make that move when the service already depends on PostgreSQL and the document is the aggregate. I leave a dedicated document cluster where the cluster is the product.
Id names survive the move. A Mongo _id that is not Id gets an [Identity] map. The query does not survive unchanged. An aggregation pipeline becomes LINQ, or it becomes SQL. Plan that as a rewrite of the query, not as a connection-string change.
PostgreSQL is still the database
The documents live in the same instance as everything else unless the write rate says otherwise. Vacuum, indexes, and a backup you have restored once still apply. A document table grows when you keep every historical copy yourself. Marten is storing the current document. If you needed the history, that is the event store, and it has its own guide.
In development I set AutoCreateSchemaObjects to AutoCreate.CreateOrUpdate. Marten adds what is missing and does not drop a table to do it. In production the setting is AutoCreate.None. The process serves traffic. It does not migrate. db-apply and db-assert come from JasperFx: ApplyJasperFxExtensions() on the host, and RunJasperFxCommands(args) where the process would otherwise call Run(). A host that only calls Run() reports db-apply as an unknown argument. dotnet run -- db-apply runs before the new code is in rotation. db-assert fails the deploy when the database does not match the configuration. Several nodes creating indexes at startup is how a deploy gets stuck. A computed index is the cheap change. A duplicated column on a large table is a migration, and it belongs in that apply step.
JSONB is still a column. You can read it with SQL when you are diagnosing a production row. The application keeps going through Marten so the identity, the revision, and the index configuration stay in one place. A hand-written UPDATE of the data column skips the mt_version check. I treat that as an incident, the same way I would treat an ad hoc update of a relational row someone else is editing.
The test is the stale write
A test that calls Update and does not commit has not tested the document store. The fact I want is that the second writer keeps the order, and the first writer's commit throws.
The store in the fixture is the application store, against PostgreSQL. I build it once.
public async Task a_stale_save_leaves_the_first_write()
{
var orderId = Guid.NewGuid();
await using (var setup = store.LightweightSession())
{
setup.Store(new Order { Id = orderId, Status = OrderStatus.Open });
await setup.SaveChangesAsync();
}
await using var first = store.LightweightSession();
await using var second = store.LightweightSession();
var byFirst = await first.LoadAsync<Order>(orderId);
var bySecond = await second.LoadAsync<Order>(orderId);
byFirst.Status = OrderStatus.Shipped;
bySecond.Status = OrderStatus.Cancelled;
second.UpdateRevision(bySecond, bySecond.Version + 1);
await second.SaveChangesAsync();
first.UpdateRevision(byFirst, byFirst.Version + 1);
await Should.ThrowAsync<ConcurrencyException>(
() => first.SaveChangesAsync());
}Testcontainers belongs in this test. After the throw I reload through a query session and expect Cancelled. The in-memory byFirst is still Shipped. That object is the stale decision, and it is not what PostgreSQL kept.