Mapping JSON Columns in EF Core

Mapping JSON Columns in EF Core

6 min read··

databasedotnetef-core

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 lives 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 earns 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 buys you joins and migrations without buying you anything.

Stuffing them into a string column and serializing by hand loses querying, change tracking, and type safety. JSON columns with ToJson keep all three. You get a document inside the row, and LINQ still translates into the document.

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. Nesting is free: Instructions lives inside the same document, no extra 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#.

Also worth knowing: 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

This is the part that separates ToJson from a hand-rolled serialized string. LINQ translates 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, JSON_MODIFY on SQL Server, jsonb_set on PostgreSQL, updating only the changed path. Small documents make this mostly a correctness nicety; on documents holding sizable collections it is a real write amplification saver.

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, even indexed, competes poorly with a proper column. Promote hot properties to real columns; keep the long tail in the document.
  • Data with strong schema guarantees. The database will happily store a document missing half its fields. Your C# types constrain what your app writes, not what exists.
  • 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 lives and dies with its row, varies in shape, and is queried mostly through its owner. Addresses-as-snapshots, settings, metadata, 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).

The discipline is in the boundary. JSON columns are for row-scoped, shape-flexible data. The moment something needs a foreign key, independent querying at scale, or schema enforcement, it has outgrown the document and earned its 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.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.