← All tutorials

EF Core — How It Works

Understand Entity Framework Core, and how it differs from ADO.NET and Dapper

EF Core lesson banner: three paths, EF Core, Dapper, and ADO.NET, all ending in SQL.
All three reach the database. They differ in how much SQL you write yourself. Labels on this diagram are in Persian.

What will you learn here?

This tutorial explains what Entity Framework Core (EF Core) is, how it actually works under the hood, and how it compares to the two other common ways of talking to a database in .NET: raw ADO.NET and the micro-ORM Dapper. You'll see the same query written three ways, understand change tracking and the unit of work, and learn when to pick each tool.

By the end you'll know not just how to use EF Core, but why it behaves the way it does — and when a lighter tool is the better choice.

1. What is EF Core?

Entity Framework Core is Microsoft's official ORM (Object-Relational Mapper) for .NET. In plain words: it lets you work with your database using normal C# classes and objects instead of writing SQL by hand. You say db.Orders.Add(order) and SaveChanges(), and EF Core figures out the INSERT statement, runs it, and brings back the results as objects.

EF Core is the modern, cross-platform, open-source rewrite of the classic Entity Framework. It works with SQL Server, PostgreSQL, MySQL, SQLite and more through database providers.

2. What is an ORM?

A database thinks in tables, rows and columns. Your C# code thinks in classes, objects and properties. An ORM is the translator between these two worlds — it "maps" a row in the Orders table to an instance of your Order class, and vice versa.

This is often called the object–relational impedance mismatch, and the ORM exists to hide it:

C# object (Order { Id = 1, Total = 99 })
↕
ORM maps between the two
↕
Database row (Id=1 | Total=99)

3. How EF Core works

3.1 DbContext & DbSet

Everything in EF Core revolves around the DbContext. It represents a session with the database — a unit of work. Inside it, each DbSet<T> represents a table you can query and save to.

public class ShopContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlServer("...connection string...");
}

public class Order
{
    public int Id { get; set; }
    public decimal Total { get; set; }
    public DateTime CreatedAt { get; set; }
}

3.2 Change tracking

This is EF Core's "magic" — and the biggest difference from the others. When you load an entity, EF Core keeps a snapshot of it in the change tracker. Later, when you call SaveChanges(), it compares the current values against the snapshot and automatically generates the right INSERT/UPDATE/DELETE for only what changed.

var order = db.Orders.First(o => o.Id == 1);
order.Total = 150;          // you just change a property
db.SaveChanges();           // EF detects it and runs UPDATE automatically
You never wrote an UPDATE. EF Core noticed Total changed and wrote it for you. This "unit of work" batches all your changes and saves them in one transaction.

3.3 LINQ to SQL translation

You query with LINQ (C#), and EF Core's provider translates that expression tree into SQL for your specific database. The query runs on the server, not in memory.

var bigOrders = db.Orders
    .Where(o => o.Total > 100)
    .OrderByDescending(o => o.CreatedAt)
    .ToList();

// EF Core translates this to roughly:
// SELECT * FROM Orders WHERE Total > 100 ORDER BY CreatedAt DESC
If you accidentally force evaluation in memory (e.g. calling .ToList() too early, then filtering), EF can pull the whole table into memory first. Keep filtering before materializing.

3.4 A query's full lifecycle

You write a LINQ query against a DbSet
↓
EF builds an expression tree
↓
The provider translates it to SQL
↓
ADO.NET (underneath!) runs the SQL on the DB
↓
Rows are materialized into C# objects
↓
Tracked entities are added to the change tracker
Notice the key fact: EF Core uses ADO.NET internally. It's a layer on top — not a replacement for the low-level plumbing.

4. The three layers: ADO.NET, Dapper, EF Core

All three end up sending SQL over the same ADO.NET connection. The difference is how much they do for you:

EF Core — full ORM: LINQ, change tracking, migrations (most abstraction)
↓ built on top of
Dapper — micro-ORM: you write SQL, it maps results to objects
↓ built on top of
ADO.NET — raw: connections, commands, data readers (least abstraction)

5. ADO.NET in short

ADO.NET is the low-level data API built into .NET. You manage the connection, write the SQL, add parameters, execute, and read each column manually from a DataReader. Maximum control, maximum boilerplate.

using var conn = new SqlConnection(connStr);
conn.Open();

using var cmd = new SqlCommand(
    "SELECT Id, Total FROM Orders WHERE Total > @min", conn);
cmd.Parameters.AddWithValue("@min", 100);

using var reader = cmd.ExecuteReader();
var orders = new List<Order>();
while (reader.Read())
{
    orders.Add(new Order
    {
        Id = reader.GetInt32(0),
        Total = reader.GetDecimal(1)
    });
}

6. Dapper in short

Dapper is a micro-ORM: a thin set of extension methods on top of ADO.NET. You still write the SQL, but Dapper handles the boring part — opening the reader and mapping each row to your object. It's famous for being extremely fast, very close to raw ADO.NET.

using var conn = new SqlConnection(connStr);

var orders = conn.Query<Order>(
    "SELECT Id, Total FROM Orders WHERE Total > @min",
    new { min = 100 }).ToList();
One line does what ADO.NET needed ~15 for — but you're still in full control of the SQL. Dapper does not track changes or generate SQL for you.

7. Same query, three ways

"Get all orders over 100." Here's the essence of each:

ADO.NET

// open connection, build command, add param,
// execute reader, loop rows, map each column by hand

Dapper

conn.Query<Order>("SELECT * FROM Orders WHERE Total > @min",
                  new { min = 100 });

EF Core

db.Orders.Where(o => o.Total > 100).ToList();

Notice the trend: ADO.NET = you do everything; Dapper = you write SQL, it maps; EF Core = you write C#, it does the rest.

8. How each runs the same query, step by step

Let's take one task — "get all orders with a total above a given amount" — and follow exactly what each tool does internally. The SQL that hits the database is essentially identical; what differs is the work around it.

ADO.NET — you drive every step

using var conn = new SqlConnection(connStr);
conn.Open();                                   // 1. you open the connection

using var cmd = new SqlCommand(                // 2. you build the command
    "SELECT Id, Total, CreatedAt FROM Orders WHERE Total > @min", conn);
cmd.Parameters.AddWithValue("@min", 100);     // 3. you add the parameter

using var reader = cmd.ExecuteReader();        // 4. you execute, get a reader
var orders = new List<Order>();
while (reader.Read())                           // 5. you loop every row
{
    orders.Add(new Order                       // 6. you map each column by hand
    {
        Id = reader.GetInt32(0),
        Total = reader.GetDecimal(1),
        CreatedAt = reader.GetDateTime(2)
    });
}
// 7. you close everything (using does it here)
What happens: there is no magic. You control the connection, the SQL text, the parameter, the reading loop, and the mapping. If a column is added you must edit the mapping by hand.

Dapper — it does steps 4–6 for you

using var conn = new SqlConnection(connStr);

var orders = conn.Query<Order>(                 // open+execute+read+map in one call
    "SELECT Id, Total, CreatedAt FROM Orders WHERE Total > @min",
    new { min = 100 }).ToList();
What happens: you still hand Dapper the exact SQL. Internally it opens the connection if needed, runs an ADO.NET command with your parameter, then uses fast compiled code to map each row to an Order by matching column names to properties. No change tracking, no SQL generation — just mapping.

EF Core — it even writes the SQL

var orders = db.Orders
    .Where(o => o.Total > 100)                 // a LINQ expression tree
    .ToList();                                  // triggers execution here

Internally, in order:

EF builds an expression tree from your LINQ
↓
The provider translates it to parameterized SQL (WHERE [Total] > @__p_0)
↓
It runs that SQL through ADO.NET (same as the others underneath)
↓
Rows are materialized into Order objects
↓
Each object is registered in the change tracker (unless AsNoTracking)
The key insight: all three send nearly the same SQL over the same ADO.NET pipe. ADO.NET makes you do the mapping; Dapper does the mapping; EF Core writes the SQL and does the mapping and tracks the result.

9. Running a stored procedure in each

Suppose the database has this procedure:

CREATE PROCEDURE GetOrdersByMinTotal
    @min DECIMAL(18,2)
AS
BEGIN
    SELECT Id, Total, CreatedAt
    FROM Orders
    WHERE Total > @min;
END

ADO.NET — set CommandType to StoredProcedure

using var conn = new SqlConnection(connStr);
conn.Open();

using var cmd = new SqlCommand("GetOrdersByMinTotal", conn);
cmd.CommandType = CommandType.StoredProcedure;   // the important line
cmd.Parameters.AddWithValue("@min", 100);

using var reader = cmd.ExecuteReader();
var orders = new List<Order>();
while (reader.Read())
{
    orders.Add(new Order
    {
        Id = reader.GetInt32(0),
        Total = reader.GetDecimal(1),
        CreatedAt = reader.GetDateTime(2)
    });
}

You name the procedure instead of writing SQL, flip CommandType, add parameters, and map the result yourself — exactly like a normal query.

Dapper — the cleanest of the three for procs

using var conn = new SqlConnection(connStr);

var orders = conn.Query<Order>(
    "GetOrdersByMinTotal",
    new { min = 100 },
    commandType: CommandType.StoredProcedure).ToList();
Just pass commandType: CommandType.StoredProcedure and Dapper maps the result set automatically. For an output parameter, use DynamicParameters and read it back after execution. This is why Dapper is a favourite when a codebase relies heavily on stored procedures.

EF Core — depends on what the proc returns

a) Proc returns rows of an entity / mapped type:

var orders = db.Orders
    .FromSqlInterpolated($"EXEC GetOrdersByMinTotal {100}")
    .ToList();

b) Proc returns rows of a type that isn't a table — map it as a keyless entity first:

// in OnModelCreating:
modelBuilder.Entity<OrderDto>().HasNoKey();

// query:
var rows = db.Set<OrderDto>()
    .FromSqlInterpolated($"EXEC GetOrdersByMinTotal {100}")
    .ToList();

c) Proc only does work / returns a row count (no result set):

db.Database.ExecuteSqlInterpolated(
    $"EXEC ArchiveOldOrders {cutoffDate}");
With FromSql*, EF can't translate further LINQ that the DB wouldn't support on a proc result, and the returned columns must match the target type's properties. Always prefer FromSqlInterpolated / parameters over string concatenation to stay safe from SQL injection.
ToolHow you call a procMapping
ADO.NETCommandType.StoredProcedureManual
DappercommandType: StoredProcedureAutomatic
EF CoreFromSqlInterpolated / ExecuteSql*Automatic (entity / keyless type)

10. Under the hood: performance & technical differences

All three send nearly identical SQL, so the database does the same work. The performance difference is entirely on the .NET side: how rows are read, how objects are created, and how much bookkeeping each tool does per row. Let's go layer by layer.

10.1 Connection & command

There's no difference here — all three use the same ADO.NET DbConnection and connection pooling. SqlConnection doesn't really open a raw socket every time; it borrows a pooled physical connection. So "opening" a connection is cheap in all three. Parameters are sent as real SQL parameters in every case (so the DB can reuse its execution plan).

10.2 Reading rows — the DataReader

Underneath, all three read results through a DbDataReader, which is a forward-only, streaming cursor. It reads one row at a time off the network buffer — it does not load the whole result set into memory by itself. What you build on top of it is where memory is spent.

10.3 Mapping rows to objects — the real differentiator

ToolHow mapping works technicallyCost
ADO.NETYou call reader.GetInt32(0) etc. by hand. Direct, typed, no reflection, no extra allocation beyond your own object.Baseline — the fastest possible
DapperOn the first call for a given (query shape + type) it emits IL for a mapper delegate and caches it. Subsequent calls run that compiled delegate — essentially as fast as hand-written code.One-time warm-up, then ~ADO.NET speed
EF CoreBuilds a materializer too, but also creates tracked entity instances, computes a snapshot, and inserts them into the identity map / change tracker.Extra CPU + allocations per row
The myth "Dapper uses slow reflection" is outdated. Dapper reflects once to generate IL, caches the compiled delegate, and from then on it's compiled code — not reflection.

10.4 Where EF Core spends the extra time

For the exact same query, EF Core does several things the others don't:

  • Query translation: the LINQ expression tree must be parsed and translated to SQL. This is cached (the compiled query cache, keyed by the query's shape), so it's mostly a first-hit cost — but the cache lookup itself isn't free.
  • Change tracking: each materialized entity gets a tracking entry plus a snapshot of its original values, stored in the identity map. That's more allocations and more CPU per row.
  • Identity resolution: EF checks whether an entity with that key is already tracked, to return the same instance (great for correctness, not free).
  • Fix-up: when you load related data, EF wires up navigation properties between tracked entities.
This is exactly why AsNoTracking() is faster for read-only queries: it skips the snapshot, the identity map insert, and fix-up. For a large read-only list it can roughly halve the .NET-side cost and allocations.

10.5 Buffered vs streaming & memory

  • ADO.NET: you decide — stream row-by-row out of the reader and you can keep memory flat for millions of rows.
  • Dapper: defaults to buffered (Query<T> reads everything into a List). Pass buffered: false to stream lazily and keep memory low.
  • EF Core: .ToList() buffers all; AsAsyncEnumerable() / iterating the query streams. Tracked entities also stay alive in the context, so a huge tracked query keeps growing memory until the context is disposed.

10.6 Cold start vs warm path

EF Core has a noticeable first-call cost: building the model, compiling the query, warming caches. After warm-up it's much faster. ADO.NET has basically no warm-up; Dapper has a tiny per-(query,type) warm-up to emit IL. For short-lived CLI tools or serverless cold starts, this first-hit difference can matter.

10.7 Ballpark performance

ToolRelative read speed*Allocations / rowCPU overhead
ADO.NET1.0× (baseline, fastest)LowestMinimal
Dapper~1.0–1.1× (near-raw)LowVery low
EF Core (AsNoTracking)~1.2–1.5×MediumLow–medium
EF Core (tracking)~1.5–3× slowerHighestHighest

* Rough orders of magnitude for simple read queries; real numbers depend heavily on query shape, row count and version. Always benchmark your own workload (e.g. with BenchmarkDotNet).

Don't over-optimize prematurely. For most CRUD endpoints the network + the database dominate total time, and EF Core's overhead is negligible. The overhead becomes visible only on hot, high-volume read paths — which is exactly where teams selectively drop down to Dapper.

11. Full comparison

AspectADO.NETDapperEF Core
TypeRaw data APIMicro-ORMFull ORM
Who writes SQLYouYouEF (from LINQ)
Object mappingManualAutomaticAutomatic
Change trackingNoNoYes
MigrationsNoNoYes
Relationships / joinsManualManual (multi-mapping)Navigation properties & Include
PerformanceFastestNear-rawSlight overhead
BoilerplateHighLowLowest
Learning curveLow concept, lots of codeLowHigher (LINQ, tracking, config)
Best forFull control / special casesRead-heavy, performance-criticalRich domains, fast development

12. Common EF Core pitfalls

  • N+1 queries: looping over entities and lazily touching a navigation property fires one query per row. Use .Include() or a projection instead.
  • Tracking when you don't need it: for read-only lists, use .AsNoTracking() to skip snapshotting and go faster.
  • Pulling too much: select only the columns you need with a projection (.Select(o => new {...})) instead of whole entities.
  • Client-side evaluation: calling a C# method EF can't translate forces filtering in memory.
  • Long-lived DbContext: the context isn't thread-safe and accumulates tracked entities. Keep it short-lived (per request / per unit of work).

13. When to use which

Reach for EF Core when:
  • You have a rich domain model with relationships you want to navigate.
  • You value developer productivity, migrations and less SQL.
  • Most of your data access is standard CRUD.
Reach for Dapper when:
  • You have read-heavy, performance-critical queries.
  • You want full control over hand-tuned SQL but hate manual mapping.
  • You're querying complex reports or stored procedures.
Reach for raw ADO.NET when:
  • You need absolute control (bulk operations, special providers, streaming).
  • You're building a library and don't want any extra dependency.

In practice: many teams use EF Core for writes and most CRUD, and drop down to Dapper for a few hot read paths. They coexist happily on the same connection — it's not all-or-nothing.


Final summary

  • EF Core is a full ORM: LINQ in, SQL out, with change tracking and migrations.
  • It works by mapping classes to tables, tracking changes, and translating LINQ to SQL — on top of ADO.NET.
  • ADO.NET is the raw foundation; Dapper is a thin, fast mapper where you still write SQL.
  • Trade-off: more abstraction = less code but a bit more overhead; less abstraction = more control and speed.
  • You can — and often should — mix EF Core and Dapper in the same app.

Now you understand how EF Core works and where ADO.NET and Dapper fit. 🗄️