EF Core Relationships: One-to-Many, Many-to-Many, and Configuring Them

Configure EF Core relationships: one-to-many, many-to-many with skip navigations, one-to-one, foreign key conventions, HasOne and WithMany, delete behavior.

Executive Summary: EF Core relationships are three shapes with one vocabulary: one-to-many (a project has tasks), many-to-many (tasks have tags, tags have tasks), and one-to-one (a project has one settings row). The model wires them together with navigation properties on your classes plus a foreign key convention that usually just works, and the fluent API (HasOne, WithMany, UsingEntity) makes everything explicit when it does not.

The bug that brings developers here usually looks like one of two stack traces: NullReferenceException when reading task.Project.Name because the project was never loaded, or a database error like SqliteException: FOREIGN KEY constraint failed because a delete violated a relationship nobody configured. Both are the same root cause: the relationship exists in the database and in your head. However, the model, the queries, and the delete rules were never aligned on it.

EF Core relationships are the mapping between class-level associations (navigation properties) and database-level constraints (foreign keys, cascade rules). How EF Core discovers them by convention, how to configure them with the fluent API. What happens to related rows on delete. The relationships documentation is the reference; this page is the guided tour through the shapes you actually build.

The examples continue the TaskApi on .NET SDK 10.0 with EF Core 10, building on the entities and DbContext from the EF Core code-first guide. Every code block drops into that project.

On modeling, my view is direct: relationships are the part of the EF Core model where laziness costs the most. Conventions produce a working database quickly, but teams that leave every relationship implied eventually argue with their own cascade rules. Make the shapes explicit, and the model reads like the domain.

The anatomy of EF Core relationships

Every relationship has two sides in code and one side in the database. In code: navigation properties (the Project reference on a task, the Tasks collection on a project) and optionally the foreign key property (ProjectId). In the database: a foreign key column plus a delete rule. The three shapes cover everything:

Shape Example FK lives on Fluent API center
One-to-many Project to Tasks The “many” side (Tasks.ProjectId) HasOne / WithMany
Many-to-many Tasks and Tags A join table EF Core can own UsingEntity (skip navigations)
One-to-one Project to its Settings The dependent’s PK (Settings.ProjectId) WithOne / HasForeignKey

One-to-many: the shape you model most

A task belongs to one project; a project has many tasks. Two navigation properties and the FK tell the whole story to the conventions:

public class Project
{
    public Guid Id { get; set; }
    public required string Name { get; set; }
    public List<TaskItem> Tasks { get; set; } = [];
}

public class TaskItem
{
    public Guid Id { get; set; }
    public required string Title { get; set; }
    public Guid ProjectId { get; set; }
    public Project Project { get; set; } = null!;
}

EF Core sees ProjectId (navigation name plus Id) and builds the foreign key column and the relationship with no configuration. When you want the mapping explicit, the fluent API says exactly what the convention inferred:

modelBuilder.Entity<TaskItem>()
    .HasOne(t => t.Project)          // a task has one project
    .WithMany(p => p.Tasks)          // a project has many tasks
    .HasForeignKey(t => t.ProjectId) // column name, stated
    .OnDelete(DeleteBehavior.Cascade); // delete rule, stated

Notice what is on the “many” side: everything. The FK, the HasOne call, the delete behavior. That is a rule worth memorizing, because every relationship you will configure points from the dependent (the side with the FK) toward the principal.

Foreign key conventions: the naming that just works

EF Core discovers foreign keys by name before you configure anything. The patterns, in order of reliability:

  • Navigation plus Id: a Project navigation and a ProjectId property on the dependent pair up automatically.
  • Navigation only: with no FK property, EF Core creates a shadow foreign key, a column that exists in the database and in queries but not in your class. Convenient, and invisible to readers of the entity.
  • Non-standard names: a property like Owner or ProjectKey does not pair up by convention; the fluent API’s HasForeignKey names it explicitly.

The practical rule for teams: keep the FK property in the class (ProjectId) even when the navigation exists. It makes the column visible in code, keeps queries like “tasks where ProjectId == x” free of navigation chains, and removes the surprise of shadow properties.

Many-to-many with skip navigations

Tags and tasks are the classic case: a task has many tags, a tag has many tasks, and neither owns the other. Since EF Core 5, you model this with two collections and EF Core manages the join table for you:

public class TaskItem
{
    public Guid Id { get; set; }
    public required string Title { get; set; }
    public List<Tag> Tags { get; set; } = [];
}

public class Tag
{
    public Guid Id { get; set; }
    public required string Name { get; set; }
    public List<TaskItem> Tasks { get; set; } = [];
}

No join entity appears in your code: EF Core creates a TagTaskItem join table with two foreign keys. Adding or removing tags through the collections updates the join rows:

task.Tags.Add(new Tag { Name = "urgent" });
await context.SaveChangesAsync(ct);   // INSERTs the join row (and the tag)

When you want a say in the join table (a clearer name, a compound key, or an index), UsingEntity configures it without a join class:

modelBuilder.Entity<TaskItem>()
    .HasMany(t => t.Tags)
    .WithMany(t => t.Tasks)
    .UsingEntity("TaskTag",
        l => l.HasOne(typeof(Tag)).WithMany().HasForeignKey("TagsId"),
        r => r.HasOne(typeof(TaskItem)).WithMany().HasForeignKey("TasksId"));

The important line in the sand: skip navigations are for join rows with no payload. The moment the relationship needs extra columns (who tagged it, when, in which workspace), you need a real join entity with the payload, and using skip navigations against it risks silently deleting those columns when EF Core recreates join rows. The many-to-many documentation shows both styles with the exact warnings.

One-to-one: the dependent borrows the key

A one-to-one relationship gives the dependent a foreign key that is also its primary key. So there can be at most one dependent row per principal. That shape is why the fluent API needs the FK pointed out explicitly. The conventions cannot guess which side is dependent:

public class Project
{
    public Guid Id { get; set; }
    public required string Name { get; set; }
    public ProjectSettings? Settings { get; set; }
}

public class ProjectSettings
{
    public Guid ProjectId { get; set; }   // PK and FK: one row per project
    public bool AllowPublicAccess { get; set; }
}

modelBuilder.Entity<Project>()
    .HasOne(p => p.Settings)
    .WithOne(s => s!.Project)
    .HasForeignKey<ProjectSettings>(s => s.ProjectId);

One-to-one suits rows that extend another row: settings, profiles, secrets. If a second dependent row is even plausible in the future, model one-to-many now and save the migration later. Changing the shape later means reworking keys.

Delete behavior: the part with consequences

Every relationship ends with a question: when the principal row goes, what happens to the dependents? EF Core and the database agree on the answer you configure:

Behavior Effect Typical use
Cascade Dependents are deleted with the principal (loaded ones by EF Core, the rest by the database) Owned children that make no sense alone (project and its tasks)
Restrict The delete fails if dependents exist Anything that must be cleaned up explicitly first
SetNull Dependents survive with their FK set to null Optional references the dependent outlives

The mechanics matter in production: for dependents already tracked in memory, EF Core performs the delete itself. For rows it never loaded, the database’s own ON DELETE rule does the work. A large cascade you never loaded is one SQL statement in the database, which is efficient and equally irreversible. The cascade delete documentation covers all four behaviors and the client-side variants.

Changing a relationship’s delete rule changes the schema, so it arrives as a migration. The EF Core migrations guide covers reviewing and shipping that change safely.

A navigation property is only populated if the query asks for it. By default EF Core loads the row you queried, not its relationships, which is exactly the NullReferenceException from the introduction. Include states the requirement up front:

var project = await context.Projects
    .Include(p => p.Tasks)         // load the tasks too
    .FirstAsync(p => p.Id == projectId, ct);

Lazy loading (navigations fetch on demand) exists as an opt-in package, and it is off by default for a reason. It turns one list endpoint into N+1 hidden database calls, a failure mode with its own guide. This page stops at Include. Tracking behavior, projections as the alternative to Include, and finding N+1 in your logs all live in the query efficiency guide.

How real systems do this

  • Relationships are explicit in OnModelCreating. Teams write the HasOne/WithMany call even where conventions would succeed. This is because the delete behavior and key choice deserve to be read, not inferred by someone six months later.
  • DTOs stop navigation leaks at the boundary. Entities with rich navigations never serialize. Controllers project into response records, the pattern from the Web API guide, so circular references and accidental deep loading stop at the API edge.
  • Delete rules get named in review. A migration that adds a cascade is treated like a destructive statement, because it is one. The delete behavior table above is the checklist.

A near-miss I remember clearly: a team added a cascade from Project to TaskItem after the fact, generated the migration, and only in review did someone notice that the migration would also cascade from Project to a table of archived reports that shared the FK by convention. Nobody had asked for that second relationship; the convention had found the name and wired it silently. The fix was one line of explicit configuration naming the intended navigation. The takeaway: implicit relationships are real relationships. The only way to know what your schema will do is to read the model in one place.

Decision framework: choosing the shape

  1. How many dependents can exist per principal? Many: one-to-many. At most one, by nature (one settings row): one-to-one. Both sides hold many: many-to-many.
  2. Does the join row carry data? No: skip navigations, EF Core owns the join table. Yes: a real join entity, configured like any one-to-many pair, twice.
  3. Should dependents die with the principal? Yes, and they are meaningless alone: cascade. No, callers must clean up first: restrict. Dependents outlive, optionally attached: set null.
  4. Convention or configuration? Solo project, obvious names: convention works. Team codebase: write the fluent API so the delete behavior and keys are visible in one place.
  5. Which navigations do you actually query? Model those. The rest are dead weight that invites accidental Include chains and serialization surprises.

When NOT to model a relationship

  • When the association is not real. Do not add navigations because a future report might join those tables. Queries can join by foreign key without a navigation property, and the model stays honest about what the domain means.
  • When the join needs a payload and you are tempted to use skip navigations anyway. A join row with an AddedAt column is an entity. Using the many-to-many shortcut risks EF Core deleting that column when it recreates join rows.
  • When one-to-one is really one-to-many in disguise. If the requirement conversation contains “for now, just one”, model the many side. Converting the key shape later is the expensive direction.

Common mistakes

  • Reading a navigation that was never loaded. task.Project.Name on an unloaded navigation throws or returns null. Include what you need or query by FK.
  • Assuming a safe default delete rule. Required relationships cascade by default. One innocent delete can take a subtree of dependents with it. State OnDelete explicitly.
  • Skip navigations over a join payload. The hidden columns get rewritten when EF Core manages join rows it does not know are yours. Model the join entity when it has data.
  • Serializing entities with circular navigations. Project to Tasks to Project loops; project into DTOs at the boundary and the loops end there.
  • Shadow FKs nobody knew about. A navigation without an FK property creates a column invisible in the entity. Keep the FK property in the class so the column has a name in code.
  • Changing relationship shapes casually. Moving from one-to-one to one-to-many or re-pointing an FK rewrites keys and cascades. Treat shape changes as expensive and time them like schema milestones.

Key takeaways

  • Every relationship has two sides in code (navigations, FK) and one in the database (FK column plus delete rule). The dependent is the side with the FK.
  • One-to-many is HasOne/WithMany from the dependent; many-to-many uses skip navigations plus UsingEntity when the join row needs shaping. One-to-one borrows the principal’s key as the dependent’s PK.
  • Conventions pair navigations with FKs by name and create shadow keys when the property is missing. Explicit fluent configuration makes intent reviewable.
  • Delete behavior is part of the schema: cascade for owned children, restrict for explicit cleanup, set null for optional references. Every rule change ships as a migration.
  • Include is how a navigation gets data. Lazy loading hides N+1 queries behind property access and stays off by default.
  • Model the relationships you query, project entities into DTOs at the boundary, and keep join payloads in real join entities.

FAQ

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

Two navigation properties plus a foreign key usually suffice: a List of children on the parent, a parent reference plus ParentId on the child. The conventions build the FK column. For explicit control, use HasOne(t => t.Project).WithMany(p => p.Tasks).HasForeignKey(t => t.ProjectId).OnDelete(…) in OnModelCreating, so the key and delete rule are visible in one place.

How does many-to-many work in EF Core 5 and later?

Two collection navigations, Tags on TaskItem and Tasks on Tag, are enough. EF Core creates and manages the join table automatically, and adding or removing items through the collections writes the join rows. Use UsingEntity to name the table or shape its keys. If the join row needs payload columns, model it as a real entity with two one-to-many relationships instead.

What does Include do in EF Core?

It states that related data must be loaded with the main query, as a JOIN or subsequent statement. Without Include (or an explicit load), navigation properties stay unpopulated and dereferencing them throws NullReferenceException. Include what the code will read, and prefer projections when you only need specific columns from the related row.

What does Cascade delete mean in EF Core?

Dependents are deleted when their principal is deleted: rows EF Core has loaded are deleted by SaveChanges, and rows it never loaded are deleted by the database’s ON DELETE CASCADE rule in one statement. It is the default for required relationships, which is why teams write OnDelete explicitly: the rule decides whether deleting a project destroys its tasks.

How do I change the delete behavior of a relationship?

Append OnDelete(DeleteBehavior.Restrict) or SetNull to the relationship configuration in OnModelCreating. Then generate and review a migration, because the database’s foreign key rule changes with it. Restrict makes the delete fail until dependents are cleaned up, SetNull leaves them in place with a null FK. Both are safer defaults than cascade for data you cannot recreate.

Model the questions you actually ask

Relationships are where the EF Core model becomes the domain: the shapes say who owns whom, the delete rules say what dies together, and the navigations you keep say what your code actually queries. Make them explicit, keep the foreign keys visible, keep join payloads in real entities, and let projections carry data outward instead of raw navigations. Next, managing schema changes safely with EF Core migrations covers how all of this evolves in production. Every guide in the series lives on the .NET series index. The rule of thumb: every relationship states its key, its shape, and its delete rule in code. This is because a delete you cannot explain is a delete you cannot undo.

Last updated on 7 November 2026

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *