# Mapping JSON Columns in EF Core

> Some data does not deserve its own table: settings blobs, address snapshots, flexible metadata. JSON columns give you document flexibility inside a relational row, and with ToJson in EF Core you keep LINQ querying into the JSON. Here is how to map, query, update, and index them.

Published: 2026-08-27. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/ef-core-json-columns

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`:

```csharp
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:

```csharp
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`:

```csharp
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**](https://milanjovanovic.tech/blog/owned-types-ef-core-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:

```csharp
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:

```sql
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:

```csharp
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**](https://milanjovanovic.tech/blog/ef-core-raw-sql-queries) against the same column is always available.

## Partial Updates

Change tracking works through the document.
Modify one property and save:

```csharp
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**](https://milanjovanovic.tech/blog/change-tracker-ef-core) 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:

```csharp
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**](https://milanjovanovic.tech/blog/ef-core-computed-columns), applied to JSON extraction.

On PostgreSQL, add a GIN or expression index with raw SQL in a migration:

```csharp
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**](https://milanjovanovic.tech/blog/solving-race-conditions-with-ef-core-optimistic-locking).

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.
