Free .NET Web API Course

EF Core Relationships - One-to-One, One-to-Many, Many-to-Many

Configure one-to-one, one-to-many, and many-to-many relationships in EF Core with Fluent API. Code examples, cascade delete, and best practices.

dotnet webapi-course

efcore relationships one-to-many many-to-many one-to-one fluent-api navigation-properties cascade-delete entity-framework webapi dotnet-webapi-zero-to-hero-course foreign-key principal-entity dependent-entity join-entity database postgresql dotnet-10

14 min read
4.3K views

You’ve configured your entities with Fluent API and your CRUD operations are running smoothly. But here’s the thing — real applications don’t have isolated tables sitting by themselves. A customer places orders. An order contains products. A user has exactly one profile. These connections between entities are what make your data model actually useful.

If you’ve ever stared at EF Core’s HasOne, HasMany, WithOne, WithMany methods and wondered which combination you need — or worse, watched a cascade delete wipe out half your database — this article is for you.

We’ll configure all three relationship types in EF Core 10 with .NET 10 using a practical e-commerce domain. No abstract Blog and Post examples that every tutorial uses. Real entities you’d find in production code. Let’s get into it.

What Are Relationships in EF Core?

A relationship in Entity Framework Core (EF Core) defines how two entities are connected through foreign keys and navigation properties. If you’re new to EF Core, start with Getting Started with Entity Framework Core in ASP.NET Core first. EF Core maps the references between your C# objects to foreign key constraints in the database, keeping both sides in sync automatically. The official EF Core relationship documentation covers every edge case, but this article focuses on the practical patterns you’ll use daily.

Before we configure anything, let’s nail down the terminology:

  • Principal entity — The entity that contains the primary key being referenced (e.g., Customer)
  • Dependent entity — The entity that contains the foreign key (e.g., Order)
  • Navigation property — A C# property that references the related entity (e.g., Order.Customer or Customer.Orders)
  • Foreign key property — The property in the dependent that stores the principal’s key value (e.g., Order.CustomerId)

EF Core supports three types of relationships:

TypeExampleForeign Key Location
One-to-OneCustomer → CustomerProfileOn either entity (typically the dependent)
One-to-ManyCustomer → OrdersOn the “many” side (dependent)
Many-to-ManyProduct ↔ TagIn a join table (separate or implicit)

Configuring One-to-Many Relationships

One-to-many is the most common relationship type in any application. One principal entity is associated with zero or many dependent entities. Think: one customer has many orders, one category has many products.

The Entities

Here are our Customer and Order entities:

public class Customer
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
// Navigation property — collection of related orders
public ICollection<Order> Orders { get; set; } = new List<Order>();
}
public class Order
{
public Guid Id { get; set; }
public DateTime OrderDate { get; set; }
public decimal TotalAmount { get; set; }
// Foreign key
public Guid CustomerId { get; set; }
// Navigation property — reference to the parent
public Customer Customer { get; set; } = null!;
}

Two things make this a one-to-many: the ICollection<Order> on Customer (the “many” side) and the Customer reference on Order (the “one” side). The CustomerId foreign key connects them.

Convention vs Fluent API

EF Core is smart enough to detect this relationship automatically via conventions. If you follow naming patterns — CustomerId matching the principal’s Id — you don’t need any Fluent API code.

But here’s the catch: the default delete behavior is Cascade. Delete a customer, and all their orders vanish silently. Haven’t we all faced this at some point?

Here’s the explicit Fluent API configuration using IEntityTypeConfiguration:

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.TotalAmount)
.HasPrecision(18, 2);
builder.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(o => o.CustomerId);
}
}

Here’s what the fluent chain does:

  • HasOne(o => o.Customer) — This Order has one related Customer
  • WithMany(c => c.Orders) — That Customer has many Order entities
  • HasForeignKey(o => o.CustomerId) — The FK linking them
  • OnDelete(DeleteBehavior.Restrict) — Prevent deleting a customer who has orders

PRO TIP: Always be explicit about delete behavior. The default Cascade for required relationships can accidentally wipe out important data. Use Restrict for business entities and handle deletion logic in your application code.

Querying One-to-Many

If you’re building Minimal API endpoints, inject your DbContext via dependency injection and load related data with Include:

var customerWithOrders = await dbContext.Customers
.Include(c => c.Orders)
.FirstOrDefaultAsync(c => c.Id == customerId, cancellationToken);

For large collections, consider pagination instead of loading everything at once.

Configuring One-to-One Relationships

In a one-to-one relationship, each entity on both sides is associated with at most one entity on the other side. Think: a customer has exactly one profile, or an order has exactly one shipping address record.

The Entities

public class CustomerProfile
{
public Guid Id { get; set; }
public string PhoneNumber { get; set; } = string.Empty;
public string ShippingAddress { get; set; } = string.Empty;
public DateTime DateOfBirth { get; set; }
// Foreign key pointing to Customer
public Guid CustomerId { get; set; }
// Navigation back to Customer
public Customer Customer { get; set; } = null!;
}

And add the navigation to Customer:

// Add to the Customer class
public CustomerProfile? Profile { get; set; }

The key decision here is which side holds the foreign key. The entity with the FK is the dependent. Here, CustomerProfile depends on Customer — a profile can’t exist without a customer.

Fluent API Configuration

public class CustomerProfileConfiguration : IEntityTypeConfiguration<CustomerProfile>
{
public void Configure(EntityTypeBuilder<CustomerProfile> builder)
{
builder.HasKey(cp => cp.Id);
builder.Property(cp => cp.PhoneNumber).HasMaxLength(20);
builder.Property(cp => cp.ShippingAddress).HasMaxLength(500);
builder.HasOne(cp => cp.Customer)
.WithOne(c => c.Profile)
.HasForeignKey<CustomerProfile>(cp => cp.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(cp => cp.CustomerId)
.IsUnique();
}
}

Notice two key differences from one-to-many:

  1. WithOne instead of WithMany — Both sides reference a single entity
  2. HasForeignKey<CustomerProfile> — You must specify the generic type parameter to tell EF Core which side is the dependent. This is required because in one-to-one relationships, EF Core can’t infer it automatically

The unique index on CustomerId enforces the “one-to-one” constraint at the database level — each customer gets at most one profile.

Cascade delete makes sense here because a profile has no meaning without its customer. But evaluate this case by case — not every one-to-one warrants cascading.

Querying One-to-One

var customer = await dbContext.Customers
.Include(c => c.Profile)
.FirstOrDefaultAsync(c => c.Id == customerId, cancellationToken);
// Access: customer?.Profile?.PhoneNumber

Configuring Many-to-Many Relationships

A many-to-many relationship exists when entities on both sides can be associated with multiple entities on the other side. Products can have multiple tags, and each tag applies to multiple products. This was one of the most requested features, and since EF Core 5.0+, it’s natively supported without a join entity.

Simple Many-to-Many (No Payload)

When you just need to associate two entities without extra data on the relationship:

public class Product
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
}
public class Tag
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public ICollection<Product> Products { get; set; } = new List<Product>();
}

Both sides have a collection navigation property. EF Core detects this and creates a ProductTag join table behind the scenes. Isn’t that cool?

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Price).HasPrecision(18, 2);
builder.HasMany(p => p.Tags)
.WithMany(t => t.Products)
.UsingEntity(j => j.ToTable("ProductTags"));
}
}

The UsingEntity call is optional but lets you control the join table name. Without it, EF Core auto-generates one.

Adding and querying tags is straightforward:

var product = await dbContext.Products.FindAsync(productId, cancellationToken);
var tag = await dbContext.Tags.FindAsync(tagId, cancellationToken);
product!.Tags.Add(tag!);
await dbContext.SaveChangesAsync(cancellationToken);
// Query products with their tags
var productsWithTags = await dbContext.Products
.Include(p => p.Tags)
.ToListAsync(cancellationToken);

Many-to-Many with Payload (Join Entity)

Now let’s step up the relationship game! What if you need extra data on the relationship itself? An order contains products — but you also need quantity, unit price at the time of purchase, and any discounts.

This requires an explicit join entity:

public class OrderItem
{
public Guid OrderId { get; set; }
public Order Order { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
// Payload — extra data on the relationship
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal Discount { get; set; }
}

This is technically two one-to-many relationships joined through OrderItem. Here’s the configuration:

public class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>
{
public void Configure(EntityTypeBuilder<OrderItem> builder)
{
builder.ToTable("OrderItems");
// Composite primary key
builder.HasKey(oi => new { oi.OrderId, oi.ProductId });
builder.Property(oi => oi.UnitPrice).HasPrecision(18, 2);
builder.Property(oi => oi.Discount).HasPrecision(18, 2);
// Order -> OrderItems (cascade: delete order = delete its items)
builder.HasOne(oi => oi.Order)
.WithMany(o => o.Items)
.HasForeignKey(oi => oi.OrderId)
.OnDelete(DeleteBehavior.Cascade);
// Product -> OrderItems (restrict: can't delete product with existing orders)
builder.HasOne(oi => oi.Product)
.WithMany()
.HasForeignKey(oi => oi.ProductId)
.OnDelete(DeleteBehavior.Restrict);
}
}

Notice the different delete behaviors: deleting an order cascades to its items (they’re meaningless without the order), but deleting a product that’s been ordered is restricted — you’d lose historical order data. The composite key new { oi.OrderId, oi.ProductId } ensures each product appears only once per order.

Understanding Cascade Delete Behavior

Cascade delete is where most relationship bugs happen. The EF Core cascade delete documentation covers the full behavior matrix, but here’s the practical summary:

BehaviorWhat HappensUse When
CascadeDelete parent → delete all childrenChildren meaningless without parent (OrderItems)
RestrictDelete parent → throw exception if children existChildren have independent business value (Products)
SetNullDelete parent → set FK to null (FK must be nullable)Optional relationships, children can exist alone
NoActionDatabase decides (usually throws)Rare — when you handle everything manually

Default behavior for required relationships (non-nullable FK) is Cascade. For optional relationships (nullable FK), it’s ClientSetNull.

Trust me, I’ve debugged enough cascade delete disasters to say this confidently: always set OnDelete explicitly. Don’t rely on defaults.

Conventions vs Fluent API — Quick Decision Guide

ScenarioConvention Works?Fluent API Needed?
Simple one-to-many with standard FK namingYesNo (but recommended for OnDelete)
One-to-one relationshipsSometimesYes — must specify dependent side
Many-to-many (no payload)Yes (EF Core 5+)Optional — for join table naming
Many-to-many with payloadNoYes — explicit join entity required
Custom delete behaviorNoYes
Shadow foreign keysNoYes

My recommendation: use Fluent API for all relationships. It’s explicit, self-documenting, and prevents surprises when conventions don’t match your intent. If you’re using a layered architecture with a repository pattern, keeping relationship configuration in dedicated IEntityTypeConfiguration classes keeps your persistence layer clean. And if you need raw SQL performance for read-heavy queries while keeping EF Core for writes, check out Using Entity Framework Core and Dapper — the hybrid approach works well alongside proper relationship configuration.

Common Mistakes with EF Core Relationships

Mistake 1: Configuring the Same Relationship from Both Sides

// In OrderConfiguration
builder.HasOne(o => o.Customer).WithMany(c => c.Orders);
// In CustomerConfiguration — DON'T DO THIS
builder.HasMany(c => c.Orders).WithOne(o => o.Customer);

Both lines describe the same relationship. Configure it once, from one side — typically the dependent (the entity with the FK). Trust me, this has caused more debugging sessions than I’d like to admit.

Mistake 2: Missing Navigation Properties

public class Order
{
public Guid CustomerId { get; set; }
// Missing: public Customer Customer { get; set; }
}

Without navigation properties, you can’t use Include() for eager loading or navigate relationships in LINQ queries. Always add them on at least one side — ideally both. This also impacts how you serialize data in your API responses — use DTOs to control what gets returned.

Mistake 3: Forgetting Indexes on Foreign Keys

EF Core creates indexes on foreign keys automatically in most cases. But for composite foreign keys or custom scenarios, always verify by reviewing your migrations:

Terminal window
dotnet ef migrations add VerifyRelationships
dotnet ef migrations script

Mistake 4: Cascade Delete on Self-Referencing Relationships

If you have a Category with a ParentCategoryId pointing to itself, never use cascade delete. It would recursively delete all children, their children, and so on — potentially wiping your entire category tree.

// Self-referencing — ALWAYS use Restrict
builder.HasOne(c => c.ParentCategory)
.WithMany(c => c.SubCategories)
.HasForeignKey(c => c.ParentCategoryId)
.OnDelete(DeleteBehavior.Restrict);

Troubleshooting Common EF Core Relationship Errors

“The relationship between X and Y was not found.” You’re missing a navigation property on one side, or your Fluent API config references a property that doesn’t exist. Double-check that HasOne/HasMany and WithOne/WithMany point to actual navigation properties on the entity.

“Introducing FOREIGN KEY constraint may cause cycles or multiple cascade paths.” SQL Server doesn’t allow multiple cascade delete paths to the same table. Fix this by setting OnDelete(DeleteBehavior.Restrict) on one of the relationships. Extremely common with order/product/customer models.

“The entity type requires a primary key to be defined.” Your join entity for many-to-many with payload is missing HasKey(). Add a composite key: builder.HasKey(oi => new { oi.OrderId, oi.ProductId }).

“Unable to determine the relationship represented by navigation property.” EF Core can’t auto-detect the relationship — this happens with one-to-one when you don’t specify HasForeignKey<T>(). Add the generic type parameter to tell EF Core which side is the dependent.

Include() returns empty collections despite data existing. Check that your foreign key values actually match. Run dotnet ef migrations script to verify the FK constraint exists. Also ensure you’re not filtering results with a global query filter.

Complete Domain Model Reference

Here’s our full e-commerce domain with all three relationship types in one view. Note the C# 12 collection expressions (= []) — equivalent to new List<T>() but more concise:

// One-to-Many: Customer -> Orders
// One-to-One: Customer -> CustomerProfile
// Many-to-Many (no payload): Product -> Tags
// Many-to-Many (with payload): Order -> OrderItems -> Product
public class Customer
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public CustomerProfile? Profile { get; set; } // 1:1
public ICollection<Order> Orders { get; set; } = []; // 1:N
}
public class CustomerProfile
{
public Guid Id { get; set; }
public string PhoneNumber { get; set; } = string.Empty;
public string ShippingAddress { get; set; } = string.Empty;
public Guid CustomerId { get; set; } // FK
public Customer Customer { get; set; } = null!; // 1:1
}
public class Order
{
public Guid Id { get; set; }
public DateTime OrderDate { get; set; }
public decimal TotalAmount { get; set; }
public Guid CustomerId { get; set; } // FK
public Customer Customer { get; set; } = null!; // N:1
public ICollection<OrderItem> Items { get; set; } = []; // 1:N (join)
}
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public ICollection<Tag> Tags { get; set; } = []; // M:N
}
public class Tag
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public ICollection<Product> Products { get; set; } = []; // M:N
}
public class OrderItem
{
public Guid OrderId { get; set; } // FK + PK
public Order Order { get; set; } = null!;
public Guid ProductId { get; set; } // FK + PK
public Product Product { get; set; } = null!;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal Discount { get; set; }
}

Summary

EF Core relationships map the connections between your C# entities to foreign key constraints in the database. The three types — one-to-one, one-to-many, and many-to-many — cover every real-world data modeling scenario you’ll encounter.

Key takeaways:

  • One-to-many is the most common — use HasOne().WithMany().HasForeignKey()
  • One-to-one requires specifying the dependent side with HasForeignKey<T>()
  • Simple many-to-many (EF Core 5+) needs no join entity — just collection navigation properties on both sides
  • Many-to-many with payload requires an explicit join entity with its own configuration
  • Always set OnDelete explicitly — cascade defaults can destroy production data
  • Configure relationships from one side only — typically the dependent entity

Take the time to configure relationships properly early in your project. Pair solid relationship configuration with proper error handling and RESTful API patterns, and your API becomes production-ready. If you need to track changes across your entities, relationships also play a key role in building a proper audit trail.

What relationship patterns are you using in your EF Core projects? Drop a comment and let me know.

Happy Coding :)

What are the three types of relationships in EF Core?

EF Core supports three relationship types: one-to-one (one entity maps to exactly one other entity), one-to-many (one entity maps to multiple entities), and many-to-many (multiple entities on both sides can reference each other). These are configured using Fluent API methods like HasOne, HasMany, WithOne, and WithMany.

How do you configure a many-to-many relationship in EF Core?

In EF Core 5.0 and later, simple many-to-many relationships are configured automatically when both entities have collection navigation properties pointing to each other. EF Core creates the join table behind the scenes. For many-to-many with additional data (payload), you need an explicit join entity class configured with two one-to-many relationships and typically a composite primary key.

What is the difference between HasOne and HasMany in EF Core?

HasOne indicates that the entity being configured has a reference navigation to a single related entity. HasMany indicates it has a collection navigation to multiple related entities. They combine to define relationships: HasOne().WithMany() creates one-to-many, HasOne().WithOne() creates one-to-one, and HasMany().WithMany() creates many-to-many.

Do I need a join entity for many-to-many in EF Core?

Not for simple associations. Since EF Core 5.0, you can define many-to-many relationships using only collection navigation properties on both entities — EF Core creates the join table automatically. However, if you need additional data on the relationship (like quantity, date, or price), you must create an explicit join entity class.

How does cascade delete work in EF Core relationships?

Cascade delete automatically removes dependent entities when the principal entity is deleted. EF Core defaults to Cascade for required relationships (non-nullable FK) and ClientSetNull for optional relationships (nullable FK). You can override this with OnDelete(DeleteBehavior.Restrict) to prevent deletion when dependents exist, or SetNull to clear the foreign key.

What are navigation properties in Entity Framework Core?

Navigation properties are C# properties on an entity class that reference related entities. A reference navigation (e.g., Order.Customer) points to a single related entity. A collection navigation (e.g., Customer.Orders) points to multiple related entities. They enable Include-based eager loading and LINQ query navigation without writing explicit joins.

Should I use conventions or Fluent API for EF Core relationships?

Conventions work for simple scenarios where naming follows EF Core patterns. However, Fluent API is recommended for production code because it makes relationships explicit, lets you control delete behavior, and prevents surprises. Fluent API is required for one-to-one relationships (to specify the dependent side) and many-to-many with payload.

How do I configure a one-to-one relationship in EF Core?

Use HasOne().WithOne().HasForeignKey<DependentType>(). The key difference from one-to-many is that you must specify the dependent type as a generic parameter in HasForeignKey<T>() because EF Core cannot automatically determine which side is the dependent. Add a unique index on the foreign key to enforce the constraint at the database level.

What's your Feedback?
Do let me know your thoughts around this article.

.NET + AI: Build Smarter, Ship Faster

Join 8,000+ developers learning to leverage AI for faster .NET development, smarter architectures, and real-world productivity gains.

AI + .NET tips
Productivity hacks
100% free
No spam, unsubscribe anytime