Complex Types and JSON Columns
Map value objects such as addresses and money with ComplexProperty, as table columns or as a JSON column, including optional and struct types.
modelBuilder.Entity<Customer>(c =>
{
c.ComplexProperty(x => x.ShippingAddress); // columns
c.ComplexProperty(x => x.BillingAddress).ToJson(); // one JSON column
});A complex type is a value with no identity of its own: an address, a money amount, a geo point. It has no key, is never tracked separately and is compared by its values. You map it with ComplexProperty (or [ComplexType] on the class), and EF stores it inside the owner's row. That is the difference from an entity, which has a key and its own table, and from an owned type, which is an entity with a hidden key.
Two storage shapes
By default the properties are flattened into columns of the owner's table, named {Property}_{Member}: ShippingAddress_City, ShippingAddress_PostalCode. In EF Core 10 you can instead call .ToJson() and store the whole value in one JSON column: jsonb on PostgreSQL, nvarchar(max) on SQL Server unless the provider is configured for its native json type. A list of values maps with ComplexCollection(...).ToJson(), which suits order lines that are always read and written with the order.
| Shape | Choose it when | Cost |
|---|---|---|
| Flattened columns | You filter, sort or index on members (City, Country) | One column per member; schema changes per field |
| JSON column | The value is read and written whole; it has many or changing fields; it is a collection | Filters need JSON functions and special indexes; any change rewrites the whole document |
| Separate entity | It needs identity, its own lifecycle or references from elsewhere | A join and a table |
Optional and struct complex types
EF Core 10 supports optional complex types: Address? BillingAddress. With flattened columns all of them become nullable, and EF reads the value as null when the columns are null. That only works if the type has at least one required member, otherwise an address with every member null would be indistinguishable from no address; model building fails with a message saying the mapping requires at least one required property, and suggests a discriminator or a JSON column. In JSON the whole column is simply NULL. Struct complex types, such as readonly record struct GeoPoint(double Latitude, double Longitude), are supported too.
Value semantics in practice
Because complex types have no identity, the same Address instance may be assigned to two customers, or to both the shipping and billing properties, which owned types reject. Records fit well: replace the value with customer.Shipping = customer.Shipping with { City = "Hamburg" }, and change tracking compares member values, so the UPDATE touches only the changed column (flattened) or rewrites the one JSON column.
LINQ over members is translated: c.Shipping.City == "Berlin" becomes c."Shipping" ->> 'City' on PostgreSQL and JSON_VALUE([c].[Shipping], '$.City') on SQL Server. Each member filter extracts a value from the document at query time, so an ordinary B-tree index on the column cannot serve it; filter on the members you need, and check the generated SQL with ToQueryString() before relying on it.
Failure modes
A JSON column filtered by a hot endpoint without an expression or GIN index scans the table. JSON property names are the C# names unless you configure them, so renaming a C# property orphans the data already stored under the old key, with no migration generated to fix it. Keep fields you query as real columns and treat the JSON document's member names as a contract.
Example
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
var app = builder.Build();
app.MapGet("/customers/in/{city}", async (string city, int? page, int? pageSize, ShopDb db, CancellationToken ct) =>
{
var size = Math.Clamp(pageSize ?? 50, 1, 100);
var number = Math.Max(page ?? 1, 1);
return await db.Customers
.AsNoTracking()
.Where(c => c.ShippingAddress.City == city) // plain column: indexable
.OrderBy(c => c.Id)
.Skip((number - 1) * size)
.Take(size)
.Select(c => new { c.Id, c.Name, c.ShippingAddress.City, c.ShippingAddress.Country })
.ToListAsync(ct);
});
app.MapPut("/customers/{id:int}/shipping-city", async (int id, string city, ShopDb db, CancellationToken ct) =>
{
var customer = await db.Customers.FindAsync([id], ct);
if (customer is null) return Results.NotFound();
customer.ShippingAddress = customer.ShippingAddress with { City = city }; // UPDATE of one column
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
app.Run();
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(customer =>
{
// Flattened: ShippingAddress_Street, ShippingAddress_City, ... (we filter by city)
customer.ComplexProperty(c => c.ShippingAddress, address =>
{
address.Property(a => a.City).HasMaxLength(100);
address.Property(a => a.Country).HasMaxLength(2).IsFixedLength();
});
// Optional, stored as one nullable jsonb column.
customer.ComplexProperty(c => c.BillingAddress, address => address.ToJson("billing_address"));
// Struct complex type, flattened to two double precision columns.
customer.ComplexProperty(c => c.Location);
});
// A list of values that is always loaded and saved with the order.
modelBuilder.Entity<Order>().ComplexCollection(o => o.Lines, lines => lines.ToJson());
}
}
public sealed record Address(string Street, string City, string? PostalCode, string Country);
public readonly record struct GeoPoint(double Latitude, double Longitude);
public sealed record OrderLine(string Sku, int Quantity, decimal UnitPrice);
public sealed class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public required Address ShippingAddress { get; set; }
public Address? BillingAddress { get; set; } // null means "same as shipping"
public GeoPoint Location { get; set; }
}
public sealed class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public List<OrderLine> Lines { get; set; } = [];
}When to use it
- Store shipping and billing addresses on Customer as value objects without an Address table or Include
- Keep marketing preferences with many optional flags in one nullable jsonb column on the customers table
- Store the line items of a checkout snapshot as a JSON collection that is always read and written with the order
More examples
An optional complex type with only optional members
With flattened columns, a customer whose preferences object exists but has every member null would be stored exactly like a customer with no preferences. EF refuses to guess and fails when the model is built, so the problem surfaces on the first query in any environment rather than as wrong data. A JSON column stores <code>NULL</code> and <code>{}</code> differently, which resolves the ambiguity.
using Microsoft.EntityFrameworkCore;
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Flattened, this fails at model build:
// "... this mapping requires at least one required property - to allow
// distinguishing between 'null' and empty values ..."
// modelBuilder.Entity<Customer>().ComplexProperty(c => c.Preferences);
// Fix 1: a JSON column, where NULL and {} are different values.
modelBuilder.Entity<Customer>().ComplexProperty(c => c.Preferences, p => p.ToJson());
// Fix 2 (alternative): keep columns but make one member required,
// for example a non-nullable Language with a default.
}
}
public sealed class MarketingPreferences
{
public string? Language { get; set; }
public bool? Newsletter { get; set; }
public bool? Sms { get; set; }
}
public sealed class Customer
{
public int Id { get; set; }
public required string Email { get; set; }
public MarketingPreferences? Preferences { get; set; }
}The schema and the JSON filter on PostgreSQL
Flattened members become ordinary columns that take normal indexes and constraints. The JSON filter uses the <code>->></code> operator with the C# member name as the key, and without the expression index in the last line PostgreSQL scans every row. EF does not create JSON expression indexes for you; add them in a migration with <code>migrationBuilder.Sql</code>.
CREATE TABLE "Customers" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"Name" text NOT NULL,
"Location_Latitude" double precision NOT NULL,
"Location_Longitude" double precision NOT NULL,
"ShippingAddress_City" character varying(100) NOT NULL,
"ShippingAddress_Country" character(2) NOT NULL,
"ShippingAddress_PostalCode" text,
"ShippingAddress_Street" text NOT NULL,
billing_address jsonb,
CONSTRAINT "PK_Customers" PRIMARY KEY ("Id")
);
CREATE TABLE "Orders" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"CustomerId" integer NOT NULL,
"Lines" jsonb NOT NULL,
CONSTRAINT "PK_Orders" PRIMARY KEY ("Id")
);
-- db.Customers.Where(c => c.BillingAddress != null && c.BillingAddress.City == "Berlin")
SELECT c."Id", c."Name", c.billing_address, c."Location_Latitude", c."Location_Longitude",
c."ShippingAddress_City", c."ShippingAddress_Country", c."ShippingAddress_PostalCode", c."ShippingAddress_Street"
FROM "Customers" AS c
WHERE c.billing_address IS NOT NULL AND (c.billing_address ->> 'City') = 'Berlin';
-- Needed before that filter runs on a hot path:
CREATE INDEX ix_customers_billing_city ON "Customers" ((billing_address ->> 'City'));Reading and replacing a JSON collection
A complex collection behaves like part of the order: no keys, no separate table, no Include. That fits lines that are written once at checkout and read with the order. Incremental edits like <code>AddLineAsync</code> rewrite the whole document, so they need the <code>xmin</code> concurrency token unless only one writer ever edits an order. Choose a separate <code>OrderLine</code> entity instead when other tables reference individual lines, such as returns or shipments per line, or when you report across lines from many orders.
using Microsoft.EntityFrameworkCore;
public static class OrderLines
{
// Lines live in one jsonb column, so this is a single-row read and a single-column write.
// The write replaces the whole document, so two concurrent calls would each drop the
// other's line without the xmin check: the loser gets DbUpdateConcurrencyException.
public static async Task<bool> AddLineAsync(ShopDb db, int orderId, string sku, int quantity, decimal unitPrice, CancellationToken ct)
{
var order = await db.Orders.SingleAsync(o => o.Id == orderId, ct);
var existing = order.Lines.FindIndex(l => l.Sku == sku);
if (existing >= 0)
order.Lines[existing] = order.Lines[existing] with { Quantity = order.Lines[existing].Quantity + quantity };
else
order.Lines.Add(new OrderLine(sku, quantity, unitPrice));
try
{
await db.SaveChangesAsync(ct); // UPDATE "Orders" SET "Lines" = @p0 WHERE "Id" = @p1 AND xmin = @p2
return true;
}
catch (DbUpdateConcurrencyException)
{
return false; // the caller retries with a fresh context or returns 409
}
}
// Aggregates over the collection are translated with jsonb_to_recordset.
public static Task<int> CountOrdersWithSkuAsync(ShopDb db, string sku, CancellationToken ct) =>
db.Orders.CountAsync(o => o.Lines.Any(l => l.Sku == sku), ct);
}
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Order>(order =>
{
order.ComplexCollection(o => o.Lines, lines => lines.ToJson());
order.Property(o => o.Version).IsRowVersion(); // xmin on Npgsql
});
}
public sealed record OrderLine(string Sku, int Quantity, decimal UnitPrice);
public sealed class Order
{
public int Id { get; set; }
public List<OrderLine> Lines { get; set; } = [];
public uint Version { get; set; }
}
Discussion