Dos bibliotecas dominan el acceso a datos en .NET: EF Core, un ORM completo que gestiona todo el ciclo de vida de tu base de datos, y Dapper, un micro-ORM que te ofrece SQL puro con solo lo suficiente de mapping. Elegir entre ellos — o combinarlos — depende de la complejidad de tus consultas, la experiencia SQL de tu equipo y cuánto de tu dominio vive en el esquema de base de datos.
Filosofía
EF Core: ORM Completo
EF Core trata la base de datos como un detalle de implementación. Modelas tu dominio en clases C#, defines relaciones con propiedades de navegación y dejas que el framework genere SQL, rastree cambios y gestione migraciones. La abstracción te permite cambiar de proveedor de base de datos sin modificar el código de aplicación.
El costo es complejidad: la capa ORM introduce convenciones, configuraciones y comportamientos que debes comprender para evitar trampas de rendimiento como consultas N+1 o cargas de tabla completa no intencionales.
Dapper: Micro-ORM
Dapper extiende IDbConnection con un puñado de métodos de extensión (Query<T>, Execute, QueryMultiple). Tú escribes SQL; Dapper mapea el conjunto de resultados a tus tipos. Esa es toda la superficie de la API. No hay rastreo de cambios, sistema de migraciones ni carga diferida — y tampoco magia.
La ventaja es predecibilidad: el SQL que escribes es el SQL que se ejecuta. La sobrecarga de rendimiento sobre ADO.NET puro es insignificante.
Código Lado a Lado
Los ejemplos a continuación usan una entidad Product con una Category asociada. No son
solo ilustraciones: una versión ejecutable de esta comparación vive en
samples/ef-core-vs-dapper,
que ejecuta ambas bibliotecas contra la misma base de datos SQL Server sembrada y verifica
con aserciones cada afirmación de esta sección — mismas filas en ambas, JOINs de una sola
sentencia, actualizaciones dirigidas de una sola columna. Si algo deja de ser cierto en una
versión futura de EF Core o Dapper, ese sample falla ruidosamente.
// Modelo compartido usado en todos los ejemplos
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 Simple — Obtener Todos los Productos
EF Core
// DbContext resuelve la conexión, rastrea las entidades retornadas por defecto
var products = await context.Products
.AsNoTracking() // deshabilita el rastreo para consultas de solo lectura
.ToListAsync();Dapper
// Tú controlas completamente el SQL — la selección explícita de columnas 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);Inserción
EF Core
var product = new Product { Name = "Widget", Price = 9.99m, CategoryId = 1 };
context.Products.Add(product);
await context.SaveChangesAsync();
// product.Id se llena después de SaveChanges — EF lee la clave generadaDapper
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
});Actualización
EF Core
var product = await context.Products.FindAsync(id);
if (product is null) return;
product.Price = 14.99m;
await context.SaveChangesAsync();
// El rastreo de cambios detecta la modificación de Price y emite un 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 });Eliminación
EF Core
// ExecuteDelete evita cargar la entidad en memoria primero (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 con JOIN — Productos con Nombre de Categoría
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 genera una sola consulta con JOIN cuando proyectas con 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 (División de Resultado en Dos Tipos)
El parámetro splitOn de Dapper mapea una fila de resultado a múltiples 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 le dice a Dapper dónde comienzan las columnas de Category
(product, category) =>
{
product.Category = category;
return product;
},
splitOn: "Id" // la segunda columna "Id" dispara la división
);EF Core maneja esto automáticamente cuando usas Include o proyectas con Select.
Procedimiento Almacenado
EF Core
// FromSqlRaw mapea el resultado de un procedimiento almacenado a un tipo de entidad
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
);Seguridad en Consultas Parametrizadas
Ambas bibliotecas parametrizan por defecto, previniendo inyección SQL. Nunca interpoles entrada de usuario directamente en cadenas SQL.
// SEGURO — Dapper usa consultas parametrizadas para todas las propiedades del objeto anónimo
var results = await conn.QueryAsync<Product>(
"SELECT * FROM Products WHERE Name LIKE @Search",
new { Search = $"%{userInput}%" }
);
// INSEGURO — nunca hagas esto
var results = await conn.QueryAsync<Product>(
$"SELECT * FROM Products WHERE Name LIKE '%{userInput}%'"
);Aquí está el sample verificando toda esta sección en una sola ejecución — el SQL generado por EF impreso junto al equivalente escrito a mano, y cada afirmación de equivalencia comprobada contra la base de datos en lugar de afirmada en prosa:

Benchmarks de Rendimiento
Los números a continuación están medidos con BenchmarkDotNet, no citados del folclore. La
suite es EfCoreVsDapperBenchmark
— ejecútala tú mismo contra la base de datos sembrada del sample (1.000 productos,
exactamente 500 con precio mayor a 10 para el escenario JOIN). Cada método paga su propia
configuración realista: los benchmarks de EF construyen un DbContext por invocación como
lo haría una petición web, Dapper y ADO.NET abren una SqlConnection del pool por
invocación, y todas las lecturas materializan una 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 conexiones caliente| Escenario | Método | Media | Asignado | vs base |
|---|---|---|---|---|
| SELECT 1.000 filas | 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× | |
| Búsqueda por PK (1 fila) | 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× | |
| Proyección JOIN, 500 filas | EF Core proyección 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× |

Cuatro cosas que estas mediciones realmente dicen — dos de las cuales contradicen las ratios que citaba una versión anterior de este artículo (eran aproximadas y no reproducibles; ya no están):
AsNoTracking es la victoria más barata de la tabla. La brecha del folclore —
"Dapper es 2× EF" — solo existe contra EF Core con tracking (2,8× aquí, y 5× la
asignación de memoria). Agregar una llamada de método la reduce a 1,6× y recorta la
asignación en 60%. Si una lectura nunca necesita SaveChanges, esa llamada ya debería
estar ahí.
Las proyecciones convergen. En el JOIN hacia un DTO, Dapper es solo 1,3× más rápido.
Cuando EF Core proyecta con Select en lugar de materializar entidades con tracking, las
dos bibliotecas hacen casi el mismo trabajo — el instinto de "reescríbelo en Dapper" paga
mucho menos en consultas EF bien escritas que en las descuidadas.
Las compiled queries son situacionales, no magia. En el SELECT de 1.000 filas la
compiled query rindió peor que un simple AsNoTracking en la media (con mucha más
varianza) — el costo de traducción se amortiza cuando la materialización domina. Se ganó
su lugar en la búsqueda por PK (313 vs 370 µs), que es exactamente la forma de ruta
caliente para la que existe esta característica.
Dapper le ganó a ADO.NET puro en la búsqueda por PK (152 vs 230 µs). A esta escala estás midiendo la maquinaria de comandos cacheados, no la sobrecarga de mapeo — trata a Dapper y al ADO.NET escrito a mano como el mismo piso de rendimiento y elige Dapper por la legibilidad.
Dos notas de honestidad. Estos son tiempos de LocalDB: los round trips son casi gratis,
lo que maximiza la sobrecarga visible del mapper — contra una base de datos remota, la
latencia de red domina y cada ratio de arriba se comprime hacia 1. Y los números de
inserción masiva desaparecieron de esta tabla porque un benchmark masivo defendible (TVP
vs AddRange vs SqlBulkCopy) necesita su propia metodología; el patrón TVP de abajo
sigue siendo la herramienta correcta, solo que sin un multiplicador inventado adjunto.
Compiled Queries en EF Core
Pre-compila una expresión de consulta para omitir la sobrecarga de traducción LINQ en cada llamada:
// Definir una vez a nivel de clase o estático — la compilación ocurre una sola 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 en todos lados — sin traducción LINQ→SQL en llamadas posteriores
var product = await GetByIdQuery(context, 42);Usa compiled queries para rutas calientes que devuelven resultados pequeños — la forma de búsqueda por PK, donde midieron 15% más rápido arriba. En consultas que materializan cientos de filas el costo de traducción ya es ruido junto a la materialización, y el beneficio medido desaparece. La primera llamada aún paga el costo de compilación; las posteriores lo omiten.
Dapper con TVP para Operaciones Masivas
// Parámetro de valor de tabla para inserciones masivas — mucho más rápido que fila por fila
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") }
);EF Core no tiene una API propia de inserción masiva — ExecuteUpdate/ExecuteDelete
(EF Core 7+) cubren actualizaciones y eliminaciones por conjuntos, pero las inserciones
pasan por el batching de SaveChanges. Para volúmenes realmente grandes, baja a
SqlBulkCopy o a un paquete de terceros como EFCore.BulkExtensions.
Fortalezas de EF Core
Migraciones
EF Core gestiona el ciclo de vida de tu esquema. dotnet ef migrations add genera una clase de migración C# a partir del diff de tu modelo; dotnet ef database update la aplica.
dotnet ef migrations add AddProductDiscountColumn
dotnet ef database updateObtienes un historial completo de migraciones en control de versiones, soporte para rollback y seeding a través de HasData.
Rastreo de Cambios
El mapa de identidad de EF Core significa que puedes cargar una entidad, mutarla en código de aplicación y llamar a SaveChanges — el ORM emite un UPDATE dirigido solo para las columnas modificadas.
// EF rastrea el snapshot original y compara en SaveChanges
var product = await context.Products.FindAsync(id);
product!.Price = newPrice; // solo Price cambió
product.Name = newName; // Name también cambió
await context.SaveChangesAsync();
// SQL generado: UPDATE Products SET Price=@p0, Name=@p1 WHERE Id=@p2Ese comentario sobre el SQL generado es comprobable, así que el sample lo comprueba. Cambia una propiedad y registra lo que realmente se ejecuta:

Composición LINQ
Construye consultas dinámicamente sin manipulación de cadenas:
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);
// El SQL solo se genera aquí, combinando todos los filtros en una sola cláusula WHERE
var results = await query.ToListAsync();Carga de Relaciones
// Carga eager — una sola consulta con JOIN
var orders = await context.Orders
.Include(o => o.Customer)
.Include(o => o.Lines)
.ThenInclude(l => l.Product)
.ToListAsync();
// Carga explícita — cargar navegación después del hecho
await context.Entry(order).Collection(o => o.Lines).LoadAsync();
// Split query — SQL separado por Include, evita explosión cartesiana
var orders = await context.Orders
.Include(o => o.Lines)
.AsSplitQuery()
.ToListAsync();Nunca uses lazy loading en Web APIs. Dispara un nuevo viaje de ida y vuelta a la base de datos por cada acceso a propiedad de navegación, multiplicando silenciosamente tu conteo de consultas con cada solicitud HTTP.
Independencia de Base de Datos
Cambia de proveedor modificando la llamada UseXxx en el registro DI:
// SQL Server
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(connectionString));
// PostgreSQL
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseNpgsql(connectionString));
// SQLite (pruebas / embebido)
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlite("Data Source=app.db"));Fortalezas de Dapper
Control Total del SQL
Escribes exactamente el SQL que se ejecuta. Esto importa para:
- Consultas que usan características específicas de la base de datos (CTEs, funciones de ventana, operadores JSON)
- Consultas de reportes donde los planes de ejecución finamente ajustados son críticos
- Procedimientos almacenados con parámetros de salida complejos
// Función de ventana — difícil de expresar en 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 });Techo de Rendimiento
Cuando el rendimiento es la restricción — lecturas de alta frecuencia, endpoints de reportes que sirven miles de usuarios concurrentes — la mínima sobrecarga de Dapper te permite exprimir cada milisegundo.
Parámetros de Salida de Procedimientos Almacenados
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");El FromSqlRaw de EF Core no admite parámetros de salida directamente — necesitarías bajar a DbCommand manualmente.
Modelo Mental Más Simple
Dapper tiene un README de tres páginas. No hay convenciones que aprender, ni propiedades shadow, ni estados de entidad, ni clases proxy. Un desarrollador que conoce SQL puede ser productivo en minutos.
Usando Ambos en el Mismo Proyecto
EF Core y Dapper no son mutuamente excluyentes. Un patrón híbrido común usa EF Core para escrituras y gestión de relaciones, y Dapper para consultas de lectura complejas y reportes.
Registro en DI
// Registrar ambos — DbContext para escrituras, fábrica IDbConnection para lecturas
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// Registrar una fábrica para que los repositorios puedan abrir conexiones Dapper bajo demanda
builder.Services.AddScoped<IDbConnection>(_ =>
new SqlConnection(builder.Configuration.GetConnectionString("Default")));Patrón Repository — Híbrido
public class ProductRepository
{
private readonly AppDbContext _context;
private readonly IDbConnection _db;
public ProductRepository(AppDbContext context, IDbConnection db)
{
_context = context;
_db = db;
}
// Las escrituras van a través de EF Core — rastreo de cambios, validación, 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;
}
// La consulta de lectura compleja va a través de Dapper — SQL puro, sin 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 });
}
// Las búsquedas simples aún usan EF Core con 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);
}Compartir Transacciones
Cuando necesitas que EF Core y Dapper participen en la misma transacción:
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Escritura de EF Core
_context.Orders.Add(newOrder);
await _context.SaveChangesAsync();
// Operación Dapper usando la misma conexión subyacente y transacción
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;
}Al compartir una transacción, usa _context.Database.GetDbConnection() en lugar de abrir un nuevo IDbConnection. Esto garantiza que Dapper opere en exactamente la misma conexión a la que está ligada la transacción de EF Core.
Este patrón es la cuarta comprobación del sample ejecutable: una inserción de EF Core y una actualización de Dapper dentro de una misma transacción, ambas visibles antes del rollback, ambas desaparecidas después — una transacción gobernando genuinamente ambas bibliotecas, verificado con aserciones en lugar de asumido.
Cuándo Elegir EF Core
- Aplicaciones greenfield donde posees el esquema y quieres migraciones en control de versiones
- Modelos de dominio ricos con relaciones complejas, lógica de validación y eventos de dominio
- Equipos con experiencia SQL mixta — LINQ reduce la barrera para un acceso a datos correcto y seguro
- Iteración rápida — agregar una columna es un cambio de una línea en el modelo más una migración
- Múltiples objetivos de base de datos — un codebase, múltiples proveedores
- Testeabilidad unitaria —
UseInMemoryDatabaseo SQLite en memoria para pruebas rápidas sin BD real
Cuándo Elegir Dapper
- Rutas de lectura críticas para rendimiento — dashboards de reportes, endpoints API de alta frecuencia
- Base de datos existente con un esquema que no controlas
- Gran inversión en SQL — el equipo ya tiene procedimientos almacenados, vistas y consultas optimizadas
- Consultas analíticas complejas — funciones de ventana, CTEs, consultas recursivas, JSON shredding
- Microservicios con acceso a datos simple — no se necesita un ORM completo
- Read replicas / lado de lectura CQRS — Dapper es una opción natural para el lado de consultas de CQRS
Matriz de Decisión
| Criterio | EF Core | Dapper | Híbrido |
|---|---|---|---|
| Migraciones de esquema | Nativas | Manual / Flyway | EF Core gestiona el esquema |
| Rastreo de cambios | Sí | No | EF Core para escrituras |
| Composición LINQ | Sí | No | EF Core para escrituras |
| Control SQL puro | Limitado | Total | Dapper para lecturas |
| Soporte procedimientos almacenados | Parcial | Total | Dapper |
| Rendimiento (lecturas) | Bueno (AsNoTracking cierra casi toda la brecha) | Excelente | Excelente |
| Rendimiento (escrituras) | Bueno | Bueno | Bueno |
| Operaciones masivas | Vía extensiones | TVP nativo | Dapper |
| Curva de aprendizaje | Media-Alta | Baja | Media |
| Soporte multi-BD | Sí | Manual | Mixto |
| Test doubles | InMemory / SQLite | Necesita BD real o mock | Necesita BD real o mock |
| Experiencia SQL necesaria | Baja | Alta | Media |
| Mejor para | Greenfield, apps de dominio | Lecturas críticas, BD existente | Apps grandes con cargas mixtas |
Resumen
EF Core y Dapper resuelven problemas diferentes. EF Core es el predeterminado correcto para nuevas aplicaciones — gestiona migraciones, mantiene tu esquema y código sincronizados, y permite a desarrolladores con menos experiencia SQL escribir consultas seguras y correctas. Dapper gana su lugar en rutas de lectura críticas para rendimiento y cuando tu equipo posee SQL complejo que un ORM solo oscurecería.
Para la mayoría de las aplicaciones medianas y grandes, la respuesta es ambos: EF Core para operaciones del lado de comando y lecturas simples, Dapper para reportes y endpoints de consulta de alta frecuencia. El patrón de transacción compartida mostrado arriba significa que nunca tienes que elegir entre corrección y rendimiento.