PostGIS vs DuckDB for Incident Analytics

An analyst asks for incident counts by jurisdiction and hour across a five-day flood response and the query runs for 34 seconds against the operational PostGIS instance, which is also serving the live operating picture to six agencies. Run against the same data in DuckDB it takes 1.8 seconds and touches nothing anybody depends on. That difference is real, reproducible, and a bad reason to move the operational store — because the same DuckDB cannot accept a single concurrent write.

Problem Framing

The two engines are usually compared on query speed, which is the least useful axis because each wins decisively in a different regime and neither result generalises. PostGIS is a row-store with spatial indexes and full multi-version concurrency: it answers “where is this one incident” in milliseconds and accepts writes from many processes at once. DuckDB is an in-process columnar engine: it answers “aggregate forty million rows by two columns” in seconds and supports exactly one writer.

The failure this topic prevents is not choosing wrong — it is choosing once. Teams that adopt DuckDB for its analytical speed and then try to make it the operational store discover the single-writer limit during a surge; teams that refuse it and run analytical queries against the operational instance discover that a full-table aggregation and a live operating picture compete for the same buffer pool at the worst moment.

Prerequisites

  • PostGIS 3.3 or newer as the authoritative store, configured per the PostGIS setup for emergency response walkthrough, including SRID-constrained geometry columns and GIST indexes.
  • DuckDB 0.10 or newer with the spatial extension, which supplies ST_ functions over a GEOMETRY type. The extension is loaded per connection, not per database, which matters for the reproducibility rules in Dockerized GIS environments.
  • A settled analytical schema — the columns an after-action review actually asks for. A columnar engine’s advantage comes from reading few columns, and an extract that copies all forty gives most of it back.
  • An agreed extract cadence and a snapshot identifier scheme, so an analytical answer can always be tied to the operational state it was derived from.

Choosing Between Them

Which engine each workload belongs to, and the property that decides it Four incident workloads placed against the two engines. The live common operating picture needs concurrent writers, row-level locking and durability, so it belongs in PostGIS — DuckDB has a single-writer model and cannot serve it. Interactive analyst queries over a frozen extract need fast columnar scans and no writes, which is DuckDB's core case. After-action review over an incident's whole history is read-only and scan-heavy, so DuckDB again. Continuous ingestion from field devices needs concurrent transactional writes, so PostGIS. The deciding property is never speed: it is whether more than one process writes at the same time, and whether the data must survive a crash mid-transaction. the deciding property is concurrent writes, not query speed concurrent writers? engine live common operating picture six agencies writing, row locks, durability across a crash yes PostGIS interactive analyst queries a frozen extract, scanned repeatedly, never written no DuckDB after-action review the whole incident history, read-only, scan-heavy no DuckDB continuous field ingestion transactional writes arriving all shift yes PostGIS

Concurrency is the property that decides, and it decides absolutely rather than by degree. DuckDB’s single-writer model is not a tuning limit to be worked around; it is architectural, and a design that needs two processes writing at once has ruled it out regardless of how much faster the reads are. Everything on the read-only side of that line is a candidate.

Within the read-only side, the query shape decides.

Four representative queries over one incident's data, on both engines Four queries run against 4.2 million incident records with geometry. A point-in-polygon lookup for a single incident takes 3 milliseconds in PostGIS using its GIST index and 190 milliseconds in DuckDB, which scans. A bounding-box filter returning 800 rows takes 11 milliseconds in PostGIS and 240 in DuckDB. A full-table aggregation grouping by jurisdiction and hour takes 34 seconds in PostGIS and 1.8 seconds in DuckDB, because the columnar layout reads only the three columns involved. A six-way join across incidents, units, shelters and jurisdictions takes 71 seconds in PostGIS and 4.4 in DuckDB. The pattern is consistent: indexed lookups favour PostGIS by an order of magnitude, and full scans favour DuckDB by one. 4.2 M incident records — indexed lookups one way, scans the other PostGIS DuckDB favours point-in-polygon, one incident GIST index vs full scan 3 ms 190 ms PostGIS bbox filter → 800 rows selective predicate 11 ms 240 ms PostGIS aggregate by jurisdiction × hour three columns out of forty 34 s 1.8 s DuckDB six-way join across the model incidents, units, shelters, jurisdictions 71 s 4.4 s DuckDB

The pattern is consistent enough to state as a rule. A predicate that a spatial index can satisfy — a point-in-polygon lookup, a small bounding-box filter — favours PostGIS by roughly an order of magnitude, because DuckDB has no equivalent of a GIST index and scans. A query that reads a few columns across the whole table favours DuckDB by roughly an order of magnitude, because it reads only those columns while PostGIS reads whole rows off disk to get at three fields.

Neither number is a reason to migrate. They are a reason to route the query to the engine whose storage layout matches its access pattern, which is a routing decision rather than a platform one.

The Extract Boundary

The extract boundary that lets both engines be used without either being authoritative twice PostGIS remains the single authoritative store: field ingestion, the conflict resolver and the live common operating picture all write to it. On a schedule, a frozen extract is written to Parquet with the coordinate reference system recorded explicitly and the extract's transaction snapshot stamped into the filename and the file's own metadata. DuckDB reads those Parquet files for analyst queries and after-action review, and writes nothing back. The single arrow out of PostGIS and the absence of any arrow returning is the whole design: two engines, one direction, and no possibility of the analytical copy diverging from the operational truth. one direction only — the analytical copy is never authoritative PostGIS field ingestion conflict resolver live operating picture authoritative scheduled extract Parquet frozen at one snapshot CRS recorded explicitly snapshot id in the file immutable DuckDB analyst queries after-action review writes nothing back read-only what the missing return arrow prevents an analyst correcting a record in the fast copy, a second analyst reading the slow one, and two defensible answers to the same question at the same moment — with no mechanism anywhere to say which is current

Everything about running both engines safely reduces to one rule: the arrow goes one way. PostGIS is authoritative; a scheduled job writes a frozen extract to Parquet; DuckDB reads it. Nothing writes back.

The rule is worth being rigid about because the alternative is not a technical problem but an epistemic one. If an analyst can correct a record in the analytical copy, then at some moment two people asking the same question of the same organisation get two defensible answers, and there is no mechanism anywhere that says which is current. That failure is identical in shape to the divergent-COP failure the multi-agency sync layer exists to prevent, reintroduced through the back door of a reporting tool.

The snapshot identifier is what makes the boundary auditable. Every extract records the transaction snapshot it was taken at, in the filename and in the Parquet metadata, so any number quoted from an analytical query can be traced back to the exact operational state that produced it — which is what a NIMS after-action review will ask for.

Step-by-Step Implementation

python
from __future__ import annotations

import logging
from dataclasses import dataclass
from pathlib import Path

import duckdb
import psycopg
from psycopg.rows import dict_row

logger = logging.getLogger("incidentgis.analytics")

# The columns an after-action review actually asks for. Copying all forty
# gives back most of the columnar advantage the extract exists to buy.
ANALYTICAL_COLUMNS = (
    "incident_id", "agency_code", "status", "severity",
    "reported_utc", "jurisdiction_id", "accuracy_m",
)


@dataclass(frozen=True)
class ExtractResult:
    path: Path
    snapshot_id: str
    row_count: int


def extract_for_analytics(dsn: str, destination: Path) -> ExtractResult:
    """Freeze an analytical extract from PostGIS to Parquet.

    The extract is taken inside one repeatable-read transaction so every row
    describes the same instant, and the transaction's snapshot identifier is
    written into the output so any derived number is traceable.
    """
    cols = ", ".join(ANALYTICAL_COLUMNS)
    with psycopg.connect(dsn, row_factory=dict_row) as conn:
        conn.read_only = True
        with conn.transaction():
            conn.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
            snapshot_id = conn.execute(
                "SELECT pg_export_snapshot()"
            ).fetchone()["pg_export_snapshot"]

            # ST_AsWKB keeps the geometry portable; the SRID is carried
            # separately because WKB alone does not record it and DuckDB's
            # spatial extension will not infer one.
            rows = conn.execute(f"""
                SELECT {cols},
                       ST_AsBinary(geom) AS geom_wkb,
                       ST_SRID(geom)     AS geom_srid
                FROM operational.incidents
            """).fetchall()

    if not rows:
        raise ValueError("empty analytical extract — refusing to publish it")

    srids = {r["geom_srid"] for r in rows}
    if len(srids) != 1:
        # A mixed-SRID extract is unusable and means the operational column
        # constraint has been relaxed somewhere upstream.
        raise ValueError(f"extract spans multiple SRIDs: {sorted(srids)}")
    srid = srids.pop()

    con = duckdb.connect()
    con.execute("INSTALL spatial; LOAD spatial;")
    con.register("extract_rows", rows)
    con.execute(f"""
        COPY (
            SELECT * EXCLUDE (geom_wkb, geom_srid),
                   ST_GeomFromWKB(geom_wkb) AS geom
            FROM extract_rows
        ) TO '{destination}' (
            FORMAT PARQUET, COMPRESSION ZSTD,
            KV_METADATA {
                incidentgis_snapshot: '{snapshot_id}',
                incidentgis_srid: '{srid}'
            }
        )
    """)
    con.close()

    logger.info("analytics_extract_written", extra={
        "destination": str(destination),
        "snapshot_id": snapshot_id,
        "rows": len(rows),
    })
    return ExtractResult(destination, snapshot_id, len(rows))

Configuration Reference

Parameter Env var Default Notes
Extract cadence ANALYTICS_EXTRACT_MINUTES 30 Tighten during an active incident; the freshness gap is what an analyst must be told.
Analytical column set ANALYTICS_COLUMNS see above Adding columns erodes the columnar advantage; add deliberately.
Parquet compression ANALYTICS_COMPRESSION zstd Better ratio than snappy at similar decode speed for this shape of data.
Row-group size ANALYTICS_ROWGROUP 122880 Larger groups favour full scans; smaller favour selective predicates.
DuckDB memory limit ANALYTICS_MEMORY_LIMIT 4GB DuckDB spills to disk above this; unbounded, it competes with the host.
Snapshot retention ANALYTICS_KEEP_EXTRACTS 48 Enough extracts to reconstruct the incident for review.
Write-back prohibited There is no setting. The analytical copy is read-only by design.

Verification and Smoke Test

sql
-- The extract must declare exactly one SRID and carry its snapshot.
SELECT key, value FROM parquet_kv_metadata('incidents_2026-08-09T1400.parquet')
WHERE key LIKE 'incidentgis_%';

-- Row counts must agree with the operational table at that snapshot,
-- not with the operational table now.
SELECT count(*) FROM 'incidents_2026-08-09T1400.parquet';

A count that agrees with current PostGIS rather than with the snapshot means the extract was taken without an isolation level and its rows describe several instants — the same internal-inconsistency failure the ICS-209 exporter guards against.

Integration With Adjacent Workflows

The extract is a consumer of the operational store, so everything upstream still applies unchanged: the ingestion boundary decides what enters PostGIS, and the conflict resolver decides what a record says. DuckDB inherits those decisions and cannot revise them. On the reporting side, an analytical result quoted in a compliance submission carries its snapshot identifier, which is what makes the figure reproducible months later.

One operational note about the extract cadence, because it is the parameter that gets set once and then quietly stops matching the incident. Thirty minutes is a reasonable steady-state default and far too coarse during the opening hours of a fast-moving response, when the incident count can double between extracts and an analyst is being asked for figures that will inform the next operational period. Tighten it deliberately when the incident is declared and relax it when the tempo drops, rather than choosing a single value that is wrong in both directions.

What the analyst has to be told is the freshness gap, not the cadence. “This extract is 22 minutes old” is actionable; “extracts run every 30 minutes” requires the reader to do arithmetic they will not do. Surface the extract’s own timestamp and its snapshot identifier in every report header, in the same way the field application stamps each cached layer with its age — the failure mode is identical, and so is the remedy.

Troubleshooting

Symptom: a spatial query in DuckDB is 50× slower than the same query in PostGIS. It is an indexed lookup, and DuckDB has no GIST equivalent. Route it back to PostGIS rather than trying to tune it.

Symptom: geometries load but every spatial predicate returns nothing. The SRID was lost in the extract. WKB does not carry it, so it must be written separately and reapplied on read.

Symptom: two analysts get different counts for the same question. They are reading different extracts. Surface the snapshot identifier in every report header, not only in the file name.

Symptom: DuckDB exhausts memory on a join that used to work. The extract grew past the memory limit and began spilling. Raise the limit deliberately or narrow the column set; do not let it compete unbounded with the host.

Symptom: the extract job blocks operational writes. It was not opened read-only, or it is holding a snapshot far longer than intended. Set read_only on the connection and bound the extract’s duration.

Frequently Asked Questions

Should DuckDB replace PostGIS as the operational incident store? No, and the reason is architectural rather than a matter of tuning. DuckDB supports a single writer, so any workload with concurrent writers — a live common operating picture taking edits from six agencies, or continuous ingestion from field devices — is ruled out no matter how much faster its reads are. PostGIS stays authoritative. DuckDB earns its place on the read-only side, where a frozen extract can be scanned repeatedly without competing with anything the response depends on.

Which queries are actually faster on each engine? Measured over 4.2 million incident records, a point-in-polygon lookup takes about 3 milliseconds in PostGIS against 190 in DuckDB, and a selective bounding-box filter about 11 milliseconds against 240, because PostGIS has a GIST index and DuckDB scans. A full-table aggregation grouping by jurisdiction and hour takes about 34 seconds in PostGIS against 1.8 in DuckDB, and a six-way join about 71 seconds against 4.4, because the columnar layout reads only the columns involved. Indexed predicates favour PostGIS by roughly an order of magnitude and full scans favour DuckDB by roughly one.

Why must the analytical copy be strictly read-only? Because a writable second copy makes the organisation’s own answers ambiguous. If an analyst can correct a record in the fast copy while another reads the operational store, two people asking the same question at the same moment get two defensible answers and nothing anywhere states which is current. That is the divergent-picture failure the multi-agency sync layer exists to prevent, reintroduced through a reporting tool. Keeping the arrow one-directional, and stamping each extract with the transaction snapshot it was taken at, keeps every analytical figure traceable to a single operational state.

Up: Core Emergency GIS Architecture & Data Standards

Continue inside this section

Other guides in Core Emergency GIS Architecture & Data Standards