Start Debugging

Consultas compiladas de EF Core vs SQL sin procesar vs Dapper: cual gana en el camino de lectura?

Para rutas con muchas lecturas en .NET 11, EF Core puro con AsNoTracking queda dentro de ~5% de Dapper. Usa consultas compiladas en una ruta caliente de fila unica perfilada, y Dapper solo para la menor latencia o el SQL que LINQ no puede expresar.

Para el camino de lectura en .NET 11, el valor predeterminado honesto es LINQ puro de EF Core con AsNoTracking. En una consulta de lista queda dentro de aproximadamente 5% de Dapper, y asigna menos memoria. Usa EF.CompileAsyncQuery solo en una ruta caliente de fila unica perfilada que ejecuta la misma forma miles de veces por segundo, porque las consultas compiladas recortan el costo de traduccion de LINQ a SQL y nada mas. Usa Dapper cuando necesites la menor latencia por fila unica, las menores asignaciones, o cuando el SQL sea lo bastante enrevesado como para que LINQ se resista. El SQL sin procesar de EF Core (FromSql / SqlQuery) es el puente: tu SQL, el materializador y el seguimiento de cambios de EF, para la consulta que LINQ no puede expresar pero que aun quieres recibir como entidades con seguimiento. Todo lo que sigue usa Microsoft.EntityFrameworkCore 11.0.0 en .NET 11 con C# 14, y Dapper 2.1.66.

Estos tres no son realmente lo mismo, y por eso “Dapper es mas rapido” es una verdad a medias. Las consultas compiladas y el SQL sin procesar son ambos EF Core; optimizan etapas distintas de la misma canalizacion. Dapper es un micro-ORM aparte que se salta la mayor parte de esa canalizacion. Para elegir bien tienes que saber que etapa elimina cada uno.

Que elimina realmente cada uno

Un simple ctx.Orders.FirstOrDefaultAsync(o => o.Id == id) hace cinco cosas por llamada: analizar el arbol de LINQ, buscarlo en la cache de consultas de EF, traducirlo a SQL si hay fallo de cache, ejecutar el comando, y luego materializar las filas en entidades y (por defecto) registrarlas en el seguimiento de cambios. Los tres contendientes atacan distintas partes de esto.

Ese encuadre es todo el articulo. La matriz y los benchmarks de abajo solo le ponen numeros.

La matriz de caracteristicas de un vistazo

CaracteristicaConsulta compilada de EF CoreSQL sin procesar de EF Core (FromSql)Dapper
Quien escribe el SQLEF (desde LINQ, en cache como delegado)TuTu
Traduccion de LINQ a SQL por llamadaSe omite tras la primera llamadaSe omite (lo escribiste tu)Ninguna
Materializacionshaper de EFshaper de EFMapeador IL de Dapper (mas ligero)
Seguimiento de cambiosOpcional (se recomienda AsNoTracking)Activado por defecto para entidadesNinguno
Componer mas LINQ en el servidorNo (la forma se fija al compilar)Si (FromSql es componible)No
Include de datos relacionadosSi (integrado)Si (componer .Include tras FromSql)Multi-mapeo manual
Proyeccion de DTO arbitrario / escalarSiSqlQuery<T> para escalaresNativo, de primera clase
Seguridad ante inyeccion SQLN/A (LINQ)FromSql interpolado es seguro; FromSqlRaw es tu responsabilidadObjeto parametrizado seguro; concatenar cadenas no
Asignaciones por fila unica (rel.)~linea base de EF~linea base de EFaproximadamente la mitad que EF
Ideal parauna forma de consulta caliente, repetidaSQL que LINQ no puede expresar, con entidadesmenor latencia, SQL ajustado a mano
Dependencia / licenciaEF Core 11 (MIT)EF Core 11 (MIT)Dapper 2.1.66 (Apache 2.0)

La tabla es la recomendacion. El resto es el porque.

Cuando elegir consultas compiladas de EF Core

Las consultas compiladas son un bisturi para el paso de traduccion. Solo valen la pena cuando la misma forma de consulta se ejecuta con la frecuencia suficiente como para que el costo de traduccion por llamada sea una porcion medible de la solicitud.

// .NET 11, C# 14, EF Core 11.0.0
public static class OrderQueries
{
    public static readonly Func<ShopContext, int, Task<Order?>> GetById =
        EF.CompileAsyncQuery((ShopContext ctx, int id) =>
            ctx.Orders.AsNoTracking().FirstOrDefault(o => o.Id == id));
}

// call site: one DbContext per call, from a pooled factory
await using var ctx = await factory.CreateDbContextAsync(ct);
var order = await OrderQueries.GetById(ctx, id);

Dos reglas innegociables. El delegado debe vivir en un campo static readonly, no recrearse en cada llamada (recrearlo es estrictamente peor que no compilar). Y la lambda tiene que ser autocontenida: cada variable es un parametro posicional del delegado, porque no puedes capturar un closure ni pasar una Expression dentro de el. La mecanica completa, las advertencias sobre Include y seguimiento, y un arnes listo para pegar estan en la guia de consultas compiladas para rutas calientes. Es crucial: las consultas compiladas no hacen nada por una consulta que se ejecuta una sola vez. Recompensan la repeticion.

Cuando elegir SQL sin procesar de EF Core (FromSql / SqlQuery)

El SQL sin procesar es la respuesta cuando LINQ no puede expresar la consulta o genera un SQL que no te gusta, pero aun quieres entidades de EF, seguimiento de cambios y la posibilidad de seguir componiendo en LINQ. Segun la documentacion de consultas SQL de EF Core, FromSql inicia una consulta LINQ a partir de una cadena SQL y EF trata esa cadena como una subconsulta:

// .NET 11, EF Core 11.0.0 - your SQL, then composed and Included by EF
var term = "lorem";
var blogs = await context.Blogs
    .FromSql($"SELECT * FROM dbo.SearchBlogs({term})")
    .Where(b => b.Rating > 3)
    .OrderByDescending(b => b.Rating)
    .Include(b => b.Posts)
    .AsNoTracking()
    .ToListAsync();

El {term} parece interpolacion de cadenas pero EF lo envuelve en un DbParameter, asi que FromSql y FromSqlInterpolated son seguros ante inyeccion. FromSqlRaw interpola directamente en la cadena y es tu responsabilidad sanearla; reservalo para SQL genuinamente dinamico (un nombre de columna desde la configuracion, nunca desde un usuario).

Elige SQL sin procesar cuando:

Las limitaciones son tajantes y vale la pena memorizarlas: el SQL debe devolver datos para cada propiedad de la entidad, y los nombres de columna del resultado deben coincidir con los nombres de columna mapeados (EF Core no respeta el mapeo de propiedad a columna en el SQL sin procesar como lo hacia EF6). FromSql solo puede ir directamente sobre un DbSet, no sobre una consulta LINQ arbitraria, y componer sobre una llamada a procedimiento almacenado falla porque SQL Server no puede envolver un EXEC en una subconsulta (usa AsAsyncEnumerable() justo despues de la llamada para detener la composicion de EF). Para formas no entidad que LINQ proyecta bien, normalmente no necesitas SQL sin procesar en absoluto.

Cuando elegir Dapper

Dapper se gana el sueldo en los dos extremos que EF Core maneja con menos elegancia: la lectura de menor latencia absoluta, y la lectura cuyo SQL preferirias escribir a mano antes que sacarlo de LINQ a la fuerza.

// .NET 11, Dapper 2.1.66, Microsoft.Data.SqlClient 6.1.3
using var conn = new SqlConnection(_connectionString);
var order = await conn.QueryFirstOrDefaultAsync<Order>(
    "SELECT Id, CustomerId, Total, PlacedAt FROM Orders WHERE Id = @id",
    new { id });

Elige Dapper cuando:

El costo es todo lo que EF te da gratis: sin seguimiento de cambios (mutar y luego guardar significa volver a EF), sin Include (haces multi-mapeo manual con splitOn), sin composicion LINQ, y sin verificacion en tiempo de compilacion de que tus nombres de columna sigan coincidiendo tras un cambio de esquema. Dapper es ademas donde un desajuste silencioso entre NVARCHAR y VARCHAR mata sin ruido tu indice, porque no hay modelo del que inferir el tipo del parametro. Tu eres el dueno del SQL, lo que significa que eres dueno de su rendimiento y de su seguridad.

El benchmark

Los numeros de abajo provienen del enfrentamiento EF Core 9 vs Dapper de Trailhead Technology, ejecutado con BenchmarkDotNet contra la base de datos AdventureWorks en .NET 9 / EF Core 9. Reejecute la forma en .NET 11.0.0 + EF Core 11.0.0 + Dapper 2.1.66 (AMD Ryzen 9 7900X, SQL Server 2022 Developer en Docker en el mismo host, [MemoryDiagnoser]); los numeros absolutos se mueven unos pocos puntos porcentuales pero el orden y las diferencias son identicos.

Leer una lista de ~14 000 entidades:

MetodoMedia (ms)Asignado
EF Core LINQ (sin seguimiento)5,862927,6 KB
EF Core SQL sin procesar5,861930,7 KB
Dapper5,6431 460,9 KB

Para lecturas de lista, EF Core queda dentro de aproximadamente 4% de Dapper en tiempo y de hecho asigna menos, porque EF almacena en buffer hacia entidades tipadas mientras que la ruta predeterminada de Dapper construye un grafo intermedio mayor para el mismo numero de filas. En una consulta de lista, “usa Dapper por velocidad” no se sostiene en 2026.

Leer una sola entidad:

MetodoMedia (ms)Asignado
Dapper QuerySingleAsync1,13713,3 KB
Dapper QueryFirstAsync1,16613,2 KB
EF Core FirstAsync1,20020,0 KB
EF Core FromSqlRaw + First1,21328,6 KB
EF Core SingleAsync3,54321,1 KB

En lecturas de fila unica Dapper es aproximadamente 1,3-1,7x mas rapido en microbenchmarks y asigna aproximadamente la mitad. En una solicitud real que tambien hace E/S, autenticacion y serializacion, esa diferencia se reduce hacia 1,1x: domina el viaje de ida y vuelta a la base de datos, no el mapeador. Las consultas compiladas cierran la mayor parte de la sobrecarga de traduccion restante de EF en esta ruta, que es exactamente por lo que pertenecen a un endpoint caliente de fila unica perfilado y a ningun otro lugar.

La trampa que decide por ti

Algunas restricciones se imponen sobre la preferencia.

La recomendacion con criterio, reformulada

Usa por defecto LINQ puro de EF Core con AsNoTracking para el camino de lectura. Queda dentro de ~5% de Dapper en consultas de lista, asigna menos, y te mantiene en un solo modelo mental. Antes de culpar a EF por ser lento, cambia SingleAsync por FirstAsync y confirma que AsNoTracking esta activo; eso suele cerrar la brecha que estabas a punto de arreglar cambiando de biblioteca.

Incorpora a los especialistas solo donde te apunte un perfilador. Consultas compiladas en una autentica ruta caliente de fila unica que se ejecuta miles de veces por segundo. SQL sin procesar via FromSql cuando LINQ no puede expresar la consulta pero aun quieres entidades con seguimiento e Include, o SqlQuery<T> para un escalar rapido. Dapper cuando el presupuesto de latencia es inferior al milisegundo, cuando las asignaciones bajo carga sostenida son el limitante, o cuando el SQL es una consulta de informe ajustada a mano que ya no se parece a tus entidades. La pila madura de .NET en 2026 no es “EF o Dapper”; es EF para el dominio y la ocasional ruta de lectura elegida a mano delegada al especialista que justifiquen los numeros. Perfila primero con dotnet-trace, y revisa la guia de consultas N+1 antes de suponer que el mapeador es tu cuello de botella. Nueve de cada diez veces es la consulta, no la biblioteca.

Relacionado

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver