Skip to main content
SEMastery
Data Accessbeginner

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.

11 min readUpdated November 15, 2025

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.

How a C# class becomes a database table in EF Core

EF Core decides mapping using three layers, and each layer can override the one before it.

The three ways to configure mapping

Conventions
Annotations
Fluent API

Steps

1

Conventions

Smart defaults EF Core guesses for you

2

Annotations

Attributes you put on the class

3

Fluent API

Code in OnModelCreating, most powerful

Each layer can override the previous one

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.

OptionWhere it livesPowerGood for
ConventionsNowhere, automaticLowSimple models, fast start
Data annotationsOn the classMediumSmall rules close to the property
Fluent APIIn OnModelCreatingHighEverything, 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.

An owned Address is folded into the Customer's table by default

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.

FeatureOwned entityComplex type
Has a hidden keyYesNo
Tracked by identityYesNo
Can be a collectionYes (OwnsMany)Limited
Can use a separate tableYesNo, always inline
Best forCollections, separate tablesSimple 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

Value object?
Need a list?
Separate table?
Pick mapping

Steps

1

Value object?

No key, belongs to a parent

2

Need a list?

Many addresses then OwnsMany

3

Separate table?

Yes then owned entity

4

Pick mapping

Otherwise use complex type

A simple decision path for value objects

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.

Table splitting: two entities share one row

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.

A value conversion runs both ways

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

Related Posts