FromSql
Start an entity query from hand-written SQL with FromSql, keep every value parameterised, and know which SQL can be composed with LINQ.
public static IQueryable<TEntity> FromSql<TEntity>(this DbSet<TEntity> source, FormattableString sql) where TEntity : class- Added in .NET EF Core 7.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$source | DbSet<TEntity> | The entity set the rows map to. The SQL must return every mapped column of the entity, with matching column names. |
$sql | FormattableString | An interpolated string. Each {hole} becomes a DbParameter; identifiers such as table or column names cannot be parameters. Values can also be DbParameter instances for explicit types or names. |
Return value
IQueryable<TEntity>A query rooted in the given SQL. Further LINQ operators are composed around it as a subquery, and the results are tracked entities unless AsNoTracking is applied.
Errors
InvalidOperationException at execution when a mapped column is missing from the result set, or when LINQ is composed over SQL that cannot be a subquery. The provider's DbException for SQL errors.
Changelog
- PHP EF Core 3.0The string-based FromSql was split into FromSqlRaw(string, params object[]) and FromSqlInterpolated(FormattableString).
- PHP EF Core 7.0FromSql(FormattableString) added as the preferred name; FromSqlInterpolated remains as an equivalent.
- PHP EF Core 8.0Database.SqlQuery<T> can return unmapped types, so DTO rows no longer need a keyless entity.
FromSql lets a query start from SQL you write while the rest stays LINQ. It is for SQL that LINQ cannot express or that EF Core translates badly: PostgreSQL full-text search, a hand-tuned CTE, a table-valued function, a query hint. The rows still map to an entity type, so the result works with Where, OrderBy, Include, tracking and query filters.
Parameters are the point
The argument is a FormattableString, not a string. In FromSql($"... WHERE \"Sku\" = {sku}") the hole is sent as a parameter (@p0 or $1), never pasted into the text, so it is safe from injection. The type system enforces this: a string variable does not compile here. Its sibling FromSqlRaw(string, params object[]) does accept any string, and concatenating input into it is the classic EF Core injection bug.
| API | Input | Use |
|---|---|---|
FromSql / FromSqlInterpolated | FormattableString | Default for entity queries |
FromSqlRaw | string + parameters | SQL whose structure is built at run time from an allow-list |
Database.SqlQuery<T> | FormattableString | Scalars or DTOs that are not entities |
Database.ExecuteSqlAsync | FormattableString | Commands that return no rows |
Parameters carry values, not identifiers. A client-chosen sort column is best handled with a LINQ OrderBy switch after FromSql. If it must be in the SQL text, map it through a fixed allow-list ("price" => "\"Price\"") and concatenate only that allow-listed text into FromSqlRaw; that is the single exception to the rule that nothing is ever interpolated or concatenated into raw SQL.
Composition rules
When you add LINQ after FromSql, EF Core wraps your SQL as a subquery: SELECT ... FROM (your sql) AS p WHERE ... ORDER BY .... That works only for SQL that can be a subquery: a single SELECT, no trailing semicolon, no stored procedure call. For non-composable SQL (EXEC on SQL Server, a procedure that returns rows), execute it straight away with ToListAsync and do the rest in memory. On SQL Server an ORDER BY inside a subquery is rejected without TOP, so order in LINQ.
The SQL must return every mapped column of the entity, with names that match the column mapping; a missing column fails at execution. It cannot load related data itself; add Include after it. Results are tracked by default, so add AsNoTracking for reads, and the entity's global query filters are applied to the outer query.
Production failure modes
- A developer builds
var sql = $"... '{term}'"into astringand switches toFromSqlRawbecauseFromSqlwould not compile. EF Core's analyzer reports warning EF1002 for an interpolated string passed toFromSqlRaw; treat it as an error. - Full-text search over
to_tsvector(...)computed per row reads the whole table on every request unless a GIN index exists on exactly that expression (or on a stored generated tsvector column the query uses). - A column renamed in a migration breaks the hand-written SQL only at run time. Cover each raw query with an integration test against the real database.
Example
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<CatalogDb>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Catalog")));
var app = builder.Build();
// PostgreSQL full-text search, which LINQ cannot express, composed with LINQ paging.
// Needs a GIN index on exactly this to_tsvector(...) expression, or every search scans Products:
// CREATE INDEX ix_products_search ON "Products"
// USING GIN (to_tsvector('english', "Name" || ' ' || "Description"));
app.MapGet("/products/search", async (string q, int? maxPrice, CatalogDb db, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(q) || q.Length > 100)
return Results.BadRequest("q must be 1-100 characters.");
var query = db.Products
.FromSql($@"
SELECT p.*
FROM ""Products"" AS p
WHERE to_tsvector('english', p.""Name"" || ' ' || p.""Description"")
@@ websearch_to_tsquery('english', {q})")
.AsNoTracking();
if (maxPrice is int max)
query = query.Where(p => p.Price <= max); // added outside the raw SQL, still parameterised
var results = await query
.OrderBy(p => p.Price)
.Take(20)
.Select(p => new ProductHit(p.Id, p.Sku, p.Name, p.Price))
.ToListAsync(ct);
return Results.Ok(results);
});
app.Run();
public record ProductHit(int Id, string Sku, string Name, decimal Price);
public class Product
{
public int Id { get; set; }
public string Sku { get; set; } = "";
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public decimal Price { get; set; }
}
public class CatalogDb(DbContextOptions<CatalogDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}When to use it
- Searching products with PostgreSQL websearch_to_tsquery and still paging and projecting the results with LINQ
- Reading daily revenue rows into a record with Database.SqlQuery<T> for a dashboard endpoint
- Reviewing a pull request that switched from FromSql to FromSqlRaw and finding user input concatenated into the SQL
Frequently asked questions
What is the difference between FromSql and FromSqlRaw?
FromSql takes a FormattableString, so every interpolated value becomes a parameter and a plain string does not compile. FromSqlRaw takes any string plus separate parameter values; it is safe only when the values are passed as arguments, never concatenated.
Can I call a stored procedure with FromSql?
Yes, as long as nothing is composed over it: a procedure call cannot be a subquery. Call ToListAsync or AsAsyncEnumerable directly after FromSql and filter in memory, or turn the procedure into a function that can be selected from. If the entity has global query filters, EF Core composes them around the call and it fails; IgnoreQueryFilters() fixes that but also drops tenant isolation, so pass the tenant id to the procedure as a parameter, or read the rows with Database.SqlQuery<T> and enforce tenancy in the SQL.
Do global query filters apply to FromSql?
Yes. EF Core composes the entity's filters around your SQL as an outer WHERE. They do not apply to Database.SqlQuery or ExecuteSqlAsync.
More examples
Pitfall: string building into FromSqlRaw
In <code>FromSqlRaw</code> the <code>{0}</code> placeholders are turned into parameters, but only when the values are passed as arguments. A quoted <code>'{0}'</code> or a concatenated value goes into the SQL text. For any lookup this simple, plain LINQ is the right answer.
using Microsoft.EntityFrameworkCore;
public sealed class CustomerLookup(ShopDb db)
{
// Injectable: email = "x' OR '1'='1" returns every customer.
public Task<List<Customer>> ByEmailUnsafe(string email, CancellationToken ct) =>
db.Customers.FromSqlRaw("SELECT * FROM \"Customers\" WHERE \"Email\" = '" + email + "'")
.ToListAsync(ct);
// Safe: the hole becomes a parameter; the SQL text never contains the value.
public Task<List<Customer>> ByEmail(string email, CancellationToken ct) =>
db.Customers.FromSql($"SELECT * FROM \"Customers\" WHERE \"Email\" = {email}")
.AsNoTracking()
.ToListAsync(ct);
// Also safe: FromSqlRaw with positional parameters.
public Task<List<Customer>> ByEmailRaw(string email, CancellationToken ct) =>
db.Customers.FromSqlRaw("SELECT * FROM \"Customers\" WHERE \"Email\" = {0}", email)
.AsNoTracking()
.ToListAsync(ct);
}
public class Customer
{
public int Id { get; set; }
public string Email { get; set; } = "";
}
public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
}Database.SqlQuery for rows that are not entities
<code>SqlQuery<T></code> is not tied to an entity, so no global query filter is applied: the tenant predicate is written into the SQL by hand. That is the main reason to prefer <code>FromSql</code> on a filtered entity when the result is entity-shaped.
using Microsoft.EntityFrameworkCore;
public record DailyRevenue(DateOnly Day, decimal Revenue, int Orders);
public sealed class RevenueReport(ShopDb db)
{
// Column aliases must match the record's property names.
public Task<List<DailyRevenue>> Last30Days(Guid tenantId, CancellationToken ct) =>
db.Database.SqlQuery<DailyRevenue>($@"
SELECT date_trunc('day', o.""PlacedAt"")::date AS ""Day"",
sum(o.""Total"") AS ""Revenue"",
count(*)::int AS ""Orders""
FROM ""Orders"" AS o
WHERE o.""TenantId"" = {tenantId}
AND o.""PlacedAt"" >= now() - interval '30 days'
GROUP BY 1")
.OrderBy(r => r.Day)
.ToListAsync(ct);
}
public class Order
{
public int Id { get; set; }
public Guid TenantId { get; set; }
public decimal Total { get; set; }
public DateTimeOffset PlacedAt { get; set; }
}
public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}A client-chosen sort column through an allow-list
The rule is: never put values into <code>FromSqlRaw</code> text. An identifier picked from a fixed allow-list is the single exception, because the client only chooses a key and the SQL text comes from your code; <code>{0}</code> is still bound as a parameter to <code>categoryId</code>. <code>PageLinq</code> shows the better default, which needs no raw SQL and no exception to the rule.
using Microsoft.EntityFrameworkCore;
public sealed class ProductBrowse(CatalogDb db)
{
// Identifiers cannot be parameters, so map the client's choice to fixed SQL text.
private static readonly Dictionary<string, string> SortColumns = new(StringComparer.OrdinalIgnoreCase)
{
["price"] = "\"Price\"",
["name"] = "\"Name\"",
["newest"] = "\"CreatedAt\" DESC",
};
public async Task<List<Product>> Page(string sort, int categoryId, CancellationToken ct)
{
if (!SortColumns.TryGetValue(sort, out var orderBy))
throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown sort.");
// The one allowed exception: orderBy is fixed text from the dictionary, never client input.
// No interpolation (which EF1002 flags); categoryId is still a parameter through {0}.
var sql = "SELECT * FROM \"Products\" WHERE \"CategoryId\" = {0} ORDER BY " + orderBy + " LIMIT 50";
return await db.Products
.FromSqlRaw(sql, categoryId)
.AsNoTracking()
.ToListAsync(ct);
}
// Usually better: no raw SQL at all, the sort is a LINQ switch.
public Task<List<Product>> PageLinq(string sort, int categoryId, CancellationToken ct)
{
var query = db.Products.AsNoTracking().Where(p => p.CategoryId == categoryId);
query = sort.ToLowerInvariant() switch
{
"price" => query.OrderBy(p => p.Price),
"name" => query.OrderBy(p => p.Name),
"newest" => query.OrderByDescending(p => p.CreatedAt),
_ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown sort."),
};
return query.Take(50).ToListAsync(ct);
}
}
public class Product
{
public int Id { get; set; }
public int CategoryId { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
public class CatalogDb(DbContextOptions<CatalogDb> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
Discussion