Backend · 4 de ago de 2026 · 37 min de leitura

Dapper vs. Entity Framework Core: performance, produtividade e decisões arquiteturais

Dapper vs. Entity Framework Core: performance, produtividade e decisões arquiteturais

Por Mateus Gabriel Barbosa — Software Engineer .NET | Node.js | React | SQL | Angular

Quando surge uma discussão sobre acesso a dados em aplicações .NET, uma das perguntas mais comuns é:

Dapper ou Entity Framework Core: qual é melhor?

Normalmente, a conversa rapidamente se transforma em uma comparação de performance. De um lado, desenvolvedores defendem o Dapper por ser mais rápido e oferecer controle total sobre o SQL. Do outro, equipes preferem o EF Core pela produtividade, pelo change tracking e pela facilidade de manutenção.

A verdade é que as duas ferramentas resolvem problemas semelhantes em níveis de abstração diferentes.

Neste artigo, vou comparar Dapper e Entity Framework Core 8 executados no .NET 8, analisando os resultados de um microbenchmark publicado, consumo de memória, produtividade, controle sobre o SQL, manutenção, transações, concorrência, consultas complexas, melhores práticas e os cenários nos quais cada ferramenta faz mais sentido.

A conclusão não será simplesmente "use Dapper" ou "use EF Core".

Uma decisão técnica madura precisa considerar não apenas o tempo de uma consulta isolada, mas o custo total da solução — e, como veremos, precisa considerar também a margem de erro dos números que costumam ser citados.

O que é o Dapper?

O Dapper é um micro-ORM que adiciona métodos de extensão sobre as conexões do ADO.NET.

Ele não tenta esconder o banco de dados. O desenvolvedor escreve o SQL, fornece os parâmetros e informa o tipo para o qual o resultado deve ser convertido.

const string sql = """
    SELECT
        Id,
        CustomerId,
        Total,
        CreatedAt
    FROM Orders
    WHERE Id = @Id
    """;

var order = await connection.QuerySingleOrDefaultAsync<OrderDto>(
    new CommandDefinition(
        sql,
        new { Id = orderId },
        cancellationToken: cancellationToken));

Repare no CommandDefinition. Nos métodos assíncronos do Dapper que retornam Task, como QuerySingleOrDefaultAsync, é por meio dele que o CancellationToken é informado — as sobrecargas mais curtas e mais divulgadas não expõem esse parâmetro. Já a API de streaming QueryUnbufferedAsync devolve IAsyncEnumerable<T> e aceita cancelamento na enumeração, via WithCancellation. É um detalhe fácil de ignorar quando se usa apenas as sobrecargas curtas.

O Dapper oferece:

  • consultas síncronas e assíncronas;
  • parâmetros (incluindo DynamicParameters e expansão automática de listas para cláusulas IN);
  • mapeamento para objetos tipados;
  • objetos dinâmicos;
  • multi-mapping;
  • múltiplos result sets (QueryMultiple);
  • execução buffered e unbuffered;
  • suporte completo a stored procedures, inclusive parâmetros de saída;
  • baixo overhead sobre o ADO.NET.

Ele não oferece nativamente:

  • change tracking;
  • migrations;
  • gerenciamento completo de relacionamentos;
  • geração automática de comandos de atualização;
  • controle automático de concorrência;
  • LINQ para geração de SQL.

O Dapper é, essencialmente, uma camada eficiente de execução e materialização sobre o ADO.NET.

O que é o Entity Framework Core?

O Entity Framework Core é um ORM completo.

Além de executar consultas e materializar objetos, ele oferece consultas com LINQ, change tracking, gerenciamento de relacionamentos, persistência de grafos, migrations, concorrência otimista, filtros globais, conversores de valores, integração com injeção de dependência, abstração por providers, interceptors, logging, transações e geração de comandos de inserção, atualização e exclusão.

Uma consulta equivalente seria:

var order = await context.Orders
    .AsNoTracking()
    .Where(order => order.Id == orderId)
    .Select(order => new OrderDto
    {
        Id = order.Id,
        CustomerId = order.CustomerId,
        Total = order.Total,
        CreatedAt = order.CreatedAt
    })
    .SingleOrDefaultAsync(cancellationToken);

Nesse caso, o desenvolvedor expressa a intenção por meio de LINQ e o EF Core traduz a expressão para SQL.

Essa abstração aumenta a produtividade, mas também introduz trabalho adicional durante a execução.

A versão 8 trouxe, entre outras coisas: mapeamento e consulta de coleções de tipos primitivos, tipos complexos com semântica de value object, melhorias no suporte a JSON, suporte oficial a HierarchyId no SQL Server, melhorias em lazy loading e nas APIs de change tracking — e, o ponto mais relevante para esta discussão, consultas SQL cru retornando tipos não mapeados, que abordo mais adiante.

O benchmark oficial

Para evitar comparações entre versões diferentes do runtime, vou usar o benchmark publicado no próprio repositório do Dapper, executado no .NET 8.

Ambiente informado:

BenchmarkDotNet v0.13.7
Windows 10 (10.0.19045.3693/22H2)
Intel Core i7-3630QM CPU 2.40GHz (Ivy Bridge), 8 lógicos / 4 físicos
.NET SDK 8.0.100
.NET 8.0.0 (X64 RyuJIT AVX)
ShortRun

O teste executa um SELECT contra o banco e materializa o resultado em um objeto.

Recorte dos resultados publicados — e aqui vale reproduzir as colunas que quase ninguém copia:

Implementação Método Média StdDev Erro Alocado
Hand Coded SqlCommand 119,70 μs 0,706 1,067 7.584 B
Dapper QueryFirstOrDefault 133,73 μs 1,301 2,186 11.608 B
Dapper Query (buffered) 136,14 μs 1,755 2,653 11.888 B
Dapper Query (unbuffered) 195,01 μs 0,888 1,343 12.008 B
EF Core First (Compiled) 265,45 μs 17,745 26,828 7.521 B
EF Core First 317,12 μs 1,354 2,046 11.306 B
EF Core FromSqlRaw ("SqlQuery") 322,34 μs 23,990 40,314 18.195 B
EF Core First (No Tracking) 337,82 μs 27,814 46,740 17.986 B

Fonte: benchmark oficial no repositório DapperLib/Dapper (README, seção Performance).

Duas observações sobre o código desse benchmark, que vale ler antes de citar a tabela:

  • A linha rotulada "SqlQuery" no README não mede o Database.SqlQuery<T> do EF Core 8. O método é Context.Posts.FromSqlRaw("select * from Posts where Id = {0}", i).First() — ou seja, SQL cru sobre uma entidade mapeada, com tracking ligado. É outra coisa. Renomeei a linha na tabela acima para não induzir ao erro.
  • O DbContext é criado uma única vez no [GlobalSetup] e reutilizado por todas as iterações, e cada iteração consulta um Id diferente. Consequência: nos cenários com tracking, o change tracker acumula entidades ao longo da execução. Isso não invalida o benchmark, mas é mais um motivo para não tratar a comparação tracking vs. no-tracking dessa tabela como resultado definitivo.

Todas as consultas retornam a entidade Post completa — não há projeção em nenhum dos cenários.

E um limite que vale explicitar: os métodos medidos são síncronos (QueryFirstOrDefault<T>, Context.Posts.First(...), ExecuteReader, EF.CompileQuery). Os resultados não devem ser lidos como comparação entre as APIs assíncronas correspondentes, que é o estilo usado nos exemplos deste artigo.

Por fim, uma nota de procedência: este benchmark é mantido pelo próprio projeto Dapper. É uma fonte válida, pública e reproduzível — mas não é um estudo independente.

Interpretando corretamente esses números

Os multiplicadores mais citados

Na comparação entre Dapper e EF Core convencional:

Dapper:  133,73 μs
EF Core: 317,12 μs

O EF Core levou aproximadamente 2,37 vezes o tempo do Dapper. Visto pelo outro ângulo, o Dapper apresentou latência cerca de 57,8% menor naquele cenário específico.

Contra a query compilada:

Dapper:            133,73 μs
EF Core compilado: 265,45 μs

O EF Core compilado levou aproximadamente 1,98 vez o tempo do Dapper — latência do Dapper cerca de 49,6% menor.

E, comparado ao ADO.NET manual:

Hand Coded: 119,70 μs
Dapper:     133,73 μs

O Dapper levou aproximadamente 11,7% mais tempo que a implementação manual. É exatamente essa proximidade que justifica o rótulo de micro-ORM de baixo overhead.

O detalhe que muda a leitura: o banco está dentro da medição

Os multiplicadores acima escondem um fato simples: os 119,70 μs da implementação manual não são overhead de ORM. São o custo do cenário — a ida e volta ao banco, a leitura do reader, a construção do objeto. Nenhuma das bibliotecas comparadas está pagando apenas o custo da própria abstração: todas as medições incluem execução no banco, leitura do reader e construção do objeto — ainda que os detalhes de implementação não sejam idênticos entre elas.

Tomando a implementação manual como referência daquele benchmark, as distâncias entre as médias ficam assim:

Dapper:               133,73 − 119,70 ≈  14 μs
EF Core compilado:    265,45 − 119,70 ≈ 146 μs
EF Core convencional: 317,12 − 119,70 ≈ 197 μs

Essa conta ajuda a visualizar o quanto cada opção se distancia da implementação manual — mas ela não é uma medição isolada do overhead de cada ORM, e três ressalvas importam:

  • É uma subtração entre médias de métodos diferentes, não um experimento que isole a camada de ORM.
  • A implementação manual não é um piso teórico. Ela usa um SqlCommand preparado no [GlobalSetup], com o parâmetro reutilizado, e materialização manual campo a campo. Outra implementação, outro driver ou outra forma de leitura daria outro número.
  • Dividir os deltas entre si (197 ÷ 14) amplifica enormemente qualquer variação no menor valor. Não é um resultado que eu defenderia.

O que a conta mostra, e isso basta, é onde o número importa. Se o seu banco responde em 100 ms, os 183 μs de diferença somem no ruído. Se ele responde em algumas centenas de microssegundos num hot path, o overhead do ORM passa a ser uma parcela relevante do custo da camada de dados.

A margem de erro importa mais do que a média

Olhe novamente para as colunas StdDev e Erro da tabela. Elas contam uma história que a coluna de média esconde:

  • Dapper QueryFirstOrDefault: 133,73 μs com erro de 2,186 μs — medição estável.
  • EF Core First: 317,12 μs com erro de 2,046 μs — também estável.
  • EF Core First (No Tracking): 337,82 μs com erro de 46,740 μs.

No BenchmarkDotNet, a coluna Error é metade do intervalo de confiança de 99,9% da média — não a faixa de resultados observados. Com isso, o intervalo de confiança aproximado de First (No Tracking) vai de cerca de 291 μs a 385 μs, e se sobrepõe ao de First (cerca de 315 a 319 μs).

O que se pode concluir: esta execução do benchmark não sustenta a afirmação de que AsNoTracking seja mais lento. Sobreposição de intervalos não prova que os dois sejam equivalentes — prova apenas que estes dados não bastam para separá-los. Uma comparação confiável exigiria um benchmark dedicado.

Isso importa porque, ao ler a tabela de forma ingênua, alguém concluiria que AsNoTracking piora a performance — e depois encontraria, na seção de boas práticas deste mesmo artigo, a recomendação de usá-lo. Não há contradição: além da variância alta, o benchmark consulta uma única linha e reutiliza o mesmo DbContext entre as iterações, de modo que o comportamento do change tracker e da identity resolution também influencia o resultado. O benefício do no-tracking tende a ficar mais evidente conforme cresce o número de entidades materializadas; uma consulta de uma única linha não é cenário adequado para medi-lo de forma conclusiva. E a relação não é sempre a mesma: tracking e identity resolution podem ser vantajosos quando as entidades já estão no contexto ou se repetem no result set.

A mesma cautela vale para a query compilada: 265,45 μs com erro de 26,828 μs. Esta execução aponta um ganho — mesmo considerando os intervalos, o limite superior (~292 μs) fica abaixo do limite inferior da consulta convencional (~315 μs). Ainda assim, a variância é uma ordem de grandeza maior que a do Dapper, e estimar o tamanho do ganho com precisão exigiria um benchmark dedicado.

Regra geral: um benchmark sem desvio-padrão é uma opinião com casas decimais.

Um detalhe que quase sempre é ignorado

O EF Core com query compilada alocou 7.521 bytes, enquanto o Dapper alocou 11.608 bytes.

Portanto, nesse teste, o Dapper foi mais rápido, mas não foi a opção que menos alocou memória.

Afirmações como "Dapper sempre usa menos memória" ou "EF Core sempre aloca mais" não são tecnicamente corretas.

Performance possui várias dimensões: latência, throughput, alocações, uso de CPU, consumo de memória, tempo de inicialização e comportamento sob concorrência. Uma ferramenta pode vencer em uma dimensão e perder em outra.

O cache interno do Dapper: o número mais subestimado da tabela

O mesmo projeto publica um grupo chamado "Dapper cache impact", que executa a mesma chamada com e sem o cache interno do Dapper:

Método Média Alocado
ExecuteParameters_Cache 96,75 μs 2.184 B
QueryFirstParameters_Cache 96,86 μs 2.824 B
ExecuteParameters_NoCache 623,42 μs 10.001 B
QueryFirstParameters_NoCache 630,77 μs 10.640 B

Antes de interpretar: essas linhas não são comparáveis às de materialização acima. Elas executam select @id as Id, @name as Name — sem leitura de tabela — e algumas retornam Void. O comando ainda vai ao banco e é executado lá; o que não existe é o acesso à Posts. O que essas linhas comparam é uma coisa só, contra si mesma.

E é preciso ser exato sobre o que muda entre os dois grupos. Os dois usam SQL parametrizado. A única diferença é a flag passada ao CommandDefinition: CommandFlags.None contra CommandFlags.NoCache. Essa flag impede que as informações geradas pela chamada sejam adicionadas ao cache. Como resultado, na ausência de uma entrada preexistente, as iterações seguintes precisam reconstruir o processamento de parâmetros e materialização. Portanto, o teste não mede o efeito de concatenar valores na string.

Feita a ressalva, o contraste é notável: nesse cenário, executar as chamadas com CommandFlags.NoCache, impedindo que elas populassem o cache ao longo das iterações, tornou-as cerca de 6,4× mais lentas.

Por que isso importa na prática? O Dapper guarda informações sobre cada query que executa para materializar objetos e processar parâmetros rapidamente, e a chave desse cache leva em conta o texto do SQL. SQL gerado dinamicamente, sempre diferente a cada chamada, não reaproveita nada — e a documentação alerta que, nesse cenário, é possível inclusive esbarrar em problemas de memória pelo crescimento das entradas internas.

E vale distinguir os dois cenários, porque não são o mesmo: CommandFlags.NoCache impede que a chamada adicione novas informações ao cache. O Dapper ainda pode consultar uma entrada que já exista; portanto, a flag não deve ser entendida como um bypass incondicional de todo o cache. SQL com texto sempre diferente é outra coisa — o cache continua existindo e sendo populado, mas cada consulta forma uma identidade distinta, o que reduz o reaproveitamento e tende a aumentar o número de entradas internas. O benchmark quantifica apenas o primeiro caso.

Resumindo: use parâmetros principalmente por segurança e, secundariamente, para manter o texto do SQL estável. O benchmark mostra que o cache interno pode ter impacto expressivo, mas não mede o custo de SQL dinâmico ou concatenado.

O benchmark prova que Dapper é sempre duas vezes mais rápido?

Não.

O benchmark demonstra que, em uma consulta simples de uma única linha, executada naquele ambiente e naquele hardware, o Dapper apresentou menor latência.

Ele não prova que uma API construída com Dapper atenderá duas vezes mais requisições que uma API construída com EF Core.

O tempo total de uma operação pode ser representado de forma simplificada como:

Tempo total =
    latência de rede
  + aquisição da conexão
  + execução no banco
  + leitura dos resultados
  + materialização
  + processamento do ORM
  + regras da aplicação

Considere uma consulta que demora 100 milissegundos no banco. Uma diferença de 183 microssegundos na camada de materialização representa menos de 0,2% do tempo total.

Agora considere uma consulta extremamente simples, executada milhares de vezes por segundo, com o banco na mesma rede e poucos dados retornados. Nesse caso, o overhead do ORM pode se tornar dominante.

Vale registrar ainda: o hardware do benchmark é um Ivy Bridge de 2012 e a configuração é ShortRun, single-threaded, uma linha. O perfil relativo muda em CPUs modernas, sob concorrência e com result sets maiores. Reexecute no seu ambiente antes de tomar decisões arquiteturais.

Por isso, a pergunta correta não é:

Qual ORM é mais rápido em um benchmark?

A pergunta correta é:

O overhead do ORM representa uma parcela relevante do tempo da minha operação real?

Por que o Dapper tende a ser mais rápido?

O Dapper executa um fluxo relativamente direto:

SQL escrito pelo desenvolvedor
→ DbCommand
→ DbDataReader
→ objeto

O EF Core executa etapas adicionais, dependendo do tipo de consulta:

Expressão LINQ
→ análise da árvore de expressão
→ tradução para SQL
→ cache da consulta
→ execução
→ materialização
→ identity resolution
→ change tracking, quando habilitado

O EF Core armazena em cache as consultas com o mesmo formato, mas ainda existe uma camada de abstração maior.

Essa abstração não é necessariamente desperdício. Ela está entregando funcionalidades que o Dapper não oferece.

O erro é esperar o custo de um micro-ORM enquanto se utiliza um ORM completo.

Vantagens do Dapper

Menor overhead em consultas simples

Como o SQL é fornecido diretamente, existe pouca lógica entre o comando e o resultado. Em consultas simples e frequentes, isso tende a significar menor overhead. No benchmark analisado, as medições do Dapper também apresentaram menor variância — mas esse comportamento precisa ser confirmado no ambiente e na carga reais da sua aplicação antes de virar expectativa.

Controle completo sobre o SQL

Com Dapper, o desenvolvedor controla diretamente JOINs, CTEs, window functions, subqueries, stored procedures, hints, locking, paginação, recursos específicos do banco e o formato exato dos dados retornados.

Não existe dúvida sobre qual SQL foi gerado porque o SQL foi escrito explicitamente.

Ótimo para relatórios e read models

Dashboards, relatórios financeiros, exportações e telas analíticas frequentemente precisam combinar diversas tabelas em um DTO específico. Nesses casos, construir um grafo de entidades pode ser desnecessário.

Boa adaptação a bancos legados

Em bancos com stored procedures, views complexas, nomenclaturas inconsistentes, schemas controlados por outros sistemas e recursos específicos do fornecedor, o Dapper normalmente oferece menos resistência.

Recursos que o EF Core não cobre bem

Dois em particular: multi-mapping (dividir uma linha em vários objetos) e múltiplos result sets em uma única ida ao banco (QueryMultiple). Também o suporte pleno a parâmetros de saída e valores de retorno de stored procedures.

Menos comportamento implícito

O desenvolvedor controla exatamente quando a consulta acontece, qual comando será executado, quais colunas serão retornadas e quais parâmetros serão enviados.

Desvantagens do Dapper

Mais código manual

O desenvolvedor precisa administrar SQL, parâmetros, inserts, updates, deletes, transações, concorrência, mapeamentos e evolução do schema. Em um sistema grande, isso pode resultar em muito código repetitivo.

Ausência de change tracking

O Dapper não sabe quais propriedades de um objeto foram alteradas. É necessário criar o comando manualmente:

const string sql = """
    UPDATE Orders
    SET
        Status = @Status,
        UpdatedAt = @UpdatedAt
    WHERE Id = @Id
    """;

await connection.ExecuteAsync(
    new CommandDefinition(
        sql,
        new
        {
            order.Id,
            order.Status,
            UpdatedAt = DateTime.UtcNow
        },
        transaction,
        cancellationToken: cancellationToken));

Concorrência precisa ser implementada

No EF Core, um concurrency token pode ser configurado no modelo. Com Dapper, uma estratégia equivalente precisa ser criada explicitamente:

UPDATE Orders
SET
    Status = @NewStatus,
    Version = Version + 1
WHERE Id = @Id
  AND Version = @ExpectedVersion;

Se nenhuma linha for alterada, existe um possível conflito de concorrência — e cabe a você tratar isso.

Migrations não fazem parte do núcleo

É necessário utilizar scripts SQL, DbUp, FluentMigrator, EF Core apenas para migrations ou outra ferramenta de versionamento do banco.

Testabilidade exige mais cuidado

Como o Dapper roda sobre ADO.NET, ele funciona normalmente contra uma conexão SQLite in-memory — isso não é privilégio do EF Core. O problema é outro: o SQL que você escreveu à mão para SQL Server pode simplesmente não ser válido em SQLite, então o teste ou não roda, ou roda contra um dialeto que não é o de produção.

Risco de espalhar SQL pela aplicação

Sem uma arquitetura clara, o projeto pode terminar com SQL dentro de controllers, services, handlers, jobs e endpoints. O problema não é usar SQL manual — é não definir onde ele deve ficar.

Maior acoplamento ao banco

Uma consulta criada para SQL Server pode utilizar sintaxe que não funciona em PostgreSQL, MySQL ou Oracle. A portabilidade também não é automática no EF Core, mas sua abstração por providers reduz parte desse acoplamento.

Vantagens do EF Core

Maior produtividade

Para sistemas com muito CRUD, o EF Core elimina uma quantidade significativa de código repetitivo.

var order = await context.Orders
    .SingleAsync(order => order.Id == orderId, cancellationToken);

order.Cancel();

await context.SaveChangesAsync(cancellationToken);

Change tracking

O EF Core acompanha entidades adicionadas, propriedades modificadas, entidades removidas, valores originais, relacionamentos e estados. Esse mecanismo é particularmente útil na persistência de agregados e grafos.

Migrations

O EF Core permite versionar alterações do modelo e gerar migrations para evoluir o schema.

Isso não elimina a necessidade de revisão. Uma migration gerada automaticamente pode produzir alterações destrutivas, rebuilds de tabelas, locks prolongados e migrações inadequadas para grandes volumes.

Migrations são uma ferramenta de automação, não uma substituição para conhecimento de banco de dados.

Concorrência otimista

Propriedades podem ser configuradas como concurrency tokens. Ao atualizar uma entidade, o comando verifica se o valor original continua igual no banco; se outro processo alterou o registro, o EF Core lança DbUpdateConcurrencyException.

Persistência de grafos

Quando uma operação envolve várias entidades relacionadas, o EF Core detecta os estados, propaga chaves, ordena comandos, atualiza relacionamentos e executa o SaveChanges dentro de uma transação.

Consultas composáveis

IQueryable<Order> query = context.Orders;

if (filter.CustomerId is not null)
{
    query = query.Where(order =>
        order.CustomerId == filter.CustomerId);
}

if (filter.Status is not null)
{
    query = query.Where(order =>
        order.Status == filter.Status);
}

Essa composição é útil em pesquisas, filtros dinâmicos e especificações.

Testabilidade

Provider SQLite (inclusive in-memory) e a possibilidade de inspecionar o SQL gerado com ToQueryString() antes mesmo de executar. Vale a ressalva de que a própria Microsoft desencoraja o provider InMemory para testes que precisem representar o comportamento de um banco relacional — e o SQLite também tem diferenças de dialeto, tipos e comportamento em relação ao banco de produção. Para fidelidade real, os dois mundos precisam do provider verdadeiro ou de um container.

Desvantagens do EF Core

Maior overhead

O EF Core executa mais trabalho do que o Dapper porque oferece mais funcionalidades. Em hot paths com consultas pequenas e muito frequentes, esse custo pode ser mensurável — e, como vimos, também mais variável.

O SQL pode ser ignorado

LINQ não elimina SQL. Apenas adiciona uma camada de tradução.

Um desenvolvedor que utiliza EF Core profissionalmente ainda precisa entender índices, cardinalidade, planos de execução, JOINs, locks, isolamento, paginação, custo de transferência, normalização e modelagem relacional.

Overfetching

// carrega todas as colunas
var customers = await context.Customers
    .AsNoTracking()
    .ToListAsync(cancellationToken);

Uma projeção é mais adequada:

var customers = await context.Customers
    .AsNoTracking()
    .Select(customer => new CustomerListItem
    {
        Id = customer.Id,
        Name = customer.Name
    })
    .ToListAsync(cancellationToken);

N+1 queries

Lazy loading pode transformar um loop simples em várias consultas:

foreach (var order in orders)
{
    Console.WriteLine(order.Items.Count);
}

Dependendo da configuração (proxies ou ILazyLoader), cada acesso a Items pode disparar uma nova consulta.

Explosão cartesiana

Múltiplos Include de coleções no mesmo nível podem multiplicar a quantidade de linhas retornadas:

var customers = await context.Customers
    .Include(customer => customer.Orders)
    .Include(customer => customer.Addresses)
    .ToListAsync(cancellationToken);

Se um cliente possuir 10 pedidos e 10 endereços, a combinação pode produzir 100 linhas para representar o mesmo cliente.

Nesses casos, avalie projeções, AsSplitQuery, consultas separadas, DTOs ou Dapper para o read model.

Comportamento gerado precisa ser monitorado

Uma pequena alteração na consulta LINQ pode modificar significativamente o SQL gerado. O SQL precisa ser inspecionado e medido.

O que mudou nessa conversa: SqlQuery<T>

Esta é a novidade que mais afeta o argumento clássico de "use Dapper para os read models".

O EF 7 já permitia consultas SQL cru retornando tipos escalares. O EF Core 8 estendeu isso para qualquer tipo CLR mapeável, sem incluir esse tipo no modelo do EF:

var orders = await context.Database
    .SqlQuery<PendingOrderDto>($"""
        SELECT
            o.Id,
            c.Name AS CustomerName,
            o.Total
        FROM Orders o
        JOIN Customers c ON c.Id = o.CustomerId
        WHERE o.Status = {status}
        """)
    .ToListAsync(cancellationToken);

Dois detalhes relevantes:

  1. Não é uma string interpolada comum. SqlQuery captura os valores interpolados e os converte em parâmetros SQL. Não há risco de injection ao usar {status} dessa forma. (A variante SqlQueryRaw existe para quando você precisa montar a string manualmente — aí a responsabilidade volta a ser sua.)
  2. O resultado é composável com LINQ — se o SQL for composável. O EF Core trata o SQL fornecido como uma subconsulta e monta a consulta externa por cima, então você pode aplicar Where, OrderBy e paginação. Mas isso exige um SQL válido dentro de uma subquery: nada de ponto e vírgula final, de query hints no fim (OPTION (...) no SQL Server) ou de ORDER BY sem OFFSET 0/TOP 100 PERCENT. E o SQL Server não permite compor sobre chamada de stored procedure — nesse caso, use AsEnumerable() ou AsAsyncEnumerable() logo em seguida para impedir que o EF tente compor.

Uma limitação que precisa ficar clara: esses tipos não fazem parte do modelo de entidades. A documentação é explícita — eles não têm chave definida e não podem declarar relacionamentos com outros tipos; tipos com relacionamentos precisam ser mapeados no modelo. Além disso, o tipo precisa ter uma propriedade para cada valor do result set, embora não precise corresponder a nenhuma tabela e possa usar construtores parametrizados e atributos de mapeamento como [Column].

Feita a ressalva, isso cobre boa parte dos casos de "preciso de SQL manual para um DTO" sem introduzir uma segunda tecnologia — e o custo de manter duas stacks é real.

Sendo justo com os dois lados, duas ressalvas:

  • O benchmark oficial não mede esse recurso. A linha rotulada "SqlQuery" no README executa FromSqlRaw sobre uma entidade mapeada, não Database.SqlQuery<T>. Não existe, naquele conjunto de dados, nenhum número que diga quanto custa o recurso descrito nesta seção. Quem quiser saber precisa medir — e eu não vou inventar o número aqui.
  • SqlQuery<T> não substitui o Dapper por completo. Ele não faz multi-mapping nem múltiplos result sets em uma ida ao banco, e o suporte a parâmetros de saída de stored procedures continua limitado. Esse território segue sendo do Dapper.

O ponto não é "o EF Core 8 tornou o Dapper obsoleto". É que o argumento "preciso de Dapper porque preciso escrever SQL" ficou consideravelmente mais fraco a partir do EF Core 8. O argumento que permanece forte é "preciso de Dapper porque preciso de menor overhead, ou de recursos que o EF Core não expõe".

E o Dapper.AOT?

Se o recorte é .NET 8, vale conhecer o Dapper.AOT — um pacote de ferramentas de build, mantido pelo próprio time do Dapper.

Ele usa interceptors (recurso do compilador disponível a partir do SDK do .NET 8) para substituir, em tempo de compilação, as chamadas Dapper compatíveis por implementações geradas, reduzindo ou eliminando nessas chamadas o uso de reflexão e de código emitido em runtime. Os objetivos declarados são compatibilidade com Native AOT e trimming, além de analisadores que apontam usos problemáticos do Dapper no seu código.

Três ressalvas importantes:

  • É um pacote separado do Dapper, com ciclo de vida próprio, e o opt-in tem dois passos: habilitar o namespace de interceptors no .csproj e ligar o gerador por atributo, tipicamente [module: DapperAot]. Sem o segundo, o build avisa que encontrou chamadas candidatas mas nenhuma habilitada, e nada muda.
  • Nem toda API ou extensão do Dapper convencional tem equivalente no modo AOT, e alguns recursos — certos type handlers e configurações globais, por exemplo — pedem atenção.
  • Os números do benchmark oficial citados neste artigo não foram obtidos com Dapper.AOT. Se você quiser saber quanto ele muda o quadro no seu caso — inclusive na dimensão de alocações, onde o Dapper perdeu para a query compilada do EF Core —, precisa medir.

Melhores práticas com Dapper

Sempre utilize parâmetros

Nunca concatene valores diretamente:

// Incorreto — vulnerável a SQL injection, e produz um texto SQL
// diferente a cada valor recebido
var sql = $"SELECT * FROM Users WHERE Email = '{email}'";

Utilize parâmetros:

const string sql = """
    SELECT Id, Name, Email
    FROM Users
    WHERE Email = @Email
    """;

var user = await connection.QuerySingleOrDefaultAsync<UserDto>(
    new CommandDefinition(
        sql,
        new { Email = email },
        cancellationToken: cancellationToken));

Use parâmetros primeiro por segurança. Como efeito secundário, manter o texto do SQL estável favorece o reaproveitamento do cache interno do Dapper — e a documentação do projeto alerta que gerar strings sempre diferentes pode prejudicar esse reaproveitamento e fazer as entradas internas crescerem.

Repetindo o que disse na seção do cache, para não deixar dúvida: os ~97 μs contra ~623 μs não medem parametrizado contra concatenado. Medem cache habilitado contra CommandFlags.NoCache. As duas recomendações são válidas; os números pertencem só à segunda.

Retorne DTOs específicos

Evite utilizar a mesma classe como entidade de banco, modelo de domínio, contrato da API, modelo de relatório e modelo de exportação. A consulta deve retornar apenas o formato necessário.

Escolha o método correto

  • QuerySingle: exatamente uma linha;
  • QuerySingleOrDefault: zero ou uma linha;
  • QueryFirst: pelo menos uma linha;
  • QueryFirstOrDefault: a primeira ou o valor padrão;
  • Query: coleção.

Entenda buffered vs. unbuffered

O padrão do Dapper é bufferizar todo o reader. É o comportamento adequado na maioria dos casos, porque minimiza locks compartilhados no banco e reduz o tempo de rede. Para result sets muito grandes onde a memória importa, passe buffered: false.

Sobre os números: no benchmark, a variante unbuffered aparece com 195,01 μs contra 136,14 μs. Mas aquele teste filtra um único Id e chama .First() logo em seguida — ou seja, mede o overhead do formato em uma consulta de uma linha, não a relação entre buffering, memória e latência em grandes volumes. Para o cenário de streaming, que é justamente onde o unbuffered existe, seria preciso um benchmark próprio.

Controle explicitamente as transações

await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);

await using var transaction =
    await connection.BeginTransactionAsync(cancellationToken);

await connection.ExecuteAsync(
    new CommandDefinition(insertOrderSql, order, transaction,
        cancellationToken: cancellationToken));

await connection.ExecuteAsync(
    new CommandDefinition(insertItemsSql, items, transaction,
        cancellationToken: cancellationToken));

await transaction.CommitAsync(cancellationToken);

No Microsoft.Data.SqlClient, descartar uma SqlTransaction ainda pendente provoca rollback, então o await using protege este exemplo caso o commit não seja alcançado. Porém, esse comportamento é específico do provider: a documentação geral de DbTransaction recomenda não usar o Dispose como substituto universal para um Rollback explícito. Um try/catch continua útil quando você precisa controlar, registrar ou tratar separadamente uma falha no rollback.

Evite criar um ORM interno

Um erro comum é escolher Dapper por sua simplicidade e depois construir repository genérico, gerador de SQL, change tracker próprio, includes automáticos e abstração genérica de relacionamentos.

Nesse momento, a equipe passa a manter um ORM interno, geralmente menos testado e menos completo que o EF Core.

Melhores práticas com EF Core

Use projeções

var orders = await context.Orders
    .AsNoTracking()
    .Where(order => order.Status == OrderStatus.Pending)
    .Select(order => new PendingOrderDto
    {
        Id = order.Id,
        CustomerName = order.Customer.Name,
        Total = order.Total
    })
    .ToListAsync(cancellationToken);

Use AsNoTracking em leituras — sabendo o que ele faz

Para consultas genuinamente somente leitura, o tracking é desnecessário, e o benefício cresce com o volume de entidades materializadas.

Mas atenção a um efeito colateral: consultas com tracking realizam identity resolution — se o mesmo registro aparecer várias vezes no result set, o EF Core reutiliza a mesma instância. Uma consulta AsNoTracking não faz isso, e pode criar objetos duplicados em determinados grafos.

Quando você quer o benefício do no-tracking sem perder a deduplicação, existe o meio-termo:

var orders = await context.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(order => order.Items)
    .ToListAsync(cancellationToken);

Avalie queries compiladas apenas em hot paths

Em um artigo com código assíncrono, a versão útil é a assíncrona:

private static readonly Func<AppDbContext, int, Task<Order?>>
    GetOrderByIdAsync = EF.CompileAsyncQuery(
        (AppDbContext context, int id) =>
            context.Orders.FirstOrDefault(order => order.Id == id));

No benchmark analisado — que usa EF.CompileQuery, a variante síncrona — a query compilada reduziu o tempo de 317,12 μs para 265,45 μs, cerca de 16,3%. Como discuti acima, aquela execução aponta um ganho, mas com variância alta (erro de 26,828 μs) — trate os 16,3% como ordem de grandeza, não como precisão.

Compiled queries fazem sentido em consultas executadas com grande frequência, estáveis e comprovadamente relevantes no profiling. Aplicá-las em todas as consultas aumenta a complexidade sem garantir ganho perceptível.

Utilize ExecuteUpdate e ExecuteDelete — com atenção

Não carregue milhares de entidades para atualizar uma coluna:

await context.Orders
    .Where(order => order.Status == OrderStatus.Expired)
    .ExecuteUpdateAsync(
        setters => setters.SetProperty(
            order => order.Status,
            OrderStatus.Cancelled),
        cancellationToken);

A operação é executada diretamente no banco, sem materializar entidades. Mas a documentação oficial é explícita sobre as consequências:

  • executam imediatamente, no momento da chamada — não acumulam para o SaveChanges;
  • não têm nenhuma interação com o change tracker. Se você já carregou uma entidade e depois roda ExecuteUpdate sobre ela, o change tracker fica com valores potencialmente desatualizados, e um SaveChanges posterior que persista novamente essas propriedades pode sobrescrever a alteração feita em massa. A recomendação da documentação é evitar misturar modificações rastreadas e não rastreadas sobre as mesmas entidades;
  • não abrem uma transação que englobe várias chamadas. Cada comando roda em sua própria transação; se você precisa de atomicidade entre dois ExecuteUpdate, ou entre um ExecuteUpdate e um SaveChanges, abra a transação explicitamente;
  • não aplicam automaticamente concurrency tokens. Em vez disso, retornam o número de linhas afetadas — é com esse número que você implementa a verificação de concorrência, filtrando o token no próprio Where;
  • não passam pelo SaveChanges, e portanto não acionam o que está atrelado a ele, como ISaveChangesInterceptor. Interceptors de comando (IDbCommandInterceptor) continuam enxergando o SQL normalmente — a execução passa pelo mesmo pipeline de comandos relacionais.

Inspecione o SQL

Use logs, ToQueryString(), interceptors, Application Insights, OpenTelemetry, ferramentas do banco e planos de execução. Uma consulta LINQ elegante pode produzir um SQL caro.

Avalie DbContext pooling

O pooling reduz o custo de criação do contexto e as alocações. Exige cuidado com estados que variam por requisição: tenant, usuário, permissões e filtros contextuais.

Evite lazy loading em caminhos críticos

Em sistemas de produção, prefiro tornar explícito quais dados serão carregados. Código explícito é mais fácil de revisar, medir, testar e otimizar.

Comparação prática

PERFORMANCE E CONSULTAS

Critério                    Dapper              EF Core

Overhead em consultas       Muito baixo         Geralmente maior
Controle sobre o SQL        Direto              LINQ ou SQL bruto
Previsibilidade do SQL      Alta                Exige inspeção
Consultas complexas         Excelente com SQL   Depende da tradução
PRODUTIVIDADE E MODELO

Critério                     Dapper             EF Core

Produtividade em CRUD       Média               Excelente
Change tracking             Não                 Sim
Migrations                  Não nativo          Sim
Relacionamentos             Manual              Integrado
Concorrência otimista       Manual              Suporte integrado
Persistência de grafos      Manual              Suporte integrado
Volume de código manual     Maior               Menor
RECURSOS DE ACESSO A DADOS

Critério                     Dapper             EF Core

Stored procedures           Suporte amplo       Mais limitações
Multi-mapping               Nativo              Sem equivalente nativo
Múltiplos result sets       QueryMultiple       Sem equivalente nativo
Banco legado                Ótima adaptação     Depende do modelo
Bulk insert                 API específica      API específica
TESTES E COMPATIBILIDADE

Critério                     Dapper                EF Core

SQLite in-memory             Possível               Possível
Fidelidade dos testes        Depende do dialeto     Depende do provider
Integração fiel              Banco real/container   Banco real/container
Portabilidade                Menor                  Maior, não completa
Native AOT no recorte        Via Dapper.AOT         Não suportado plenamente
Curva inicial                Baixa com bom SQL      Média

Sobre bulk insert: nenhum dos dois é a ferramenta certa para dezenas de milhares de linhas. SqlBulkCopy (ou o equivalente do seu provider) resolve o problema em outra ordem de grandeza. Vale saber disso antes de culpar o ORM.

Dapper e EF Core podem coexistir?

Sim. Em muitos sistemas, essa é a solução mais equilibrada.

EF Core para escrita

Agregados, regras transacionais, CRUD, relacionamentos, concorrência, migrations e persistência de grafos.

Dapper para leitura

Dashboards, relatórios, exportações, consultas analíticas, read models, stored procedures e hot paths identificados por profiling.

Compartilhando a mesma transação

Este é o ponto prático que costuma travar a adoção do modelo. Como o EF Core expõe a conexão e a transação atuais, você consegue executar Dapper dentro da mesma unidade de trabalho:

using Microsoft.EntityFrameworkCore.Storage; // GetDbTransaction()

await using var transaction =
    await context.Database.BeginTransactionAsync(cancellationToken);

var connection = context.Database.GetDbConnection();
var dbTransaction = context.Database.CurrentTransaction!.GetDbTransaction();

await connection.ExecuteAsync(
    new CommandDefinition(
        auditSql,
        parameters,
        dbTransaction,
        cancellationToken: cancellationToken));

await context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);

BeginTransactionAsync já abre a conexão, então não é preciso abri-la manualmente — embora abrir a conexão antes também não crie outra transação. O importante é que EF Core e Dapper utilizem a mesma DbConnection e a mesma DbTransaction. A falsa sensação de atomicidade normalmente aparece quando o Dapper usa outra conexão, quando a transação não é repassada ao CommandDefinition ou quando uma transação criada externamente não é associada ao contexto por meio de UseTransaction ou UseTransactionAsync.

Esse modelo se aproxima de uma separação entre comandos e consultas, mas não exige uma implementação completa de CQRS.

A introdução de duas tecnologias também possui custo: duas formas de acesso a dados, padrões diferentes, maior carga cognitiva, observabilidade duplicada e possibilidade de consultas redundantes.

Portanto, a combinação deve resolver um problema real, não apenas atender a uma preferência técnica — e, no EF Core 8, vale antes verificar se SqlQuery<T> não resolve o seu caso sem trazer a segunda dependência.

Quando escolher Dapper?

Eu escolheria Dapper quando:

  • o menor overhead possível é um requisito real e comprovado;
  • existem stored procedures importantes, especialmente com parâmetros de saída;
  • preciso de multi-mapping ou de múltiplos result sets em uma ida ao banco;
  • o banco é legado;
  • as consultas são complexas e muito específicas;
  • os read models diferem muito das entidades;
  • existe um hot path comprovado por profiling;
  • recursos específicos do banco são fundamentais;
  • a equipe possui domínio sólido de SQL.

Quando escolher EF Core?

Eu escolheria EF Core quando:

  • o sistema possui muito CRUD;
  • existem agregados e relacionamentos;
  • produtividade é prioridade;
  • migrations são importantes;
  • concorrência otimista precisa ser padronizada;
  • a equipe trabalha bem com LINQ;
  • o sistema modifica grafos de entidades;
  • testabilidade sem banco real tem valor no fluxo da equipe;
  • manutenção e consistência são mais importantes que microssegundos;
  • a performance atual já atende ao requisito.

Antes de substituir EF Core por Dapper

Antes de reescrever uma consulta, eu verificaria:

  1. O SQL gerado está correto? (ToQueryString())
  2. A consulta utiliza projeção?
  3. O tracking é realmente necessário?
  4. Existe N+1?
  5. Há explosão cartesiana?
  6. Os índices corretos existem?
  7. O plano de execução está adequado?
  8. A consulta retorna mais colunas do que deveria?
  9. A paginação é eficiente?
  10. O problema está no ORM ou no banco?
  11. Uma query compilada produziria ganho relevante?
  12. SqlQuery<T> do EF Core 8 já resolveria, sem trazer outra dependência?
  13. A diferença é perceptível no percentil P95 ou P99 — e está fora da margem de erro da medição?
  14. O ganho compensa o aumento de código manual?

Trocar o ORM antes de responder essas perguntas normalmente é otimização prematura.

Conclusão

No benchmark oficial, executado no .NET 8, o Dapper apresentou menor latência:

Dapper:               133,73 μs
EF Core compilado:    265,45 μs
EF Core convencional: 317,12 μs

Para uma consulta simples de uma linha, executada de forma síncrona naquele ambiente, o Dapper foi significativamente mais rápido — e ficou a cerca de 11,7% da implementação manual. Esse resultado é consistente com sua principal proposta: mapeamento de objetos com custo próximo ao ADO.NET escrito à mão.

Mas o mesmo benchmark, lido por inteiro, mostra três coisas que raramente aparecem nas discussões:

  1. O EF Core com query compilada alocou menos memória que o Dapper (7.521 B contra 11.608 B).
  2. A suposta penalidade do AsNoTracking não se sustenta nesses dados — a variância é alta demais para separar os dois cenários.
  3. Executar as chamadas com CommandFlags.NoCache, impedindo que elas populassem o cache ao longo das iterações, aumentou a latência em cerca de 6,4× naquele teste. O resultado mostra o peso que o cache pode ter no processamento de parâmetros e na materialização, mas vem de um cenário diferente — outro SQL, outro objetivo — e não deve ser comparado diretamente aos números de Dapper contra EF Core.

Por outro lado, o EF Core oferece funcionalidades que o Dapper deliberadamente não tenta fornecer: change tracking, migrations, persistência de grafos, gerenciamento de relacionamentos, concorrência, geração de comandos e composição de consultas com LINQ. E, desde a versão 8, oferece também SQL cru retornando tipos não mapeados — reduzindo um dos motivos históricos para adotar Dapper.

Portanto, não existe um vencedor absoluto.

Dapper tende a vencer em overhead, controle e previsibilidade do SQL.

EF Core tende a vencer em produtividade, consistência, manutenção, testabilidade e modelagem de operações transacionais.

Minha abordagem é simples:

Comece com a ferramenta que oferece o melhor equilíbrio para a maior parte do sistema. Meça o comportamento real, no seu ambiente, e otimize apenas os pontos que justificam uma solução diferente.

Engenharia de software não consiste em escolher a ferramenta mais rápida em um benchmark.

Consiste em entender quanto custa cada abstração, quais benefícios ela entrega, em qual parte do sistema esse custo realmente importa — e em saber ler um benchmark inteiro, não apenas a coluna da média.

Referências

  1. Dapper — repositório, documentação e benchmark oficial — https://github.com/DapperLib/Dapper
  2. Projeto de benchmarks do Dapper — https://github.com/DapperLib/Dapper/tree/main/benchmarks/Dapper.Tests.Performance
  3. Dapper.AOT — repositório — https://github.com/DapperLib/DapperAOT
  4. Dapper.AOT — documentação — https://aot.dapperlib.dev/
  5. Microsoft — novidades do Entity Framework Core 8 — https://learn.microsoft.com/ef/core/what-is-new/ef-core-8.0/whatsnew
  6. Microsoft — consultas SQL cru (incluindo tipos não mapeados) — https://learn.microsoft.com/ef/core/querying/sql-queries
  7. Microsoft — introdução à performance do EF Core — https://learn.microsoft.com/ef/core/performance/
  8. Microsoft — consultas eficientes com EF Core — https://learn.microsoft.com/ef/core/performance/efficient-querying
  9. Microsoft — tópicos avançados de performance e queries compiladas — https://learn.microsoft.com/ef/core/performance/advanced-performance-topics
  10. Microsoft — tracking e no-tracking — https://learn.microsoft.com/ef/core/querying/tracking
  11. Microsoft — single queries e split queries — https://learn.microsoft.com/ef/core/querying/single-split-queries
  12. Microsoft — ExecuteUpdate e ExecuteDelete — https://learn.microsoft.com/ef/core/saving/execute-insert-update-delete
  13. Microsoft — concorrência otimista no EF Core — https://learn.microsoft.com/ef/core/saving/concurrency
  14. Microsoft — transações no EF Core — https://learn.microsoft.com/ef/core/saving/transactions
  15. Microsoft — diagnóstico de problemas de performance — https://learn.microsoft.com/ef/core/performance/performance-diagnosis
  16. .NET Blog — EF Core 8 Preview 1: Raw, lazy, and on-time — https://devblogs.microsoft.com/dotnet/announcing-ef8-preview-1/
#.NET #Micro-ORM #ORM #Performance #SQL