microsoft7 min read

Entity Framework Tutorial: Learn ORM from Scratch (2026)

Entity Framework Tutorial: Learn ORM from Scratch (2026)

Published:  |  Category: Microsoft  |  Reading time: ~15 min
Entity Framework Tutorial: Learn ORM from Scratch (2026)

The first time I hand-rolled SQL for a week only to have a schema change break every single query, I knew there had to be a better way. That is when I turned to Entity Framework. Over the years, I have used EF6, EF Core, and watched it evolve from a sometimes-painful black box into a mature, performant ORM that I genuinely enjoy working with. If you are tired of writing boilerplate ADO.NET code and maintaining endless SQL strings, EF Core will change the way you think about data access.

Entity Framework Core is Microsoft's lightweight, extensible, open-source object-relational mapper for .NET. It bridges the gap between your C# domain models and your relational database, tracking changes and generating SQL so you can focus on business logic. In this tutorial, I will take you from database-first setup through advanced querying patterns, sharing hard-won lessons about performance tuning and migration strategies.

Database-First vs Code-First Approach

Entity Framework Core gives you two primary workflows: Database-First and Code-First. In Database-First, you start with an existing database and scaffold entity classes and a DbContext from it. This is common when working with legacy databases or databases managed by a DBA team. The dotnet ef dbcontext scaffold command generates your model automatically, and you can customize it with partial classes and fluent configuration.

Code-First, on the other hand, starts with your C# classes and generates the database from them. This approach gives you full control over the domain model and lets you evolve the schema through migrations. I prefer Code-First for greenfield projects because the database becomes a persistence detail rather than the source of truth for your object model. Both approaches produce the same runtime behavior, so choose based on who owns the schema.

dotnet ef dbcontext scaffold "Server=.;Database=ShopDb;Trusted_Connection=true;" Microsoft.EntityFrameworkCore.SqlServer -o Models

dotnet ef migrations add InitialCreate
dotnet ef database update

Defining Entities and Relationships

EF Core maps C# classes to database tables using conventions, data annotations, or fluent API. Conventions handle most cases automatically — a property named Id or EntityNameId becomes the primary key, navigation properties infer foreign keys, and collections imply one-to-many relationships. When conventions do not match your schema, the fluent API in OnModelCreating gives you explicit control without polluting your domain classes.

Relationships are the heart of any data model. A one-to-many relationship between Blog and Post requires a foreign key property on Post and a collection navigation property on Blog. Many-to-many relationships were cumbersome in early EF Core versions but gained first-class support in EF Core 5 with automatic join table management. Understanding relationship fixup — how EF Core automatically wires navigation properties when you assign a foreign key — saves you from subtle bugs.

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; } = string.Empty;
    public List Posts { get; set; } = new();
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; } = string.Empty;
    public int BlogId { get; set; }
    public Blog Blog { get; set; } = null!;
}

Querying Data with LINQ and Eager Loading

Writing queries with LINQ to Entities is where EF Core shines. You write strongly-typed C# expressions, and the LINQ provider translates them into parameterized SQL. This eliminates SQL injection risks and gives you compile-time checking. However, the translation is not always intuitive — certain C# constructs like custom methods or complex string manipulations may not translate and will throw runtime exceptions or be evaluated client-side.

Eager loading with the Include method fetches related data in a single query via JOINs. Use ThenInclude for nested relationships. A common mistake is lazy loading — enabled by default in EF6 but opt-in in EF Core — which fires a separate SQL query for each navigation property access, causing the dreaded N+1 problem. Enable lazy loading only for prototyping; for production, always eager load or use explicit loading.

var blogs = await context.Blogs
    .Include(b => b.Posts)
        .ThenInclude(p => p.Comments)
    .Where(b => b.Rating > 3)
    .OrderByDescending(b => b.CreatedAt)
    .Select(b => new BlogSummary
    {
        Id = b.BlogId,
        Title = b.Url,
        PostCount = b.Posts.Count
    })
    .ToListAsync();

Migrations and Schema Evolution

EF Core migrations let you evolve your database schema as your model changes. When you add a new property, rename a column, or introduce a new entity, you create a migration that generates the corresponding ALTER TABLE, CREATE TABLE, or other DDL statements. Migrations are timestamped and sequential, giving you a version history of your schema that can be replayed forward or rolled back.

In team environments, migration conflicts arise when two developers add migrations from the same baseline. The solution is to rebase your migration on the latest version before merging. EF Core's migrations are idempotent when applied with the Script-Migrations command, which generates a single SQL script safe to run multiple times. For production, I always generate a SQL script and have DBAs review it rather than applying migrations directly.

dotnet ef migrations add AddProductReviews --context AppDbContext
dotnet ef migrations script --from PreviousMigration --to AddProductReviews --output upgrade.sql

dotnet ef database update --connection "Server=prod;Database=MyDb;..."

Performance Tuning and Change Tracking

EF Core's change tracker maintains snapshots of entity states — Added, Modified, Deleted, Unchanged, and Detached. When you call SaveChangesAsync, it compares current values to original snapshots and generates UPDATE statements for only the changed properties. This is powerful but has overhead. For bulk inserts or read-only queries, use AsNoTracking to bypass change tracking entirely and gain a significant performance boost.

Another performance pattern is using raw SQL for complex operations that cannot be expressed efficiently in LINQ. EF Core allows you to execute raw SQL via FromSqlRaw and ExecuteSqlRaw, which map results to entities or arbitrary types. Batch operations like updating thousands of rows are best done with ExecuteUpdate and ExecuteDelete introduced in EF Core 7, which generate a single SQL statement instead of loading entities into memory.

var products = await context.Products
    .AsNoTracking()
    .Where(p => p.Price > 100)
    .ToListAsync();

await context.Products
    .Where(p => p.Category == "Discontinued")
    .ExecuteUpdateAsync(setters =>
        setters.SetProperty(p => p.IsActive, false));

Concurrency Handling and Transactions

When multiple users modify the same record simultaneously, you need concurrency control. EF Core supports optimistic concurrency using a concurrency token — typically a rowversion column in SQL Server or a Guid property. When saving, EF Core includes the original token value in the WHERE clause. If another user changed the row first, no rows match, and EF Core throws a DbUpdateConcurrencyException. You then decide whether to retry, overwrite, or merge changes.

For atomic multi-table operations, use transactions. EF Core's default behavior wraps a single SaveChangesAsync call in a transaction automatically. For operations spanning multiple save calls, use the Database.BeginTransactionAsync method to create an explicit transaction. The transaction can be committed or rolled back as a unit, ensuring data consistency even when the process crashes mid-way.

using var transaction = await context.Database.BeginTransactionAsync();
try
{
    context.Orders.Add(newOrder);
    await context.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    await transaction.RollbackAsync();
    var entry = ex.Entries.Single();
    await entry.ReloadAsync();
}

Frequently Asked Questions

Is Entity Framework Core faster than Dapper?

Dapper is faster for simple queries because it generates minimal overhead. However, EF Core's change tracking, caching, and LINQ translation add value for complex scenarios. In my benchmarks, EF Core is within 10-20% of Dapper for most real-world queries, and the productivity gain is enormous.

When should I use raw SQL instead of LINQ?

Use raw SQL for bulk operations, complex reporting queries with window functions, or when the LINQ translation produces inefficient SQL. EF Core's FromSqlRaw and ExecuteSqlRaw handle these cases cleanly.

How do I handle many-to-many relationships?

In EF Core 5+, define two entities with collection navigation properties and let EF Core manage the join table automatically. Alternatively, model the join entity explicitly if it needs additional payload data like CreatedDate.

Should I use Database-First or Code-First?

If the database is managed by a DBA team or already exists, use Database-First. If you control the schema and want your domain model to drive development, use Code-First. Both ultimately map C# classes to tables.

Originally published on Ayodhyyya. Last updated June 1, 2026.