Calculating Distance Matrices with SQL

Building an N×N pairwise distance matrix in DuckDB is an inherently O(N2)O(N^2) operation whose production failures come not from the algorithm but from the unbounded Cartesian product it materializes before any distance predicate is applied. This walkthrough sits under Vectorized Aggregations and isolates the exact bottleneck — a naive CROSS JOIN over a coordinate column that spills, thrashes NVMe, and OOM-kills the session — then gives a minimal session setup, a candidate-pruning two-stage rewrite, plan-validation queries, and fallback routing for invalid geometry and out-of-memory (OOM) conditions.

When DuckDB Spatial encounters an unfiltered self-join on a coordinate array, the planner expands the full product first and only then evaluates ST_Distance. For a million points that is 101210^{12} pairs and roughly a terabyte of intermediate state — orders of magnitude past any sane memory_limit. The objective is to never construct rows that the distance threshold will immediately discard.

Naive Cartesian distance matrix versus a degree-space pre-filtered two-stage pipeline One INPUT of N points forks into two rows. The upper NAIVE row runs a CROSS JOIN producing the full N-squared product that spills to disk, feeds ST_Distance evaluated on every pair, and ends in an OOM-kill. The lower OPTIMIZED row first applies an ABS degree-space bounding-box test on the bare numeric columns, pushed below the join, which discards more than 90 percent of pairs on a downward pruned branch; the surviving roughly 10 percent flow into an exact ST_Distance check bounded at 50000 metres and emit a bounded distance matrix. INPUT N points (x, y) coordinate column one CRS NAIVE · CROSS JOIN full N × N product ≈ N² rows materialized spills to disk ST_Distance(a, b) runs on every pair geometry decoded first no predicate pushdown heap spike memory_limit breached OOM-kill PRE-FILTER · ABS() |a.x − b.x| < 0.45 |a.y − b.y| < 0.45 pushed below the join ST_Distance(a, b) survivors only exact ≤ 50 000 m ≈ 10% of pairs OUTPUT bounded matrix heap holds candidates pruned 90%+ — never reaches the kernel

Root-Cause Analysis of Matrix Blowup

Distance-matrix queries fail along four distinct axes, and each has a different fix. Treating an OOM crash as “needs more RAM” usually masks one of the first three.

  • No spatial pre-filter. A bare CROSS JOIN forces an exact distance kernel on every pair. The fix is a cheap coordinate-range (bounding-box) test that runs before ST_Distance, so the planner discards distant pairs during the scan rather than after materializing them. This is the same envelope-pruning principle that drives R-tree-backed spatial indexing for join workloads.
  • Predicate placement that defeats pruning. Wrapping the coordinate columns in ST_Point(...) and filtering only on the constructed geometry hides the cheap numeric comparison from the optimizer. The degree-space ABS(a.x - b.x) test must operate on the bare numeric columns so it can be pushed down ahead of geometry construction.
  • CRS / unit mismatch. ST_Distance on EPSG:4326 inputs returns degrees, not metres, so a <= 50000 threshold silently means nothing. Both inputs must be projected to a metric CRS first; the rules for that live in the CRS mapping & transformations reference.
  • Unbounded intermediate set. DuckDB decodes geometry — stored internally as WKB, as covered in ST_Geometry vs WKB — before applying predicates, so an unpruned product spikes the heap and spills. Whether the working set fits at all is governed by the in-memory vs disk storage boundaries for your dataset.

Deterministic Configuration

Enforce strict resource boundaries before generating any matrix. DuckDB’s out-of-core executor handles spilling, but distance workloads need temp storage on a low-latency block device and join reordering enabled. Confirm the prerequisites first:

INSTALL spatial;
LOAD spatial;

-- Cap resident memory below total RAM: the candidate set, decoded geometry,
-- and result region coexist, so leave headroom to avoid an OS OOM-kill.
SET memory_limit = '6GB';

-- Match physical cores. Distance kernels are SIMD-bound; oversubscribing
-- hyperthread siblings contends for the same units and slows the scan.
SET threads = 4;

-- Spill to a fast local NVMe path, never a network mount — distance spills
-- are write-heavy and a slow device turns a spill into a stall.
SET temp_directory = '/mnt/fast_nvme/duckdb_spill';

-- Allow hash-based partitioning and join reordering; the matrix does not
-- depend on input order, so preserving it only blocks the optimizer.
SET preserve_insertion_order = false;

Boundary check: validate CRS alignment before execution. Mismatched projections silently corrupt distance output rather than raising an error. Project both inputs with ST_Transform to a metric CRS (EPSG:3857 or a local UTM zone) so ST_Distance returns metres.

Optimized Execution Pattern

The fix replaces full Cartesian expansion with a two-stage filter: a cheap degree-space bounding-box pre-filter that prunes the overwhelming majority of pairs, then an exact ST_Distance only on the surviving candidates. Pushing the spatial predicate into the scan phase is the core technique shared across Modern Spatial SQL Query Patterns.

Before — the naive form materializes every pair before measuring:

-- ANTI-PATTERN: full N×N product built before any distance is evaluated.
SELECT a.id AS src_id, b.id AS tgt_id,
       ST_Distance(a.geom, b.geom) AS dist_meters
FROM points a
CROSS JOIN points b
WHERE a.id <> b.id
  AND ST_Distance(a.geom, b.geom) <= 50000;  -- runs on EVERY pair

After — the numeric pre-filter collapses the candidate set before geometry is touched:

WITH source_points AS (
    SELECT id, x, y, ST_Point(x, y) AS geom
    FROM staging.coordinates
    WHERE x BETWEEN -180 AND 180
      AND y BETWEEN -90 AND 90      -- drop out-of-range coords up front
),
filtered_candidates AS (
    SELECT
        a.id AS src_id,
        b.id AS tgt_id,
        ST_Distance(a.geom, b.geom) AS dist_meters
    FROM source_points a
    CROSS JOIN source_points b
    WHERE a.id <> b.id
      -- Cheap degree-space window on bare numeric columns; the optimizer
      -- pushes this down so ST_Distance never sees the far pairs.
      AND ABS(a.x - b.x) < 0.45     -- ±0.45° ≈ 50 km at mid-latitudes
      AND ABS(a.y - b.y) < 0.45
)
SELECT src_id, tgt_id, dist_meters
FROM filtered_candidates
WHERE dist_meters <= 50000;          -- exact threshold on survivors only

The behavioral change is entirely in when the comparison happens. The degree-space ABS() test is a coarse, fast gate on indexed-friendly numeric columns; the precise dist_meters <= 50000 check only ever runs on the few pairs that survive it. On clustered point sets this prunes 90%+ of pairs before any geometry kernel executes.

Trade-off: the 0.45° window is latitude-dependent — a degree of longitude shrinks toward the poles. For high-latitude data, project to a metric CRS first and pre-filter on projected metre coordinates, or tighten the window per latitude band rather than trusting one global constant.

Bounded aggregation over the candidate set

Once the matrix is bounded, summarize it with columnar batch aggregation instead of holding the raw pairs. Chaining the threshold scan straight into a GROUP BY keeps the work inside vectorized aggregations and avoids re-materializing the full set:

SELECT
    CASE WHEN dist_meters <= 1000  THEN 'local'
         WHEN dist_meters <= 10000 THEN 'regional'
         ELSE 'distant' END AS proximity_tier,
    count(*)               AS pair_count,
    avg(dist_meters)       AS mean_dist,
    percentile_cont(0.5) WITHIN GROUP (ORDER BY dist_meters) AS median_dist
FROM filtered_candidates
WHERE dist_meters <= 50000
GROUP BY proximity_tier
ORDER BY proximity_tier;

For nearest-neighbour or top-K extraction, rank inside a partition instead of materializing the whole matrix — a pattern detailed under window functions for geospatial. ROW_NUMBER() over a per-source partition lets the planner stop after K rows rather than full-sorting every pair:

WITH ranked AS (
    SELECT src_id, tgt_id, dist_meters,
           ROW_NUMBER() OVER (PARTITION BY src_id ORDER BY dist_meters) AS nn_rank
    FROM filtered_candidates
)
SELECT src_id, tgt_id, dist_meters
FROM ranked
WHERE nn_rank <= 5;   -- 5 nearest neighbours per source point

Diagnostic Queries & Plan Validation

Confirm the pre-filter is actually pushed down rather than applied after the join. Capture the plan and read the row estimate at each node:

EXPLAIN ANALYZE
SELECT src_id, tgt_id, dist_meters
FROM filtered_candidates
WHERE dist_meters <= 50000;

Diagnostic thresholds: in the EXPLAIN ANALYZE output, the row count entering the ST_Distance projection should be a small fraction of . If the join node reports a cardinality near the full product, the ABS() predicate did not push down — check that the filter references bare numeric columns and not the constructed geometry. A healthy plan shows the FILTER node below the distance projection; an anti-pattern shows the distance computed first.

Track memory pressure and spill volume during a run with the duckdb_memory() table function:

-- One-liner monitor: which operators hold memory and how much spilled.
SELECT tag, memory_usage_bytes, temporary_storage_bytes
FROM duckdb_memory()
ORDER BY memory_usage_bytes DESC;

If temporary_storage_bytes climbs into the gigabytes for a query you expected to stay in memory, the candidate set is not being pruned — return to the plan and tighten the bounding-box window. If runtime degrades by more than ~15% across identical workloads after an engine upgrade or a data-distribution shift, re-capture the plan and re-run on a fresh connection to discard any session-level state before concluding it is a regression.

Geometry Validation & Fallback Routing

Null or nonsensical distances trace back to malformed coordinates or invalid geometry entering the kernel. Gate construction with a range check and route failures to a quarantine table rather than letting them halt the pipeline:

WITH validated_points AS (
    SELECT id, x, y,
        CASE
            WHEN x BETWEEN -180 AND 180 AND y BETWEEN -90 AND 90
                 AND ST_IsValid(ST_Point(x, y))
                THEN ST_Point(x, y)
            ELSE NULL                 -- quarantine out-of-range / invalid
        END AS geom
    FROM staging.coordinates
)
SELECT a.id AS src_id, b.id AS tgt_id,
       ST_Distance(a.geom, b.geom) AS dist_meters
FROM validated_points a
CROSS JOIN validated_points b
WHERE a.id <> b.id
  AND a.geom IS NOT NULL
  AND b.geom IS NOT NULL
  AND ABS(a.x - b.x) < 0.45
  AND ABS(a.y - b.y) < 0.45;

For inputs that are polygons rather than points, repair self-intersections with ST_MakeValid before measuring, since ST_Distance on an invalid ring can return spurious zeros. Never allow an unvalidated cast into a production distance kernel.

When the candidate set still breaches memory_limit despite pruning — common with dense urban clusters where the bounding box no longer prunes — fall back to chunked execution. Process one batch of source points at a time and append, so the heap only ever holds one chunk’s worth of pairs:

-- Chunked matrix: bound the working set to one source batch at a time.
INSERT INTO distance_results
SELECT a.id AS src_id, b.id AS tgt_id,
       ST_Distance(a.geom, b.geom) AS dist_meters
FROM source_points a
CROSS JOIN source_points b
WHERE a.id BETWEEN ? AND ?          -- driver loops over id ranges
  AND a.id <> b.id
  AND ABS(a.x - b.x) < 0.45
  AND ABS(a.y - b.y) < 0.45
  AND ST_Distance(a.geom, b.geom) <= 50000;

Driving that loop from Python — sizing chunks against memory_limit and streaming each batch out — is the batch processing pipeline pattern; collecting the bounded result straight into a GeoDataFrame follows the DuckDB-to-GeoPandas sync workflow.

External Reference Standards. For strict geometry-type compliance, see the Open Geospatial Consortium Simple Feature Access specification. Kernel-specific optimization flags are documented in the DuckDB Spatial Extension overview.

See also

Up: Vectorized Aggregations · Modern Spatial SQL Query Patterns