Tracing Slow SQL Queries in Python
A single slow query in a single trace is easy to find and usually not the most important one. The query worth fixing is the one that costs the most in total, across every request that issues it, and finding it needs query spans that can be grouped by statement across the whole fleet. This page covers making query spans groupable, ranking by total time, joining application spans to the database's own statistics, and handing the result to the execution plan where the actual reason lives. It is a task article under database and I/O performance observability, part of the Python profiling and performance observability section.
Prerequisites
pip install "opentelemetry-sdk>=1.27.0,<2.0.0" \
"opentelemetry-instrumentation-sqlalchemy>=0.48b0,<1.0.0" \
"sqlalchemy>=2.0.0,<3.0.0"
Implementation
Step 1 — Record a sanitised template on every query span. The statement attribute is what lets spans group: two executions of the same query with different parameters must produce the same attribute value. Drivers that use placeholders produce templates naturally; the risk is drivers or ORMs that interpolate values before execution, which produce a distinct string per call. Checking one real span for values in its statement is worth doing before trusting any aggregation.
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
SQLAlchemyInstrumentor().instrument(
engine=engine,
enable_commenter=True, # trace context appended as a SQL comment
commenter_options={"db_driver": False, "db_framework": False},
)
Expected Output: a query span whose statement groups with every other execution of the same query.
{
"name": "SELECT app",
"attributes": {
"db.system": "postgresql",
"db.statement": "SELECT id, sku, price FROM products WHERE id = %(id_1)s",
"db.operation": "SELECT",
"db.sql.table": "products"
},
"durationMs": 18.4
}
Step 2 — Rank templates by total time. Total time is execution count multiplied by average duration, per template, over a window. It is what the database spends and what users collectively wait, and it is a query any trace backend that supports grouping can answer. Sorting by it rather than by latency is the single most useful change to how slow queries are prioritised.
-- against a span store that supports SQL over spans
SELECT attributes['db.statement'] AS template,
count(*) AS executions,
avg(duration_ms) AS avg_ms,
sum(duration_ms) / 1000 / 60 AS total_minutes
FROM spans
WHERE service = 'checkout' AND attributes['db.system'] = 'postgresql'
AND start_time > now() - interval '1 hour'
GROUP BY template
ORDER BY total_minutes DESC
LIMIT 10;
Expected Output: the ranking that decides what to optimise.
template executions avg_ms total_minutes
SELECT data FROM sessions WHERE key = %(key)s 904112 3.0 45.2
SELECT id, sku, price FROM products WHERE id = … 70214 18.0 21.1
SELECT … FROM products WHERE name ILIKE %(q)s 1702 402.0 11.4
SELECT … FROM orders JOIN … GROUP BY … 2 2410.0 0.1
Step 3 — Attach the trace context to the SQL. Appending the trace and span identifiers as a comment means the database's own slow-query log and statistics carry them. A query found in the database's view — the path a database administrator usually takes — can then be traced back to the request and code that issued it, and a slow span in a trace can be matched to the database's record of the same execution. This join is what turns two teams looking at two tools into one investigation.
Step 4 — Record rows returned. A query that is slow because it returns fifty thousand rows needs pagination or a narrower selection, not an index. One that is slow while returning one row needs an index or a better plan. Recording the row count on the span, which most instrumentation can do from the cursor, separates the two without opening the database at all.
from sqlalchemy import event
from opentelemetry import trace
@event.listens_for(engine, "after_cursor_execute")
def _rows(conn, cursor, statement, parameters, context, executemany):
span = trace.get_current_span()
if span.is_recording() and cursor.rowcount is not None and cursor.rowcount >= 0:
span.set_attribute("db.response.returned_rows", cursor.rowcount)
Step 5 — Take the template to the execution plan. The span identifies which query and how much it costs. Why it costs that — a sequential scan, a poor join order, lock waits — is visible only in the database's plan and wait statistics. Taking the template from the top of the total-time ranking, substituting representative values and reading the plan is the step where the fix is actually found.
EXPLAIN (ANALYZE, BUFFERS)
SELECT data FROM sessions WHERE key = 'representative-value';
Reading the ranking correctly
The total-time ranking is powerful and it has three properties worth understanding before acting on it.
It rewards small improvements to frequent queries. Shaving one millisecond from a query executed nine hundred thousand times an hour saves fifteen minutes of database time per hour. That is often achievable with a covering index or a narrower column list, and it is invisible in any latency-focused view because the query was never slow.
It can point at a query that should not run at all. A session read at the top of the ranking is frequently a sign that it is executed more often than necessary — once per middleware layer, or once per template include — rather than a sign that it is slow. The fix is then caching it for the duration of the request, and the query count per request from database and I/O performance observability confirms whether that is the case.
It is window-sensitive. A ranking over one hour at midday and one over one hour at night can differ substantially, because workloads change through the day. Batch jobs dominate at night; interactive queries dominate during the day. Taking the ranking over the peak period — which is when the database's capacity actually matters — is usually the right choice.
There is also a caveat about sampling. If traces are sampled, the executions counted in the span store are a fraction of the real total, and the ranking is proportional rather than absolute. Tail sampling that preferentially keeps slow traces distorts it further, over-representing slow queries. For an accurate total-time ranking, the database's own statistics — which count every execution — are the better source, and the trace context in the SQL comment is what lets the two be reconciled.
ORM-generated queries
Most Python services do not write SQL directly, and ORM-generated queries have properties that affect all of the above.
The statement template from an ORM is usually long, repetitive and stable, which is good for grouping and occasionally bad for readability: a query selecting forty columns by name produces a statement several hundred characters long before the interesting part. Truncating the attribute keeps span size reasonable, and the table attribute becomes the more useful grouping dimension for a first look.
ORMs also generate query shapes that are hard to see from the calling code. Lazy loading of a relationship produces a separate query per accessed object; an eager-loading option produces a join or a second query with an IN list whose length varies with the result set. The second case is particularly relevant to grouping, because an IN list with a different number of placeholders is a different template — ten items and eleven items produce two statements that do not group together. Some drivers normalise these, and where they do not, the ranking fragments one logical query into many. Recognising the pattern and grouping by the statement's prefix rather than its full text recovers the correct total.
Finally, the most expensive ORM query is frequently not one statement but one call site that issues many. That is the repetition problem, and it is invisible in any per-statement ranking — each individual execution is cheap. The query count per request, and the most-repeated-statement attribute on the server span, are what surface it.
Configuration options
| Attribute or setting | Purpose | Note |
|---|---|---|
db.statement |
groups executions | template only, never values |
db.operation |
SELECT, INSERT, … | coarse grouping |
db.sql.table |
the primary table | helps attribute cost to data |
db.response.returned_rows |
result size | separates "too much data" from "slow plan" |
| SQL commenter | trace context in the SQL | joins to database-side logs |
| Statement truncation | bounded attribute size | long generated SQL inflates spans |
Verification
Confirm the statement attribute groups correctly by checking the number of distinct values against the number of distinct queries the code actually issues.
SELECT count(DISTINCT attributes['db.statement']) AS distinct_templates,
count(*) AS executions
FROM spans
WHERE service = 'checkout' AND start_time > now() - interval '1 hour'
AND attributes['db.system'] IS NOT NULL;
Expected Output: tens or low hundreds of templates against hundreds of thousands of executions.
distinct_templates executions
84 981204
Distinct templates close to the number of executions means values are being interpolated into the statement, which breaks grouping and puts data into telemetry — the first thing to fix before trusting any ranking.
Common mistakes
Ranking by latency. Error signature: an optimisation of a rare slow query with no effect on database load. Root cause: latency ignores frequency. Remediation: rank by total time.
Values in the statement attribute. Error signature: no two spans share a statement, and data appears in telemetry. Root cause: interpolated SQL captured after substitution. Remediation: capture templates; verify with the distinct count above.
Treating the span as the diagnosis. Error signature: a guess at an index that does not help. Root cause: the span shows cost, not cause. Remediation: take the template to the execution plan.
No link to the database's view. Error signature: an application team and a database team investigating the same query separately. Root cause: nothing joins the two records. Remediation: enable SQL commenting.
Ranking from sampled traces as though they were complete. Error signature: totals that disagree with the database's own statistics. Root cause: sampling, particularly sampling that favours slow traces. Remediation: use the database's statistics for totals and traces for attribution.
Frequently Asked Questions
Why rank queries by total time rather than by p99 latency?
Because total time is what the database and the users actually pay. A query taking five milliseconds and executed a million times an hour costs far more than one taking two seconds executed twice. Latency rankings direct effort at the dramatic queries rather than the expensive ones.
What is SQL commenting and why use it?
Appending the trace context to each statement as a comment. The database records the comment in its own logs and statistics, so a slow query found in the database can be traced back to the request and code that issued it, and the reverse.
Should the statement attribute contain parameter values?
Never. Values leak data into telemetry and make every execution a distinct attribute value, which prevents grouping and inflates storage. Record the template with placeholders.
Can spans show why a query is slow?
They show that it is slow, how often it runs and how many rows it returns. The reason — a missing index, a poor plan, lock contention — is visible in the database's execution plan and wait statistics. The span's job is to identify which query to examine.