DuckDB Spatial vs PostGIS, GeoPandas and Sedona

Every comparison between spatial engines is unanswerable until the workload is named, because the four in common use lead on four different axes and none of them leads twice. This page sits inside the migrating to DuckDB Spatial reference and does the comparison the way it is actually decidable: by axis rather than by score, with the operational cost — usually the deciding factor — treated as a first-class dimension rather than a footnote.

The recurring mistake in engine comparisons is to benchmark one query shape and generalise. A selective indexed lookup measures what a server is built for; a full-layer aggregation measures what a columnar engine is built for; and publishing either as “which is faster” produces a number that is correct, reproducible and useless for anyone whose workload has a different shape.

The Execution Model Shift

The four engines differ less in their spatial functions — all four are ultimately calling GEOS or an equivalent for topology — than in what surrounds those functions. DuckDB is an embedded columnar engine with a query planner and no server. PostGIS is a row-oriented transactional server with a mature authorisation and durability story. GeoPandas is an in-memory dataframe library with no planner at all. Sedona is a distributed query layer over Spark. The spatial predicate is nearly the same in all four; everything about how it is scheduled, parallelised and paid for is different.

Five axes, four engines, no overlap Analytical scan throughput, lookup latency, concurrent writes, interactive iteration and horizontal scale — each has a different leader. AXIS LEADER WHY analytical scan throughput DuckDB columnar, parallel, prunes by statistics single-row lookup latency PostGIS a server with an index and a pool concurrent write throughput PostGIS DuckDB permits one writer interactive iteration GeoPandas no planner, no boundary scale beyond one machine Sedona the only one that distributes

No engine leads twice. Naming the axis is the whole of the comparison.

Runtime Configuration & Memory Guardrails

A like-for-like comparison requires the engines to be configured comparably, which is harder than it sounds because their defaults assume different deployments.

-- DuckDB, configured to be comparable rather than to win. A default DuckDB
-- takes every core; a default Postgres backend takes one, so a comparison
-- against defaults measures the hardware allocation, not the engines.
SET threads = 4;                       -- match the parallelism you gave the other side
SET memory_limit = '8GB';              -- match work_mem × the backend count, roughly
SET temp_directory = '/var/tmp/duckdb_bench';
SET enable_object_cache = false;       -- so repeated runs are comparable

Comparing Against PostGIS

The honest summary is that DuckDB and PostGIS are complementary rather than competing, and the boundary between them is access pattern rather than data size. A query that touches a large fraction of a layer favours DuckDB by a wide margin, because it scans columnar data in parallel and prunes with statistics. A query that fetches a handful of rows by key favours PostGIS, because an index descent on a server is already close to optimal and there is no work to parallelise.

Where the comparison becomes genuinely one-sided is concurrency and durability. PostGIS accepts many concurrent writers, enforces roles and row-level security, and offers point-in-time recovery. DuckDB does none of those things, and no amount of analytical speed substitutes. That is why the stable end state for most teams is both — PostGIS as the system of record, DuckDB reading a periodically refreshed snapshot, with the boundary between them also serving as an isolation boundary. The mechanics of establishing that are in PostGIS to DuckDB migration, and the measured crossover curves are in performance crossover benchmarks.

Comparing Against GeoPandas

GeoPandas is the easiest of the four to compare fairly, because its behaviour changes shape rather than degrading. Below the memory ceiling it is genuinely competitive and often more pleasant — there is no planner, no boundary, and the next question is a line away. Above it, the process does not slow down; it stops. That cliff, rather than any per-operation speed, is the migration argument, and it is why the hybrid arrangement of DuckDB reducing and GeoPandas receiving is where most ported workflows settle rather than a full rewrite. The operator-by-operator mapping is in GeoPandas to DuckDB migration.

What each engine costs to operate DuckDB is a library and a file; PostGIS is a server; GeoPandas is a Python environment; Sedona is a Spark cluster. ENGINE WHAT YOU OPERATE ONGOING COST DuckDB a library and a file close to nothing PostGIS a database server backups, upgrades, monitoring GeoPandas a Python environment trivial to run, hard to pin Sedona a Spark cluster the largest of the four Operational cost is usually the deciding factor rather than throughput, and it is the axis most benchmarks leave out entirely.

Most engine decisions are settled here rather than on any performance number.

Comparing Against Sedona

Sedona occupies a different position from the other three: it is the only one that exceeds a single machine, and that capability comes with the only substantial operational commitment in the set. The comparison is therefore not really about speed but about whether the data justifies a cluster, and the honest threshold has moved considerably in DuckDB’s favour over the last few years. A single machine with a few hundred gigabytes of memory and fast local storage handles datasets that would once have required a distributed engine.

The cases where Sedona still wins are genuine and worth stating: data that does not fit on the largest available machine, workloads already embedded in a Spark pipeline where moving out would cost more than it saves, and organisations whose data platform is Spark and for whom a second engine is an operational cost rather than a saving. What has changed is that “the data is large” is no longer sufficient on its own — a hundred million geometries is a single-node workload now.

Trade-off Analysis: A Spark cluster’s per-query startup cost is measured in seconds even when the query is trivial, which makes it a poor fit for anything interactive regardless of data size. DuckDB’s startup is measured in milliseconds. For an exploratory workload, that difference dominates the comparison entirely and no amount of distributed throughput compensates — which is why teams running Sedona in production frequently also run DuckDB for analysis.

What each engine cannot do DuckDB has no concurrent writes or authorisation; PostGIS does not scan like a columnar engine; GeoPandas has a hard memory ceiling; Sedona needs a cluster and starts slowly. ENGINE WHAT IT CANNOT DO CONSEQUENCE DuckDB concurrent writes, roles, a listener needs something in front for OLTP PostGIS scan a layer at columnar speed heavy analytics eventually strain it GeoPandas exceed memory, or parallelise a cliff rather than a slope Sedona run without a cluster; start fast poor fit for anything interactive

Boundaries rather than weaknesses — and a real architecture usually crosses two of them.

Building a Comparison You Can Defend

Most engine comparisons fail review not because the measurements are wrong but because they cannot be reproduced or generalised. Making one defensible takes four decisions, all of which have to be made before any timing is recorded, and none of which is technically difficult.

Choose the query set from the workload, not from a benchmark suite. Five queries that represent what the system actually does beat any number of synthetic ones, because the whole finding of this page is that engines lead on different axes — so the axis your workload sits on is the only one whose result transfers. Write them down, with their expected result cardinality, before running anything.

Equalise the index state explicitly. This is the single most common way a comparison is accidentally rigged. A PostGIS table that has carried a GiST index for years against a DuckDB table freshly loaded from Parquet is not a comparison of engines; it is a demonstration that indexes work. Index both sides for the queries that would use one, or index neither, and say which.

Control the cache and report both states. A cold first run and a warm repeat can differ by an order of magnitude, and each answers a different question — cold tells you what a first query after a deploy feels like, warm tells you what a repeated dashboard query costs. Reporting only the warm number and calling it the result is how published benchmarks come to disagree with production.

Record the shape of the data alongside the timings. Row counts, total vertex counts, and the cardinality of each result. Those three numbers are what let someone else tell whether your finding applies to their data, and without them a timing is an anecdote.

-- The shape record that should accompany every timing. Vertex count is the
-- one people leave out, and it is the one that predicts topology cost.
SELECT 'incidents' AS relation,
       count(*)                     AS rows,
       sum(ST_NPoints(geom))        AS vertices,
       avg(ST_NPoints(geom))        AS avg_vertices
FROM incidents
UNION ALL
SELECT 'zones', count(*), sum(ST_NPoints(geom)), avg(ST_NPoints(geom)) FROM zones;

Trade-off Analysis: Running a full comparison properly costs an afternoon — building the query set, equalising the configurations, running each query ten times cold and warm on both engines, and recording the shapes. Against that, the decision it informs typically commits a team to an architecture for years. The comparisons that get skipped are the ones where the answer was already known, and those are also the ones most likely to be wrong.

Reading a result that surprises you

When one engine loses a query it should have won, the useful reflex is to check the plan rather than to re-run the timing. Three explanations cover nearly every surprise. The predicate may not be equivalent across the two systems, in which case the queries are answering different questions and the timing is meaningless. The index may not be being used on one side, which the plan states directly. Or the result cardinality may be much larger than expected, which turns a query that was supposed to measure filtering into one that measures output.

All three are visible in the plan and none is visible in the clock, which is the general form of the advice on this page: the plan is the measurement and the timing is the summary. A comparison built on timings alone can be reproduced but cannot be explained, and a comparison that cannot be explained does not survive its first challenge.

Execution Plan Validation

The comparison that matters is not between engines in the abstract but between the plan each one produces for your query, and all four expose it.

-- DuckDB: look for the index scan, the row counts per operator, and whether
-- the filter landed on the scan or above it.
EXPLAIN ANALYZE
SELECT z.zone_id, count(*)
FROM incidents i JOIN zones z ON z.geom && i.geom
WHERE ST_Contains(z.geom, i.geom)
GROUP BY z.zone_id;

The equivalent in PostGIS is EXPLAIN (ANALYZE, BUFFERS), and the number to compare across the two is not the time but the row counts at each stage: how many candidate pairs the index produced, and how many survived the exact predicate. If those differ between engines the queries are not equivalent, and any timing comparison between them is meaningless.

Two engines, one dataset, one storage format

One consequence of the axis-by-axis view is worth drawing out because it changes the architecture rather than the benchmark: when the engines are complementary, the thing to standardise is not the engine but the format. GeoParquet is readable by all four of the engines discussed here, which means a dataset landed once can be queried by whichever engine suits the question without being copied, converted or kept in step.

That is a materially different arrangement from the usual one, where each engine owns its own storage and a copy exists per engine. Copies drift, and reconciling two answers that came from two copies of the same data is among the least rewarding work in a data platform. A single columnar dataset with several readers removes the class of problem entirely, and it is the arrangement the analytical half of a spatial platform tends to converge on regardless of which engine started it.

-- One dataset, written once, with the metadata every reader needs. Nothing
-- here is DuckDB-specific: the same files are readable from Spark, from
-- GeoPandas via pyarrow, and from PostGIS through an external table.
COPY (
    SELECT parcel_id, region, year, land_use, geometry,
           ST_XMin(geometry) AS bbox_xmin, ST_YMin(geometry) AS bbox_ymin,
           ST_XMax(geometry) AS bbox_xmax, ST_YMax(geometry) AS bbox_ymax
    FROM parcels
    ORDER BY region, year, land_use
) TO 's3://lake/parcels' (FORMAT PARQUET, PARTITION_BY (region, year), ROW_GROUP_SIZE 65536);

The bbox columns are there for the same reason they are everywhere else in this reference: statistics exist for numbers and not for geometry, so materialising the envelope is what makes a spatial predicate prunable by any reader rather than only by one that has a spatial index.

Failure Modes & Diagnostics

Comparing against defaults. A default DuckDB takes every core; a default Postgres backend takes one. Unless both are configured deliberately, the comparison measures how the hardware was allocated.

Indexing one side only. The most common way an engine comparison is accidentally rigged, and worth a factor of ten to a hundred on its own.

Measuring a query that does not represent the workload. A single benchmark query generalises badly precisely because the engines lead on different axes. Take the five queries that matter most and report them separately rather than averaging them into one number.

Ignoring operational cost. A comparison that concludes “engine X is 3× faster” without noting that X requires a cluster and Y requires a file has left out the dimension most teams actually decide on.

Forgetting the boundary conditions. Concurrency, authorisation and durability are not slow in DuckDB; they are absent. No throughput measurement expresses that, and it is frequently the whole answer.

Frequently Asked Questions

Which engine should I choose?

Name the workload first, because the answer changes completely with it. Analytical scans over a fixed dataset on one machine: DuckDB. Concurrent transactional access with roles and durability: PostGIS. Interactive exploration of something that fits in memory: GeoPandas. Data that genuinely exceeds one machine: Sedona. Most real architectures use two of these, not one.

Is DuckDB a replacement for PostGIS?

For the analytical half of a PostGIS workload, usually yes. For the transactional half, no — there are no concurrent writers, no roles, no row-level security and no network listener, and those are absences rather than slow implementations. The durable arrangement is both, with a scheduled snapshot as the interface.

When is Spark or Sedona still the right answer?

When the data does not fit on the largest machine you can reasonably rent, when the workload is already embedded in a Spark pipeline, or when Spark is the organisation’s data platform and a second engine is a cost rather than a saving. “The data is large” no longer settles it on its own — a hundred million geometries is a single-node workload today.

How do I run a comparison I can trust?

With your own data, your own five most important queries, and both engines configured comparably: same index state, same cache state, same CPU budget. Report the queries separately rather than averaging, and record vertex counts and result cardinality alongside the timings, because those are what make the numbers reproducible.

Does DuckDB replace GeoPandas in a notebook?

Not really, and it does not need to. The comfortable arrangement is DuckDB doing the reduction — the join, the filter, the aggregation over everything — and GeoPandas receiving a result small enough to plot and explore. That keeps the interactive ergonomics without the memory cliff, which is the only thing the cliff was ever a problem for.

What about cloud warehouses with spatial support?

They occupy a fifth position: managed, distributed, and billed per query. The comparison against DuckDB is mostly economic rather than technical — a scan that costs pennies on a laptop can cost meaningfully more as a warehouse query, and a scan that is impossible on a laptop is routine in a warehouse. The technical axes above still apply; the operational one becomes a pricing question.

See also

Up: Migrating to DuckDB Spatial