Sitemap
Santanu Chandra

Santanu Chandra

Aspiring Software Engineer | Proficient in ASP.NET Core, React, Docker & CI/CD | Passionate about building scalable web apps.

EF Core vs Dapper vs ADO.NET: Stop Picking Sides, Start Picking the Right Tool

9 min readSep 24, 2026

--

Three ways to talk to SQL Server from .NET, what each one really costs you, and how to choose without starting a religious war in your team.

Press enter or click to view image in full size

Introduction

Every .NET team eventually has this argument:

“EF Core is too slow, let’s use Dapper.”
“Dapper means writing SQL everywhere, let’s use EF Core.”
“Both are wrappers. Raw ADO.NET is the only true way.”

They’re all partly right, and all partly wrong. These three tools sit on the same stack, at different levels of abstraction:

Your Code
↓
EF Core (full ORM: LINQ, change tracking, migrations)
↓
Dapper (micro-ORM: SQL in, objects out)
↓
ADO.NET (raw provider API: connections, commands, readers)
↓
Database

EF Core itself uses ADO.NET underneath, and Dapper is a set of extension methods over IDbConnection. So the real question is how much do you want to be abstracted away from, and what are you willing to pay for it?

In this article we’ll implement the same small feature with all three: a Products table with read, insert, update, and transactional operations. Then we'll compare them honestly.

Setup used in all examples: .NET 8, SQL Server, and these packages:

dotnet add package Microsoft.Data.SqlClient
dotnet add package Dapper
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

Table:

CREATE TABLE Products (
Id INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(200) NOT NULL,
Price DECIMAL(18,2) NOT NULL,
Stock INT NOT NULL DEFAULT 0,
IsActive BIT NOT NULL DEFAULT 1
);

Model:

public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
public bool IsActive { get; set; } = true;
}

1. ADO.NET: The Foundation

ADO.NET is the low-level data access API in .NET. You open a connection, build a command, add parameters, execute it, and read the results yourself. Nothing is hidden.

Reading data

using System.Data;
using Microsoft.Data.SqlClient;
public class ProductAdoRepository
{
private readonly string _connectionString;
public ProductAdoRepository(string connectionString)
=> _connectionString = connectionString;
public async Task<List<Product>> GetActiveAsync(
decimal minPrice, CancellationToken ct = default)
{
const string sql = @"
SELECT Id, Name, Price, Stock, IsActive
FROM Products
WHERE IsActive = 1 AND Price >= @MinPrice
ORDER BY Name";
var products = new List<Product>();
await using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand(sql, conn);
cmd.Parameters.Add(new SqlParameter("@MinPrice", SqlDbType.Decimal)
{
Value = minPrice,
Precision = 18,
Scale = 2
});
await using var reader = await cmd.ExecuteReaderAsync(ct);
int idOrd = reader.GetOrdinal("Id");
int nameOrd = reader.GetOrdinal("Name");
int priceOrd = reader.GetOrdinal("Price");
int stockOrd = reader.GetOrdinal("Stock");
int activeOrd = reader.GetOrdinal("IsActive");
while (await reader.ReadAsync(ct))
{
products.Add(new Product
{
Id = reader.GetInt32(idOrd),
Name = reader.GetString(nameOrd),
Price = reader.GetDecimal(priceOrd),
Stock = reader.GetInt32(stockOrd),
IsActive = reader.GetBoolean(activeOrd)
});
}
return products;
}
}

That’s about 40 lines for one query. Every column is mapped by hand, but nothing is hidden and nothing is wasted.

Insert and return the new Id

public async Task<int> InsertAsync(Product p, CancellationToken ct = default)
{
const string sql = @"
INSERT INTO Products (Name, Price, Stock, IsActive)
OUTPUT INSERTED.Id
VALUES (@Name, @Price, @Stock, @IsActive)";
await using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@Name", p.Name);
cmd.Parameters.AddWithValue("@Price", p.Price);
cmd.Parameters.AddWithValue("@Stock", p.Stock);
cmd.Parameters.AddWithValue("@IsActive", p.IsActive);
var id = await cmd.ExecuteScalarAsync(ct);
return (int)id!;
}

Transaction: reduce stock safely

public async Task<bool> ReduceStockAsync(int productId, int quantity,
CancellationToken ct = default)
{
await using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync(ct);
using SqlTransaction tx = conn.BeginTransaction();
try
{
const string sql = @"
UPDATE Products
SET Stock = Stock - @Qty
WHERE Id = @Id AND Stock >= @Qty";
await using var cmd = new SqlCommand(sql, conn, tx);
cmd.Parameters.AddWithValue("@Id", productId);
cmd.Parameters.AddWithValue("@Qty", quantity);
int rows = await cmd.ExecuteNonQueryAsync(ct);
if (rows == 0)
{
tx.Rollback();
return false; // not enough stock
}
tx.Commit();
return true;
}
catch
{
tx.Rollback();
throw;
}
}

ADO.NET in one paragraph

  • Strengths: maximum speed and control, minimal allocations, access to every provider feature (bulk copy, streaming, table-valued parameters).
  • Weaknesses: a lot of boilerplate, manual mapping, and it’s easy to leak connections or forget parameters.
  • Best for: hot paths, bulk operations, streaming large results, and library or infrastructure code.

2. Dapper: SQL You Write, Mapping You Don’t

Dapper, built by the Stack Overflow team, removes only the tedious part of ADO.NET: mapping rows to objects and objects to parameters. You still write the SQL.

Reading data

using Dapper;
using Microsoft.Data.SqlClient;
public class ProductDapperRepository
{
private readonly string _connectionString;
public ProductDapperRepository(string connectionString)
=> _connectionString = connectionString;
public async Task<IEnumerable<Product>> GetActiveAsync(
decimal minPrice, CancellationToken ct = default)
{
const string sql = @"
SELECT Id, Name, Price, Stock, IsActive
FROM Products
WHERE IsActive = 1 AND Price >= @MinPrice
ORDER BY Name";
await using var conn = new SqlConnection(_connectionString);
return await conn.QueryAsync<Product>(
new CommandDefinition(sql, new { MinPrice = minPrice },
cancellationToken: ct));
}
}

The 40 lines have become about 8, with the same SQL and the same parameterization.

Insert

public async Task<int> InsertAsync(Product p, CancellationToken ct = default)
{
const string sql = @"
INSERT INTO Products (Name, Price, Stock, IsActive)
OUTPUT INSERTED.Id
VALUES (@Name, @Price, @Stock, @IsActive)";
await using var conn = new SqlConnection(_connectionString);
return await conn.ExecuteScalarAsync<int>(
new CommandDefinition(sql, p, cancellationToken: ct));
}

Passing the whole object works because Dapper matches property names to @Parameters.

Joins with multi-mapping

This is where Dapper starts asking a bit more of you:

public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}

public class Order
{
public int Id { get; set; }
public DateTime OrderDate { get; set; }
public decimal Total { get; set; }
public Customer Customer { get; set; } = null!;
}
public async Task<IEnumerable<Order>> GetOrdersWithCustomersAsync()
{
const string sql = @"
SELECT o.Id, o.OrderDate, o.Total,
c.Id, c.Name
FROM Orders o
INNER JOIN Customers c ON c.Id = o.CustomerId";
await using var conn = new SqlConnection(_connectionString);
return await conn.QueryAsync<Order, Customer, Order>(
sql,
(order, customer) =>
{
order.Customer = customer;
return order;
},
splitOn: "Id");
}

splitOn tells Dapper where one object's columns end and the next one's begin. Get it wrong and you'll see confusing null objects.

IN clauses made easy

var ids = new[] { 1, 5, 9 };
var items = await conn.QueryAsync<Product>(
"SELECT * FROM Products WHERE Id IN @Ids",
new { Ids = ids });

Dapper in one paragraph

  • Strengths: near-ADO.NET speed, tiny API, full SQL control, and it’s great for reports and complex queries.
  • Weaknesses: no change tracking, no migrations, no LINQ, and raw SQL strings mean no compile-time safety. Rename a column and your code breaks at runtime.
  • Best for: read-heavy services, reporting, legacy or complex SQL, and stored procedures.

3. EF Core: The Productivity Powerhouse

EF Core is a full ORM. You describe your model in C#, write LINQ, and it generates SQL, tracks changes, and manages your schema through migrations.

DbContext and configuration

using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(e =>
{
e.ToTable("Products");
e.Property(p => p.Name).IsRequired().HasMaxLength(200);
e.Property(p => p.Price).HasPrecision(18, 2);
});
}
}

Register it in Program.cs:

builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

Reading data

public class ProductEfRepository
{
private readonly AppDbContext _db;
public ProductEfRepository(AppDbContext db) => _db = db;
public Task<List<Product>> GetActiveAsync(
decimal minPrice, CancellationToken ct = default)
{
return _db.Products
.AsNoTracking()
.Where(p => p.IsActive && p.Price >= minPrice)
.OrderBy(p => p.Name)
.ToListAsync(ct);
}
}

The C# compiler checks this, refactoring tools understand it, and no SQL string is involved. Note AsNoTracking(): for read-only queries it skips change tracking and is noticeably cheaper.

Insert

public async Task<int> InsertAsync(Product p, CancellationToken ct = default)
{
_db.Products.Add(p);
await _db.SaveChangesAsync(ct);
return p.Id; // populated automatically
}

Update with change tracking

var product = await _db.Products.FindAsync(id);
if (product is null) return;
product.Price = 49.99m; // EF notices this
await _db.SaveChangesAsync(); // only the Price column is updated

Bulk update without loading entities (EF Core 7+)

await _db.Products
.Where(p => p.IsActive)
.ExecuteUpdateAsync(s => s
.SetProperty(p => p.Price, p => p.Price * 1.10m));

This runs as a single UPDATE statement, so it removes one of the classic EF performance complaints.

Projections: fetch only what you need

public record ProductListItem(int Id, string Name, decimal Price);
var list = await _db.Products
.AsNoTracking()
.Where(p => p.IsActive)
.Select(p => new ProductListItem(p.Id, p.Name, p.Price))
.ToListAsync();

Transaction

SaveChanges is already transactional. For multi-step work:

await using var tx = await _db.Database.BeginTransactionAsync(ct);
var product = await _db.Products.FirstAsync(p => p.Id == id, ct);
if (product.Stock < qty) return false;
product.Stock -= qty;
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return true;

For real concurrency safety, add a concurrency token ([Timestamp] byte[] RowVersion) so two simultaneous updates can't silently overwrite each other.

Migrations

dotnet ef migrations add AddProducts
dotnet ef database update

Schema versioning comes free, and neither ADO.NET nor Dapper offers it.

EF Core in one paragraph

  • Strengths: highest productivity, LINQ with compile-time checking, migrations, change tracking, and a rich ecosystem.
  • Weaknesses: more overhead per query, a learning curve (tracking, lazy loading, Include behavior), and generated SQL you should still inspect.
  • Best for: most CRUD-heavy business applications, domain models, and teams that value speed of delivery.

The Comparison

Press enter or click to view image in full size

What About Performance?

Here’s the honest picture, without inventing benchmark numbers:

  • ADO.NET is the baseline. Nothing between you and the database means the least overhead.
  • Dapper is typically within a small margin of ADO.NET, because it does very little beyond cached mapping.
  • EF Core does the most work per query (building the query, materializing entities, tracking them), so it’s usually the slowest on a raw per-call basis. But the gap narrows a lot with AsNoTracking(), projections via Select, compiled queries, and ExecuteUpdate/ExecuteDelete.

For most web APIs the database round trip and the query plan dominate. A missing index will cost you far more than the ORM you picked. Don’t choose a tool from a blog benchmark; measure your own workload with BenchmarkDotNet and a profiler.

Common Mistakes (in All Three)

1. SQL injection through string concatenation. This applies to ADO.NET and Dapper equally:

// ❌ Never
var sql = $"SELECT * FROM Products WHERE Name = '{name}'";
// ✅ Always parameterize
var products = await conn.QueryAsync<Product>(
"SELECT * FROM Products WHERE Name = @Name", new { Name = name });

EF Core’s FromSql (with interpolation) parameterizes for you, but FromSqlRaw with concatenation does not:

// ✅ Safe: interpolated values become parameters
var items = await _db.Products
.FromSql($"SELECT * FROM Products WHERE Name = {name}")
.ToListAsync();

2. The N+1 problem in EF Core:

// ❌ One query for orders, then one per order for its items
var orders = await _db.Orders.ToListAsync();
foreach (var o in orders)
Console.WriteLine(o.Items.Count); // lazy loading → N extra queries
// ✅ One query
var orders = await _db.Orders.Include(o => o.Items).ToListAsync();

3. Tracking everything by default. Use AsNoTracking() on every read-only query.

Get Santanu Chandra’s stories in your inbox

Join Medium for free to get updates from this writer.

4. Forgetting to dispose connections in ADO.NET. Always use await using.

5. SELECT * habits. Every tool benefits from selecting only the columns it needs.

The Best Answer: Use Them Together

The strongest real-world pattern is usually EF Core for writes and Dapper for heavy reads, and they can even share the same connection and transaction:

using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
public async Task CreateProductAndLogAsync(Product product, CancellationToken ct)
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
// EF Core for the write
_db.Products.Add(product);
await _db.SaveChangesAsync(ct);
// Dapper on the SAME connection + transaction
var conn = _db.Database.GetDbConnection();
await conn.ExecuteAsync(new CommandDefinition(
"INSERT INTO AuditLog (Message, CreatedAt) VALUES (@Message, SYSUTCDATETIME())",
new { Message = $"Product {product.Id} created" },
transaction: tx.GetDbTransaction(),
cancellationToken: ct));
await tx.CommitAsync(ct);
}

Both operations commit or roll back together. You get EF’s productivity for most of your code and Dapper’s control where you need it.

Which One Should You Choose?

Choose EF Core when:

  • You’re building a typical business app with lots of CRUD
  • You want migrations, a rich domain model, and fast delivery
  • Your team prefers LINQ over hand-written SQL

Choose Dapper when:

  • You have complex reports or hand-optimized SQL
  • You’re working with an existing database or stored procedures
  • You want speed with minimal boilerplate

Choose ADO.NET when:

  • You’re on an extreme hot path, or doing bulk loads (SqlBulkCopy) and streaming
  • You’re writing a library or infrastructure with minimal dependencies
  • You need full control of provider-specific features

Choose a mix when: you want to be pragmatic. Most mature systems end up here.

Final Thoughts

ADO.NET gives you control, Dapper gives you control with less typing, and EF Core gives you productivity in exchange for some overhead. None of them is “the best.” The best choice depends on your team’s skills, your performance needs, and how much SQL you want to own.

If you’re unsure, start with EF Core, measure, and reach for Dapper (or raw ADO.NET) only where the profiler tells you to. Optimizing based on evidence beats optimizing based on opinion.

If this helped you, share it with a teammate who’s still arguing about ORMs. 🙂

--

--

Written by Santanu Chandra

Aspiring Software Engineer | Proficient in ASP.NET Core, React, Docker & CI/CD | Passionate about building scalable web apps.