What Is Entity Framework Core? The ORM Idea and EF Core's Architecture
Starting from plain ADO.NET code we see why ORMs exist: EF Core's DbContext, model, provider and change tracker, the different SQL SQLite and SQL Server generate from the same LINQ, EF6 versus EF Core, a Dapper comparison and how the series is organized.
Version note: The code in this post is written for EF Core 6 and runs unchanged on EF Core 10.
Guess First
A bookstore database has a table called Books. You want every book cheaper than 30, sorted by title. In .NET, with no library at all, plain ADO.NET looks like this:
using Microsoft.Data.Sqlite;
using SqliteConnection connection = new("Data Source=bookstore.db");
connection.Open();
using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT Id, Title, Price FROM Books WHERE Price < $maxPrice ORDER BY Title";
command.Parameters.AddWithValue("$maxPrice", 30m);
List<Book> books = new();
using SqliteDataReader reader = command.ExecuteReader();
while (reader.Read())
{
books.Add(new Book
{
Id = reader.GetInt32(0),
Title = reader.GetString(1),
Price = reader.GetDecimal(2)
});
}
Two questions. First: when an Isbn column is added to the table tomorrow, how many places in this code do you have to touch? Second: in a moment we will write the same query with Entity Framework Core as a single LINQ expression. Who writes the SQL then, and what does it look like?
The Cost of Mapping by Hand
The code above works and it is fast. But three separate jobs are tangled inside it. You write the SQL as a string. You add the parameter. You turn each row into a Book object by hand, relying on column order. When you write GetString(1), the only thing that knows 1 means Title is your memory. If the column order in the SELECT changes, the compiler stays silent and you find out at runtime.
The answer to the Isbn question hides here too: the SELECT list, the mapping block, the INSERT command, the UPDATE command and every other query that touches this table.
The real issue is that the two worlds think differently. The database speaks in tables, rows and foreign keys. C# speaks in classes, objects and references. In the database a book's author is a number like AuthorId = 7; in C# it is an object you can walk through as book.Author.Name. The database has no idea which property of an object changed. C# has no idea which row belongs to which object. The literature calls this the object-relational impedance mismatch: the friction of forcing the same data into two different shapes.
What Does an ORM Do?
An ORM (Object-Relational Mapper) is the layer that carries this friction for you. Think of an interpreter who speaks both languages: you talk in C#, it passes things on to the database in SQL, and it turns the answer back into C# objects. A good ORM takes on four jobs:
The first is mapping: which class goes to which table, which property to which column. The second is query translation: turning the expression you wrote in LINQ into that database's SQL dialect. The third is change tracking: keeping track of whether the objects you read have changed afterwards. The fourth is saving: generating the right INSERT, UPDATE and DELETE commands from those changes. EF Core adds a fifth: creating the database schema from the model and keeping it current with migrations.
Let's write the same book query with EF Core. First the entity class and the DbContext:
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = "";
public decimal Price { get; set; }
}
public class BookstoreContext : DbContext
{
public DbSet<Book> Books => Set<Book>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlite("Data Source=bookstore.db")
.LogTo(Console.WriteLine, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information);
}
}
Then the query itself:
using BookstoreContext db = new();
List<Book> cheapBooks = db.Books
.Where(b => b.Price < 30m)
.OrderBy(b => b.Title)
.ToList();
Thanks to the LogTo line, every command EF Core sends to the database lands in the console. Here is the relevant part of the output:
info: 11/4/2026 00:46:45.735 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "b"."Id", "b"."Price", "b"."Title"
FROM "Books" AS "b"
WHERE ef_compare("b"."Price", '30.0') < 0
ORDER BY "b"."Title"
That answers the second question. EF Core wrote the SQL and put every property of the entity into the SELECT list on its own. When you add Isbn, the only place you change is the Book class; the query, the mapping and the INSERT follow automatically. We will come back to that odd ef_compare function shortly.
The Parts of EF Core
This short example already shows all the main parts of EF Core. A DbContext represents one working session with the database. Think of it as a shopping cart: you put in the objects you read, add and change, and when you call SaveChanges the whole cart goes to the checkout. A DbSet<Book> is the door to Book entities within that session; the LINQ you write on it is translated to SQL.
The model is the map EF Core builds by looking at your classes: which entity goes to which table, which property is the key, which column may be NULL. Conventions and your own configuration build that map together; that is the topic of the third and fourth posts. The provider is the package that translates the model and LINQ into the language of a specific database. The change tracker is the ledger that records the state of every object in the cart. Let's watch it at work:
Book first = cheapBooks[0];
Console.WriteLine(db.Entry(first).State);
first.Price = 27.50m;
Console.WriteLine(db.Entry(first).State);
int affected = db.SaveChanges();
Console.WriteLine($"{affected} row(s) updated");
Console.WriteLine(db.Entry(first).State);
Unchanged
Modified
info: 11/4/2026 00:46:45.738 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
UPDATE "Books" SET "Price" = @p0
WHERE "Id" = @p1;
SELECT changes();
1 row(s) updated
Unchanged
We never said "this object changed". The change tracker kept a copy of the original values of every entity that came back from the query. During SaveChanges it compared that copy with the current state, saw that only Price had changed and generated an UPDATE for that column alone. Once saved, the entity went back to Unchanged.
This output is from EF Core 6. Run the same code on EF Core 10 and the UPDATE ends with RETURNING 1; instead of SELECT changes();. Not a single character of your code changed; what changed is the SQL the provider generates for SQLite. We will point out differences like this throughout the series.
Provider: Same LINQ, Different SQL
Back to ef_compare. SQLite has no real decimal type; EF Core stores decimal values as TEXT in SQLite. To compare two numbers stored as text correctly, the provider uses an ef_compare function it registers itself. What happens if we point the same model at the SQL Server provider? You don't even need a SQL Server to see it, because ToQueryString generates the SQL without connecting to the database:
public class SqlServerBookstoreContext : DbContext
{
public DbSet<Book> Books => Set<Book>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("Server=localhost;Database=Bookstore;Trusted_Connection=True;");
}
}
using SqlServerBookstoreContext db = new();
string sql = db.Books
.Where(b => b.Price < 30m)
.OrderBy(b => b.Title)
.ToQueryString();
Console.WriteLine(sql);
SELECT [b].[Id], [b].[Price], [b].[Title]
FROM [Books] AS [b]
WHERE [b].[Price] < 30.0
ORDER BY [b].[Title]
Same LINQ, different SQL. SQL Server uses square brackets, and since decimal is a native type there, a plain < comparison is enough. For this example we added the Microsoft.EntityFrameworkCore.SqlServer package. For PostgreSQL there is Npgsql's Npgsql.EntityFrameworkCore.PostgreSQL package, and Microsoft ships its own provider for Cosmos DB. The expectation that "everything works the same when you switch providers" is misleading, though. Every database has its own types and limits; SQLite's decimal is the first example. Throughout the series we will run the examples on SQLite, which needs no setup, and use SQL Server in Docker only for SQL Server-specific topics, saying so when we do.
EF6 and EF Core Are Not the Same Thing
The names are confusing, so let's be precise. Entity Framework first shipped in 2008 with .NET Framework 3.5 SP1. What we call "EF6" today is the continuation of that line: the EntityFramework NuGet package, the System.Data.Entity namespace, the EDMX designer and pieces like DbModelBuilder, a classic ORM from the .NET Framework world. It still gets patches (its latest version on NuGet is 6.5.2) and since 6.4 it can run on .NET Core too, but it is in maintenance mode; Microsoft's new feature work happens in EF Core.
EF Core is a separate product, rewritten from scratch. Its first release, 1.0, shipped in June 2016. Its packages start with Microsoft.EntityFrameworkCore, it is cross-platform, it is built around the provider model and it has no designer file like EDMX. This series is about EF Core. If an example you find online contains using System.Data.Entity;, it was written for EF6 and won't compile in an EF Core project.
The sneakiest trap is the version number: EF Core 6 is not EF6. One is the EF Core release that shipped with .NET 6, the other is the sixth major version of classic Entity Framework.
A Quick Comparison with Dapper
At the other end of the ORM spectrum sits Dapper. Dapper is a micro-ORM: you still write the SQL, and Dapper only binds the parameters and turns the result into objects. Here is the ADO.NET code from the first example rewritten with Dapper:
using Dapper;
using Microsoft.Data.Sqlite;
using SqliteConnection connection = new("Data Source=bookstore.db");
List<Book> books = connection
.Query<Book>("SELECT Id, Title, Price FROM Books WHERE Price < @maxPrice ORDER BY Title",
new { maxPrice = 30m })
.ToList();
The mapping chore is gone, but the SQL is still yours. Dapper has no LINQ translation, no change tracking, no relationship management and no migrations. That is not a shortcoming but a deliberate choice. Dapper is very comfortable for reporting queries where you want full control over the SQL and for read-heavy endpoints. EF Core shines on the write side, when you manage related data as a whole and evolve the schema together with your code. If you want to get to know Dapper in depth, have a look at the Dapper series; we will discuss when to choose which again in the architecture post of this series.
How This Series Is Organized
The series has two blocks. The first 23 posts are the core: DbContext, the model, relationships, migrations, querying, change tracking, saving, transactions and concurrency, raw SQL, ASP.NET Core integration, performance, testing and architecture. All code in this block is written against the EF Core 6 API, and every example is verified by building and running it on both EF Core 6 (net6.0) and EF Core 10 (net10.0). All examples share one domain: a bookstore made of Author, Book, Order, OrderItem and Tag entities.
Why EF Core 6? Its support ended on 12 November 2024, and we are not suggesting you pick it for a new project. But the EF Core 6 API surface exists unchanged in every modern version running in production today. Everything you learn here helps you in an old project and in a new project you start on EF Core 10. If you are starting a new project, use EF Core 10; it is an LTS release supported until November 2028.
The second block is evolution through versions. Post 24 covers moving from EF Core 3.1 and 5 to 6, and posts 25–28 cover EF Core 7, 8, 9 and 10 in turn: "this is how we did it in EF Core 6, this is how we do it with the new version". Support for EF Core 8 and 9 ends together on 10 November 2026. The series will continue with EF Core 11, planned for November 2026.
Common Pitfalls
The first pitfall is using the ORM as an excuse not to know SQL. EF Core writes the SQL for you, but it doesn't take the responsibility away from you. The cause of a slow page is almost always in the generated SQL. Make LogTo and ToQueryString a habit from day one; in this series we will look at the generated SQL in almost every example.
The second is the wrong package. Searching NuGet for EntityFramework and installing classic EF6, or trying to carry a DbModelBuilder example from a blog post written for EF6 into an EF Core project, is a common sight. Every EF Core package starts with the Microsoft.EntityFrameworkCore prefix.
The third is thinking of the DbContext as a database connection. The DbContext opens and closes the connection itself when needed. It is a short-lived working session and is not designed to be a single instance living for the whole application. The ledger kept by the change tracker keeps growing in a long-lived context. We will look at its lifetime in detail in the ASP.NET Core integration post.
The fourth is assuming the package version is independent of the target framework. EF Core 6 targets net6.0, while EF Core 10 runs only on net10.0. You can't install EF Core 10 into a .NET 8 project, and keeping every EF Core package in a project on the same version is the least painful path.
A Small Experiment
★☆☆ Run the ADO.NET example and the EF Core example against the same database. Then add public string Isbn { get; set; } = ""; to the Book class, delete and recreate the database, and count how many places you had to change in each version.
★★☆ Change the condition in the query to b.Price >= 30m. Look at the LogTo output of the SQLite context and the ToQueryString output of the SQL Server context. What number is the result of ef_compare compared with?
★★★ In the change tracking example, assign first.Price the value it already has and call SaveChanges. Before running it, predict the entity's state and the number of rows returned, then check. Can you explain the result in terms of the copy kept by the change tracker?
In this post we met EF Core from a distance, part by part. In the next post we will start from an empty folder and build the first EF Core application: which packages you need, what the dotnet-ef tool is for, how to define a DbContext and a DbSet, how EnsureCreated creates the database and how to read the generated SQL.