AlgoMaster Logo

Relationships (1:1, 1:N, M:N)

High Priority23 min readUpdated June 6, 2026

A real e-commerce schema isn't a pile of independent tables. Customers have orders, orders have items, items reference products, products belong to categories, customers have a shipping address. Modeling these links is what turns a database from a set of disconnected lists into something an application can actually query. This lesson covers how EF Core represents the three relationship cardinalities (one-to-one, one-to-many, many-to-many), how to declare them with both conventions and the Fluent API, and how to control what happens to children when a parent is deleted.

Why Relational Data Needs Relationships

A flat schema breaks down fast. Imagine storing each order as a row with the customer's name and email duplicated alongside every product name and price. The first customer who changes their email has to be updated across thousands of rows, and a typo in one product name will leak into every order that bought it. Relationships fix this by giving each concept its own table and using foreign keys to point from one row to another.

In EF Core, a relationship between two entities is expressed through three things: a foreign key (FK) property on the dependent side, a reference navigation that points at the principal, and (for one-to-many) a collection navigation on the principal that points back at all the dependents. Get those three right and EF Core figures out the rest.

The diagram below shows the core e-commerce graph used throughout this lesson.

A Customer has one Address, places many Orders, and writes many Reviews. An Order has many OrderItems. Each OrderItem points at one Product. A Product sits in many Categorys, and each Category holds many Products. Every arrow in this picture corresponds to a navigation property in C# and a foreign key in the database.

Navigation Properties and Foreign Keys

Before getting into specific cardinalities, it helps to nail down the three pieces EF Core looks for. Take a customer placing many orders. On the principal side (Customer), there's a collection navigation:

On the dependent side (Order), there's a reference navigation back to the principal and a foreign key property holding the principal's primary key value:

CustomerId is the foreign key, the actual column the database stores. Customer is the reference navigation, a CLR-only property that EF Core populates when you load related data. Orders is the collection navigation on the other side. EF Core picks up all three by convention because the names line up: a property named {NavigationName}Id or {TypeName}Id is recognized as the FK, and a matching reference plus collection on either side establishes the relationship.

Conventions handle the common case, but the moment you want anything non-default, you need either a data annotation or the Fluent API. The next sections work through each cardinality with both.

One-to-Many (Customer to Orders)

One-to-many is the most common relationship in any business schema. One customer, many orders. One order, many line items. One category, many products. EF Core models this as a collection navigation on the principal and a reference navigation plus FK on the dependent.

By convention, the example from the last section already works:

Running dotnet ef migrations add AddOrders and applying it produces an Orders table with a CustomerId column, a foreign key constraint pointing at Customers.Id, and an index on CustomerId (EF Core auto-indexes FK columns because they're almost always filtered or joined on).

That's the convention path. When you need to override something (different FK name, optional vs required, cascade behavior), use the Fluent API in OnModelCreating:

The chain reads like a sentence: an Order HasOne Customer, that Customer WithMany Orders, joined by the CustomerId foreign key. OnDelete(DeleteBehavior.Restrict) tells the database to refuse to delete a customer who still has orders. The defaults are covered in detail in the cascade section below.

[ForeignKey] is the data annotation equivalent for the FK part. You put it on the navigation property and name the FK property, or on the FK property and name the navigation:

Here the FK column is BuyerId and the navigation is called Buyer instead of Customer. Without the annotation, EF Core would create a separate shadow CustomerId column and never connect it to BuyerId, which is a confusing bug to chase down. Annotations are fine for small overrides; Fluent API fits better when the relationship has multiple non-default aspects.

Once the relationship is wired up, inserts look natural:

You never set CustomerId manually. EF Core sees the orders attached to Alice's Orders collection, generates the INSERT for the customer first to get her generated Id, then sets CustomerId on each order before inserting them. The graph is saved in one transaction.

Many-to-One (The Other Side of 1:N)

Many-to-one is just one-to-many viewed from the dependent side. Order.Customer is the same relationship as Customer.Orders, you're just looking at it from the order's perspective. EF Core treats them as a single relationship in the model; you only need to configure it once.

In Fluent API, you can start from either end:

Both produce the exact same model. Pick whichever side reads better for your team. A common style is to configure each entity's outgoing relationships in its own configuration class, so the entity that owns the FK declares the relationship.

The interesting case is when you want a many-to-one without a collection on the other side. Take Review referencing the customer who wrote it. You don't always want a Customer.Reviews collection if you never plan to load it.

Without a Reviews collection on Customer, you declare the relationship with WithMany() (empty argument), telling EF Core "this is many-to-one, but I don't expose the collection":

The database structure is identical to the customer-orders case, but the C# model is leaner. You can still query reviews by customer (ctx.Reviews.Where(r => r.CustomerId == id)), you just can't navigate from a Customer instance to its reviews in memory.

One-to-One (Customer to Address)

One-to-one is less common and the most ambiguous of the three. A Customer has at most one Address. The ambiguity is: which side holds the FK? EF Core can't always guess, so you usually have to tell it.

Two layouts come up:

The cleanest is a separate FK on the dependent. The address has a CustomerId column with a unique constraint:

HasForeignKey<Address> is the part that resolves the ambiguity: the FK lives on Address, not Customer. EF Core also adds a unique index on Address.CustomerId so the database enforces "at most one address per customer."

The other layout is a shared primary key. The address's Id is also the FK pointing at the customer:

Now Address.Id is simultaneously its primary key and a foreign key to Customer.Id. There's no separate CustomerId column. This is compact and the join is on indexed primary keys, but it's also more rigid: you can't reuse the address row for any other purpose, and creating an address requires knowing the customer's id first.

Required vs optional dependent controls whether the customer must have an address. Customer.ShippingAddress being declared as Address? (nullable reference) makes the relationship optional. To force it the other way (every customer must have an address), make it non-nullable and mark the FK as required:

IsRequired() on a one-to-one makes the FK column non-nullable. The application now can't save a customer without also saving an address, which usually forces you to handle them as a single aggregate in your code. Most schemas leave the address optional and let it be added later.

Many-to-Many (Product to Category)

A product can sit in several categories ("Electronics" and "Headphones"), and each category holds many products. Before EF Core 5, you had to define an explicit join entity yourself, manage its rows, and use it in every query. EF Core 5 introduced skip navigations: you write a List<T> on each side, and EF Core creates and manages a hidden join table for you.

That's the entire model. Convention picks up the two collections and generates a join table named CategoryProduct with CategoriesId and ProductsId columns, both forming a composite primary key. The diagram below shows what's actually in the database.

Products has many rows in CategoryProduct, and so does Categories. The join table itself has no entity class in the C# model: it's a pure plumbing table EF Core inserts into when you add a category to a product's collection. From the application side, the experience is just lists:

Three inserts happen in one transaction: one for each new Category, one for the Product, and two rows in CategoryProduct connecting them.

The auto-generated join table is fine until you need to store data on the relationship itself. Say you want to record when a product was added to a category, or who added it. Now the relationship needs its own properties, which means a real entity. EF Core lets you customize the join entity with UsingEntity:

Now ProductCategory is a first-class entity. You can query it directly (ctx.Set<ProductCategory>().Where(pc => pc.AddedAt > someDate)), expose it through its own DbSet, or keep using the skip navigations on Product and Category. The skip navigations still work; EF Core just routes inserts through your custom join entity.

Loading product.Categories on hundreds of products without .Include(p => p.Categories) causes one extra round-trip per product (the classic N+1 problem covered in Ch 07). Eager-load the collection with Include, or project only the data you need.

Self-Referencing Relationships

Sometimes an entity points at another row in its own table. A Category has subcategories ("Electronics" contains "Headphones" and "Speakers"). An employee has a manager who is also an employee. The model looks circular but it's just one-to-many with the same type on both sides:

ParentCategoryId is nullable because top-level categories have no parent. EF Core recognizes the pattern by convention: a self-referencing FK paired with a reference and a collection of the same type. The Fluent API equivalent is the same as any other 1:N:

Restrict is the right cascade choice here. Cascading deletes on a self-referencing FK in SQL Server is actually disallowed (it would form a cycle in the cascade graph), so you have to either restrict or set null. Querying the tree usually involves a recursive CTE on the database side; EF Core 7+ has limited support for it, but for deep trees you often still write raw SQL.

Cascade Delete Behaviors

When a principal row is deleted, what happens to its dependents? "Cascade delete" is the umbrella name for the answer, and EF Core exposes five distinct behaviors via DeleteBehavior. The default is different depending on whether the relationship is required, and getting it wrong is one of the easier ways to lose data or create undeletable rows.

The table below summarizes the behaviors.

BehaviorWhat happens to dependents when principal is deletedDefault for
CascadeDependents are deleted too, both in the DB and in the change trackerRequired relationships
RestrictDB raises an error if any dependents exist; EF Core does not null out FKs(Never default)
SetNullDependent FKs are set to NULL in the DBOptional relationships
NoActionSame as Restrict in most providers, but EF Core does not pre-load and check; relies entirely on DB(Never default)
ClientSetNullEF Core sets FKs to NULL in tracked entities; DB itself does not cascadeOptional (EF Core default in some scenarios)

Cascade is dangerous on tables you don't want auto-cleaned. Deleting a customer cascading to their orders also cascades to the order items and any reviews. One DELETE FROM Customers WHERE Id = 1 can wipe out thousands of rows. That's fine for personal data deletion under GDPR; it's catastrophic if you actually wanted to archive the orders.

Restrict is the safer default for business data. Deleting a customer with orders throws a DbUpdateException at SaveChangesAsync, forcing the application to handle the situation deliberately (archive the orders first, soft-delete the customer, etc.).

SetNull makes sense for optional relationships where the dependent should survive the principal's deletion. Deleting a category, for example, might null out the ParentCategoryId of its subcategories so they become top-level rather than being deleted.

ClientSetNull is the "EF Core handles it, the DB doesn't" version of SetNull. The DB has no cascade rule; EF Core loads tracked dependents and nulls their FKs before issuing the delete. The trade-off: only entities currently in the change tracker get updated. Untracked dependents are left with FKs pointing at a deleted parent, which the next SaveChanges will probably reject.

NoAction is essentially "trust the database to error out." It's similar to Restrict in effect but doesn't pre-emptively load dependents. On SQL Server it's how you break cascade cycles (e.g., the self-referencing category case).

Configuring the behavior is part of the relationship in Fluent API:

The cascade flow for a customer-orders-items chain looks like this:

The cascade chains. If Customer-Order is Cascade and Order-OrderItem is also Cascade, deleting one customer can delete all their orders and all the line items in those orders, all in one transaction. That's powerful and easy to misuse.

A cascade delete on a customer with 10,000 orders, each with 5 items, generates a single DB delete that has to find and remove 60,001 rows. On a busy table this can hold locks long enough to time out other transactions. For large cleanups, batch the deletes in smaller chunks or use a background process rather than a single cascading delete in a request handler.

Relationship Cardinalities Compared

The three cardinalities show up so often that it's worth seeing them side by side. The table below summarizes how to declare each one and when to use it.

CardinalityExampleConventions look forFluent APIWhen to use
1:1Customer has one AddressTwo reference navigations, FK side ambiguousHasOne(...).WithOne(...).HasForeignKey<TDependent>(...)Splitting a row into a required/optional partner, or modeling a "has-exactly-one" relationship
1:NCustomer has many OrdersCollection on principal, reference and {Type}Id FK on dependentHasOne(...).WithMany(...).HasForeignKey(...)The default for parent/child relationships, the most common shape
M:NProduct belongs to many CategoriesTwo collections, one on each sideHasMany(...).WithMany(...) (auto join), or UsingEntity<T> for custom joinTagging, many-to-many tagging-like relationships, or any link with no clear owner

The choice of cardinality is a modeling decision driven by the data, not by EF Core. EF Core just gives you a clean way to express whichever one matches reality. The most common mistake is overusing 1:1 (often a sign that the two entities should actually be one), and the next most common is underusing M:N (modeling it as two 1:N tables with a clunky middle entity when skip navigations would be cleaner).

Required vs Optional Relationships

A relationship is required when the FK column can't be NULL: the dependent must reference a principal. It's optional when the FK is nullable: the dependent can exist without a principal. Two things drive which one you get: nullability of the FK property and nullability of the reference navigation.

EF Core picks up int vs int? and treats the FK as required or optional in the database. Reference navigations are governed by nullable reference types: Customer (non-nullable) means required; Customer? means optional. If you have nullable reference types enabled (the default in new projects), keep these two consistent or the model becomes confusing.

You can also force the requirement explicitly in Fluent API, which is sometimes needed when the FK is a shadow property:

IsRequired() makes the FK column non-nullable and switches the default cascade from ClientSetNull to Cascade. That last bit is a sharp edge: marking a relationship required also enables cascade delete by default. If you don't want cascade, add .OnDelete(DeleteBehavior.Restrict) explicitly.

The defaults can be summarized:

RelationshipDefault cascade
Required (non-nullable FK)Cascade
Optional (nullable FK)ClientSetNull

A pattern many teams adopt: always set OnDelete explicitly on every relationship and never rely on defaults. It's a couple more lines of Fluent API per entity but it removes a class of "I didn't realize that would cascade" surprises.

Putting It Together with Migrations

Once you've declared a relationship (via conventions, annotations, or Fluent API), the workflow from Ch 04 takes over: dotnet ef migrations add AddRelationships generates the migration, and dotnet ef database update applies it. The generated migration creates FK columns, indexes, and constraint references. Changing the cascade behavior later requires another migration that drops and recreates the FK constraint.

Loading related data is a Ch 06 topic: Include, ThenInclude, projection. For now, just know that navigation properties don't auto-load. Accessing customer.Orders on a freshly queried customer might give you an empty list even if the customer has orders, because EF Core didn't fetch them. That's intentional, the alternative would be making every query load the entire object graph.

A small end-to-end example pulls everything together. The entities below give a Customer an optional shipping Address, many Orders (each with many OrderItems pointing at Products), and many Reviews. Product belongs to many Categorys.

EF Core handles the entire graph insert in one transaction. Customer first (so it gets an Id), then address with the customer's id, then order, then the two order items, then the products and categories, then the join rows. You never wrote a single CustomerId = ... assignment; the change tracker figured it out from the navigation properties.

Quiz

Relationships Quiz

10 quizzes