TIL: How to Set Up a Unilateral One-to-One Relationship with Entity Framework Core
11 Mar 2026 #todayilearned #csharpIn another episode of Adventures with Entity Framework while migrating a legacy app…
Entity Framework Core only populated a child entity on one item in a result. To honor the 20-minute rule, and for my future self, here’s what I found:
TL;DR: You don’t need the WithOne() and HasForeignKey() when configuring the relationship.
#1. Let’s create an optional one-to-one relationship.
Let’s create a Movie and an Award table.
USE Movies;
GO
CREATE TABLE Awards (
Id INT PRIMARY KEY IDENTITY(1,1),
Name NVARCHAR(100) NOT NULL
);
CREATE TABLE Movies (
Id INT PRIMARY KEY IDENTITY(1,1),
Name NVARCHAR(100) NOT NULL,
AwardId INT NULL /* <-- Optional. No FK here */
);
GO
Since the relationship is optional, the AwardId is nullable. This type dictates what JOIN Entity Framework Core uses.
#2. Configure the one-to-one relationship
Let’s configure the relationship using HasOne() and WithOne().
using Microsoft.EntityFrameworkCore;
namespace LookMaWhatEntityFrameworkDoes;
public class Award
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Movie
{
public int Id { get; set; }
public string Name { get; set; }
public int? AwardId { get; set; }
// ^^^
// Optional
public Award Award { get; set; }
}
public class MovieContext : DbContext
{
public MovieContext(DbContextOptions<MovieContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<Movie>()
.HasOne(m => m.Award)
.WithOne()
.HasForeignKey<Movie>(m => m.AwardId)
.OnDelete(DeleteBehavior.Restrict);
}
public DbSet<Movie> Movies { get; set; }
public DbSet<Award> Awards { get; set; }
}
To verify Copilot’s answer, I went through the official docs here.
#3. Retrieve movies and their awards
And now, let’s create an award, add two movies, and retrieve them to check their awards.
using Microsoft.EntityFrameworkCore;
namespace LookMaWhatEntityFrameworkDoes;
[TestClass]
public class MovieTests
{
[TestMethod]
public async Task AllAwardsPlease()
{
const string connectionString = $"Server=(localdb)\\MSSQLLocalDB;Database=Movies;Trusted_Connection=True;";
var options = new DbContextOptionsBuilder<MovieContext>()
.UseSqlServer(connectionString)
.LogTo(Console.WriteLine)
.Options;
// 1. Let's create an "Oscar"
using (var context = new MovieContext(options))
{
context.Awards.Add(new Award
{
Name = "Oscar"
});
context.SaveChanges();
}
// 2. Let's create two movies that have won an "Oscar"
using (var context = new MovieContext(options))
{
var oscar = await context.Awards.FirstOrDefaultAsync();
Assert.IsNotNull(oscar);
context.Movies.AddRange(
new Movie
{
Name = "Forrest Gump",
AwardId = oscar.Id
},
new Movie
{
Name = "Titanic",
AwardId = oscar.Id
}
);
context.SaveChanges();
}
// 3. Let's retrieve all movies, expecting to have an Award
using (var context = new MovieContext(options))
{
var movies = await context.Movies
// Imagine more filters here
.Include(m => m.Award)
// ^^^^^
// Yes, I'm including it here
.ToListAsync();
foreach (var movie in movies)
{
Assert.IsNotNull(movie);
Assert.IsNotNull(movie.Award);
// ^^^^^
// Assert.IsNotNull failed.
// Play sad trumpet sound.
}
}
}
}
Sorry for the foreach inside the Assert. That’s not a good idea. But I’m lazy and I’m taking too long writing this.
Yes, it fails. Play sad trumpet, please. The second movie’s award isn’t populated. Arrggg!
My fault!
4. Ignore WithOne()
Since I’m setting an unidirectional relationship, one movie/one award/multiple movies, only configuring HasOne() is enough.
Using WithOne() was telling Entity Framework Core that one award could only belong to one movie. And that’s not the case here.
public class MovieContext : DbContext
{
public MovieContext(DbContextOptions<MovieContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Remove the entire configuration
// Or,
modelBuilder
.Entity<Movie>()
.HasOne(m => m.Award);
// ^^^^^
// Just this
}
public DbSet<Movie> Movies { get; set; }
public DbSet<Award> Awards { get; set; }
}
And since I’m following default naming conventions, I could even delete the configuration. Ahh, Cascade sets to null, which is fine here.
Yes, the right answer is to do nothing.
Et voilà!
For debugging and problem-solving tips, read Street-Smart Coding. Those two skills are more relevant now in the era of AI-assisted coding.