A tradução LINQ do EF Core lida com a maioria das consultas de forma elegante, mas alguns cenários exigem SQL bruto: funções de janela, CTEs, agregações complexas ou recursos específicos do banco de dados que o LINQ não consegue expressar. O EF Core fornece várias APIs para isso — cada uma com diferentes características de segurança.
Cada afirmação deste artigo é verificada por
samples/ef-core-raw-sql
— 19 verificações que incluem uma injeção de SQL real contra o padrão vulnerável, para que a diferença de segurança entre as APIs seja dado e não prosa (veja Verifique você mesmo).
As Três APIs de SQL Bruto
| API | Caso de Uso | Rastreamento de Entidades | Seguro contra Injeção de SQL |
|---|---|---|---|
FromSqlRaw | Consultar entidades com SQL bruto | Sim | Somente com parâmetros |
FromSqlInterpolated | Consultar entidades com SQL interpolado | Sim | Sempre |
ExecuteSqlRaw | SQL sem consulta (INSERT/UPDATE/DELETE) | N/A | Somente com parâmetros |
ExecuteSqlInterpolated | Sem consulta com SQL interpolado | N/A | Sempre |
FromSqlRaw
Consulta entidades usando uma string SQL bruta. Sempre use parâmetros para entrada do usuário:
// SEGURO — consulta parametrizada
var categoryId = 5;
var products = await _db.Products
.FromSqlRaw("SELECT * FROM Products WHERE CategoryId = {0}", categoryId)
.ToListAsync();
// SEGURO — parâmetros nomeados (SQL Server)
var products = await _db.Products
.FromSqlRaw("SELECT * FROM Products WHERE CategoryId = @categoryId",
new SqlParameter("@categoryId", categoryId))
.ToListAsync();Nunca concatene a entrada do usuário em uma string de consulta FromSqlRaw. FromSqlRaw("SELECT * FROM Products WHERE Name = '" + userInput + "'") é uma vulnerabilidade de injeção de SQL — e não teórica: o sample passa ' OR '1'='1 por exatamente esse padrão e todas as linhas da tabela voltam, enquanto a mesma entrada através de FromSqlInterpolated vira o parâmetro @p0 e não encontra nenhuma. O analisador do EF Core marca a concatenação em FromSqlRaw com o aviso EF1003; trate-o como erro de compilação.
Composição com LINQ
Consultas SQL brutas podem ser compostas com LINQ — o EF Core as envolve em uma subconsulta:
// SQL bruto para a consulta base, depois compõe com LINQ
var products = await _db.Products
.FromSqlRaw("SELECT * FROM Products WHERE CategoryId = {0}", categoryId)
.Where(p => p.Price > 50) // Adiciona cláusula WHERE
.OrderBy(p => p.Name) // Adiciona ORDER BY
.Include(p => p.Category) // Adiciona JOIN
.ToListAsync();SQL gerado 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 — Sempre Seguro contra Injeção de SQL
FromSqlInterpolated trata os valores de interpolação de string do C# como parâmetros SQL automaticamente:
// Isso parece interpolação de string, mas o EF Core converte em 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();Apesar da sintaxe $"...", o EF Core NÃO concatena os valores. Ele os extrai e cria parâmetros SQL adequados:
-- SQL real executado (sem possibilidade de injeção):
SELECT * FROM Products WHERE CategoryId = @p0 AND Name LIKE @p1
-- @p0 = 5, @p1 = 'Widget%'Prefira FromSqlInterpolated em vez de FromSqlRaw para todas as consultas que incluam valores fornecidos pelo usuário ou valores em tempo de execução. A API interpolada é sempre segura; a API bruta requer disciplina para ser usada corretamente.
Consultas Complexas Onde o LINQ Não é Suficiente
Funções de Janela
O EF Core não consegue traduzir funções de janela como ROW_NUMBER(), RANK() ou LAG():
// Função de janela para produtos classificados por preço dentro da categoria
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 não é uma propriedade em Product, mapeie para um DTO em vez disso:
// DTO para o resultado
public record RankedProduct(int Id, string Name, decimal Price, int CategoryId, int PriceRank);
// Use SQL bruto com Dapper ou ADO.NET para resultados que não são entidades
// Ou adicione PriceRank como propriedade [NotMapped] e use FromSqlRawPara consultas que não são entidades, use _db.Database.SqlQueryRaw<T> (EF Core 7+):
// EF Core 7+ — consulta para qualquer tipo, não apenas tipos de entidade
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();O CAST(... AS int) não é opcional. ROW_NUMBER() retorna bigint no SQL Server, e materializá-lo em uma propriedade int do DTO lança InvalidCastException em tempo de execução — os tipos precisam coincidir exatamente, nada é convertido em silêncio. (Records posicionais de C# funcionam bem como destino de SqlQueryRaw<T>; o que morde são os tipos de coluna.)
CTEs (Expressões de Tabela Comum)
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();Pesquisa de Texto Completo
A pesquisa de texto completo do SQL Server não pode ser expressa em LINQ:
var searchTerm = "entity framework performance";
var results = await _db.Articles
.FromSqlInterpolated(
$"SELECT * FROM Articles WHERE CONTAINS(Content, {searchTerm})")
.Include(a => a.Author)
.ToListAsync();Recursos Específicos do Banco de Dados
// SQL Server — instrução 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 Operações Sem Consulta
Para INSERT, UPDATE, DELETE ou DDL que não retornam entidades:
// Atualização em massa com ExecuteSqlInterpolated (sempre 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($"Foram atualizados {rowsAffected} produtos");
// Ou com ExecuteSqlRaw e 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));As extensões LINQ ExecuteUpdateAsync e ExecuteDeleteAsync do EF Core 7+ geralmente são melhores que o SQL bruto para operações em massa porque são type-safe e se compõem com LINQ. Use SQL bruto quando a consulta for genuinamente complexa.
Chamando Stored Procedures
// Stored procedure que retorna entidades
var products = await _db.Products
.FromSqlRaw("EXEC dbo.GetProductsByCategory @CategoryId = {0}", categoryId)
.ToListAsync();
// Stored procedure com parâmetro de saída
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($"Foram processados {totalCount} pedidos");Os resultados de uma stored procedure não podem ser compostos com LINQ. FromSqlRaw("EXEC ...") seguido de .Where(...), .Include(...) ou qualquer coisa que precise traduzir-se para SQL lança InvalidOperationException — um EXEC não pode ser embrulhado em uma subconsulta como um SELECT pode. Filtre dentro da procedure, ou materialize com ToListAsync() primeiro e filtre em memória.
Sem Rastreamento com SQL Bruto
As consultas SQL brutas participam do rastreamento de alterações do EF Core por padrão. Para consultas somente leitura:
var products = await _db.Products
.FromSqlInterpolated($"SELECT * FROM Products WHERE Price > {minPrice}")
.AsNoTracking() // Sem rastreamento de alterações — melhor desempenho para leituras
.ToListAsync();Quando o SQL Bruto Supera o LINQ
| Cenário | Usar SQL Bruto |
|---|---|
| Funções de janela (ROW_NUMBER, RANK, LAG) | Sim |
| CTEs ou consultas recursivas | Sim |
| Pesquisa de texto completo | Sim |
| Instruções MERGE / UPSERT | Sim |
| Stored procedures | Sim |
| Hints específicos do banco de dados (NOLOCK, FORCESEEK) | Sim |
| Agregações complexas com ROLLUP/CUBE | Sim |
| Consultas que geram SQL ruim via LINQ | Sim |
| CRUD simples e consultas filtradas | Não — LINQ é suficiente |
Verificando o SQL Gerado
Antes de recorrer ao SQL bruto, verifique o que o LINQ gera — pode ser perfeitamente adequado:
// Registrar consultas no console em desenvolvimento
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
if (builder.Environment.IsDevelopment())
options.LogTo(Console.WriteLine, LogLevel.Information);
});Ou inspecionar a consulta sem executá-la:
var query = _db.Products
.Where(p => p.CategoryId == 5)
.OrderBy(p => p.Price)
.Select(p => new { p.Id, p.Name, p.Price });
// Obter o SQL sem executar
var sql = query.ToQueryString();
Console.WriteLine(sql);Exemplo Específico do PostgreSQL
// Consulta JSONB do PostgreSQL — não pode ser expressa em 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();Verifique você mesmo
Cada afirmação acima é verificada por
samples/ef-core-raw-sql
— 19 verificações que lançam exceção se falharem (EF Core 10, SQL Server LocalDB). Os destaques:
- A injeção é real:
' OR '1'='1concatenado emFromSqlRawvaza as 12 linhas semeadas; a entrada idêntica através deFromSqlInterpolatednão encontra nenhuma. - Compor
Where+OrderBy+Includesobre umSELECTbruto executa como uma única instrução, com a subconsulta visível noToQueryString(). ROW_NUMBER()em uma propriedadeintdo DTO lançaInvalidCastException; comCAST(... AS int), o rank 1 cai no produto mais caro de cada categoria.- Uma CTE materializa em um record comum; uma stored procedure materializa entidades rastreadas, recusa composição LINQ com
InvalidOperationExceptione devolve seu parâmetro OUTPUT. AsNoTracking()mantém o change tracker vazio (contra 11 entidades rastreadas sem ele);ToQueryString()executa zero instruções;MERGEpercorre seus dois caminhos, insert e update.

Execute primeiro o seed.sql
e depois dotnet run — o seed também cria as duas stored procedures. A pesquisa de texto completo e o exemplo JSONB do PostgreSQL são as duas seções não verificadas: o LocalDB não tem mecanismo de texto completo e o sample roda apenas no SQL Server.
Quando o SQL Bruto Se Torna o Caso Comum
Se você perceber que a maioria das suas consultas migrou para SQL bruto, trate isso como um sinal e não como um hábito. Nesse ponto a comparação em EF Core vs Dapper é a relevante — o Dapper foi construído exatamente para esse estilo de acesso e não cobra por um change tracker que você não está usando.
Antes de concluir que o LINQ é o gargalo, no entanto, compare o SQL gerado com os padrões
de o problema de consultas N+1. Uma consulta LINQ lenta é
muito mais frequentemente um Include ou uma projeção ausente do que uma limitação real
do tradutor.