I once inherited a codebase where every value object was an EF Core Owned Type. The C# models looked clean, but the main Orders
table in SQL Server had over 150 columns. Queries were timing out, and simple reports were a nightmare to build.
The culprit? A well-intentioned feature used in the wrong way.
Owned Types are a great tool, but if you don’t understand how they translate to SQL Server, you can create a maintenance headache that’s hard to untangle. After using them in production for years, I’ve learned where they shine and where they just cause pain.
This is my practical take for anyone using EF Core 8+ with SQL Server.
What’s actually happening under the hood?
Owned Types let you group related properties in your C# code without creating a separate table in the database. Think of an Address
object on a Customer
. In your code, it’s a neat, self-contained object. In the database, EF Core flattens it right into the Customers
table.
It’s a way to keep your domain model expressive without cluttering your database schema with tiny tables and foreign keys for things that don’t need their own identity.
The simplest example
Here’s the classic Address
value object stored with a Customer
.
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public Address BillingAddress { get; set; }
}
[Owned]
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
}
While the [Owned]
attribute works, I always use the Fluent API in production for more control over things like column names and lengths.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.OwnsOne(c => c.BillingAddress, a =>
{
a.Property(p => p.Street).HasMaxLength(200);
a.Property(p => p.City).HasMaxLength(100);
a.Property(p => p.PostalCode).HasMaxLength(20);
});
}
How SQL Server sees it
Here’s the sneaky part. To SQL Server, there is no Address
object. EF Core generates a single, flat table:
CREATE TABLE Customers (
Id INT PRIMARY KEY IDENTITY,
Name NVARCHAR(200) NOT NULL,
BillingAddress_Street NVARCHAR(200),
BillingAddress_City NVARCHAR(100),
BillingAddress_PostalCode NVARCHAR(20)
);
See that BillingAddress_Street
column? That _
naming convention is the default. This is critical to remember when you’re writing raw SQL queries or adding indexes.
Here’s when they work great
I reach for Owned Types in a few specific situations:
- Truly Cohesive Data: An
Address
is the perfect example.Street
,City
, andPostalCode
almost always travel together. Another great one is aMoney
type withAmount
andCurrency
properties. You rarely query for just the currency without the amount. - Avoiding Pointless Joins: If you have 2-3 properties that you always need with the parent entity, making them an Owned Type saves you a
JOIN
. For a small amount of data, this is a clean performance win. - Improving the Domain Model: It keeps your C# code tidy. You can pass around an
Address
object instead of five separate string parameters. This makes the code’s intent much clearer.
This one burned me once in production: we had Money
objects scattered as PriceAmount
and PriceCurrency
columns all over the place. Refactoring them into an Owned Type eliminated dozens of joins in our pricing engine, and we saw a measurable speed-up on product listing pages.
And here’s when they’re a bad idea
This is where developers often get into trouble. Avoid Owned Types when:
- The Object Might Be Shared: If an
Address
needs to be used by both aCustomer
and aVendor
, it’s not an owned type anymore. It’s a shared entity that needs its own table and primary key. Don’t try to force it. - You’re Creating Super-Wide Tables: SQL Server performance can suffer with extremely wide rows. Adding three or four complex Owned Types to one entity can easily push you past 50 columns. This impacts I/O, as SQL Server has to read more data from disk for every query.
- You Might Need to Query it Independently: We once used an Owned Type for audit fields (
CreatedBy
,CreatedAt
,ModifiedBy
,ModifiedAt
). It seemed smart at first. But then the business wanted reports on “all records modified by user X last month.” The queries to scan every table and check the owned columns were a disaster. It was a painful, two-sprint refactor to pull that data out into its own structure. - You’re Nesting Them Deeply: EF Core lets you put an Owned Type inside another Owned Type. Just don’t. The column names become
Address_Geolocation_Latitude
, and the generated SQL gets weird fast. Keep it one level deep.
The SQL Server performance gotchas
Remember, an Owned Type makes your table wider. If you SELECT *
(which EF Core often does), you’re pulling back all those extra columns, whether you need them or not.
The most common mistake is forgetting to index the properties inside the Owned Type. If your users are frequently searching for customers by postal code, you absolutely need an index on that flattened column.
modelBuilder.Entity<Customer>()
.OwnsOne(c => c.BillingAddress, a =>
{
// ... other properties
a.HasIndex(p => p.PostalCode)
.HasDatabaseName("IX_Customers_PostalCode"); // I like explicit names
});
Always check your query plans in SQL Server Management Studio (SSMS). If you see a costly table scan happening, you probably forgot an index on an owned property.
Don’t guess, just test it
You should always have a simple integration test to prove your mappings work as expected against a real database (like SQL Server LocalDB).
[Test]
public async Task Customer_WithBillingAddress_SavesAndLoadsCorrectly()
{
// Arrange
var customer = new Customer
{
Name = "Acme Corp",
BillingAddress = new Address
{
Street = "123 Main St",
City = "Seattle",
PostalCode = "98101"
}
};
// Act
context.Customers.Add(customer);
await context.SaveChangesAsync();
context.ChangeTracker.Clear(); // Ensure we fetch from DB
// Assert
var saved = await context.Customers.FirstAsync();
Assert.IsNotNull(saved.BillingAddress);
Assert.AreEqual("Seattle", saved.BillingAddress.City);
}
The Final Takeaway: When would I use it?
So, here’s my rule of thumb.
I always use Owned Types for:
- Small, immutable value objects (2-5 properties).
- Data that has no identity or meaning outside of its parent (e.g.,
Money
,DateRange
). - When I know for a fact I will never need to query that object on its own or share it between other entities.
I actively avoid Owned Types for:
- Anything that might become a shared entity later. If there’s even a 10% chance, I’ll just create a separate table from day one. Migrating is painful.
- Large collections of properties (more than 5-7). This is a code smell that you’re probably hiding a real entity.
- Generic concepts like audit trails or tags. These almost always need to be queried independently down the line.
Owned Types are a storage optimization that makes your C# code cleaner. But it’s an optimization. Make sure it fits your query patterns before you commit, or you’ll be paying for it later.