Map a JSON column in EF Core by modeling the data as an owned type and calling ToJson on it: OwnsOne(o => o.Shipping, b => b.ToJson()).
The owned object is stored as a single JSON document in one column of its owner's row, nvarchar(max) on SQL Server or jsonb on PostgreSQL.
LINQ still translates into the document, and changing one property generates a partial update rather than a full rewrite.
Not every object in your domain needs a table. User preferences, a shipping address snapshot frozen at order time, integration metadata with a shape you do not control: normalizing these into tables adds joins and migrations and gives you nothing in return.
Stuffing them into a string column and serializing by hand loses querying, change tracking, and type safety.
JSON columns with ToJson keep all three.
Mapping with ToJson
JSON columns build on owned types.
Model the payload as a plain class, own it, and call ToJson:
public class Order
{
public Guid Id { get; set; }
public DateTime CreatedAtUtc { get; set; }
public ShippingInfo Shipping { get; set; } = null!;
}
public class ShippingInfo
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string CountryCode { get; set; } = string.Empty;
public DeliveryInstructions? Instructions { get; set; }
}
public class DeliveryInstructions
{
public string? GateCode { get; set; }
public bool LeaveAtDoor { get; set; }
}
The configuration is one call on the owned navigation:
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.OwnsOne(o => o.Shipping, shipping =>
{
shipping.ToJson();
shipping.OwnsOne(s => s.Instructions);
});
}
}
The resulting table has a single Shipping column of type nvarchar(max) on SQL Server or jsonb on PostgreSQL, holding the nested document.
Instructions is stored inside the same document, and nesting needs no configuration beyond declaring the ownership.
Collections work the same way with OwnsMany:
builder.OwnsMany(o => o.StatusHistory, h => h.ToJson());
This is a natural fit for the value-object style of modeling I covered in owned types and DDD: the JSON column is an implementation detail, and the domain model stays clean C#.
Since EF Core 8, primitive collections (List<string>, int[]) on an entity are mapped automatically with no configuration, stored as JSON on SQL Server and SQLite.
On PostgreSQL, Npgsql maps them to native arrays instead.
Querying into the Document
Unlike a hand-rolled serialized string, ToJson lets LINQ translate into JSON path operations:
var orders = await context.Orders
.Where(o => o.Shipping.City == "Oslo")
.Where(o => o.Shipping.Instructions!.LeaveAtDoor)
.OrderBy(o => o.Shipping.CountryCode)
.ToListAsync();
On PostgreSQL that becomes jsonb extraction operators; on SQL Server, JSON_VALUE calls:
SELECT o."Id", o."CreatedAtUtc", o."Shipping"
FROM "Orders" AS o
WHERE (o."Shipping" ->> 'City') = 'Oslo'
AND CAST(o."Shipping" #>> '{Instructions,LeaveAtDoor}' AS boolean)
ORDER BY o."Shipping" ->> 'CountryCode'
Projections reach inside too, so you can select just a fragment without materializing the owner:
var cities = await context.Orders
.Select(o => o.Shipping.City)
.Distinct()
.ToListAsync();
The translation coverage keeps expanding with each EF release (EF 8 brought JSON collection querying so you can run Any over an OwnsMany document; EF 9 and 10 keep filling gaps).
When you hit an edge the provider cannot translate, raw SQL against the same column is always available.
Partial Updates
Change tracking works through the document. Modify one property and save:
var order = await context.Orders.FirstAsync(o => o.Id == orderId);
order.Shipping.Instructions!.GateCode = "4471";
await context.SaveChangesAsync();
EF Core does not rewrite the whole document.
It generates a targeted patch that updates only the changed path (JSON_MODIFY on SQL Server, jsonb_set on PostgreSQL).
For small documents the saving is minor; for documents holding sizable collections it keeps the UPDATE statement and its parameters small, though on nvarchar(max) and jsonb the database still writes the whole new document value.
The usual change tracker rules apply: the owned instances are tracked with their owner, and replacing the whole Shipping object marks the full document modified.
How Do You Index a Value Inside a JSON Column?
The first JSON column that ends up in a hot WHERE clause will need an index, and EF Core's fluent API stops at the column boundary.
Two patterns cover it.
On SQL Server, extract the value into a computed column and index that:
builder.Property<string>("ShippingCity")
.HasComputedColumnSql("JSON_VALUE([Shipping], '$.City')", stored: true);
builder.HasIndex("ShippingCity");
That is the same mechanism I described in computed columns in EF Core, applied to JSON extraction.
On PostgreSQL, add a GIN or expression index with raw SQL in a migration:
migrationBuilder.Sql(
"""
CREATE INDEX ix_orders_shipping_city
ON "Orders" ((("Shipping" ->> 'City')));
""");
migrationBuilder.Sql(
"""
CREATE INDEX ix_orders_shipping_gin
ON "Orders" USING gin ("Shipping");
""");
The expression index serves equality on one known path; the GIN index serves containment queries across arbitrary paths. More on picking between Postgres index types in PostgreSQL indexes for .NET developers.
Where JSON Columns Are the Wrong Tool
The failure mode of document-in-row is using it for things that are actually relational:
- Anything referenced by other tables. There are no foreign keys into a JSON document. If another entity needs to point at it, it is a table.
- Data queried independently of its owner at scale. Filtering ten million rows by a JSON property competes poorly with a proper column unless you index that exact path. Promote hot properties to real columns; keep the long tail in the document.
- Data with strong schema guarantees. The database accepts a document missing half its fields. Your C# types only constrain what your app writes.
- Concurrent partial edits from multiple writers. Two processes editing different parts of the same document still conflict at the row level. If that is your workload, split the document or handle it with optimistic concurrency.
My heuristic: JSON columns hold data that is created and deleted with its row, varies in shape, and is queried mostly through its owner. That covers address snapshots, settings, metadata, and denormalized read models. Everything else stays relational.
Summary
ToJson turns owned types into JSON documents inside a relational row, and it keeps the two things hand-rolled serialization throws away: LINQ translation into the document and change tracking with partial updates.
Map the payload as owned types, query it like any navigation, and when a document property becomes hot, index it through a computed column (SQL Server) or an expression/GIN index (PostgreSQL).
Use JSON columns for row-scoped data with a flexible shape. When something needs a foreign key, independent querying at scale, or schema enforcement, move it to its own table.
Frequently Asked Questions
How do I map a JSON column in EF Core?
Model the data as an owned type and call ToJson in the owned navigation configuration, for example OwnsOne(e => e.Details, b => b.ToJson()). Collections work the same way with OwnsMany. EF Core serializes the object into a single JSON column.
Can I query inside a JSON column with LINQ?
Yes. Queries like Where(o => o.Details.City == "Oslo") translate to JSON path operations in SQL, such as JSON_VALUE on SQL Server or jsonb arrows on PostgreSQL. Filtering, ordering, and projecting into JSON properties all translate.
Does EF Core update the whole JSON document on every change?
No. When you change a single property inside a ToJson owned type, EF Core generates a partial update using JSON_MODIFY on SQL Server or jsonb_set on PostgreSQL, touching only the changed path.
Should I use jsonb or a separate table?
Use a JSON column when the data is always loaded with its owner, has flexible or evolving shape, and never needs foreign keys or independent querying at scale. Use a table when you need referential integrity, frequent standalone queries, or joins.
Can I index values inside a JSON column?
Not directly through EF Core mapping, but you can add a computed column extracting the JSON value and index that on SQL Server, or create a GIN index or an expression index on jsonb in PostgreSQL via raw SQL in a migration.



