//JorgenHoc
← Todos los artículos
EF CorePor Jorge CalderónActualizado 11 min read

Consultas SQL Directas en EF Core — Cuándo y Cómo Usarlas

Aprende cuándo el SQL directo supera a LINQ en EF Core, cómo usar FromSqlRaw, FromSqlInterpolated y ExecuteSqlRaw de forma segura, y cómo prevenir la inyección SQL con parametrización.

#entity-framework#dotnet#database

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

APICaso de UsoSeguimiento de EntidadesSeguro contra Inyección SQL
FromSqlRawConsultar entidades con SQL directoSolo con parámetros
FromSqlInterpolatedConsultar entidades con SQL interpoladoSiempre
ExecuteSqlRawSQL sin consulta (INSERT/UPDATE/DELETE)N/ASolo con parámetros
ExecuteSqlInterpolatedSin consulta con SQL interpoladoN/ASiempre

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.Name

FromSqlInterpolated — 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 FromSqlRaw

Para 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

EscenarioUsar SQL Directo
Funciones de ventana (ROW_NUMBER, RANK, LAG)
CTEs o consultas recursivas
Búsqueda de texto completo
Sentencias MERGE / UPSERT
Procedimientos almacenados
Sugerencias específicas de base de datos (NOLOCK, FORCESEEK)
Agregaciones complejas con ROLLUP/CUBE
Consultas que generan SQL deficiente mediante LINQ
CRUD simple y consultas filtradasNo — 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'='1 concatenado en FromSqlRaw filtra las 12 filas sembradas; la entrada idéntica a través de FromSqlInterpolated no encuentra ninguna.
  • Componer Where + OrderBy + Include sobre un SELECT directo se ejecuta como una sola sentencia, con la subconsulta visible en ToQueryString().
  • ROW_NUMBER() en una propiedad int del DTO lanza InvalidCastException; con CAST(... 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 InvalidOperationException y devuelve su parámetro OUTPUT.
  • AsNoTracking() mantiene el change tracker vacío (frente a 11 entidades rastreadas sin él); ToQueryString() ejecuta cero sentencias; MERGE recorre sus dos caminos, insert y update.
Salida de consola del sample de SQL directo: 19 comprobaciones que pasan. FromSqlRaw con parámetros posicionales y con nombre devuelve 4 productos; componer Where, OrderBy e Include sobre SQL directo se ejecuta como una sola sentencia. La sección de inyección muestra la entrada ' OR '1'='1 filtrando las 12 filas a través de FromSqlRaw concatenado, y devolviendo 0 filas a través de FromSqlInterpolated donde se convierte en el parámetro @p0. SqlQueryRaw de un ROW_NUMBER en una propiedad int lanza InvalidCastException, y con CAST as int devuelve 12 filas con el rango 1 en el producto más caro de cada categoría; una CTE se materializa en un DTO. Un procedimiento almacenado materializa entidades rastreadas, rechaza la composición LINQ con InvalidOperationException y devuelve su parámetro OUTPUT de 6. AsNoTracking deja el tracker vacío frente a 11 rastreadas sin él, ToQueryString ejecuta cero sentencias, y MERGE recorre sus caminos de insert y update.
Las 19 comprobaciones, directo de la consola — las dos filas de inyección son las que hay que leer primero: entrada idéntica, las 12 filas filtradas por concatenación frente a 0 por interpolación.
💡

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.

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados