Mert Özen Further With Every Line
Database Entity Framework Core — #20

Owned Types and Value Converters in EF Core: Addresses, Enums and Strongly Typed Ids

Mert Özen Nov 5, 2026 11 min 3 views
Owned Types and Value Converters in EF Core: Addresses, Enums and Strongly Typed Ids

We tie identity-less values to their owner with OwnsOne and OwnsMany, store enums as text and wrap Ids in their own types. With real output we see a change lost for lack of a value comparer and an OwnsMany pitfall specific to SQLite.

Version note: The code in this post is written for EF Core 6 and runs unchanged on EF Core 10.

Guess First: What If the Address Has No Id?

We are adding a shipping address to the order. An address is a street, a city and a postal code. It has no meaning on its own; nobody talks about "address number 3", we only say "this order's address". So we don't give it an Id:

public class Address
{
    public string Street { get; set; } = "";
    public string City { get; set; } = "";
    public string PostalCode { get; set; } = "";
}

public class Order
{
    public int Id { get; set; }
    public DateTime OrderedAt { get; set; }
    public Address ShippingAddress { get; set; } = new();
}

The context only has DbSet<Order> Orders, and OnModelCreating is empty. What happens when you call EnsureCreated? Are the address columns added to the Orders table, or does a separate Addresses table appear? Neither:

System.InvalidOperationException: The entity type 'Address' requires a primary key to be defined. If you intended to use a keyless entity type, call 'HasNoKey' in 'OnModelCreating'. For more information on keyless entity types, see https://go.microsoft.com/fwlink/?linkid=2141943.

EF saw the navigation on Order and took Address for a separate entity. An entity must have a key. What we wanted to tell EF was "this is not an entity, it is part of the order". That is called an owned type. In this post we look at owned types and their close relatives, value converters: you deciding how a value is stored in the database.

OwnsOne: A Value That Lives in Its Owner's Table

We attach the address to the order. Let's also add an optional billing address:

public class Order
{
    public int Id { get; set; }
    public DateTime OrderedAt { get; set; }
    public Address ShippingAddress { get; set; } = new();
    public Address? BillingAddress { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>().OwnsOne(o => o.ShippingAddress, a =>
    {
        a.Property(x => x.Street).HasMaxLength(200);
        a.Property(x => x.City).HasColumnName("ShipCity");
    });
    modelBuilder.Entity<Order>().OwnsOne(o => o.BillingAddress);
}

OwnsOne tells EF "Address belongs to the order and shares its lifecycle". In the lambda you can configure the owned type's properties just as you would an entity's. Putting an [Owned] attribute on the Address class instead of using the Fluent API does the same job. The resulting table:

CREATE TABLE "Orders" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY AUTOINCREMENT,
    "OrderedAt" TEXT NOT NULL,
    "ShippingAddress_Street" TEXT NOT NULL,
    "ShipCity" TEXT NOT NULL,
    "ShippingAddress_PostalCode" TEXT NOT NULL,
    "BillingAddress_Street" TEXT NULL,
    "BillingAddress_City" TEXT NULL,
    "BillingAddress_PostalCode" TEXT NULL
);

No separate table. The address properties became columns of Orders following the NavigationName_PropertyName pattern, and the ShipCity name we gave City stuck. Because BillingAddress is nullable, its columns accept NULL. HasMaxLength(200) doesn't show up in the DDL because every string in SQLite is TEXT; on SQL Server the same setting would become nvarchar(200). Notice that we used the same Address class for two different navigations. As an entity that would mean two separate relationships; as an owned type it is just two separate groups of columns.

Think of an apartment's balcony. The balcony has no deed of its own; it is part of the apartment's deed. Sell the apartment and the balcony goes with it; you can't sell the balcony alone. An owned type works the same way: no identity of its own, it exists through its owner's identity.

Working with Owned Types

Let's save and read:

db.Orders.Add(new Order
{
    OrderedAt = new DateTime(2026, 11, 4),
    ShippingAddress = new Address { Street = "221B Baker Street", City = "London", PostalCode = "NW1 6XE" }
});
db.SaveChanges();
db.ChangeTracker.Clear();

var order = db.Orders.Single();
Console.WriteLine($"{order.ShippingAddress.City} / billing: {(order.BillingAddress == null ? "null" : "set")}");

The output is London / billing: null. The INSERT is a single command that includes the address columns: INSERT INTO "Orders" ("OrderedAt", "ShipCity", "ShippingAddress_PostalCode", "ShippingAddress_Street"). We didn't write Include when reading, yet the address came back. Owned types are always loaded with their owner; every address column is in the SELECT list. Since all billing address columns are NULL, EF left BillingAddress as null.

You can also query through the owned type's properties:

var londonOrders = db.Orders.Where(o => o.ShippingAddress.City == "London").ToList();
SELECT "o"."Id", "o"."OrderedAt", "o"."BillingAddress_City", "o"."BillingAddress_PostalCode", "o"."BillingAddress_Street", "o"."ShipCity", "o"."ShippingAddress_PostalCode", "o"."ShippingAddress_Street"
FROM "Orders" AS "o"
WHERE "o"."ShipCity" = 'London'

The natural way to change the address, in value object style, is to assign a new object:

order.ShippingAddress = new Address { Street = "10 Downing Street", City = "London", PostalCode = "SW1A 2AA" };
db.SaveChanges();

EF puts the new object in place of the old one and updates only the columns that changed. The city stayed the same, so it is not in the SET list:

UPDATE "Orders" SET "ShippingAddress_PostalCode" = @p0, "ShippingAddress_Street" = @p1
WHERE "Id" = @p2
RETURNING 1;

EF Core 6 sends SELECT changes(); here instead of RETURNING 1. An owned type has no DbSet of its own; if you write db.Set<Address>() you get an exception starting with Cannot create a DbSet for 'Address' because it is configured as a shared-type entity type. You always reach the address through its owner.

A note: EF Core 8 introduced complex types, which became a better fit than owned types for value objects. The EF Core 10 documentation also recommends complex types over owned types for values embedded in a table. We will compare the two in the version posts; in EF Core 6 the tool we have is the owned type.

OwnsMany: A Collection with Its Own Table

Let an author have several social media links. These belong to the author too and mean nothing on their own:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<SocialLink> Links { get; set; } = new();
}

public class SocialLink
{
    public string Platform { get; set; } = "";
    public string Url { get; set; } = "";
}

A collection doesn't fit in the columns of one row, so OwnsMany creates a separate table:

modelBuilder.Entity<Author>().OwnsMany(a => a.Links, l =>
{
    l.ToTable("AuthorLinks");
    l.WithOwner().HasForeignKey("AuthorId");
    l.Property<int>("Id");
    l.HasKey("Id");
});
CREATE TABLE "AuthorLinks" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_AuthorLinks" PRIMARY KEY AUTOINCREMENT,
    "Platform" TEXT NOT NULL,
    "Url" TEXT NOT NULL,
    "AuthorId" INTEGER NOT NULL,
    CONSTRAINT "FK_AuthorLinks_Authors_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id") ON DELETE CASCADE
);

CREATE INDEX "IX_AuthorLinks_AuthorId" ON "AuthorLinks" ("AuthorId");

The SocialLink class has neither an Id nor a foreign key; both are shadow properties. We will see why the last two lines are there in the pitfalls section. When you read the author, the links come along automatically again, this time with a JOIN (EF Core 10, db.Authors.Single()):

SELECT "a1"."Id", "a1"."Name", "a0"."Id", "a0"."AuthorId", "a0"."Platform", "a0"."Url"
FROM (
    SELECT "a"."Id", "a"."Name"
    FROM "Authors" AS "a"
    LIMIT 2
) AS "a1"
LEFT JOIN "AuthorLinks" AS "a0" ON "a1"."Id" = "a0"."AuthorId"
ORDER BY "a1"."Id"

EF Core 6 uses the alias "t" instead of "a1" for the subquery in the same query. Delete the author and the links go with it through cascade; it is a one-to-many relationship, but its lifecycle depends entirely on the owner.

Value Converters: Storing an Enum as Text

An owned type spread one value over several columns. A value converter does the opposite: it decides in which type and how a single property is stored in the database. The most common example is the order status:

public enum OrderStatus
{
    Pending,
    Paid,
    Shipped,
    Cancelled
}

By default EF stores an enum as a number. Seeing 2 in the table means that the day someone inserts a new value in the middle of the enum, every record changes meaning. To store it as text, HasConversion<string>() on the property is enough; we used it for the discriminator in the previous post. If you don't want to write it for every OrderStatus in the project, use EF Core 6's pre-convention configuration:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<OrderStatus>().HaveConversion<string>();
}

Now every property of this type is a TEXT column. Enum comparisons in queries are translated straight to text as well:

var shipped = db.Orders.Where(o => o.Status == OrderStatus.Shipped).ToList();
SELECT "o"."Id", "o"."FeaturedBookId", "o"."Status"
FROM "Orders" AS "o"
WHERE "o"."Status" = 'Shipped'

The cost is a bit more storage, and old records becoming unreadable when you rename an enum value. Renaming now means a data migration.

Strongly Typed Ids: Identities That Don't Mix

To the compiler, int bookId and int orderId are the same thing. Pass one where the other belongs and the code compiles and fetches the wrong record. The way to prevent this is to wrap the identity in its own type:

public readonly record struct BookId(int Value);

public class Book
{
    public BookId Id { get; set; }
    public string Title { get; set; } = "";
    public List<string> Keywords { get; set; } = new();
}

public class Order
{
    public int Id { get; set; }
    public OrderStatus Status { get; set; }
    public BookId FeaturedBookId { get; set; }
}
modelBuilder.Entity<Book>()
    .Property(b => b.Id)
    .HasConversion(id => id.Value, value => new BookId(value));

modelBuilder.Entity<Order>()
    .Property(o => o.FeaturedBookId)
    .HasConversion(id => id.Value, value => new BookId(value));

The converter takes two expressions: from C# to the database, and from the database back to C#. The database shows an INTEGER column, the code shows BookId. The query db.Books.Single(b => b.Id == new BookId(1)) is translated to WHERE "b"."Id" = 1. We use a record struct to get equality for free; a type used as a key must compare with value semantics.

Don't overlook one difference. Look at the DDL and the Books.Id column has no AUTOINCREMENT: "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY. In EF Core 6, a key with a converter does not become a generated key by convention; you have to supply the value, for example new Book { Id = new BookId(1), ... }. EF Core 7 added support for generated values on converted keys via ValueGeneratedOnAdd(); we will see it in the version posts. In EF Core 6, making this type Guid-based and generating the value in the application is a practical alternative.

Value Comparers: How Does EF Notice a Change?

Book.Keywords is a List<string>. EF Core 6 cannot map it directly, so we convert it into a single comma-separated string:

modelBuilder.Entity<Book>()
    .Property(b => b.Keywords)
    .HasConversion(
        list => string.Join(',', list),
        text => text.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());

EF logs a warning while building the model:

warn: 4.11.2026 00:52:12.357 CoreEventId.CollectionWithoutComparer[10620] (Microsoft.EntityFrameworkCore.Model.Validation)
      The property 'Book.Keywords' is a collection or enumeration type with a value converter but with no value comparer. Set a value comparer to ensure the collection/enumeration elements are compared correctly.

Let's see what the warning means. We read the book and add a word to the list:

var dune = db.Books.Single(b => b.Id == new BookId(1));
dune.Keywords.Add("desert");
Console.WriteLine(db.Entry(dune).State);
db.SaveChanges();

The console prints Unchanged, and SaveChanges sends no UPDATE at all. The change is lost. The reason is how change tracking works: EF takes a snapshot of the property when it reads it, and compares it with the current value during DetectChanges. The default comparison is reference equality. Adding an element to the list doesn't change the reference, so EF thinks the two values are equal. And since the snapshot is the very same list, you changed it too.

The fix is a value comparer that tells EF how to compare this type, how to compute its hash and how to take a snapshot:

using Microsoft.EntityFrameworkCore.ChangeTracking;

modelBuilder.Entity<Book>()
    .Property(b => b.Keywords)
    .HasConversion(
        list => string.Join(',', list),
        text => text.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(),
        new ValueComparer<List<string>>(
            (a, b) => a!.SequenceEqual(b!),
            list => list.Aggregate(0, (hash, s) => HashCode.Combine(hash, s.GetHashCode())),
            list => list.ToList()));

The third expression is the real hero: the snapshot is now a copy of the list. Run the same code again and the warning disappears, the state becomes Modified and the UPDATE goes out:

UPDATE "Books" SET "Keywords" = @p0
WHERE "Id" = @p1
RETURNING 1;

Read the book again and you get sci-fi|classic|desert. The rule is simple: if you write a converter for a mutable type, write the comparer too. Primitive collections in EF Core 8 can map types like List<string> without a converter; that topic is also for the version posts.

Common Pitfalls

The first pitfall is specific to SQLite and annoying. If you write OwnsMany without the last two lines, with only ToTable and WithOwner, EF builds the owned table's key as the composite ("AuthorId", "Id"). SQLite cannot auto-increment part of a composite key, and the first SaveChanges blows up like this: SQLite Error 19: 'NOT NULL constraint failed: AuthorLinks.Id'. Giving it a single-column key with l.Property<int>("Id") and l.HasKey("Id") solves the problem. The EF documentation also states that the default key is composite, and its OwnsMany example defines the key explicitly in exactly this way.

The second pitfall is giving the same owned object to two owners:

var shared = new Address { Street = "1 Main St", City = "Izmir", PostalCode = "35000" };
db.Orders.Add(new Order { OrderedAt = DateTime.Today, ShippingAddress = shared });
db.Orders.Add(new Order { OrderedAt = DateTime.Today, ShippingAddress = shared });
db.SaveChanges();

An owned object gets its identity from its owner; the same instance cannot belong to two orders at once. EF tracks the object with a single owner, the other order's address columns stay empty, and the save is rejected with SQLite Error 19: 'NOT NULL constraint failed: Orders.ShippingAddress_Street'. If the address were optional you wouldn't even get an error; you would silently lose one order's address. Give each owner its own copy.

The third pitfall: all columns of an optional owned type being NULL. EF reads that as "no address". If your domain has a notion of an address that exists but has every field empty, you can't express it with NULL. The fourth is the converter's effect on queries: the converter runs on the C# side, and the database only sees the converted value. Because Keywords is comma-separated text, the query db.Books.Where(b => b.Keywords.Contains("classic")) cannot be translated to SQL; on EF Core 6 and 10 you get an InvalidOperationException saying could not be translated. If you need that, the data deserves a table of its own.

A Small Experiment

★☆☆ Add OrderStatus Status to Order, convert it to text with ConfigureConventions and write a Pending order to the table. Then delete the converter line, recreate the database and look at how the same record appears in the column.

★★☆ Run OwnsMany without the HasKey("Id") line and see the primary key in the GenerateCreateScript() output and the SaveChanges error with your own eyes. If you then want to try the same model with SQL Server instead of UseSqlite, you need a SQL Server in Docker; compare whether the behavior is different there.

★★★ In the comparer we wrote for Keywords, change the snapshot expression to list => list. See that the state stays Unchanged again, and explain why in terms of the snapshot and the current value being the same object.

In this post we decided how data changes shape on its way to the database. But what if you want to step in at the moment SaveChanges is called and stamp a CreatedAt on every record automatically, log slow queries, or touch the outgoing SQL? In the next post we look at interceptors and EF Core's events.