Catch Slow Queries and Rewrite Them on the Fly With EF Core Command Interceptors

Catch Slow Queries and Rewrite Them on the Fly With EF Core Command Interceptors

Author - Abdul Rahman (Bhai)

EFCore

3 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

DbContext is deliberately a black box - you ask for a list of orders and it hands one back, without you ever seeing the SQL in between. Underneath that box, though, EF Core is issuing plain ADO.NET calls: DbConnection, DbCommand, and DbDataReader. A DbCommandInterceptor gives you a hook into that exact layer, so you can watch - or rewrite - the actual command before and after it reaches the database.

That matters because plenty of production incidents live in that gap between "the LINQ query looked fine" and "the SQL it generated was not." This article covers the command interceptor variants that let you close that gap: catching slow commands, injecting hints, centralizing failure telemetry, and spotting the query-per-row pattern that quietly kills throughput.

Why we gonna do?

A repository method that loops over orders and calls order.Items.Sum(...) on a lazily-loaded navigation property looks like ordinary C# - a single in-memory calculation. In reality, if lazy loading is enabled, each access to Items is a separate round trip to the database. With ten orders that's ten extra queries nobody notices in a demo. With ten thousand orders in production, that's ten thousand extra round trips stacked on top of whatever the original query already cost.

The same blind spot shows up with individual slow commands - a query that takes 15 seconds under real data volume runs in milliseconds against a small local database, so it never gets flagged until customers complain. And when a command does fail outright, the exception surfaces wherever the calling code happens to catch it, which means there's rarely one place that sees every database failure across the whole application.

A DbCommandInterceptor sits at the one layer that sees every command regardless of which repository issued it, which makes it the right place to catch all three of these problems before they reach production.

How we gonna do?

Detect slow-running commands after execution

Overriding ReaderExecutedAsync lets you inspect how long a command took immediately after it finishes, without changing anything about the result. The same threshold check is worth reusing for ScalarExecutedAsync and NonQueryExecutedAsync too, since not every database call goes through ExecuteReader - schema checks and raw counts often go through ExecuteScalar or ExecuteNonQuery instead:


public class SlowCommandInterceptor(ILogger<SlowCommandInterceptor> logger) : DbCommandInterceptor
{
    private static readonly TimeSpan SlowThreshold = TimeSpan.FromSeconds(5);

    public override async ValueTask<DbDataReader> ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken cancellationToken = default)
    {
        var reader = await base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
        WarnIfSlow(command, eventData.Duration);
        return reader;
    }

    public override async ValueTask<object?> ScalarExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        object? result,
        CancellationToken cancellationToken = default)
    {
        var status = await base.ScalarExecutedAsync(command, eventData, result, cancellationToken);
        WarnIfSlow(command, eventData.Duration);
        return status;
    }

    public override async ValueTask<int> NonQueryExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
    {
        var resultValue = await base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
        WarnIfSlow(command, eventData.Duration);
        return resultValue;
    }

    private void WarnIfSlow(DbCommand command, TimeSpan duration)
    {
        if (duration > SlowThreshold)
        {
            logger.LogWarning("Slow command detected ({Duration}): {CommandText}", duration, command.CommandText);
        }
    }
}
            

DbCommandInterceptor doesn't implement ISingletonInterceptor, so register it scoped, matching your DbContext lifetime.

Inject query hints before a command executes

Overriding ReaderExecutingAsync instead gives you the command before it runs, which means you can rewrite CommandText itself. This is provider-specific - the example below adds a SQL Server join hint - but the same technique applies to any hint your database engine supports:


public class JoinHintInterceptor : DbCommandInterceptor
{
    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        if (command.CommandText.Contains("JOIN", StringComparison.OrdinalIgnoreCase))
        {
            command.CommandText += " OPTION (FORCE ORDER)";
        }

        return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
    }
}
            

Because this overrides the same method family used for reads and writes, it catches hints for updates and deletes too, not just SELECT statements - something a LINQ-level interceptor can't do.

Centralize command failure telemetry

CommandFailedAsync fires whenever any command throws, regardless of which repository issued it - a natural place to feed a single telemetry pipeline instead of relying on scattered try/catch blocks:


public class CommandFailureInterceptor(ILogger<CommandFailureInterceptor> logger) : DbCommandInterceptor
{
    public override async Task CommandFailedAsync(
        DbCommand command,
        CommandErrorEventData eventData,
        CancellationToken cancellationToken = default)
    {
        logger.LogError(
            "Command failed. Source: {Source}, Error: {Message}",
            eventData.CommandSource,
            eventData.Exception.Message);

        return await base.CommandFailedAsync(command, eventData, cancellationToken);
    }
}
            

This doesn't replace normal exception handling in your application - the exception still propagates as usual. It just guarantees that every command failure is observed in one place as well, even if the calling code swallows it.

Watch for the same pattern on scalar and non-query commands

The SlowCommandInterceptor shown above already covers this by overriding all three Executed methods, so a slow schema check or a slow bulk update gets flagged exactly the same way a slow SELECT does - no separate interceptor needed.

Flag repeated identical commands as a Cartesian-explosion warning sign

Lazy loading executes synchronously, so this check overrides the synchronous ReaderExecuting instead of the async version. Tracking the previous command's text is a blunt but effective way to flag the exact pattern from the earlier example - the same query shape firing over and over inside a loop:


public class RepeatedCommandInterceptor(ILogger<RepeatedCommandInterceptor> logger) : DbCommandInterceptor
{
    private string _previousCommandText = string.Empty;

    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        if (_previousCommandText == command.CommandText)
        {
            logger.LogWarning(
                "Possible Cartesian explosion from lazy loading. Repeated command: {CommandText}",
                command.CommandText);
        }

        _previousCommandText = command.CommandText;
        return base.ReaderExecuting(command, eventData, result);
    }
}
            

This heuristic won't catch every N+1 pattern, but it's enough to surface the ones that matter most - the ones firing inside a loop over a result set that's only going to get bigger in production.

Summary

Key takeaways from this article:

  • DbCommandInterceptor operates at the ADO.NET layer, below LINQ, so it sees every command regardless of which repository or code path issued it
  • ReaderExecutedAsync (and its scalar/non-query siblings) let you measure duration after execution without touching the result
  • ReaderExecutingAsync lets you rewrite CommandText before it runs - useful for provider-specific hints on both reads and writes
  • CommandFailedAsync is a single seam for centralizing failure telemetry across the whole application
  • Tracking repeated commands is a simple, effective way to catch Cartesian explosions caused by lazy loading

What to read next: if you haven't already, start the series from Add Cross-Cutting Concerns to EF Core Without Touching Your Repositories, or lock down your schema with Enforce Database Schema Standards Automatically With EF Core Conventions.

  • Efcore
  • EF Core
  • Entity Framework Core
  • DbCommandInterceptor
  • Slow Query Detection
  • Query Hints
  • Cartesian Explosion
  • Lazy Loading
  • .NET