JSON Columns in EF Core: Storing Complex Objects as JSON
Not every piece of data fits neatly into separate relational columns. Configuration blobs, metadata, nested structures — sometimes JSON is the right storage format. EF Core 7 introduced support for mapping .NET objects to JSON columns, letting you store complex structures in a single column while still querying into them with LINQ.
Basic JSON Column Mapping
Consider a product with shipping dimensions that vary wildly between product types:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public ProductMetadata Metadata { get; set; } = new();
}
public class ProductMetadata
{
public double WeightKg { get; set; }
public Dimensions? Dimensions { get; set; }
public List<string> Tags { get; set; } = [];
public Dictionary<string, string> Attributes { get; set; } = new();
}
public class Dimensions
{
public double LengthCm { get; set; }
public double WidthCm { get; set; }
public double HeightCm { get; set; }
}
Map the metadata to a JSON column using ToJson():
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.OwnsOne(p => p.Metadata, metadata =>
{
metadata.ToJson();
metadata.OwnsOne(m => m.Dimensions);
});
}
The Products table now has a Metadata column containing JSON like:
{
"WeightKg": 2.5,
"Dimensions": { "LengthCm": 30, "WidthCm": 20, "HeightCm": 10 },
"Tags": ["fragile", "electronics"],
"Attributes": { "Colour": "Black", "Material": "Aluminium" }
}
Querying into JSON Columns
The real power is LINQ support. EF Core translates property access on JSON-mapped objects into SQL JSON path queries:
// Filter by a property inside the JSON
var heavyProducts = await context.Products
.Where(p => p.Metadata.WeightKg > 10)
.ToListAsync();
// Filter by nested property
var tallProducts = await context.Products
.Where(p => p.Metadata.Dimensions != null
&& p.Metadata.Dimensions.HeightCm > 50)
.ToListAsync();
// Order by a JSON property
var products = await context.Products
.OrderByDescending(p => p.Metadata.WeightKg)
.ToListAsync();
On SQL Server, these translate to JSON_VALUE expressions:
SELECT * FROM [Products]
WHERE CAST(JSON_VALUE([Metadata], '$.WeightKg') AS float) > 10
Updating JSON Data
Updates work naturally through the object model:
var product = await context.Products.FindAsync(1);
product!.Metadata.WeightKg = 3.0;
product.Metadata.Tags.Add("updated");
product.Metadata.Dimensions = new Dimensions
{
LengthCm = 35,
WidthCm = 25,
HeightCm = 12
};
await context.SaveChangesAsync();
EF Core detects changes within the JSON structure and updates the entire JSON column. It doesn't do partial JSON updates — the whole column is rewritten.
Collections in JSON
JSON columns can contain collections, including collections of complex objects:
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public List<OrderEvent> EventLog { get; set; } = [];
}
public class OrderEvent
{
public string EventType { get; set; } = string.Empty;
public DateTime OccurredAt { get; set; }
public string? Details { get; set; }
}
modelBuilder.Entity<Order>()
.OwnsMany(o => o.EventLog, events =>
{
events.ToJson();
});
Query collections with standard LINQ:
var recentlyShipped = await context.Orders
.Where(o => o.EventLog.Any(e =>
e.EventType == "Shipped"
&& e.OccurredAt > DateTime.UtcNow.AddDays(-7)))
.ToListAsync();
Projecting JSON Properties
Select specific properties from JSON columns in projections:
var summaries = await context.Products
.Select(p => new
{
p.Name,
Weight = p.Metadata.WeightKg,
TagCount = p.Metadata.Tags.Count
})
.ToListAsync();
JSON Columns vs Separate Tables
When should you use JSON columns instead of a related table?
Use JSON when:
- The data is always loaded with the parent entity
- You don't need foreign key relationships from the nested data
- The structure is flexible or varies between records
- You don't need to query the nested data frequently
Use separate tables when:
- You need to query the related data independently
- Other entities need to reference the nested data
- You need database-level constraints on the nested data
- The collection could grow very large
Provider Support
JSON column support varies by database provider:
- SQL Server — Full support using
JSON_VALUEandOPENJSON(EF Core 7+) - PostgreSQL (Npgsql) — Full support using
jsonbcolumns, with rich querying via@>operators - SQLite — Basic support using
json_extract(EF Core 7+)
Limitations
- No partial updates — EF Core rewrites the entire JSON column on any change, which can be inefficient for large JSON documents
- Indexing — You can create computed columns based on JSON values and index those, but you can't directly index JSON paths in all providers
- No primitives at the root — the JSON column must map to a complex type, not a simple list of strings (use a value converter for that)
// For simple lists, use a value converter instead
modelBuilder.Entity<Product>()
.Property(p => p.Tags)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default)!);
JSON columns strike a pragmatic balance between relational rigour and the flexibility that real-world applications need. Use them for genuinely semi-structured data, and keep your core domain in proper relational columns.