Most EF Core bugs I've seen in production aren't from bad code. They're from code that looks right. It compiles, it passes review, it works fine locally against a database with twelve rows in it. Then it hits a table with five thousand rows, or a second replica, or a request that gets cancelled halfway through, and it falls over in a way nobody wrote a test for.
None of the mistakes below are exotic. They're the default behavior of EF Core when you don't opt out of it, or the default behavior of a deployment when nobody thought about what "five pods start at the same time" actually means. Here's the setup I use and the list of ways it goes wrong if you skip a step.
The entity
namespace Sample.Domain.Posts;
public sealed class Post
{
public Guid Id { get; private set; } = Guid.CreateVersion7(); // sequential → index-friendly
public required string Title { get; set; }
public required string Slug { get; init; }
public string Body { get; set; } = string.Empty;
public DateTimeOffset? PublishedAt { get; private set; }
public Guid AuthorId { get; init; }
public uint RowVersion { get; set; } // optimistic concurrency token
public void Publish(TimeProvider clock)
{
if (PublishedAt is not null)
throw new DomainException("Post is already published.");
PublishedAt = clock.GetUtcNow();
}
}
Two things here that are easy to skip and annoying to retrofit later. Timestamps are stored as UTC (DateTimeOffset), rendered in the user's timezone only at the edge — I do the same thing on my project, storing everything UTC and rendering in Asia/Turkey, because "what timezone is this in" is a much worse question to answer after the data already exists in three different formats.
Second: the clock comes in as TimeProvider, not a call to DateTime.UtcNow buried inside the method. It's a small thing, but it's the difference between a test that can assert "publishing sets the timestamp to exactly this value" and a test that has to accept "sometime around now."
Configuration, not attributes
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder b)
=> b.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
public sealed class PostConfiguration : IEntityTypeConfiguration<Post>
{
public void Configure(EntityTypeBuilder<Post> b)
{
b.ToTable("posts");
b.HasKey(p => p.Id);
b.Property(p => p.Title).HasMaxLength(200).IsRequired();
b.HasIndex(p => p.Slug).IsUnique();
b.HasIndex(p => new { p.PublishedAt, p.Id });
b.Property(p => p.RowVersion).IsRowVersion();
}
}
Mapping lives in IEntityTypeConfiguration<T> classes instead of [MaxLength] and [Required] attributes scattered across the entity. The entity is domain code — it shouldn't need to know it's being persisted to a posts table with a unique index on Slug. Keep persistence concerns out of the domain and the entity stays readable as just "what a Post is," not "what a Post is plus how Postgres stores it."
Migrations as a deploy step, not a startup hook
dotnet tool install --global dotnet-ef
dotnet ef migrations add CreatePosts -p src/Sample.Infrastructure -s src/Sample.Api
dotnet ef database update -s src/Sample.Api
# Production: generate idempotent SQL, review it, apply it in the deploy pipeline
dotnet ef migrations script --idempotent -o migrate.sql -s src/Sample.Api
This is the one I'd flag hardest: don't call db.Database.Migrate() at app startup in a multi-replica deployment. It looks convenient: the app migrates itself, one less step to remember. But five pods racing to alter the same schema on boot is how you get a half-migrated database at 3am, and you find out about it from an alert, not from a code review comment. Migrations run as a separate deploy step, an init container or a pipeline stage, with a single runner. On a K3s deployment where the whole point is running multiple replicas, this isn't an edge case, it's the normal startup path.
The other rule, same as I'd apply to any migration system including Laravel's: migrations are append-only once merged. You don't go back and edit a migration that's already shipped, you write a new one.
Reads that don't drag the change tracker along
[ApiController]
[Route("api/posts")]
public sealed class PostsController(IPostService posts) : ControllerBase
{
[HttpGet]
[ProducesResponseType<PagedResult<PostDto>>(StatusCodes.Status200OK)]
public async Task<IActionResult> Index([FromQuery] PostQuery query, CancellationToken ct)
=> Ok(await posts.SearchAsync(query, ct));
[HttpGet("{id:guid}", Name = "GetPost")]
public async Task<IActionResult> Show(Guid id, CancellationToken ct)
=> await posts.FindAsync(id, ct) is { } dto ? Ok(dto) : NotFound();
[HttpPost]
public async Task<IActionResult> Store(CreatePostRequest request, CancellationToken ct)
{
var dto = await posts.CreateAsync(request, ct);
return CreatedAtRoute("GetPost", new { id = dto.Id }, dto); // 201 + Location
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Destroy(Guid id, CancellationToken ct)
{
await posts.DeleteAsync(id, ct);
return NoContent(); // 204
}
}
// The read side — project to a DTO in SQL, don't materialize entities you won't mutate
public async Task<PagedResult<PostDto>> SearchAsync(PostQuery q, CancellationToken ct)
{
var query = db.Posts
.AsNoTracking()
.Where(p => p.PublishedAt != null && p.PublishedAt <= clock.GetUtcNow());
if (!string.IsNullOrWhiteSpace(q.Search))
query = query.Where(p => EF.Functions.ILike(p.Title, $"%{q.Search}%"));
var total = await query.CountAsync(ct);
var items = await query
.OrderByDescending(p => p.PublishedAt).ThenBy(p => p.Id)
.Skip((q.Page - 1) * q.PageSize).Take(q.PageSize)
.Select(p => new PostDto(p.Id, p.Title, p.Slug, p.PublishedAt)) // projection
.ToListAsync(ct);
return new PagedResult<PostDto>(items, total, q.Page, q.PageSize);
}
This is the routine version. The interesting part is the list of ways a piece of code that looks just like this one goes wrong.
The traps that cost real money
Forgetting AsNoTracking() on reads. The change tracker snapshots every entity it loads so it can detect what changed on SaveChanges(). On a list endpoint returning 5,000 rows you're never going to mutate, that snapshotting is pure overhead. It's easy to miss because nothing throws — the endpoint just gets slower and nobody notices until the row count grows.
N+1 via Include in a loop. Lazy loading is off by default, which is the right call. But a foreach with a .Load() inside it, or an Include chain built inside a loop, recreates the exact N+1 problem lazy loading was supposed to prevent. The fix isn't a rule you remember, it's counting queries in tests, logging query counts and asserting on them, because this one hides well in code review.
Cartesian explosion. Two collection Includes in a single query multiply rows: if a post has several comments and several tags, you don't get one row per relationship back, you get every combination multiplied together. AsSplitQuery() fixes it by running separate queries instead of one join, but you have to know to reach for it.
Client-side evaluation. If a Where clause can't translate to SQL, EF Core 3+ throws instead of silently pulling the whole table into memory and filtering there. That's on purpose. The fix is to read the exception and rewrite the predicate, not to slap a .ToList() before the filter to make the error go away. That "fix" is how you get the exact behavior the throw was designed to prevent.
No cancellation token. Every async DB call should take the request's CancellationToken. Skip it and an aborted request (the user closed the tab, the client timed out) keeps the query running and burning DB time for work nobody's waiting on anymore.
SaveChanges per row in a loop. Updating rows by loading each one, mutating it, and calling SaveChangesAsync() inside the loop turns one update into a round trip per row. Batch the changes into one SaveChanges call, or better, use ExecuteUpdateAsync / ExecuteDeleteAsync for set-based operations that don't need entities loaded into memory at all.
Long-lived DbContext. It's registered Scoped for a reason. It isn't thread-safe, and its change tracker grows unbounded the longer it's kept alive. Holding onto one across requests, or sharing it across concurrent operations, is asking for either an InvalidOperationException mid-request or a slow memory leak.
Individually these read like a checklist. In practice they're the shape production incidents actually take: an endpoint that behaves fine solo and falls over under concurrent load, a migration that half-applies because two pods started within the same second.
Commit first, dispatch after
await using var tx = await db.Database.BeginTransactionAsync(ct);
var post = new Post { Title = req.Title, Slug = Slugify(req.Title), AuthorId = userId };
db.Posts.Add(post);
await db.SaveChangesAsync(ct);
await outbox.EnqueueAsync(new PostCreated(post.Id), ct); // same transaction
await tx.CommitAsync(ct);
The rule here is simple to state and easy to violate without noticing: never publish to a message bus or call a third-party API inside a transaction. If the transaction rolls back after the message already went out, you've told the rest of the system about a Post that doesn't exist. Write to an outbox table in the same transaction as the domain change, commit, then dispatch from the outbox afterward. The message only goes out if the write actually stuck.
This pattern matters more the more moving parts a system has downstream: background jobs, other services reacting to events, things that can't be un-told once they've happened. It's the same reasoning behind offloading side effects to a job runner instead of doing them inline: the database transaction is where correctness gets decided, and everything after commit is just telling the rest of the world what already happened.
None of this is advanced EF Core. It's the boring stuff: entity design, mapping, migrations, queries that don't drag more into memory than they need to. The bugs aren't hard to fix once you see them. The hard part is that the code that has them doesn't look wrong.
Top comments (0)