La traducción LINQ de EF Core gestiona la mayoría de las consultas con elegancia, pero algunos escenarios exigen SQL directo: funciones de ventana, CTEs, agregaciones complejas o características específicas de la base de datos que LINQ no puede expresar. EF Core proporciona varias APIs para esto — cada una con diferentes características de seguridad.
Cada afirmación de este artículo está verificada por
samples/ef-core-raw-sql
— 19 comprobaciones que incluyen una inyección SQL real contra el patrón vulnerable, para que la diferencia de seguridad entre las APIs sea dato y no prosa (ver Verifícalo tú mismo).
Las Tres APIs de SQL Directo
| API | Caso de Uso | Seguimiento de Entidades | Seguro contra Inyección SQL |
|---|---|---|---|
FromSqlRaw | Consultar entidades con SQL directo | Sí | Solo con parámetros |
FromSqlInterpolated | Consultar entidades con SQL interpolado | Sí | Siempre |
ExecuteSqlRaw | SQL sin consulta (INSERT/UPDATE/DELETE) | N/A | Solo con parámetros |
ExecuteSqlInterpolated | Sin consulta con SQL interpolado | N/A | Siempre |
FromSqlRaw
Consulta entidades usando una cadena SQL directa. Utiliza siempre parámetros para la entrada del usuario:
// SEGURO — consulta parametrizada
var categoryId = 5;
var products = await _db.Products
.FromSqlRaw("SELECT * FROM Products WHERE CategoryId = {0}", categoryId)
.ToListAsync();
// SEGURO — parámetros con nombre (SQL Server)
var products = await _db.Products
.FromSqlRaw("SELECT * FROM Products WHERE CategoryId = @categoryId",
new SqlParameter("@categoryId", categoryId))
.ToListAsync();Nunca concatenes la entrada del usuario en una cadena de consulta FromSqlRaw. FromSqlRaw("SELECT * FROM Products WHERE Name = '" + userInput + "'") es una vulnerabilidad de inyección SQL — y no teórica: el sample pasa ' OR '1'='1 por exactamente este patrón y vuelven todas las filas de la tabla, mientras que la misma entrada a través de FromSqlInterpolated se convierte en el parámetro @p0 y no encuentra ninguna. El analizador de EF Core marca la concatenación en FromSqlRaw con el aviso EF1003; trátalo como un error de compilación.
Composición con LINQ
Las consultas SQL directas se pueden componer con LINQ — EF Core las envuelve en una subconsulta:
// SQL directo para la consulta base, luego se compone con LINQ
var products = await _db.Products
.FromSqlRaw("SELECT * FROM Products WHERE CategoryId = {0}", categoryId)
.Where(p => p.Price > 50) // Agrega cláusula WHERE
.OrderBy(p => p.Name) // Agrega ORDER BY
.Include(p => p.Category) // Agrega JOIN
.ToListAsync();SQL generado aproximadamente:
SELECT p.*, c.*
FROM (SELECT * FROM Products WHERE CategoryId = 5) AS p
JOIN Categories AS c ON p.CategoryId = c.Id
WHERE p.Price > 50
ORDER BY p.NameFromSqlInterpolated — Siempre Seguro contra Inyección SQL
FromSqlInterpolated trata los valores de interpolación de cadenas de C# como parámetros SQL automáticamente:
// Esto parece interpolación de cadenas, pero EF Core lo convierte en parámetros
int categoryId = 5;
string nameFilter = "Widget%";
var products = await _db.Products
.FromSqlInterpolated(
$"SELECT * FROM Products WHERE CategoryId = {categoryId} AND Name LIKE {nameFilter}")
.OrderBy(p => p.Price)
.ToListAsync();A pesar de la sintaxis $"...", EF Core NO concatena los valores. Los extrae y crea los parámetros SQL apropiados:
-- SQL real ejecutado (sin posibilidad de inyección):
SELECT * FROM Products WHERE CategoryId = @p0 AND Name LIKE @p1
-- @p0 = 5, @p1 = 'Widget%'Prefiere FromSqlInterpolated sobre FromSqlRaw para todas las consultas que incluyan valores proporcionados por el usuario o valores en tiempo de ejecución. La API interpolada es siempre segura; la API raw requiere disciplina para usarse correctamente.
Consultas Complejas Donde LINQ Se Queda Corto
Funciones de Ventana
EF Core no puede traducir funciones de ventana como ROW_NUMBER(), RANK() o LAG():
// Función de ventana para productos clasificados por precio dentro de la categoría
var rankedProducts = await _db.Products
.FromSqlRaw(@"
SELECT
p.*,
ROW_NUMBER() OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS PriceRank
FROM Products p
")
.ToListAsync();Como PriceRank no es una propiedad en Product, mapea a un DTO en su lugar:
// DTO para el resultado
public record RankedProduct(int Id, string Name, decimal Price, int CategoryId, int PriceRank);
// Usa SQL directo con Dapper o ADO.NET para resultados que no son entidades
// O agrega PriceRank como propiedad [NotMapped] y usa FromSqlRawPara consultas que no son entidades, usa _db.Database.SqlQueryRaw<T> (EF Core 7+):
// EF Core 7+ — consulta a cualquier tipo, no solo tipos de entidad
var rankedProducts = await _db.Database
.SqlQueryRaw<RankedProduct>(@"
SELECT
p.Id,
p.Name,
p.Price,
p.CategoryId,
CAST(ROW_NUMBER() OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS int) AS PriceRank
FROM Products p
")
.ToListAsync();El CAST(... AS int) no es opcional. ROW_NUMBER() devuelve bigint en SQL Server, y materializarlo en una propiedad int del DTO lanza InvalidCastException en tiempo de ejecución — los tipos deben coincidir exactamente, nada se convierte en silencio. (Los records posicionales de C# funcionan bien como destino de SqlQueryRaw<T>; lo que muerde son los tipos de columna.)
CTEs (Expresiones de Tabla Común)
var topCategories = await _db.Database
.SqlQueryRaw<CategorySummary>(@"
WITH ProductCounts AS (
SELECT
CategoryId,
COUNT(*) AS ProductCount,
AVG(Price) AS AvgPrice
FROM Products
GROUP BY CategoryId
)
SELECT
c.Id,
c.Name,
pc.ProductCount,
pc.AvgPrice
FROM Categories c
JOIN ProductCounts pc ON c.Id = pc.CategoryId
ORDER BY pc.ProductCount DESC
")
.ToListAsync();Búsqueda de Texto Completo
La búsqueda de texto completo de SQL Server no puede expresarse en LINQ:
var searchTerm = "entity framework performance";
var results = await _db.Articles
.FromSqlInterpolated(
$"SELECT * FROM Articles WHERE CONTAINS(Content, {searchTerm})")
.Include(a => a.Author)
.ToListAsync();Características Específicas de la Base de Datos
// SQL Server — sentencia MERGE
await _db.Database.ExecuteSqlInterpolatedAsync($@"
MERGE Products AS target
USING (SELECT {id} AS Id, {newName} AS Name) AS source
ON target.Id = source.Id
WHEN MATCHED THEN UPDATE SET Name = source.Name
WHEN NOT MATCHED THEN INSERT (Id, Name) VALUES (source.Id, source.Name);
");ExecuteSqlRaw / ExecuteSqlInterpolated para Operaciones Sin Consulta
Para INSERT, UPDATE, DELETE o DDL que no devuelven entidades:
// Actualización masiva con ExecuteSqlInterpolated (siempre seguro)
int categoryId = 5;
decimal discountFactor = 0.9m;
int rowsAffected = await _db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE Products SET Price = Price * {discountFactor} WHERE CategoryId = {categoryId}");
Console.WriteLine($"Se actualizaron {rowsAffected} productos");
// O con ExecuteSqlRaw y parámetros explícitos
await _db.Database.ExecuteSqlRawAsync(
"UPDATE Products SET Price = Price * @discount WHERE CategoryId = @catId",
new SqlParameter("@discount", discountFactor),
new SqlParameter("@catId", categoryId));Las extensiones LINQ ExecuteUpdateAsync y ExecuteDeleteAsync de EF Core 7+ suelen ser mejores que el SQL directo para operaciones masivas porque son de tipo seguro y se componen con LINQ. Usa SQL directo cuando la consulta sea genuinamente compleja.
Llamada a Procedimientos Almacenados
// Procedimiento almacenado que devuelve entidades
var products = await _db.Products
.FromSqlRaw("EXEC dbo.GetProductsByCategory @CategoryId = {0}", categoryId)
.ToListAsync();
// Procedimiento almacenado con parámetro de salida
var outputParam = new SqlParameter("@TotalCount", SqlDbType.Int)
{
Direction = ParameterDirection.Output
};
await _db.Database.ExecuteSqlRawAsync(
"EXEC dbo.ProcessOrders @BatchSize = {0}, @TotalCount = @TotalCount OUTPUT",
100,
outputParam);
var totalCount = (int)outputParam.Value;
Console.WriteLine($"Se procesaron {totalCount} órdenes");Los resultados de un procedimiento almacenado no se pueden componer con LINQ. FromSqlRaw("EXEC ...") seguido de .Where(...), .Include(...) o cualquier cosa que deba traducirse a SQL lanza InvalidOperationException — un EXEC no puede envolverse en una subconsulta como sí puede un SELECT. Filtra dentro del procedimiento, o materializa con ToListAsync() primero y filtra en memoria.
Sin Seguimiento con SQL Directo
Las consultas SQL directas participan en el seguimiento de cambios de EF Core por defecto. Para consultas de solo lectura:
var products = await _db.Products
.FromSqlInterpolated($"SELECT * FROM Products WHERE Price > {minPrice}")
.AsNoTracking() // Sin seguimiento de cambios — mejor rendimiento para lecturas
.ToListAsync();Cuándo el SQL Directo Supera a LINQ
| Escenario | Usar SQL Directo |
|---|---|
| Funciones de ventana (ROW_NUMBER, RANK, LAG) | Sí |
| CTEs o consultas recursivas | Sí |
| Búsqueda de texto completo | Sí |
| Sentencias MERGE / UPSERT | Sí |
| Procedimientos almacenados | Sí |
| Sugerencias específicas de base de datos (NOLOCK, FORCESEEK) | Sí |
| Agregaciones complejas con ROLLUP/CUBE | Sí |
| Consultas que generan SQL deficiente mediante LINQ | Sí |
| CRUD simple y consultas filtradas | No — LINQ es suficiente |
Verificar el SQL Generado
Antes de recurrir al SQL directo, verifica lo que genera LINQ — puede ser perfectamente adecuado:
// Registrar consultas en la consola en desarrollo
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
if (builder.Environment.IsDevelopment())
options.LogTo(Console.WriteLine, LogLevel.Information);
});O inspeccionar la consulta sin ejecutarla:
var query = _db.Products
.Where(p => p.CategoryId == 5)
.OrderBy(p => p.Price)
.Select(p => new { p.Id, p.Name, p.Price });
// Obtener el SQL sin ejecutar
var sql = query.ToQueryString();
Console.WriteLine(sql);Ejemplo Específico de PostgreSQL
// Consulta JSONB de PostgreSQL — no puede expresarse en LINQ portátil
var results = await _db.Database
.SqlQueryRaw<OrderResult>(@"
SELECT id, data->>'customer_name' AS CustomerName,
(data->>'total')::numeric AS Total
FROM orders
WHERE data @> '{""status"": ""pending""}'::jsonb
ORDER BY created_at DESC
")
.ToListAsync();Verifícalo tú mismo
Cada afirmación de arriba está verificada por
samples/ef-core-raw-sql
— 19 comprobaciones que lanzan excepción si fallan (EF Core 10, SQL Server LocalDB). Lo más destacado:
- La inyección es real:
' OR '1'='1concatenado enFromSqlRawfiltra las 12 filas sembradas; la entrada idéntica a través deFromSqlInterpolatedno encuentra ninguna. - Componer
Where+OrderBy+Includesobre unSELECTdirecto se ejecuta como una sola sentencia, con la subconsulta visible enToQueryString(). ROW_NUMBER()en una propiedadintdel DTO lanzaInvalidCastException; conCAST(... AS int), el rango 1 cae en el producto más caro de cada categoría.- Una CTE se materializa en un record normal; un procedimiento almacenado materializa entidades rastreadas, rechaza la composición LINQ con
InvalidOperationExceptiony devuelve su parámetro OUTPUT. AsNoTracking()mantiene el change tracker vacío (frente a 11 entidades rastreadas sin él);ToQueryString()ejecuta cero sentencias;MERGErecorre sus dos caminos, insert y update.

Ejecuta primero su seed.sql
y luego dotnet run — el seed también crea los dos procedimientos almacenados. La búsqueda de texto completo y el ejemplo JSONB de PostgreSQL son las dos secciones no verificadas: LocalDB no tiene motor de texto completo y el sample corre solo en SQL Server.
Cuando el SQL Directo Se Vuelve el Caso Común
Si notas que la mayoría de tus consultas han derivado hacia SQL directo, conviene tratarlo como una señal y no como una costumbre. En ese punto la comparación de EF Core vs Dapper es la relevante — Dapper está construido justamente para este estilo de acceso y no te cobra por un change tracker que no estás usando.
Antes de concluir que LINQ es el cuello de botella, sin embargo, contrasta el SQL generado
con los patrones de el problema de consultas N+1. Una consulta
LINQ lenta es mucho más a menudo un Include o una proyección que faltan que una
limitación real del traductor.