Tracing PostGIS Query Spans in Spatial ETL
A PostGIS span should say what the query did, not what SQL it contained. ST_Intersects over a tile envelope against four million parcels is a different operation from a primary-key lookup, and a span named db.query with the statement as an attribute tells you neither. Name the span for the operation, attach the row count, the geometry count and the extent, and — on slow spans only — attach the plan. That turns “the database was slow” into “the intersect against parcels did a sequential scan for the tiles in this region”, which is a fix rather than an observation.
When to Use This Pattern
- Spatial queries are a meaningful share of a task’s time, which they are in any pipeline that joins against a large table.
- Query performance varies by geography — some extents are fast and some are not — and the aggregate hides it.
- An index is suspected of not being used, and you need evidence per query rather than per statement.
- Traces already exist for the pipeline, so the database spans slot into the trace the tile already has.
Complete Working Example
The wrapper names the operation, records the spatial facts, and fetches the plan only when the query was slow enough to be worth explaining.
from __future__ import annotations
import time
from contextlib import contextmanager
from typing import Any, Iterator, Optional, Sequence
import psycopg
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer("geospatial.postgis")
SLOW_QUERY_SECONDS = 2.0 # above this, spend a round trip on the plan
@contextmanager
def spatial_query(
conn: psycopg.Connection,
operation: str, # "intersect_parcels", not "SELECT"
table: str,
bbox: Optional[tuple[float, float, float, float]] = None,
srid: int = 3857,
) -> Iterator[dict]:
"""A span named for the spatial operation, with the facts that explain it."""
ctx: dict = {"rows": 0}
with tracer.start_as_current_span(f"postgis.{operation}") as span:
span.set_attribute("db.system", "postgresql")
span.set_attribute("db.operation", operation)
span.set_attribute("db.sql.table", table)
span.set_attribute("geo.srid", srid)
if bbox:
span.set_attribute("geo.bbox", ",".join(f"{v:.3f}" for v in bbox))
# Area is the single best predictor of how much work a spatial
# predicate will do, and it is one multiplication.
span.set_attribute("geo.bbox_area", (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]))
started = time.monotonic()
try:
yield ctx
except Exception as exc:
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR, str(exc)[:200]))
raise
finally:
elapsed = time.monotonic() - started
span.set_attribute("db.rows_returned", ctx.get("rows", 0))
if "geometries" in ctx:
span.set_attribute("geo.geometries_returned", ctx["geometries"])
# The plan is expensive to fetch and useless on a fast query. Only
# attach it where someone would actually read it.
if elapsed > SLOW_QUERY_SECONDS and "sql" in ctx:
span.set_attribute("db.plan", explain(conn, ctx["sql"], ctx.get("params")))
def explain(conn: psycopg.Connection, sql: str, params: Any = None) -> str:
"""A real plan with real timings — costed, not estimated."""
with conn.cursor() as cur:
cur.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) " + sql, params)
return "\n".join(row[0] for row in cur.fetchall())[:4000]
INTERSECT_SQL = """
SELECT p.parcel_id, ST_AsBinary(p.geom) AS geom
FROM parcels p
WHERE ST_Intersects(p.geom, ST_TileEnvelope(%(z)s, %(x)s, %(y)s))
"""
def parcels_for_tile(conn: psycopg.Connection, z: int, x: int, y: int) -> list[tuple]:
bbox = tile_envelope(z, x, y)
with spatial_query(conn, "intersect_parcels", "parcels", bbox=bbox) as ctx:
ctx["sql"] = INTERSECT_SQL
ctx["params"] = {"z": z, "x": x, "y": y}
with conn.cursor() as cur:
cur.execute(INTERSECT_SQL, ctx["params"])
rows = cur.fetchall()
ctx["rows"] = len(rows)
ctx["geometries"] = sum(1 for r in rows if r[1] is not None)
return rows
bbox_area and the plan, the two look like the same query behaving inconsistently.The slow-query threshold is doing something subtle that is worth naming. EXPLAIN ANALYZE executes the query again, so attaching a plan doubles that query’s cost — which is unacceptable as a default and entirely reasonable for the one query in a thousand that is already the problem. Setting the threshold from the operation’s own p95 rather than from a fixed number generalises this: each named operation gets a plan when it exceeds its own normal, which means a fast operation that degrades is explained as readily as a slow one that got worse.
Parameter & Option Reference
| Attribute | Type | Spatial notes |
|---|---|---|
| span name | postgis.<operation> |
Named for what it does. db.query groups everything into one meaningless bucket. |
geo.bbox |
str |
High cardinality, which is fine on a span. It is what makes a slow query findable on a map. |
geo.bbox_area |
float |
The best single predictor of spatial-query cost. One multiplication. |
db.rows_returned |
int |
Distinguishes “slow because it did a lot” from “slow because it did it badly”. |
geo.geometries_returned |
int |
Rows and geometries differ when the query returns nulls or aggregates. |
db.plan |
str |
Only on slow spans. EXPLAIN ANALYZE re-runs the query, so it is never free. |
SLOW_QUERY_SECONDS |
2.0 |
The threshold above which a plan is worth a second execution. |
Verification & Testing
Two behaviours to pin: the plan appears only when it should, and the attributes describe the query rather than the statement.
def test_plan_is_attached_only_when_slow(conn, exporter, monkeypatch) -> None:
monkeypatch.setattr("mymodule.SLOW_QUERY_SECONDS", 0.0)
parcels_for_tile(conn, 10, 550, 320)
slow_span = exporter.get_finished_spans()[-1]
assert "db.plan" in slow_span.attributes
exporter.clear()
monkeypatch.setattr("mymodule.SLOW_QUERY_SECONDS", 3600.0)
parcels_for_tile(conn, 14, 8802, 5121)
fast_span = exporter.get_finished_spans()[-1]
assert "db.plan" not in fast_span.attributes
def test_span_carries_the_spatial_facts(conn, exporter) -> None:
parcels_for_tile(conn, 14, 8802, 5121)
span = exporter.get_finished_spans()[-1]
assert span.name == "postgis.intersect_parcels"
assert span.attributes["geo.srid"] == 3857
assert span.attributes["geo.bbox_area"] > 0
assert span.attributes["db.rows_returned"] >= 0
def test_failure_sets_error_status(conn, exporter) -> None:
with pytest.raises(psycopg.errors.UndefinedTable):
with spatial_query(conn, "intersect_missing", "nope") as ctx:
with conn.cursor() as cur:
cur.execute("SELECT * FROM nope")
span = exporter.get_finished_spans()[-1]
assert span.status.status_code is StatusCode.ERROR
Alongside the traces, PostgreSQL’s own view is worth having, because it aggregates across every caller including the ones you have not instrumented. pg_stat_statements answers “which query shape costs the most in total”, which no per-trace view can:
SELECT substring(query, 1, 60) AS shape, calls,
round(mean_exec_time::numeric, 1) AS mean_ms,
round(total_exec_time::numeric / 1000, 1) AS total_s
FROM pg_stat_statements
WHERE query ILIKE '%ST_Intersects%'
ORDER BY total_exec_time DESC
LIMIT 10;
geo.bbox_area is on the span. Without it the outliers are indistinguishable from ordinary variance.Common Pitfalls
- Naming the span after the SQL verb.
SELECTgroups a primary-key lookup with a continental intersect. The span name should be the operation’s name in your own vocabulary, which is also what makes the trace searchable. - Attaching the statement text as an attribute. It is large, repetitive across every span, and rarely what you need — the plan is. If you want the statement, log it once at start-up with an identifier the span can reference.
- Running
EXPLAIN ANALYZEon every query. It executes the query a second time. On a slow query that is a reasonable price for an explanation; on a fast one it doubles the cost of the whole pipeline. - Omitting the extent. Spatial query cost is dominated by how much area the predicate covers. Without
geo.bbox_area, a slow query and a large query look the same, which is the distinction the trace exists to make. - Instrumenting the connection rather than the operation. Generic database auto-instrumentation gives you spans, but it names them by statement and knows nothing about geometry. It is a reasonable floor and a poor ceiling.
Frequently Asked Questions
Should I use OpenTelemetry's psycopg instrumentation instead?
Use it as well, not instead. The auto-instrumentation captures every query including the ones you did not think to wrap, which is genuinely valuable. What it cannot do is name the operation in your vocabulary or attach the extent, so the wrapper above sits on top of it for the queries that matter. Between them you get coverage and meaning.
How do I know whether the index was used without fetching the plan?
You mostly cannot, which is why the slow-query threshold exists. What you can do cheaply is watch the relationship between db.rows_returned and duration: a query returning few rows slowly is the signature of a scan that filtered most of what it read. That correlation is visible in the traces without any plan at all, and it narrows the investigation before you pay for EXPLAIN ANALYZE.
What about queries inside a transaction with many statements?
Give each meaningful statement its own span and let them nest under a transaction span. The transaction span then shows lock waits and commit time, which are frequently the real cost and are invisible when everything is one span. Keep the nesting shallow — a transaction with forty statement spans has the same readability problem as any other over-instrumented trace.
Does this work for read replicas?
Yes, and it is worth adding a db.instance attribute so replica lag can be correlated with query behaviour. A query that is fast on the primary and slow on a replica usually means the replica is applying WAL under load, and that is a much easier conclusion to reach when the span says which instance served it.
Should the geometry itself ever be a span attribute?
No. A WKT geometry can be megabytes, and attributes are shipped and indexed. The bounding box is the right summary: small, fixed size, and enough to place the query on a map. The full geometry belongs in the dead-letter queue if it failed, and nowhere at all if it did not.
Related
- OpenTelemetry tracing for spatial tasks — the trace these spans join
- Propagating trace context across Prefect tasks — getting the context to the database call
- Securing PostGIS connections in workflows — the connection this instruments
- Streaming large GeoPackage loads into PostGIS — the write path’s equivalent concerns