Duas bibliotecas dominam o acesso a dados em .NET: EF Core, um ORM completo que gerencia todo o ciclo de vida do seu banco de dados, e Dapper, um micro-ORM que oferece SQL puro com apenas o necessário de mapeamento. A escolha entre eles — ou a combinação de ambos — depende da complexidade das suas consultas, da proficiência SQL da equipe e de quanto do seu domínio vive no esquema do banco de dados.
Filosofia
EF Core: ORM Completo
EF Core trata o banco de dados como um detalhe de implementação. Você modela seu domínio em classes C#, define relacionamentos com propriedades de navegação e deixa o framework gerar SQL, rastrear mudanças e gerenciar migrações. A abstração permite trocar de provedor de banco de dados sem alterar o código da aplicação.
O custo é a complexidade: a camada ORM introduz convenções, configurações e comportamentos que você precisa entender para evitar armadilhas de desempenho como consultas N+1 ou carregamentos completos de tabela não intencionais.
Dapper: Micro-ORM
Dapper estende IDbConnection com alguns métodos de extensão (Query<T>, Execute, QueryMultiple). Você escreve SQL; Dapper mapeia o conjunto de resultados para seus tipos. Essa é toda a superfície da API. Não há rastreamento de mudanças, sistema de migrações ou carregamento lazy — e tampouco mágica.
O ganho é previsibilidade: o SQL que você escreve é o SQL que é executado. A sobrecarga de desempenho acima do ADO.NET puro é insignificante.
Código Lado a Lado
Os exemplos abaixo usam uma entidade Product com uma Category associada. Eles não são
apenas ilustrações: uma versão executável desta comparação vive em
samples/ef-core-vs-dapper,
que executa ambas as bibliotecas contra o mesmo banco SQL Server semeado e verifica com
asserções cada afirmação desta seção — mesmas linhas em ambas, JOINs de uma única
instrução, atualizações direcionadas de uma única coluna. Se algo deixar de ser verdade em
uma versão futura do EF Core ou do Dapper, esse sample falha ruidosamente.
// Modelo compartilhado usado em todos os exemplos
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
public int CategoryId { get; set; }
public Category? Category { get; set; }
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public ICollection<Product> Products { get; set; } = [];
}Consulta Simples — Buscar Todos os Produtos
EF Core
// DbContext resolve a conexão, rastreia as entidades retornadas por padrão
var products = await context.Products
.AsNoTracking() // desabilita o rastreamento para consultas somente leitura
.ToListAsync();Dapper
// Você controla completamente o SQL — seleção explícita de colunas evita SELECT *
const string sql = "SELECT Id, Name, Price, CategoryId FROM Products";
using var conn = new SqlConnection(connectionString);
var products = await conn.QueryAsync<Product>(sql);Inserção
EF Core
var product = new Product { Name = "Widget", Price = 9.99m, CategoryId = 1 };
context.Products.Add(product);
await context.SaveChangesAsync();
// product.Id é preenchido após SaveChanges — EF lê a chave geradaDapper
const string sql = @"
INSERT INTO Products (Name, Price, CategoryId)
VALUES (@Name, @Price, @CategoryId);
SELECT CAST(SCOPE_IDENTITY() AS INT);";
using var conn = new SqlConnection(connectionString);
int newId = await conn.ExecuteScalarAsync<int>(sql, new
{
Name = "Widget",
Price = 9.99m,
CategoryId = 1
});Atualização
EF Core
var product = await context.Products.FindAsync(id);
if (product is null) return;
product.Price = 14.99m;
await context.SaveChangesAsync();
// O rastreamento de mudanças detecta a modificação de Price e emite um UPDATE específicoDapper
const string sql = "UPDATE Products SET Price = @Price WHERE Id = @Id";
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync(sql, new { Price = 14.99m, Id = id });Exclusão
EF Core
// ExecuteDelete evita carregar a entidade na memória primeiro (EF Core 7+)
await context.Products
.Where(p => p.Id == id)
.ExecuteDeleteAsync();Dapper
const string sql = "DELETE FROM Products WHERE Id = @Id";
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync(sql, new { Id = id });Consulta com JOIN — Produtos com Nome da Categoria
EF Core
var results = await context.Products
.AsNoTracking()
.Include(p => p.Category)
.Where(p => p.Price > 10)
.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
.ToListAsync();
// EF gera uma única consulta com JOIN quando você projeta com SelectDapper
const string sql = @"
SELECT p.Id, p.Name, p.Price, c.Name AS CategoryName
FROM Products p
JOIN Categories c ON c.Id = p.CategoryId
WHERE p.Price > @MinPrice";
using var conn = new SqlConnection(connectionString);
var results = await conn.QueryAsync<ProductDto>(sql, new { MinPrice = 10m });Multi-Mapping (Dividir Resultado em Dois Tipos)
O parâmetro splitOn do Dapper mapeia uma linha de resultado para múltiplos objetos:
const string sql = @"
SELECT p.Id, p.Name, p.Price, p.CategoryId,
c.Id, c.Name
FROM Products p
JOIN Categories c ON c.Id = p.CategoryId";
using var conn = new SqlConnection(connectionString);
var products = await conn.QueryAsync<Product, Category, Product>(
sql,
// splitOn diz ao Dapper onde as colunas de Category começam
(product, category) =>
{
product.Category = category;
return product;
},
splitOn: "Id" // a segunda coluna "Id" aciona a divisão
);EF Core lida com isso automaticamente quando você usa Include ou projeta com Select.
Stored Procedure
EF Core
// FromSqlRaw mapeia o resultado de um stored procedure para um tipo de entidade
var products = await context.Products
.FromSqlRaw("EXEC GetProductsByCategory @CategoryId = {0}", categoryId)
.AsNoTracking()
.ToListAsync();Dapper
using var conn = new SqlConnection(connectionString);
var products = await conn.QueryAsync<Product>(
"GetProductsByCategory",
new { CategoryId = categoryId },
commandType: CommandType.StoredProcedure
);Segurança em Consultas Parametrizadas
Ambas as bibliotecas parametrizam por padrão, prevenindo SQL injection. Nunca interpole entrada do usuário diretamente em strings SQL.
// SEGURO — Dapper usa consultas parametrizadas para todas as propriedades do objeto anônimo
var results = await conn.QueryAsync<Product>(
"SELECT * FROM Products WHERE Name LIKE @Search",
new { Search = $"%{userInput}%" }
);
// INSEGURO — nunca faça isso
var results = await conn.QueryAsync<Product>(
$"SELECT * FROM Products WHERE Name LIKE '%{userInput}%'"
);Aqui está o sample verificando toda esta seção em uma única execução — o SQL gerado pelo EF impresso ao lado do equivalente escrito à mão, e cada afirmação de equivalência comprovada contra o banco em vez de afirmada em prosa:

Benchmarks de Desempenho
Os números abaixo foram medidos com BenchmarkDotNet, não citados do folclore. A suíte é
EfCoreVsDapperBenchmark
— execute-a você mesmo contra o banco semeado do sample (1.000 produtos, exatamente 500
com preço acima de 10 para o cenário JOIN). Cada método paga sua própria configuração
realista: os benchmarks de EF constroem um DbContext por invocação como uma requisição
web faria, Dapper e ADO.NET abrem uma SqlConnection do pool por invocação, e todas as
leituras materializam uma List.
BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.9106)
11th Gen Intel Core i7-1165G7 2.80GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK 10.0.303 — .NET 10.0.11, X64 RyuJIT AVX-512
SQL Server LocalDB, pool de conexões aquecido| Cenário | Método | Média | Alocado | vs base |
|---|---|---|---|---|
| SELECT 1.000 linhas | EF Core (tracking) | 2.210,5 µs | 1.079,7 KB | 1,00× |
EF Core AsNoTracking | 1.309,6 µs | 427,4 KB | 0,60× | |
| EF Core compiled query | 1.673,2 µs | 415,6 KB | 0,76× | |
| Dapper | 793,9 µs | 201,5 KB | 0,36× | |
| Raw ADO.NET | 779,7 µs | 163,8 KB | 0,36× | |
| Busca por PK (1 linha) | EF Core FirstOrDefaultAsync | 370,4 µs | 74,7 KB | 1,00× |
| EF Core compiled query | 313,2 µs | 70,3 KB | 0,87× | |
| Dapper | 151,9 µs | 6,8 KB | 0,42× | |
| Raw ADO.NET | 229,6 µs | 6,0 KB | 0,64× | |
| Projeção JOIN, 500 linhas | EF Core projeção Select | 1.451,8 µs | 283,8 KB | 1,00× |
| Dapper | 1.125,0 µs | 116,6 KB | 0,78× | |
| INSERT individual | EF Core Add + SaveChanges | 2.516,2 µs | 138,9 KB | 1,00× |
| Dapper | 1.409,7 µs | 8,6 KB | 0,58× |

Quatro coisas que essas medições realmente dizem — duas delas contradizem as proporções que uma versão anterior deste artigo citava (eram aproximadas e não reproduzíveis; já não estão):
AsNoTracking é a vitória mais barata da tabela. A diferença do folclore — "Dapper é
2× o EF" — só existe contra o EF Core com tracking (2,8× aqui, e 5× a alocação de
memória). Adicionar uma chamada de método a reduz a 1,6× e corta a alocação em 60%. Se uma
leitura nunca precisa de SaveChanges, essa chamada já deveria estar lá.
As projeções convergem. No JOIN para um DTO, o Dapper é só 1,3× mais rápido. Quando o
EF Core projeta com Select em vez de materializar entidades com tracking, as duas
bibliotecas fazem quase o mesmo trabalho — o instinto de "reescreva em Dapper" rende muito
menos em consultas EF bem escritas do que nas descuidadas.
Compiled queries são situacionais, não mágica. No SELECT de 1.000 linhas a compiled
query rendeu pior que um simples AsNoTracking na média (com muito mais variância) — o
custo de tradução se amortiza quando a materialização domina. Ela se pagou na busca por PK
(313 vs 370 µs), que é exatamente o formato de caminho crítico para o qual o recurso
existe.
Dapper venceu o ADO.NET puro na busca por PK (152 vs 230 µs). Nessa escala você está medindo a maquinaria de comandos em cache, não a sobrecarga de mapeamento — trate Dapper e ADO.NET escrito à mão como o mesmo piso de desempenho e escolha Dapper pela legibilidade.
Duas notas de honestidade. Esses são tempos de LocalDB: os round trips são quase de graça,
o que maximiza a sobrecarga visível do mapper — contra um banco remoto, a latência de
rede domina e cada proporção acima se comprime em direção a 1. E os números de inserção em
massa saíram desta tabela porque um benchmark em massa defensável (TVP vs AddRange vs
SqlBulkCopy) precisa de metodologia própria; o padrão TVP abaixo continua sendo a
ferramenta certa, só que sem um multiplicador inventado anexado.
Compiled Queries no EF Core
Pré-compile uma expressão de consulta para pular a sobrecarga de tradução LINQ em cada chamada:
// Definir uma vez no nível de classe ou estático — a compilação ocorre apenas uma vez
private static readonly Func<AppDbContext, int, Task<Product?>> GetByIdQuery =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Products
.AsNoTracking()
.FirstOrDefault(p => p.Id == id));
// Usar em qualquer lugar — sem tradução LINQ→SQL em chamadas subsequentes
var product = await GetByIdQuery(context, 42);Use compiled queries para caminhos críticos que retornam resultados pequenos — o formato de busca por PK, onde mediram 15% mais rápido acima. Em consultas que materializam centenas de linhas o custo de tradução já é ruído perto da materialização, e o benefício medido desaparece. A primeira chamada ainda paga o custo de compilação; as subsequentes o ignoram.
Dapper com TVP para Operações em Massa
// Parâmetro de valor de tabela para inserções em massa — muito mais rápido que linha por linha
var table = new DataTable();
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Price", typeof(decimal));
table.Columns.Add("CategoryId", typeof(int));
foreach (var p in products)
table.Rows.Add(p.Name, p.Price, p.CategoryId);
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync(
"INSERT INTO Products SELECT Name, Price, CategoryId FROM @Products",
new { Products = table.AsTableValuedParameter("dbo.ProductType") }
);O EF Core não tem uma API própria de inserção em massa — ExecuteUpdate/ExecuteDelete
(EF Core 7+) cobrem atualizações e exclusões baseadas em conjuntos, mas as inserções
passam pelo batching do SaveChanges. Para volumes realmente grandes, desça para
SqlBulkCopy ou um pacote de terceiros como EFCore.BulkExtensions.
Pontos Fortes do EF Core
Migrações
EF Core gerencia o ciclo de vida do seu esquema. dotnet ef migrations add gera uma classe de migração C# a partir do diff do seu modelo; dotnet ef database update a aplica.
dotnet ef migrations add AddProductDiscountColumn
dotnet ef database updateVocê obtém um histórico completo de migrações no controle de versões, suporte a rollback e seeding através de HasData.
Rastreamento de Mudanças
O mapa de identidade do EF Core significa que você pode carregar uma entidade, mutá-la no código da aplicação e chamar SaveChanges — o ORM emite um UPDATE direcionado apenas para as colunas modificadas.
// EF rastreia o snapshot original e compara no SaveChanges
var product = await context.Products.FindAsync(id);
product!.Price = newPrice; // apenas Price mudou
product.Name = newName; // Name também mudou
await context.SaveChangesAsync();
// SQL gerado: UPDATE Products SET Price=@p0, Name=@p1 WHERE Id=@p2Esse comentário sobre o SQL gerado é verificável, então o sample o verifica. Mude uma propriedade e registre o que realmente executa:

Composição LINQ
Construa consultas dinamicamente sem manipulação de strings:
IQueryable<Product> query = context.Products.AsNoTracking();
if (minPrice.HasValue)
query = query.Where(p => p.Price >= minPrice.Value);
if (!string.IsNullOrEmpty(category))
query = query.Where(p => p.Category!.Name == category);
if (inStockOnly)
query = query.Where(p => p.Stock > 0);
// O SQL só é gerado aqui, combinando todos os filtros em uma única cláusula WHERE
var results = await query.ToListAsync();Carregamento de Relacionamentos
// Eager loading — uma única consulta com JOIN
var orders = await context.Orders
.Include(o => o.Customer)
.Include(o => o.Lines)
.ThenInclude(l => l.Product)
.ToListAsync();
// Explicit loading — carregar navegação depois
await context.Entry(order).Collection(o => o.Lines).LoadAsync();
// Split query — SQL separado por Include, evita explosão cartesiana
var orders = await context.Orders
.Include(o => o.Lines)
.AsSplitQuery()
.ToListAsync();Nunca use lazy loading em Web APIs. Ele dispara uma nova ida e volta ao banco de dados para cada acesso a propriedade de navegação, multiplicando silenciosamente a contagem de consultas a cada requisição HTTP.
Independência de Banco de Dados
Troque de provedor alterando a chamada UseXxx no registro DI:
// SQL Server
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(connectionString));
// PostgreSQL
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseNpgsql(connectionString));
// SQLite (testes / embarcado)
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlite("Data Source=app.db"));Pontos Fortes do Dapper
Controle Total do SQL
Você escreve exatamente o SQL que é executado. Isso importa para:
- Consultas usando recursos específicos do banco de dados (CTEs, funções de janela, operadores JSON)
- Consultas de relatórios onde planos de execução finamente ajustados são críticos
- Stored procedures com parâmetros de saída complexos
// Função de janela — difícil de expressar em LINQ
const string sql = @"
SELECT
p.Id,
p.Name,
p.Price,
RANK() OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS PriceRank
FROM Products p
WHERE p.Price > @MinPrice";
var ranked = await conn.QueryAsync<ProductRankDto>(sql, new { MinPrice = 0m });Teto de Desempenho
Quando o throughput é a restrição — leituras de alta frequência, endpoints de relatórios servindo milhares de usuários simultâneos — a sobrecarga mínima do Dapper permite extrair cada milissegundo.
Parâmetros de Saída de Stored Procedures
var parameters = new DynamicParameters();
parameters.Add("@CustomerId", customerId);
parameters.Add("@TotalSpend", dbType: DbType.Decimal, direction: ParameterDirection.Output);
parameters.Add("@OrderCount", dbType: DbType.Int32, direction: ParameterDirection.Output);
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync("GetCustomerStats", parameters,
commandType: CommandType.StoredProcedure);
decimal totalSpend = parameters.Get<decimal>("@TotalSpend");
int orderCount = parameters.Get<int>("@OrderCount");O FromSqlRaw do EF Core não suporta parâmetros de saída diretamente — você precisaria descer para DbCommand manualmente.
Modelo Mental Mais Simples
O Dapper tem um README de três páginas. Não há convenções para aprender, sem shadow properties, sem estados de entidade, sem classes proxy. Um desenvolvedor que conhece SQL pode ser produtivo em minutos.
Usando Ambos no Mesmo Projeto
EF Core e Dapper não são mutuamente exclusivos. Um padrão híbrido comum usa EF Core para escritas e gerenciamento de relacionamentos, e Dapper para consultas de leitura complexas e relatórios.
Registro no DI
// Registrar ambos — DbContext para escritas, fábrica IDbConnection para leituras
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// Registrar uma fábrica para que repositórios possam abrir conexões Dapper sob demanda
builder.Services.AddScoped<IDbConnection>(_ =>
new SqlConnection(builder.Configuration.GetConnectionString("Default")));Padrão Repository — Híbrido
public class ProductRepository
{
private readonly AppDbContext _context;
private readonly IDbConnection _db;
public ProductRepository(AppDbContext context, IDbConnection db)
{
_context = context;
_db = db;
}
// Escritas passam pelo EF Core — rastreamento de mudanças, validação, eventos
public async Task<Product> CreateAsync(CreateProductRequest request)
{
var product = new Product
{
Name = request.Name,
Price = request.Price,
CategoryId = request.CategoryId
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
return product;
}
// Consulta de leitura complexa passa pelo Dapper — SQL puro, sem sobrecarga
public async Task<IEnumerable<ProductSalesDto>> GetTopSellersByCategoryAsync(
int categoryId, int top)
{
const string sql = @"
SELECT TOP (@Top)
p.Id,
p.Name,
p.Price,
SUM(ol.Quantity) AS TotalSold,
SUM(ol.Quantity * p.Price) AS Revenue
FROM Products p
JOIN OrderLines ol ON ol.ProductId = p.Id
JOIN Orders o ON o.Id = ol.OrderId
WHERE p.CategoryId = @CategoryId
AND o.PlacedAt >= DATEADD(month, -3, GETUTCDATE())
GROUP BY p.Id, p.Name, p.Price
ORDER BY TotalSold DESC";
return await _db.QueryAsync<ProductSalesDto>(sql, new { CategoryId = categoryId, Top = top });
}
// Buscas simples ainda usam EF Core com compiled query
private static readonly Func<AppDbContext, int, Task<Product?>> FindQuery =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Products.AsNoTracking().FirstOrDefault(p => p.Id == id));
public Task<Product?> FindByIdAsync(int id) => FindQuery(_context, id);
}Compartilhando Transações
Quando você precisa que EF Core e Dapper participem da mesma transação:
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Escrita do EF Core
_context.Orders.Add(newOrder);
await _context.SaveChangesAsync();
// Operação Dapper usando a mesma conexão e transação subjacentes
var conn = _context.Database.GetDbConnection();
await conn.ExecuteAsync(
"UPDATE Inventory SET Reserved = Reserved + @Qty WHERE ProductId = @ProductId",
new { Qty = newOrder.Quantity, ProductId = newOrder.ProductId },
transaction: _context.Database.CurrentTransaction!.GetDbTransaction()
);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}Ao compartilhar uma transação, use _context.Database.GetDbConnection() em vez de abrir um novo IDbConnection. Isso garante que o Dapper opere na exata mesma conexão à qual a transação do EF Core está vinculada.
Esse padrão é a quarta verificação do sample executável: uma inserção do EF Core e uma atualização do Dapper dentro de uma mesma transação, ambas visíveis antes do rollback, ambas desaparecidas depois — uma transação genuinamente governando as duas bibliotecas, verificado com asserções em vez de assumido.
Quando Escolher EF Core
- Aplicações greenfield onde você possui o esquema e quer migrações no controle de versões
- Modelos de domínio ricos com relacionamentos complexos, lógica de validação e eventos de domínio
- Equipes com expertise SQL misto — LINQ reduz a barreira para acesso a dados correto e seguro
- Iteração rápida — adicionar uma coluna é uma alteração de uma linha no modelo mais uma migração
- Múltiplos alvos de banco de dados — um codebase, múltiplos provedores
- Testabilidade unitária —
UseInMemoryDatabaseou SQLite em memória para testes rápidos sem BD real
Quando Escolher Dapper
- Caminhos de leitura críticos para desempenho — dashboards de relatórios, endpoints de API de alta frequência
- Banco de dados existente com esquema que você não controla
- Grande investimento em SQL — equipe já possui stored procedures, views e consultas otimizadas
- Consultas analíticas complexas — funções de janela, CTEs, consultas recursivas, JSON shredding
- Microsserviços com acesso a dados simples — sem necessidade de um ORM completo
- Read replicas / lado de leitura CQRS — Dapper é uma escolha natural para o lado de consultas do CQRS
Matriz de Decisão
| Critério | EF Core | Dapper | Híbrido |
|---|---|---|---|
| Migrações de esquema | Nativas | Manual / Flyway | EF Core gerencia o esquema |
| Rastreamento de mudanças | Sim | Não | EF Core para escritas |
| Composição LINQ | Sim | Não | EF Core para escritas |
| Controle SQL puro | Limitado | Total | Dapper para leituras |
| Suporte a stored procedures | Parcial | Total | Dapper |
| Desempenho (leituras) | Bom (AsNoTracking fecha quase toda a diferença) | Excelente | Excelente |
| Desempenho (escritas) | Bom | Bom | Bom |
| Operações em massa | Via extensões | TVP nativo | Dapper |
| Curva de aprendizado | Média-Alta | Baixa | Média |
| Suporte multi-BD | Sim | Manual | Misto |
| Test doubles | InMemory / SQLite | Precisa de BD real ou mock | Precisa de BD real ou mock |
| Expertise SQL necessário | Baixo | Alto | Médio |
| Melhor para | Greenfield, apps de domínio | Leituras críticas, BD existente | Apps grandes com cargas mistas |
Resumo
EF Core e Dapper resolvem problemas diferentes. EF Core é o padrão correto para novas aplicações — gerencia migrações, mantém seu esquema e código sincronizados, e permite que desenvolvedores com menos experiência SQL escrevam consultas seguras e corretas. Dapper merece seu lugar em caminhos de leitura críticos para desempenho e quando sua equipe possui SQL complexo que um ORM apenas obscureceria.
Para a maioria das aplicações médias a grandes, a resposta é ambos: EF Core para operações do lado de comando e leituras simples, Dapper para relatórios e endpoints de consulta de alta frequência. O padrão de transação compartilhada mostrado acima significa que você nunca precisa escolher entre correção e desempenho.