ASGI middleware¶
A per-request query panel for the terminal. FastAPI, Starlette and Litestar have never had a Django-Debug-Toolbar equivalent for SQL; this is the smallest useful version of one.
Pure ASGI3 — it speaks the protocol directly, with no framework dependency and no new packages.
Mount it¶
Every request now logs its query count, and any request that trips a detector logs the full report:
WARNING queryspy: GET /projects - 12 queries in 41.3ms (38.9ms in the database, 31.2ms of it in one statement:
SELECT task.id, task.title FROM task WHERE :param_1 = task.project_id)
N+1 detected: 11 queries for Project.tasks (lazy load)
triggered from app/api/projects.py:31 in list_projects()
SELECT task.id AS task_id, task.title AS task_title ...
fix: .options(selectinload(Project.tasks))
Options¶
| Argument | Default | Effect |
|---|---|---|
budget |
None |
Warn when a request exceeds this many statements |
threshold |
2 |
Repeats required before something is a finding |
capture_stacks |
True |
Attribute findings to your source lines |
add_headers |
True |
Add x-queryspy-queries / x-queryspy-findings |
logger |
logging.getLogger("queryspy") |
Where reports go |
on_report |
None |
Receive every RequestReport yourself |
panel |
False |
Serve the debug panel |
panel_path |
/__queryspy__ |
Where to serve it |
history |
50 |
How many requests the panel remembers |
The panel¶
The piece FastAPI has never had an equivalent of:
Then open /__queryspy__. Recent requests, their query counts, how much of the
wall-clock time was actually spent in the database, and every finding with its
source line and fix — expandable per request.
A single self-contained HTML page: no CDN, no external stylesheet, nothing fetched from anywhere. It works offline and behind a firewall.
Off by default, and it should stay off in production
The panel renders SQL. Bind parameters are never captured anywhere in
queryspy — a recorded statement keeps its :param_1 placeholders — so it
shows query shapes, never values. That is what makes it safe to look at in
staging. It still describes your schema in detail, and it is unauthenticated:
mount it where your other debug surfaces live, or gate it behind whatever you
already use.
History is bounded (history=50) so a long-running process does not grow a
buffer forever.
Routing reports somewhere else¶
def to_metrics(report):
statsd.gauge("db.queries", report.query_count, tags=[f"path:{report.path}"])
if not report.clean:
statsd.increment("db.n_plus_one")
app.add_middleware(QuerySpyMiddleware, on_report=to_metrics)
RequestReport carries method, path, query_count, findings,
duration_ms (wall clock), db_duration_ms (time actually in the driver),
slowest, a clean property, and render() for the human-readable form.
Timing is reported per request, never per finding. Attributing milliseconds to a specific finding would mean correlating the ORM and cursor layers, and that correlation is the one thing the recorder refuses to do — see the design notes. Often the headline is not the N+1 at all: twelve queries where one takes 31 of the 39 milliseconds is a slow query wearing an N+1's clothes.
Behaviour worth knowing¶
Concurrent requests are isolated. The recorder is scoped to a context variable, not a module global, so interleaved requests never record each other's queries. This holds for async lazy loads too, which run inside a greenlet that SQLAlchemy spawns — context variables propagate across that bridge.
Failed requests still report. The report is emitted from a finally, so a
request that raised — exactly the one whose queries you want to see — is not
skipped.
Headers reflect response-start. Headers must be written with
http.response.start, which for a streaming response happens before the body
runs. The header is the count at that moment; the log line is emitted after the
request completes and is always final.
Development and staging
This records every request, and stack capture is not free. It is built for
environments where you are looking at the logs. If you want it further on,
run it with capture_stacks=False and route on_report at your metrics
system rather than the logger.