top of page

Entity Framework Core: The ORM You'll Love and Hate

  • ShiftQuality Contributor
  • Jul 19, 2025
  • 5 min read

Entity Framework Core is .NET's default ORM — the layer that maps your C# classes to database tables and lets you write LINQ queries instead of raw SQL. At its best, it eliminates boilerplate data access code and keeps your application logic clean. At its worst, it generates SQL that makes your DBA weep and hides performance problems behind an abstraction that looks innocent.

The previous post in this path covered dependency injection — the pattern that makes your data access layer testable and swappable. This post covers what that data access layer actually does, and how to use EF Core without falling into the traps that catch every team eventually.

The Good: Why EF Core Exists

Writing raw SQL for every data operation is tedious, error-prone, and creates a maintenance burden. A SELECT for the user list, an INSERT for new users, an UPDATE for profile changes, a DELETE for account removal — each one is a string in your code that the compiler cannot check and that must be manually updated when the schema changes.

EF Core replaces this with strongly-typed queries. Your database tables are represented as C# classes. Your queries are LINQ expressions that the compiler checks and IntelliSense supports. Schema changes propagate through type errors, not runtime failures.

var activeUsers = await _context.Users
    .Where(u => u.IsActive && u.LastLoginDate > cutoffDate)
    .OrderBy(u => u.LastName)
    .Select(u => new UserSummary(u.Id, u.FullName, u.Email))
    .ToListAsync();

This is readable, type-safe, and generates a single SQL query with a WHERE clause, an ORDER BY, and a projection that only retrieves the columns needed. You did not write any SQL. The compiler verified the property names. If someone renames LastLoginDate, this code breaks at compile time, not at 3 AM in production.

For standard CRUD operations, EF Core is genuinely excellent. It tracks changes to entities, generates the appropriate INSERT, UPDATE, and DELETE statements, and handles concurrency. For 80% of the data access in a typical application, it is the right tool.

The N+1 Problem

The remaining 20% is where EF Core turns from helper to hazard, and the N+1 problem is the most common trap.

Consider loading orders with their items:

var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
    Console.WriteLine($"Order {order.Id}: {order.Items.Count} items");
}

This looks innocent. It generates one query to load orders. Then, when you access order.Items in the loop, EF Core's lazy loading fires a separate query for each order's items. One hundred orders means one hundred additional queries. One thousand orders means one thousand queries.

The database is doing a thousand round trips for data that could be retrieved in one. Latency multiplies. Connection pool pressure increases. The page load that was fast with ten orders is unusable with a thousand.

The fix is eager loading — telling EF Core to load related data in the same query:

var orders = await _context.Orders
    .Include(o => o.Items)
    .ToListAsync();

One query with a JOIN. All data loaded at once. The N+1 problem disappears.

The general rule: every time you navigate a relationship in EF Core, ask yourself whether that navigation triggers a query. If it does, and it's inside a loop, you have an N+1 problem.

Tracking vs. No-Tracking

By default, EF Core tracks every entity it loads. It keeps a reference to the entity and watches for changes, so it knows what to UPDATE when you call SaveChanges(). This is essential for write operations. It is waste for read-only operations.

Loading a list of products for a catalog page? You are not going to modify those products in this request. Tracking them consumes memory, increases processing time, and provides zero value.

var products = await _context.Products
    .AsNoTracking()
    .Where(p => p.IsActive)
    .ToListAsync();

AsNoTracking() tells EF Core not to track these entities. Memory usage drops. Query time decreases. For read-heavy applications — which is most web applications — applying AsNoTracking() to read queries is one of the simplest and most impactful performance improvements.

Migrations: Schema as Code

EF Core migrations manage your database schema the same way version control manages your code. When you add a property to a model class, you generate a migration that adds the corresponding column. When you deploy, the migration runs and updates the schema.

dotnet ef migrations add AddPhoneNumberToUser
dotnet ef database update

The migration is a C# file that describes the schema change — AddColumn, CreateTable, DropIndex. It is version-controlled, reviewable, and rollback-ready. Your database schema evolves alongside your code, and you can reconstruct any historical version of the schema from the migration history.

The pitfall: do not let EF Core auto-generate migrations without reviewing them. Auto-generated migrations sometimes make destructive assumptions — dropping and recreating a column instead of renaming it, or rebuilding a table instead of altering it. Always review the generated migration before applying it. A five-minute review prevents a data loss event.

When to Step Around EF Core

EF Core is a general-purpose tool. General-purpose tools have limits.

Complex reporting queries with multiple joins, window functions, CTEs, and aggregations are often clearer and faster as raw SQL. EF Core can execute raw SQL directly, and for complex read-only queries, this is often the right choice.

Bulk operations — inserting ten thousand rows, updating a million records — are not EF Core's strength. EF Core tracks every entity individually. Bulk inserting ten thousand tracked entities is dramatically slower than a bulk insert. Use libraries like EFCore.BulkExtensions or raw SQL for bulk operations.

Performance-critical hot paths where every millisecond matters may need hand-tuned SQL that exploits database-specific features. EF Core generates good SQL for common cases. For the 1% of queries where good is not fast enough, writing the SQL yourself is appropriate.

The pattern is clear: use EF Core for the 80% where it excels. Step around it for the 20% where it doesn't. Do not abandon the ORM entirely because of edge cases. Do not force the ORM on cases where it creates more problems than it solves.

The Takeaway

EF Core makes data access productive and maintainable for the vast majority of operations in a .NET application. The traps — N+1 queries, unnecessary tracking, unreviewed migrations, and forcing it on workloads it was not designed for — are avoidable with awareness and discipline.

Use eager loading for related data. Use AsNoTracking() for reads. Review your migrations. Monitor the SQL your queries generate. And know when to write raw SQL instead of fighting the abstraction.

The ORM is a productivity tool. Treat it like one — use it where it helps, step around it where it doesn't, and never forget that it is generating SQL on your behalf. Knowing what that SQL looks like is the difference between using EF Core well and using it dangerously.

Next in the "Building Real .NET Apps" learning path: We'll cover error handling and resilience patterns in .NET — how to build services that handle failures gracefully instead of crashing spectacularly.

Comments


bottom of page