EF Core JSON Columns – Mapping and Querying Structured Data

Article Sponsors

EF Core too slow? Insert data up to 14x faster and cut save time by 94%. Boost performance with extension methods fully integrated into EF Core — Bulk Insert, Update, Delete, and Merge.

Join 5,000+ developers who’ve trusted our library since 2014.

👉 Try it now — and feel the difference

Not all data fits neatly into tables. Think about the shipping details of an order, the settings of a user, or the extra specs of a product. The usual answer is to add more tables. But that also means more joins, more migrations, and more code to maintain.

EF Core JSON columns give you another option. You store a nested object inside one column of the parent row, as a JSON document. In your code you still use normal C# classes and normal LINQ queries. EF Core takes care of the JSON for you.

In this article, you will learn how JSON columns work in EF Core, how to map them with owned types and complex types, how to query and update them, and when you should avoid them. The examples use EF Core 10 (which runs on .NET 10) and SQL Server.

Note: This article focuses on SQL Server because that is where most .NET teams start. PostgreSQL gets a short note later. If you want to know how complex types work in general (without JSON), see my separate article on complex types.

What Are JSON Columns in EF Core?

A JSON column is a database column that holds a JSON document. Most relational databases support this, and it lets a relational database act a little like a document database.

EF Core added JSON column support in EF Core 7. The design is provider-agnostic, and SQL Server was the first provider to implement it. The idea is simple: you build a small object tree from your .NET types (an object with nested objects and lists), and EF Core saves that whole tree as one JSON document. If you have used the Azure Cosmos DB provider, this will feel familiar.

Why this is useful: you keep the strong structure of a relational table for the main data, and you keep the flexible, nested shape for the parts that need it.

Setting Up the Example Model

We will use a simple Order that has shipping details and a list of notes.

C#
public sealed class Order
{
    public int Id { get; set; }
    public required string OrderNumber { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public required ShippingDetails Shipping { get; set; }
    public List<OrderNote> Notes { get; set; } = [];
}

public sealed record ShippingDetails
{
    public required string Recipient { get; init; }
    public required string City { get; init; }
    public required string Country { get; init; }
    public string? TrackingNumber { get; init; }
}

public sealed record OrderNote(string Text, DateTimeOffset AddedAt);

The goal is to store Shipping and Notes inside JSON columns on the Orders table, instead of creating two more tables.

Mapping JSON Columns with Owned Types

This is the original way, available since EF Core 7. You mark the nested types as owned types, and then call ToJson().

C#
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(builder =>
    {
        builder.OwnsOne(o => o.Shipping, shipping => shipping.ToJson());
        builder.OwnsMany(o => o.Notes, notes => notes.ToJson());
    });
}

EF Core now saves the shipping details as a JSON document in a single column. For one order, it looks like this:

JSON
{
  "Recipient": "Jane Smith",
  "City": "London",
  "Country": "UK",
  "TrackingNumber": null
}

Important: Call ToJson() only on the outermost owned type. If a nested owned type is also set to a different JSON column, EF Core throws an error, because all owned types in one aggregate must live in the same JSON column.

Mapping JSON Columns with Complex Types (EF Core 10)

Complex types came in EF Core 8, and they are made for value objects, which are objects with no identity of their own (like an address or a money amount). Starting with EF Core 10, you can map a complex type to a JSON column too.

C#
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(builder =>
    {
        builder.ComplexProperty(o => o.Shipping, shipping => shipping.ToJson());
        builder.ComplexCollection(o => o.Notes, notes => notes.ToJson());
    });
}

Two things to know here. First, a collection of complex types must be mapped to JSON on relational databases. It cannot have its own table. Second, complex types are not found by convention, so you always have to configure them yourself (with ComplexProperty or the [ComplexType] attribute).

Owned Types vs Complex Types

Microsoft now says complex types are generally the better choice for value objects that use table splitting or JSON. Here are the main differences:

AspectOwned entity typesComplex types
IdentityHidden key and identityNo identity, compared by value
Sharing one instanceNot allowedAllowed
AssignmentReference semanticsValue semantics (values are copied)
.NET typeReference types onlyReference or value types
Can contain navigationsYesNo
ExecuteUpdate supportNot supportedSupported

In short: if your nested object is just data (no relationships to other entities), use a complex type. If it needs navigations to other entities, owned types are still the option.

Optional Complex Types and Structs

EF Core 10 also lets a complex property be optional. You only need to make it nullable:

C#
public ShippingDetails? ReturnAddress { get; set; }

An optional complex type must have at least one required property, so EF can tell the difference between a null object and an object whose values are all null. EF Core 10 also supports structs as complex types, but not collections of structs.

Tip: Make your complex types immutable (records work great). To change a value, create a new instance with a with expression. This avoids bugs where one shared instance changes in many places.

EF Core performance optimization sponsor banner showing bulk insert, update, delete, and merge features with 14x faster data operations.

The Native json Type in SQL Server

SQL Server 2025 and Azure SQL have a native json data type. EF Core 10 can use it. When it does, the JSON column is created as json instead of nvarchar(max). Microsoft says this new type is the better way to store JSON, and it brings notable performance gains.

EF Core 10 uses the native type by default when you call UseAzureSql, or when you set a SQL Server compatibility level of 170 or higher. On older SQL Server versions, JSON is still stored as text.

Upgrade warning: If you already have JSON columns stored as nvarchar(max) and you upgrade to EF Core 10 with UseAzureSql, EF Core will create a migration that changes those columns to json. Always read that migration before you apply it. If you are not ready, you can set a lower compatibility level to keep the old behavior. The EF Core 10 breaking changes page shows how.

A Quick Note for PostgreSQL

The Npgsql provider for EF Core 10 also supports JSON complex types with ToJson(). Its release notes call this the preferred way to map .NET types to JSON, replacing owned entities and the older POCO mapping. It also supports partial updates inside JSON documents with ExecuteUpdate.

Querying JSON Data with LINQ

The nice part is that you do not write any JSON code. You write LINQ, and EF Core translates it to SQL.

C#
var londonOrders = await context.Orders
    .Where(o => o.Shipping.City == "London")
    .Select(o => new { o.OrderNumber, o.Shipping.Recipient })
    .ToListAsync();

You can also search inside a JSON collection:

C#
var urgentOrders = await context.Orders
    .Where(o => o.Notes.Any(n => n.Text.Contains("urgent")))
    .ToListAsync();

Tip: Not every LINQ shape translates well, and the SQL can differ between providers and database versions. Use ToQueryString() or logging to check the SQL that EF Core generates, especially for queries on JSON collections.

Because complex types have value semantics, EF Core compares their contents when you compare whole values in a query. With owned entities, it compares identities instead.

Updating JSON Data

There are two ways to update data inside a JSON column.

Using SaveChanges. Load the order, change the value, and save. Since our record is immutable, we create a new copy with with.

C#
var order = await context.Orders.SingleAsync(o => o.OrderNumber == "ORD-1001");

order.Shipping = order.Shipping with { TrackingNumber = "TRK-5567" };

await context.SaveChangesAsync();

Using ExecuteUpdate (EF Core 10). Before EF Core 10, ExecuteUpdate could not touch JSON columns. Now you can update properties inside a JSON document in bulk, without loading any entities. This only works when you map the types as complex types, not as owned types.

C#
await context.Orders
    .Where(o => o.Shipping.City == "London" && o.Shipping.TrackingNumber == null)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(o => o.Shipping.TrackingNumber, "PENDING"));

Note: ExecuteUpdate runs right away in the database and does not use the change tracker. Entities you already loaded will not see the change.

Indexing and Performance

Querying inside JSON is usually slower than querying normal indexed columns. If you filter on one JSON property all the time, that is a hint to think about indexing, or to move that value into a real column.

Before EF Core 11, EF Core had no model API for indexing JSON paths, so you would write the index yourself with SQL inside a migration. EF Core 11 is still in development at the time of writing, but its docs show indexes that can target paths inside JSON complex types. This needs a database with JSON index support, such as SQL Server 2025.

C#
// EF Core 11 (in development)
modelBuilder.Entity<Distributor>()
    .HasIndex("ShippingCenters[].City");

EF Core 11 also adds EF.Functions.JsonPathExists() to check if a path exists in a JSON document. Since these are preview features, check the official docs again before you use them in production.

When to Use JSON Columns (and When Not To)

Use JSON columns when:

  • The data belongs to one parent row and is not shared (like shipping details or user settings).
  • The shape changes often, or differs between rows.
  • You usually read the data together with its parent.
  • You want fewer tables and fewer joins.

Avoid JSON columns when:

  • You filter, sort, or join on the data all the time.
  • You need foreign keys or relationships to other entities. Complex types cannot have navigations.
  • Many rows share the same data and it must stay in sync.
  • The documents grow very large.

Rule of thumb: If you would never query it on its own, JSON is a good fit. If other tables need to point to it, give it a real table.

Summary

JSON columns let you store nested data inside a single column while still using normal C# classes and LINQ. In EF Core 7 you map them with owned types and ToJson(). In EF Core 10, complex types with ToJson() are the better choice for most value objects, and they unlock bulk updates with ExecuteUpdate. On SQL Server 2025 and Azure SQL, EF Core 10 can use the native json type, but check your migrations before you upgrade.

Use JSON columns for data that belongs to one row and changes shape. Keep a real table when you need to query, join, or enforce rules on that data.

This article is sponsored by ZZZ Projects.

Thousands of developers fixed EF Core performance — with one library: Entity Framework Extensions.

👉 Insert data 14x faster with Bulk Insert

Found this article useful? Share it with your network and spark a conversation.