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
The same operation, two extents, two plansAn intersect over a small urban extent returns quickly using an index scan. The same operation over a large rural extent takes eleven seconds with a sequential scan, which the attached plan reveals.postgis.intersect_parcels · tile 14/8802/51210.4 srows 1 240 · bbox_area 5.9e5 · index scan · no plan attachedpostgis.intersect_parcels · tile 10/550/32011.2 srows 384 000db.plan: Seq Scan on parcels (actual time=0.3..10842 rows=384000)Filter: st_intersects(geom, …) · Rows Removed by Filter: 3 616 000
The second span carries its own explanation. Without 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.

What attaching plans everywhere costsExplaining every query doubles total database time. Explaining only the queries above the slow threshold adds under one per cent while covering every query anyone would investigate.EXPLAIN ON EVERY QUERYthe queriesexplaining them againEXPLAIN ABOVE THE THRESHOLD ONLYthe queries+0.8% — and every query worth reading is coveredThe 0.3% of queries above the threshold account for most of the total time, so explaining them is nearly free in relative terms.
Reading a plan is only useful for a query somebody is going to investigate, and the threshold is a cheap proxy for exactly that set.

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;
Duration against extent areaQuery duration rises roughly linearly with bounding-box area for most tiles. A separate cluster sits far above the line, corresponding to queries that fell back to a sequential scan.secondsgeo.bbox_areasequential scans — one regionindex scans — linear in areaThe outliers shared a region, and the region shared a partition whose statistics were stale.
This plot is only possible because 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. SELECT groups 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 ANALYZE on 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.

OpenTelemetry Tracing for Spatial Tasks