Window Functions for Geospatial

Geospatial workloads routinely demand sequential, context-aware computation across coordinate streams, sensor trajectories, and proximity networks — ranking the nearest neighbours per anchor, summing distance along a GPS path, or assigning each feature to a density-ranked grid cell. When the question depends on a row’s position relative to its neighbours, a plain GROUP BY collapses the detail you need and a self-join explodes the row count, so the window function becomes the primary execution primitive. This page, part of Modern Spatial SQL Query Patterns, covers how DuckDB executes spatial windows, how to keep the sort phase from spilling, and how to read the plan so a regression surfaces before it reaches production. It builds directly on the set-based, vectorized model described in the parent reference, but adds the memory and frame-specification constraints unique to windowing over geometry.

Runtime Configuration & Memory Guardrails

A window function executes strictly after the sort phase: the planner materializes each partition, orders it by the spatial or temporal key, applies the frame boundary (ROWS, RANGE, or GROUPS), and only then evaluates the spatial expression. That ordering makes spatial windowing memory-bound and acutely sensitive to sort spills, because every spilled chunk re-serializes GEOMETRY values to and from disk. The minimal reproducible session below is the starting point for every pattern on this page.

INSTALL spatial; LOAD spatial;

-- Cap threads at physical cores: spatial windowing is memory-bound, and extra
-- threads only add contention on the geometry cache without speeding the sort.
SET threads = 8;

-- Reserve headroom for OS + Python; spilled GEOMETRY re-serialization is the
-- dominant cost, so under-committing memory is cheaper than thrashing.
SET memory_limit = '16GB';

-- Keep spill files on fast local storage; never a network mount.
SET temp_directory = '/mnt/nvme/duckdb_spill';

-- Window output rarely needs source order; releasing it frees the sort to
-- parallelize across partitions.
SET preserve_insertion_order = false;

Trade-off: Raising memory_limit keeps partitions resident and avoids spill, but a single oversized partition can still exhaust the budget regardless of the limit. If a feed has one dominant key (a fleet where one vehicle reports 90% of rows), the guardrail to reach for is partition cardinality, not more RAM — see the anti-patterns below. For the broader picture of when DuckDB chooses to spill and how the spill directory is sized, the in-memory versus on-disk storage model is the authoritative reference.

A second guardrail is what you carry through the sort. Sorting and buffering full GEOMETRY objects is far more expensive than sorting raw lon/lat doubles. Where the window only needs ordering and the geometry is reconstructed afterward, project coordinates through the sort and rebuild ST_Point in the final projection.

Primary Execution Patterns

Three patterns cover the overwhelming majority of spatial windowing: sequential trajectory metrics (distance and speed along an ordered path), proximity ranking (top-N neighbours per anchor without a full cross-join), and density-ranked partitioning (grid-cell grouping ranked by feature count). All three share the same scan → sort → window pipeline.

The scan → order → window execution pipeline for spatial window functions Rows flow left to right through five operators. A sequential scan of gps_events feeds a pre-filter using ST_DWithin or a bounding box, which feeds a physical order by the partition and order key, which feeds the physical window operator evaluating LAG, SUM and RANK over a ROWS or RANGE frame, which feeds the final projection that rebuilds ST_Point and computes derived metrics. The order and window stages are bracketed as the memory-bound region: the sort materializes each partition and re-serializes GEOMETRY values to disk whenever it spills. SCAN → SORT → WINDOW · rows flow left → right Physical Scan gps_events Pre-filter ST_DWithin / bbox Physical Order PARTITION key, ORDER key Physical Window LAG · SUM · RANK frame ROWS / RANGE Projection rebuild ST_Point, derived metrics memory-bound region sort materializes partitions; spill re-serializes GEOMETRY

Sequential trajectory metrics

LAG over a partition ordered by timestamp gives each point its predecessor, so a single pass yields per-segment distance, cumulative path length, and a rolling average — no self-join, no procedural loop.

WITH trajectory AS (
  SELECT
    vehicle_id,
    ts,
    lon, lat,                                   -- carry raw coords through the sort
    ROW_NUMBER() OVER w_seq AS seq,
    ST_Distance(
      ST_Point(lon, lat),
      ST_Point(LAG(lon) OVER w_seq, LAG(lat) OVER w_seq)
    ) AS segment_dist_m
  FROM gps_events
  WINDOW w_seq AS (PARTITION BY vehicle_id ORDER BY ts)
)
SELECT
  vehicle_id,
  ts,
  ST_Point(lon, lat) AS geom,                   -- rebuild geometry after the sort
  segment_dist_m,
  SUM(segment_dist_m) OVER (
    PARTITION BY vehicle_id ORDER BY ts
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW   -- running total
  ) AS cumulative_dist_m,
  AVG(segment_dist_m) OVER (
    PARTITION BY vehicle_id ORDER BY ts
    ROWS 10 PRECEDING                                   -- 11-row smoothing window
  ) AS rolling_avg_dist_m
FROM trajectory;

Note that LAG is applied to the scalar lon/lat columns rather than to an assembled GEOMETRY. This keeps the window buffer holding 8-byte doubles instead of variable-length WKB, which is the single largest lever on sort throughput for long trajectories.

Proximity ranking without a full cross-join

Window ranking returns the k nearest features per anchor without materializing the full distance matrix. The CROSS JOIN is only safe because the ST_DWithin proximity filter bounds the candidate set before the rank is computed.

WITH ranked_proximity AS (
  SELECT
    a.id AS anchor_id,
    t.id AS target_id,
    ST_Distance(a.geom, t.geom) AS dist_m,
    RANK() OVER (
      PARTITION BY a.id ORDER BY ST_Distance(a.geom, t.geom)
    ) AS proximity_rank
  FROM anchor_points a
  CROSS JOIN target_features t
  WHERE ST_DWithin(a.geom, t.geom, 5000)   -- bound cardinality BEFORE ranking
)
SELECT * FROM ranked_proximity WHERE proximity_rank <= 3;

Without the ST_DWithin predicate the intermediate result scales as O(N×M)O(N \times M) and exhausts memory immediately. Choose the ranking function deliberately: RANK() keeps ties (two targets at identical distance both receive rank 1), while ROW_NUMBER() forces an arbitrary tie-break. For full top-N strategies and index-aware predicate pushdown, the spatial joins and proximity filters reference covers when a join out-performs a windowed rank.

Density-ranked spatial partitioning

Window primitives also drive lightweight spatial grouping. DuckDB has no built-in DBSCAN, so deriving a grid-cell key with integer arithmetic on the coordinates gives a fast, SQL-native partition you can rank and aggregate like any other window result.

WITH clustered_points AS (
  SELECT
    id,
    geom,
    floor(ST_X(geom) / 0.001) AS cell_x,   -- ~100 m cells in a projected CRS
    floor(ST_Y(geom) / 0.001) AS cell_y
  FROM urban_features
)
SELECT
  cell_x, cell_y,
  COUNT(*) AS feature_count,
  ST_Centroid(ST_Collect(geom)) AS group_center,
  ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) AS size_rank
FROM clustered_points
GROUP BY cell_x, cell_y
HAVING COUNT(*) >= 5;                       -- minimum density per cell

Because the cell size assumes the geometry is in a metric coordinate system, an unprojected lon/lat input silently produces cells that vary in real-world size with latitude — confirm the coordinate reference system before tuning thresholds. For parameter tuning and a true distance-based grouping, the DBSCAN-style spatial grouping walkthrough extends this pattern. Anchor downstream logic to the derived metrics (centroid, count) rather than the raw cell ids.

How a spatial window is executed

A window function is not a join and not a group-by, and its cost profile matches neither. DuckDB executes one in three phases, and knowing which phase is expensive tells you which part of the statement to change — partitioning is a hash, ordering is a sort, and framing is a scan over the sorted partition.

Partition, sort, sweep: the three phases of a spatial window Phase one hashes rows into partitions, phase two sorts each partition by the ordering expression and is the dominant cost, phase three sweeps the frame to evaluate the aggregate. 1 · PARTITION BY hash rows into partitions cheap · parallel across cores omit it and you get one partition on one core 2 · ORDER BY sort within each partition the dominant cost spills when one partition exceeds memory_limit 3 · frame sweep evaluate over the frame linear for a running frame quadratic for RANGE over a computed distance The geometry is never sorted. Only the scalar derived from it — a distance, an area, a grid key — enters the ORDER BY, which is why deriving that scalar into a column first is usually faster than computing it inside the OVER clause. Diagnosis follows the phases: a slow window with a small partition count is a sort problem; with a huge partition count it is a hash problem.

Each phase has its own failure mode, and the fix for one does nothing for the others.

The most common performance mistake follows directly from phase two: computing the ordering expression inside the OVER clause. ORDER BY ST_Distance(geom, :origin) re-evaluates that distance during the sort’s comparisons rather than once per row. Materialising the scalar in a preceding SELECT — or a CTE — turns a repeated geometry call into a plain numeric sort.

Execution Plan Validation

Run EXPLAIN ANALYZE on the trajectory query and read the plan bottom-up. A healthy spatial window shows three nodes in order — scan, order, window — with no spill node between them.

┌─────────────────────────────┐
│      PROJECTION (vectorized) │  cumulative_dist_m, rolling_avg_dist_m
├─────────────────────────────┤
│           WINDOW            │  SUM, AVG · PARTITION vehicle_id · frame ROWS
├─────────────────────────────┤
│            ORDER            │  vehicle_id ASC, ts ASC
├─────────────────────────────┤
│         SEQ_SCAN            │  gps_events  (est: 4.0M, actual: 4.0M)
└─────────────────────────────┘

Diagnostic Boundary: ORDER and WINDOW combined should stay under 60% of total execution time. If ORDER alone exceeds 40% of estimated cost, or a Spill/temporary file node appears, the partition cardinality is too high for the memory budget. The two reliable mitigations are reducing what flows through the sort (raw coordinates, not GEOMETRY) and lowering partition cardinality (pre-aggregate timestamps, or coarsen the partition key).

Estimated-versus-actual row drift is the second signal to watch. When the scan’s estimate diverges from the actual count by more than ~30%, the optimizer is sizing the sort buffer against stale statistics, which usually precedes an unexpected spill. The detection query below reads this directly from the analyzed plan:

EXPLAIN (FORMAT JSON) ANALYZE
SELECT vehicle_id, SUM(segment_dist_m) OVER (PARTITION BY vehicle_id ORDER BY ts)
FROM trajectory;
-- Walk the JSON tree and assert no node has "name":"SPILL"; compare
-- "cardinality" (estimated) against "operator_cardinality" (actual) per node.
Which spatial questions a window function is actually for Per-anchor ranking and running totals along a path need windows; per-zone sums need a GROUP BY; radius searches need a join with a spatial predicate. QUESTION RIGHT TOOL WHY rank the k nearest per anchor window depends on position within a group running distance along a trace window each row needs the previous one sum an attribute per zone GROUP BY no sort needed; cheaper every feature within a radius a spatial join an index can serve a join, not a frame

Two of these four are written as windows by people who wanted one of the other two.

Performance Trade-offs

The frame specification drives both correctness and cost. ROWS counts physical positions, RANGE counts logical value differences, and GROUPS counts distinct ordering values. The choice is not cosmetic — it changes how the engine evaluates each frame boundary.

Frame Boundary semantics Geospatial fit Relative cost
ROWS Fixed count of physical rows Trajectory sequencing, fixed-window smoothing Baseline; fully vectorized
RANGE Rows within a value delta of the current row “within 30 s” or “within 50 m” tolerance windows ~15–20% higher CPU; boundary recomputed per row
GROUPS N distinct ordering values Stepwise aggregation over tied timestamps Between ROWS and RANGE

Performance Trade-off: RANGE frames force the engine to compute the value boundary dynamically for every row, which blocks some vectorized fast-paths and adds roughly 15–20% CPU over the equivalent ROWS frame on a uniformly distributed key. Reserve RANGE for queries where a temporal or distance gap must be honoured explicitly; default to ROWS for ordinal sequencing. The named-window form keeps the same frame readable across multiple measures:

SELECT
  sensor_id, ts,
  ST_Distance(ST_Point(lon, lat), ST_Point(LAG(lon) OVER w, LAG(lat) OVER w)) AS step_dist,
  SUM(ST_Distance(ST_Point(lon, lat), ST_Point(LAG(lon) OVER w, LAG(lat) OVER w))) OVER w AS cumulative_dist
FROM sensor_stream
WINDOW w AS (PARTITION BY sensor_id ORDER BY ts ROWS UNBOUNDED PRECEDING);

A second trade-off is pre-materialization. When the anchor-to-target ratio in a proximity rank exceeds roughly 1:500, materializing the ST_DWithin-filtered subset into a temporary table before ranking cuts sort pressure during window evaluation, because the engine sorts the bounded candidate set rather than re-filtering inside the window operator. Below that ratio the extra write is not worth it. This composes naturally with vectorized aggregations: bin first, then window over the bins.

Edge Cases & Anti-Patterns

  • Sorting GEOMETRY through the window. Wrapping ST_Point(lon, lat) in the OVER clause forces the buffer to hold WKB. Fix: order and LAG on the raw lon/lat columns and rebuild the point in the final projection, as in the trajectory pattern above.
  • Single dominant partition. One key holding most rows makes that partition’s sort exceed the memory budget no matter how high memory_limit is set, because a partition cannot be split across the limit. Fix: add a sub-partition key (e.g. PARTITION BY vehicle_id, date_trunc('hour', ts)) or pre-aggregate before the window.
  • Unbounded CROSS JOIN in a proximity rank. Omitting ST_DWithin (or any bounding predicate) makes the candidate set O(N×M)O(N \times M) before the rank is ever computed. Fix: always bound the join in the WHERE/ON clause; the rank runs over the survivors only.
  • RANGE on a skewed ordering column. With a non-uniform key, frame-boundary scanning degrades from O(logN)O(\log N) toward O(N)O(N) per row. Fix: use RANGE only when the ordering column is roughly uniform; otherwise switch to ROWS.
  • ROW_NUMBER() where ties matter. Using ROW_NUMBER() to keep “the nearest” silently drops equidistant features. Fix: use RANK() (or DENSE_RANK()) when tied distances must all be retained.
  • Grid cells on unprojected coordinates. A degree-based floor(ST_X(geom)/0.001) produces cells that shrink toward the poles. Fix: transform to a metric CRS before deriving cell keys.

Query Regression Analysis

Spatial window queries drift quietly: a statistics change or a new dominant partition can turn an in-memory sort into a spilling one with no error, only slower wall-clock time. Capture the analyzed plan into a baseline and diff it in CI so the regression is caught before deploy.

import duckdb
import time

def profile_spatial_window(query: str, baseline_sec: float | None = None,
                           db_path: str = ":memory:") -> dict:
    con = duckdb.connect(db_path)
    con.execute("INSTALL spatial; LOAD spatial;")
    con.execute("SET threads = 8; SET memory_limit = '12GB';")

    start = time.perf_counter()
    plan = con.execute(f"EXPLAIN ANALYZE {query}").fetchall()
    elapsed = time.perf_counter() - start

    plan_text = "\n".join(row[0] for row in plan)
    spilled = ("SPILL" in plan_text.upper()) or ("temporary file" in plan_text)
    regressed = baseline_sec is not None and elapsed > 2 * baseline_sec

    return {
        "elapsed_sec": round(elapsed, 3),
        "spill_detected": spilled,
        "regressed_vs_baseline": regressed,
        "action": (
            "Cap partition size / pre-sort coordinates" if spilled
            else "Investigate plan drift" if regressed
            else "Plan optimal"
        ),
    }

Diagnostic Boundary: treat elapsed_sec > 2× baseline or spill_detected == True as a hard CI failure — both mean the sort phase has stopped fitting in memory. When this fires in production orchestration, the Python and DuckDB integration workflows cover wiring this check into a batch pipeline so a regressed window query never silently ships. Re-baseline only after a deliberate schema or volume change, never to mask a regression.

Frequently Asked Questions

Can I ORDER BY a geometry column directly?

No usefully. Geometry has no natural total order, so ordering by the column either errors or falls back to comparing serialised bytes, which groups shapes by encoding accident rather than by location. Derive a scalar and order by that: distance from a reference point, area, a grid cell key, or a space-filling-curve index if you want spatial locality preserved in one dimension.

Why is my window function using only one core?

Because it has no PARTITION BY, or because one partition holds most of the rows. DuckDB parallelises across partitions, so a window over the whole table is a single sort on a single thread no matter how many cores are available. Adding a partition key that reflects a real grouping — zone, day, tile — is usually both more correct and dramatically faster.

What is the difference between ROWS and RANGE framing here?

ROWS counts rows and is linear; RANGE compares values and has to find frame boundaries by value, which becomes quadratic when the ordering expression has many distinct values — which a computed distance always does. For spatial work ROWS BETWEEN … PRECEDING AND CURRENT ROW is almost always what is meant, and reaching for RANGE on a distance column is the classic way to turn a fast query slow. The specific traps are enumerated in spatial window frame specification gotchas.

Should I use QUALIFY or a subquery to filter on a window result?

QUALIFY — it says exactly what is meant and avoids the extra nesting level. It is not faster; DuckDB plans the two identically. Where it genuinely helps is readability of the top-k-per-group pattern, which otherwise needs a subquery whose only purpose is to expose the rank column to a WHERE.

How do I compute a running distance along a GPS trace?

Partition by the trace identifier, order by the timestamp, and use lag(geom) to reach the previous fix, then sum ST_Distance between the pair. The key detail is that the ordering must be temporal rather than spatial — a trace that doubles back on itself has points that are close in space and far apart in time, and ordering by distance silently reorders the journey.

Do window functions respect an R-tree index?

No. A window is evaluated after rows have been produced, so the index can only help the scan that feeds it. If a spatial predicate is meant to restrict which rows enter the window, put it in the WHERE clause where an index can serve it — not in the window’s frame, where it will be evaluated per row on the full input.

See also

Up: Modern Spatial SQL Query Patterns


External Reference Standards: For the formal specification of the spatial predicates used above, see the OGC Simple Features Access Standard; for kernel-level frame and window semantics, the DuckDB Spatial Extension Documentation.