Logging slow async queries with SQLAlchemy events

Attach before_cursor_execute and after_cursor_execute to engine.sync_engine, stash a perf_counter() in conn.info, and log only the statements that exceed a threshold — never the bound parameters, and never every query. This is the timing instrument from instrumenting and observing async queries, in full.

Quick Answer

echo=True and a threshold listener are not alternatives Two approaches. echo=True writes every statement the engine executes, together with the bound parameters, through the Python logging system at INFO — so a hundred queries per request become a hundred log lines each carrying whatever user data the query touched. A threshold listener writes one line per statement that exceeded the threshold, with the parameterised SQL and a count of parameters rather than their values. On a healthy system that is zero lines per request, and during an incident it is exactly the statements you want to see. echo=True every statement, at INFO including the bound parameters 100 queries per request → 100 lines development only, and even there sparingly a threshold listener only statements over the threshold parameterised SQL, parameter count only zero lines on a healthy request safe to leave enabled in production The parameters are the difference that matters: they contain e-mail addresses, tokens and references, so a debug log of every statement is a log of everything they touched.

Before — echo=True, which is not a slow-query log:

engine = create_async_engine(DATABASE_URL, echo=True)
# INFO sqlalchemy.engine.Engine SELECT customers.email ... WHERE customers.id = %(id)s
# INFO sqlalchemy.engine.Engine [generated in 0.00012s] {'id': 4821}
#                                                       ^^^^^^^^^^^ user data, in the log

After — a threshold listener on the synchronous engine:

from __future__ import annotations

import logging
import time

from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

logger = logging.getLogger("db.slow")


def install_slow_query_log(async_engine: AsyncEngine, threshold_s: float = 0.25) -> None:
    engine = async_engine.sync_engine

    @event.listens_for(engine, "before_cursor_execute")
    def _before(conn, cursor, statement, parameters, context, executemany):
        conn.info["query_started"] = time.perf_counter()

    @event.listens_for(engine, "after_cursor_execute")
    def _after(conn, cursor, statement, parameters, context, executemany):
        started = conn.info.pop("query_started", None)
        if started is None:
            return
        elapsed = time.perf_counter() - started
        if elapsed < threshold_s:
            return
        logger.warning(
            "slow query",
            extra={
                "elapsed_ms": round(elapsed * 1000, 1),
                "statement": " ".join(statement.split())[:500],
                "param_count": _count_params(parameters),
                "executemany": bool(executemany),
            },
        )


def _count_params(parameters) -> int:
    if parameters is None:
        return 0
    if isinstance(parameters, (list, tuple)):
        return len(parameters)
    return len(parameters) if hasattr(parameters, "__len__") else 1


engine = create_async_engine(DATABASE_URL)
install_slow_query_log(engine, threshold_s=0.25)

On a healthy request this produces no output at all. During an incident it produces exactly the statements that were slow, without a single user value.

Execution Context & Async Workflow Integration

These listeners run synchronously, inside the greenlet that AsyncSession established, on the event loop thread. That places two hard constraints on what they may do.

They cannot await. A listener that needs to reach a network service — pushing a metric, calling an API — has to hand the work to something else, and the only safe handoff from synchronous code on the loop is a thread-safe queue drained elsewhere.

# Async — handing work out of a synchronous listener
import queue
import threading

_slow_queries: queue.SimpleQueue = queue.SimpleQueue()


def _after(conn, cursor, statement, parameters, context, executemany):
    started = conn.info.pop("query_started", None)
    if started is None:
        return
    elapsed = time.perf_counter() - started
    if elapsed >= 0.25:
        # Non-blocking: the listener returns immediately, and a worker does the work.
        _slow_queries.put_nowait((elapsed, " ".join(statement.split())[:500]))


def _drain_forever() -> None:
    while True:
        elapsed, statement = _slow_queries.get()
        metrics.observe("db_slow_query_seconds", elapsed, statement=statement)


threading.Thread(target=_drain_forever, daemon=True).start()

And whatever they do costs wall-clock time on every statement. A listener that formats a statement, walks a parameter structure, or serialises anything is adding that cost to the fast queries too — which is why the example above does its formatting only after the threshold check has passed.

The measurement itself covers driver execution: network out, server work, network back. It excludes statement compilation, which happens before before_cursor_execute, and result hydration, which happens after after_cursor_execute returns. A handler that is slow while every statement is fast is spending its time in one of those two places, or between statements — and finding that needs the checkout-level timing described in the parent guide, not this listener.

Per-connection state, not per-module state Four steps showing why conn.info is the right place for the timestamp. Two coroutines issue queries at the same moment. Each has checked out a different connection from the pool, because a connection may only run one statement at a time. before_cursor_execute stores its start time on the connection that is about to run the statement, so the two timestamps live in different places. after_cursor_execute reads the value back from the same connection, and pops it so a statement that somehow skips the after hook cannot leave stale state behind for the next statement on that connection. two coroutines, two statements issued at the same moment ordinary under asyncio each holds a different connection one statement per connection at a time guaranteed by checkout the start time is stored on the connection conn.info["query_started"] never a module global the after hook pops it back off pop, not read — no stale state A module-level variable would have the two coroutines overwriting each other, producing timings that are wrong in ways that look plausible — occasionally negative.

Resolving Warnings, Errors & Common Mistakes

SymptomRoot CauseProduction Fix
No log output at all, and queries are definitely slowListeners attached to the AsyncEngine rather than to engine.sync_engine.Attach to sync_engine; cursor events are emitted there.
Negative or absurd elapsed timesStart time kept in a module-level variable, so concurrent coroutines overwrote each other.Use conn.info, which is per-connection.
The log floods during an incidentEvery statement is slow because the database is struggling, so every statement logs.Sample above the threshold, and rate-limit per normalised statement.
KeyError: 'query_started'A statement reached the after hook without passing the before hook — some error paths do.Use conn.info.pop(key, None) and return early on None, as above.
User data appearing in logsecho=True, or a listener logging parameters.Log the parameterised statement and a count. Treat echo=True in a deployed environment as an incident.
Log volume acceptable but uselessThe statement is logged unnormalised, so the same query appears with different whitespace and never groups.Collapse whitespace and truncate, as " ".join(statement.split())[:500] does.

Advanced: Sampling, Rate Limiting and Statement Fingerprints

A threshold is enough until the database itself degrades — at which point every statement crosses it and the log becomes the second problem. Two additions keep it useful under exactly the conditions you need it.

from __future__ import annotations

import hashlib
import random
import re
import time
from collections import defaultdict

# Collapse literals so `WHERE id = 4821` and `WHERE id = 9130` share a fingerprint.
_NUMBER = re.compile(r"\b\d+\b")
_QUOTED = re.compile(r"'[^']*'")
_IN_LIST = re.compile(r"IN\s*\([^)]*\)", re.IGNORECASE)


def fingerprint(statement: str) -> str:
    normalised = " ".join(statement.split())
    normalised = _IN_LIST.sub("IN (?)", normalised)
    normalised = _QUOTED.sub("?", normalised)
    normalised = _NUMBER.sub("?", normalised)
    return hashlib.sha1(normalised.encode()).hexdigest()[:12]


class SlowQueryReporter:
    """Threshold, then sample, then rate-limit per fingerprint."""

    def __init__(self, threshold_s: float = 0.25, sample: float = 1.0,
                 per_fingerprint_per_minute: int = 6) -> None:
        self.threshold_s = threshold_s
        self.sample = sample
        self.limit = per_fingerprint_per_minute
        self._seen: dict[str, list[float]] = defaultdict(list)

    def should_report(self, elapsed: float, statement: str) -> str | None:
        if elapsed < self.threshold_s:
            return None
        if self.sample < 1.0 and random.random() > self.sample:
            return None
        fp = fingerprint(statement)
        now = time.monotonic()
        recent = [t for t in self._seen[fp] if now - t < 60.0]
        if len(recent) >= self.limit:
            self._seen[fp] = recent
            return None
        recent.append(now)
        self._seen[fp] = recent
        return fp

The fingerprint is what makes the rate limit meaningful: without it, a thousand distinct-but-identical statements each get their own budget and the flood returns. With it, a single badly-planned query reports six times a minute and the rest of the log stays readable.

Sampling is worth setting below one only on very high-throughput services, and it changes what the log means — an absent statement no longer implies an absent problem. A useful compromise is to sample the reporting but count every occurrence in a metric, so the counter stays exact while the log stays bounded.

The other addition worth making is a second, much higher threshold that always logs and never samples. A query taking ten seconds is qualitatively different from one taking three hundred milliseconds, and it should never be dropped by a sampling decision.

What belongs in the log line Four fields and one exclusion. The normalised statement — whitespace collapsed and truncated — is what lets identical queries be grouped. The elapsed time in milliseconds is the reason the line exists. The parameter count distinguishes a single statement from an executemany of ten thousand rows without revealing anything. And a trace or request identifier is what connects the slow query back to the request that issued it. The bound parameter values must never appear: they are user data, and once in a log they are in every downstream system that log reaches. statement (normalised) whitespace collapsed, truncated so identical queries group elapsed_ms the reason the line exists and the field you alert on param_count one statement or ten thousand rows reveals nothing parameter VALUES never — this is user data and log retention is long Add a trace or request id as a fifth field if you have one: connecting a slow statement back to the request that issued it is most of the value during an incident.

Choosing a Threshold You Will Actually Act On

A threshold set by intuition is usually either so low that the log is noise or so high that nothing ever appears. Setting it from the data takes one afternoon and does not need to be repeated often.

Start by recording elapsed time for every query as a histogram metric — which is cheap, because a histogram observation is arithmetic — and leave the log threshold high enough to be silent. After a day of real traffic, look at the distribution: the p99 is a reasonable first threshold, because by construction it fires on one query in a hundred.

Then adjust for what you can act on. If the p99 is four milliseconds because the workload is all primary-key lookups, a threshold there will fire constantly on queries nobody can improve. If it is two seconds because a nightly report dominates, a threshold there will miss everything the API does. In both cases the answer is a per-context threshold rather than a single global one:

# A lower threshold for request-path queries than for batch work
from myapp.context import in_request_path

def _threshold() -> float:
    return 0.15 if in_request_path() else 2.0

Finally, decide in advance what a log line means operationally. If a slow query does not lead to anyone looking at it, the threshold is set for a purpose nobody has, and the honest move is either to alert on the metric instead or to raise the threshold until the log is short enough that someone reads it. A slow-query log that is never read is indistinguishable from one that is never written, except in cost.

Frequently Asked Questions

Why not just use the database's own slow-query log?

Use both — they answer different questions. PostgreSQL's log_min_duration_statement sees every client and cannot be bypassed by application code, which makes it authoritative. The application-side listener knows which request, which handler and which trace the statement belonged to, which is what turns "this query is slow" into "this endpoint is slow".

Does the listener measure ORM overhead too?

No. It measures from just before the driver call to just after it returns, so compilation and result hydration are both outside the interval. That is usually what you want for a slow-query log — but it means a handler that is slow with fast queries needs a different instrument, at the request or checkout level.

Is it safe to log the compiled statement?

Yes. The statement SQLAlchemy passes to the driver carries placeholders, not values — the values arrive separately in parameters. So the statement is safe to log and the parameters are not, which is exactly the split the examples here rely on.

What threshold should I start with?

Record a histogram of every query first and set the threshold at the p99 after a day of real traffic, then adjust so the log is short enough that someone reads it. Different thresholds for request-path and batch work are usually worth more than getting a single global number exactly right.