The quickest way to see EF Core's SQL is LogTo(Console.WriteLine, LogLevel.Information) in your DbContext options, or ToQueryString() on a query to read its SQL without executing it.
For more than ad-hoc debugging, query tags label a call site, a DbCommandInterceptor logs durations and slow queries, and MiniProfiler shows every query in a request.
The LINQ expression is not what your database executes. Generated SQL reveals accidental joins, missing predicates, parameter values, and repeated queries that are invisible at the C# level. EF Core gives you lightweight inspection for development and structured diagnostics for production.
Why You Need to See the SQL
SQL logging in EF Core means routing the commands the provider sends to the database, and optionally their parameter values, into a log you can read.
EF Core generates SQL behind the scenes. Most of the time it's fine, but sometimes it generates queries that are inefficient, missing indexes, or drastically different from what you expected. If you're not looking at the SQL, you're flying blind.
Check generated SQL during development, especially for complex LINQ queries.
The log output exposes N+1 problems, unnecessary subqueries, and missing WHERE clauses that are easy to miss in C#.
Understanding what EF Core produces is essential for EF Core performance optimization.
Built-in Logging
The simplest approach - configure EF Core to log SQL to the console or your logging framework:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseNpgsql(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();
});
LogTo(Console.WriteLine): Sends all EF Core log messages to the consoleEnableSensitiveDataLogging(): Shows parameter values in logs (disable in production!)EnableDetailedErrors(): Includes more context in error messages
To filter only SQL queries:
options.LogTo(
Console.WriteLine,
new[] { DbLoggerCategory.Database.Command.Name },
LogLevel.Information);
This outputs something like:
Executed DbCommand (5ms) [Parameters=[@__id_0='?' (DbType = Guid)],
CommandType='Text', CommandTimeout='30']
SELECT p."Id", p."Name", p."Price"
FROM "Products" AS p
WHERE p."Id" = @__id_0
Using ILoggerFactory
For better integration with ASP.NET Core's logging:
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
options.UseNpgsql(connectionString)
.UseLoggerFactory(loggerFactory);
});
Then configure the log level in appsettings.Development.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
"Microsoft.EntityFrameworkCore.Infrastructure": "Warning"
}
}
}
This lets you control EF Core logging granularity through configuration.
ToQueryString() for Ad-Hoc Inspection
During debugging, you can convert any LINQ query to its SQL representation without executing it:
var query = dbContext.Products
.Where(p => p.Price > 100)
.OrderBy(p => p.Name)
.Take(10);
var sql = query.ToQueryString();
Console.WriteLine(sql);
// Output:
// SELECT p."Id", p."Name", p."Price"
// FROM "Products" AS p
// WHERE p."Price" > 100.0
// ORDER BY p."Name"
// LIMIT 10
This is useful for checking query shape before execution and in provider-backed tests that verify LINQ-to-SQL translation.
Query Tags
Add descriptive tags to queries so you can identify them in logs and database monitoring tools:
var products = await dbContext.Products
.TagWith("GetPopularProducts - called from DashboardController")
.Where(p => p.OrderCount > 100)
.OrderByDescending(p => p.OrderCount)
.Take(20)
.ToListAsync();
The generated SQL includes the tag as a comment:
-- GetPopularProducts - called from DashboardController
SELECT p."Id", p."Name", p."OrderCount"
FROM "Products" AS p
WHERE p."OrderCount" > 100
ORDER BY p."OrderCount" DESC
LIMIT 20
This makes it trivial to trace slow queries back to their source code. In pg_stat_activity, slow query logs, or your database monitoring tool, you'll see the comment right alongside the query.
Interceptors for Advanced Logging
Use interceptors for fine-grained control over query logging:
public class QueryLoggingInterceptor : DbCommandInterceptor
{
private readonly ILogger<QueryLoggingInterceptor> _logger;
public QueryLoggingInterceptor(ILogger<QueryLoggingInterceptor> logger)
{
_logger = logger;
}
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
_logger.LogDebug("Executing query:\n{Sql}", command.CommandText);
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
if (eventData.Duration.TotalMilliseconds > 500)
{
_logger.LogWarning(
"Slow query detected ({Duration}ms):\n{Sql}",
eventData.Duration.TotalMilliseconds,
command.CommandText);
}
return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
}
Register the interceptor:
builder.Services.AddSingleton<QueryLoggingInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
var interceptor = sp.GetRequiredService<QueryLoggingInterceptor>();
options.UseNpgsql(connectionString)
.AddInterceptors(interceptor);
});
Detecting N+1 Queries
N+1 queries are the most common of the EF Core query performance mistakes. An interceptor can detect them (register it as scoped so the counter is per request):
public class NPlus1DetectorInterceptor : DbCommandInterceptor
{
private readonly ILogger<NPlus1DetectorInterceptor> _logger;
private int _queryCount;
private string? _currentEndpoint;
public NPlus1DetectorInterceptor(
ILogger<NPlus1DetectorInterceptor> logger)
{
_logger = logger;
}
public void ResetForRequest(string endpoint)
{
_queryCount = 0;
_currentEndpoint = endpoint;
}
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
_queryCount++;
if (_queryCount > 10)
{
_logger.LogWarning(
"Potential N+1 detected: {Count} queries for {Endpoint}.\n" +
"Latest query:\n{Sql}",
_queryCount, _currentEndpoint, command.CommandText);
}
return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
}
Using MiniProfiler
MiniProfiler gives you a visual SQL profiler in your browser:
dotnet add package MiniProfiler.AspNetCore.Mvc
dotnet add package MiniProfiler.EntityFrameworkCore
Configure it:
builder.Services.AddMiniProfiler(options =>
{
options.RouteBasePath = "/profiler";
}).AddEntityFramework();
app.UseMiniProfiler();
Navigate to /profiler/results-index to see:
- Every SQL query executed per request
- Query duration
- Duplicate query detection
- Parameter values
This is my go-to tool during development for spotting performance issues.
Structured Logging for Production
In production, log query metrics without the full SQL:
public class ProductionQueryInterceptor : DbCommandInterceptor
{
private readonly ILogger<ProductionQueryInterceptor> _logger;
public ProductionQueryInterceptor(
ILogger<ProductionQueryInterceptor> logger)
{
_logger = logger;
}
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Query executed. Duration: {DurationMs}ms, " +
"CommandType: {CommandType}, " +
"HasParameters: {HasParameters}",
eventData.Duration.TotalMilliseconds,
command.CommandType,
command.Parameters.Count > 0);
return base.ReaderExecutedAsync(
command, eventData, result, cancellationToken);
}
}
Never log full SQL with parameter values in production - it may contain sensitive data. Log metrics only.
Debug View in Tests
For integration tests, capture generated SQL:
[Fact]
public async Task GetProducts_GeneratesExpectedSql()
{
var queries = new List<string>();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_connectionString)
.LogTo(sql =>
{
if (sql.Contains("SELECT") || sql.Contains("INSERT"))
{
queries.Add(sql);
}
}, LogLevel.Information)
.Options;
await using var context = new AppDbContext(options);
var products = await context.Products
.Where(p => p.IsActive)
.ToListAsync();
queries.Should().HaveCount(1);
queries[0].Should().Contain("\"IsActive\"");
}
Summary
Use ToQueryString and development logging to understand query shape before performance becomes an incident.
Use query tags and interceptors to connect slow commands to application operations.
Keep sensitive-data logging out of production and prefer duration, command metadata, and sanitized diagnostics over parameter values.
Frequently Asked Questions
How do I see the SQL generated by EF Core?
The quickest options are LogTo(Console.WriteLine) in your DbContext options, or calling ToQueryString() on any LINQ query to get its SQL without executing it.
What does EnableSensitiveDataLogging do?
It includes parameter values in EF Core log output instead of masking them. It is invaluable in development and dangerous in production, where parameters often contain personal or secret data.
How can I find slow EF Core queries?
Use a DbCommandInterceptor and check eventData.Duration after execution, logging a warning above a threshold. During development, MiniProfiler also shows per-request query timings and duplicate queries.
What are query tags in EF Core?
TagWith adds a comment above the generated SQL, so you can trace a query seen in database monitoring tools back to the exact call site in your code.
Should EF Core SQL logging be enabled in production?
Log query durations and metadata, not full SQL text with parameters. Full statements can leak sensitive data into logs and add noticeable overhead on hot paths.



