Exploring Data Mapping Options in EF Core: A Beginner's Guide
Learn EF Core data mapping the easy way: owned entities, complex types, table splitting, value conversions, and JSON columns explained with simple analogies and code.
When you build an app with Entity Framework Core, your data lives in two worlds at once. In your C# code it is a class with properties. In the database it is a table with rows and columns. Data mapping is the bridge between these two worlds. It tells EF Core how a class turns into a table and how a property turns into a column.
This guide walks through the main mapping options EF Core gives you. We will keep the language simple, use a friendly example, and show real code you can try.
A real-life analogy: the tiffin box
Think about a tiffin box that a parent packs for a child going to school. The tiffin box is one container, but inside it there are small compartments. One compartment has rice, another has dal, another has a sweet.
From the outside it is one box. But inside, the food is organised into parts.
EF Core mapping works in a very similar way:
- The tiffin box is your database table (one row).
- The compartments are the columns.
- Sometimes a whole group of things, like "the address," is packed neatly into a few compartments inside the same box. That is what owned entities and complex types do.
- Sometimes two children share one big tiffin box. That is table splitting.
Let us start with the simplest mapping and slowly add the fancy options.
The big picture
Here is how your code travels down to the database. EF Core looks at your classes, applies mapping rules, and produces SQL.
EF Core decides mapping using three layers, and each layer can override the one before it.
The three ways to configure mapping
Steps
Conventions
Smart defaults EF Core guesses for you
Annotations
Attributes you put on the class
Fluent API
Code in OnModelCreating, most powerful
1. Conventions
Conventions are the default guesses EF Core makes. If you have a class named Product with an Id property, EF Core will guess a Products table with an Id primary key. You write nothing extra.
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}This class alone gives you a working table. The Id becomes the key automatically because of a naming convention.
2. Data annotations
When the defaults are not enough, you can add small attributes. These are easy to read and live right next to the property.
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
[Table("Products")]
public class Product
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
[Column("UnitPrice", TypeName = "decimal(10,2)")]
public decimal Price { get; set; }
}3. Fluent API
The Fluent API is the most powerful option. You write it inside OnModelCreating in your DbContext. Everything the annotations can do, the Fluent API can do, and much more.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(b =>
{
b.ToTable("Products");
b.HasKey(p => p.Id);
b.Property(p => p.Name).IsRequired().HasMaxLength(200);
b.Property(p => p.Price).HasColumnName("UnitPrice").HasPrecision(10, 2);
});
}Here is a quick comparison so you know which to reach for.
| Option | Where it lives | Power | Good for |
|---|---|---|---|
| Conventions | Nowhere, automatic | Low | Simple models, fast start |
| Data annotations | On the class | Medium | Small rules close to the property |
| Fluent API | In OnModelCreating | High | Everything, including advanced mapping |
A common, healthy habit: let conventions do most of the work, use annotations for quick validation rules, and keep the heavy mapping in the Fluent API.
Mapping a value object: owned entities
Now imagine your Customer has an Address. An address is made of street, city, and pin code. It does not really have a life of its own. There is no "address table" full of floating addresses. An address belongs to a customer.
This kind of object is called a value object. EF Core gives you two ways to map it. The older way is the owned entity.
public class Address
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string PinCode { get; set; } = string.Empty;
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public Address Address { get; set; } = new();
}You tell EF Core that Address is owned by Customer:
modelBuilder.Entity<Customer>().OwnsOne(c => c.Address, a =>
{
a.Property(p => p.Street).HasColumnName("Street");
a.Property(p => p.City).HasColumnName("City");
a.Property(p => p.PinCode).HasColumnName("PinCode");
});By default, the address columns are stored in the same row as the customer. So one Customers table holds Id, Name, Street, City, and PinCode. This is exactly like packing the address into a few compartments of the customer's tiffin box.
Owned entities have been around since EF Core 2.0. Under the hood they are still entity types, so EF Core gives them a hidden (shadow) key and tracks them by identity. That extra machinery is fine, but it is more than a simple value object really needs.
The newer way: complex types
EF Core 8 added complex types. These are a cleaner fit for true value objects. A complex type has no key and no identity. EF Core does not track it by key. It is always stored inline, in the same table as its owner.
The class stays the same. Only the configuration changes:
modelBuilder.Entity<Customer>()
.ComplexProperty(c => c.Address);That single line maps Street, City, and PinCode into the Customers table. Because a complex type has no hidden identity, comparing two addresses is simpler and there is less overhead.
EF Core 9 improved complex types to support optional (nullable) values, and EF Core 10 added the ability to store them as JSON columns. So the feature keeps getting better with each release.
Here is the key difference between the two options.
| Feature | Owned entity | Complex type |
|---|---|---|
| Has a hidden key | Yes | No |
| Tracked by identity | Yes | No |
| Can be a collection | Yes (OwnsMany) | Limited |
| Can use a separate table | Yes | No, always inline |
| Best for | Collections, separate tables | Simple single value objects |
Rule of thumb: for a small single value object like Address or Money, prefer complex types. When you need a list of value objects, or want them in a separate table, use owned entities.
Choosing between owned entity and complex type
Steps
Value object?
No key, belongs to a parent
Need a list?
Many addresses then OwnsMany
Separate table?
Yes then owned entity
Pick mapping
Otherwise use complex type
A list of value objects with OwnsMany
What if a customer can have several addresses, like home and office? A complex type does not handle collections well, so this is a job for OwnsMany. You can store the list in its own table, or even as JSON.
modelBuilder.Entity<Customer>().OwnsMany(c => c.Addresses, a =>
{
a.ToTable("CustomerAddresses");
a.WithOwner().HasForeignKey("CustomerId");
a.Property(p => p.City).HasMaxLength(100);
});Or store the whole list inline as JSON in the customer row:
modelBuilder.Entity<Customer>().OwnsMany(c => c.Addresses, a => a.ToJson());JSON mapping is great when the addresses always travel together with the customer and you do not need to query them in SQL very often.
Table splitting: two classes, one table
Sometimes you have one row but want to split it across two C# classes. Maybe a Product has a few heavy columns, like a long description or a big image blob, that you rarely need. You can put them in a separate class, ProductDetails, while both classes still map to the same table and the same row.
This is called table splitting.
modelBuilder.Entity<Product>(b =>
{
b.ToTable("Products");
b.HasOne(p => p.Details).WithOne()
.HasForeignKey<ProductDetails>(d => d.Id);
});
modelBuilder.Entity<ProductDetails>().ToTable("Products");Both Product and ProductDetails live in the Products table. They share the same primary key column. You can load just the light Product, and only pull the Details when you actually need them.
This is the opposite idea of entity splitting, where one entity maps to two tables. Both tricks exist so your code shape and your table shape do not have to match one to one.
Value conversions: changing the shape on the way in and out
Sometimes the type you use in C# is not the type you want in the database. A classic case is an enum. In code it is a nice named value; in the database you might want it stored as a readable string instead of a number.
A value conversion is a small pair of functions: one runs when saving (C# to database) and one runs when reading (database back to C#).
public enum OrderStatus { Pending, Shipped, Delivered }
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion<string>();Now Status is stored as "Pending" or "Shipped" text, which is much easier to read in the database. You can also write your own conversion for custom types:
modelBuilder.Entity<Order>()
.Property(o => o.Code)
.HasConversion(
code => code.Value, // C# -> database
value => new OrderCode(value)); // database -> C#This is how strongly typed IDs and small wrapper types get stored as plain Guid, int, or string columns.
Putting it all together
You do not have to pick only one of these. A real model often mixes them. A Customer might use a complex type for its Address, a value conversion for a strongly typed ID, and table splitting for a rarely used profile section. EF Core lets all of these live together happily.
The trick is to start simple. Use conventions first. Reach for the bigger tools only when a real need shows up. Mapping is a means to an end: a clean C# model that still produces a sensible database.
Quick recap
- Data mapping is the bridge between your C# classes and your database tables.
- There are three ways to configure it: conventions (automatic), data annotations (attributes), and the Fluent API (most powerful).
- Owned entities (
OwnsOne/OwnsMany) embed a value object inside its owner. They have a hidden key and can use collections or separate tables. - Complex types (EF Core 8+) are the cleaner choice for simple single value objects. No hidden key, always stored inline.
- Table splitting lets two entity classes share one table and one row, so you can keep heavy columns separate in code.
- Value conversions change a value on the way in and out, perfect for storing enums as strings or wrapping IDs.
- Start with conventions, add annotations for small rules, and keep advanced mapping in the Fluent API.
References and further reading
- Owned Entity Types — EF Core (Microsoft Learn)
- Complex Types — EF Core (Microsoft Learn)
- Advanced Table Mapping (table and entity splitting) — EF Core (Microsoft Learn)
- Value Conversions — EF Core (Microsoft Learn)
- What's New in EF Core 8 (complex types as value objects) — Microsoft Learn
Related Posts
5 EF Core Features You Need to Know (Beginner Friendly)
Learn 5 must-know EF Core features with simple examples: change tracking, AsNoTracking, eager loading, bulk ExecuteUpdate, and query filters.
EF Core Query Splitting: Fix Slow Queries and Cartesian Explosion
Learn how EF Core query splitting (AsSplitQuery) fixes the cartesian explosion problem with simple examples, diagrams, and real performance numbers. Know when to split and when not to.
Soft Delete with EF Core: Delete Data Without Losing It
Learn soft delete in EF Core the right way. Use an interceptor and global query filters to hide deleted rows automatically, with simple examples, diagrams, code, and best practices for .NET 10.
Multi-Tenant Applications With EF Core: A Beginner's Guide
Learn multi-tenancy in EF Core the simple way. Isolate tenant data with global query filters, ITenantService, and named filters in .NET 10, with diagrams and code.
Eager Loading of Child Entities in EF Core: A Beginner's Guide
Learn eager loading in EF Core with Include and ThenInclude. Load child entities in one query, avoid the N+1 problem, and use filtered Include with simple examples.
EF Core DbContext Options Explained: A Beginner's Friendly Guide
Learn EF Core DbContext options in simple words: AddDbContext, the options builder, retry on failure, query splitting, logging, lifetimes and pooling, with diagrams and examples.