Mapping JSON Columns in EF Core

databasedotnetef-core
Mapping JSON Columns in EF 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 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.

  • Cascade Delete in EF Core: Behaviors and Pitfalls

    DeleteBehavior in EF Core controls two different things: what EF does to tracked entities in memory, and what the database enforces with ON DELETE. The two can disagree, and when they do you get deletes that work in tests and fail in production. Here is what each DeleteBehavior value does on both sides.

  • Composite Primary Keys in EF Core

    Composite primary keys look like a simple HasKey call, but they change how Find works, how relationships are configured, and how your indexes behave. Here is how to configure them correctly and when a surrogate key is the better choice.

  • Computed Columns in EF Core

    Derived values calculated in C# drift out of sync the moment someone updates the database directly. Computed columns push the calculation into the database itself, so the value is always consistent. Here is how to map them in EF Core, and why the stored vs virtual choice decides whether you can index them.

Browse all EF Core guides →
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.