Working with Raw SQL and Stored Procedures in EF Core

LINQ covers 95 % of data access needs, but that last 5 %—legacy stored procedures, tricky window functions, mass updates—still matters. EF Core lets you drop to SQL without giving up the unit-of-work pattern or connection pooling.

Real-Life Analogy: The Red Telephone

In a modern office you normally chat via Slack (LINQ). For critical incidents you pick up the red hotline straight to the data team (raw SQL). It bypasses layers but is logged, secured, and used only when necessary.

Reading Data with Raw SQL

var dtos = await ctx.ProductSummaries
                   .FromSqlInterpolated($@"
                       SELECT Id, Name, Price
                       FROM Products
                       WHERE Price > {minPrice}")
                   .AsNoTracking()
                   .ToListAsync();
  • Interpolation produces @p0 parameters—immune to SQL injection.
  • Target can be a mapped entity or a Keyless Entity Type (modelBuilder.Entity<ProductSummary>().HasNoKey()).

Keyless Entity Types

Perfect for views or multi-column primary keys you don’t edit. They skip change tracker even without AsNoTracking().

Executing Stored Procedures

// Result set to entity graph
var orders = await ctx.Orders
                      .FromSqlRaw("EXEC dbo.GetRecentOrders @p0", days)
                      .ToListAsync();

Output Parameters & Scalar Results

var totalParam = new SqlParameter
{
    ParameterName = "@total",
    SqlDbType = SqlDbType.Decimal,
    Direction = ParameterDirection.Output,
    Precision = 18,
    Scale = 2
};

await ctx.Database.ExecuteSqlRawAsync(
    "EXEC dbo.CalcTotalSales @year, @total OUTPUT",
    new SqlParameter("@year", 2025), totalParam);

decimal totalSales = (decimal)totalParam.Value;

Multiple Result Sets

EF Core 8 adds context.Database.SqlQuery<T>() that streams multiple result sets with NextResultAsync(), keeping type safety.

Raw SQL Inside Transactions

await using var tx = await ctx.Database.BeginTransactionAsync();
await ctx.Database.ExecuteSqlAsync("UPDATE Stock SET Qty = Qty - 1 WHERE Id = @p0", sku);
order.Status = OrderStatus.Paid;
await ctx.SaveChangesAsync();
await tx.CommitAsync();

Unit of work remains intact—commits or rolls back as one.

Migrations & Stored Procedures

migrationBuilder.Sql(@"
    CREATE PROCEDURE dbo.GetRecentOrders
    @days INT
AS
BEGIN
    SELECT * FROM Orders WHERE DATEDIFF(day, CreatedAt, GETDATE()) < @days
END");

Store SQL inside migrations to keep schema evolution traceable.

Safety Checklist

  • Parameterise every variable—never string-concatenate.
  • Wrap raw SQL calls in repository or gateway classes; keep them discoverable.
  • Add integration tests that fail when the procedure signature drifts.

Final Thoughts

Raw SQL is a power tool—perfect for bulk operations, reporting joins, and enterprise procs. Use it sparingly, wrap it cleanly, and EF Core will play nicely with both worlds.

Working with Raw SQL and Stored Procedures in EF Core | SimplyAdvanced.dev