How to Benchmark DuckDB Against GeoPandas Honestly

On this page

Problem statement

Benchmarks between GIS engines are usually wrong in the same few ways, and all of them favour whichever engine the author preferred:

  • The file read is inside one timer and outside the other. DuckDB reading Parquet in place looks instant beside GeoPandas' explicit read_file, when in reality the read is happening โ€” just later.
  • The page cache is warm for the second run. Whichever engine goes second wins.
  • Only time is measured. The 82ร— memory difference measured on a real join is invisible in a stopwatch.
  • The results are never compared. A CRS mismatch in DuckDB returns zero rows without an error, so an "8ร— speed-up" can be an empty result.

A benchmark that fixes those four produces numbers you can act on โ€” and they do not all say the same thing. Measured on the same machine and the same data (Example 2):

join                                  DuckDB              GeoPandas
7,342 pts ร— 4,596 polys       0.26 s / 215 MB      0.44 s / 318 MB
2,241,404 pts ร— 51 polys      3.66 s / 215 MB     13.83 s / 932 MB
13,472,195 pts ร— 4,596 polys 50.85 s / 291 MB   121.80 s / 23,813 MB

Quick answer

Time the whole task, measure memory, run each engine in its own process, and assert the results match:

import json
import subprocess
import sys


def benchmark(engine, script, *args):
    """Each engine in a clean process: no shared caches, real peak memory."""
    result = subprocess.run(
        [sys.executable, script, engine, *map(str, args)],
        capture_output=True, text=True, check=True)
    return json.loads(result.stdout.strip().splitlines()[-1])


duck = benchmark("duck", "bench.py", points, polygons)
gpd_ = benchmark("gpd", "bench.py", points, polygons)

assert duck["result"] == gpd_["result"], (
    f"results differ: {duck['result']} vs {gpd_['result']} โ€” "
    f"check the CRS on both sides before comparing speed")

print(f"duckdb    {duck['secs']:6.2f}s  {duck['peak_mb']:7,.0f} MB")
print(f"geopandas {gpd_['secs']:6.2f}s  {gpd_['peak_mb']:7,.0f} MB")
Four ways a GIS engine benchmark misleads, and the fix for each. four mistakes, four fixesmistakefixthe file read is inside one timeronlytime the whole task, files in toanswer outboth engines in one processa subprocess each, for honest peakmemorytime measured, memory ignoredreport peak RSS โ€” it was the 82ร—differenceresults never comparedassert equality; a zero-row answeris very fastA CRS mismatch in DuckDB returns nothing without an error, which benchmarks as a speed-up spatialworkflow.io
Compare the outputs before comparing the times.

What you get โ€” from The data used on this page

13,472,195 GeoNames points; places.parquet 306 MB, allCountries.txt 1.79 GB

Define the task, isolate engines, measure memory, compare results

1. Define the task, not the operation

Benchmark "count points per polygon from these files", not "sjoin". The task includes reading the data, because that is what a real workflow does and because the two engines do it at different points.

DuckDB reads inside the query; GeoPandas reads before it. Timing only the join hides half of GeoPandas' work and all of DuckDB's.

2. Run each engine in its own process

Peak resident memory is process-wide. Running both engines in one process reports the maximum of the two, attributed to whichever ran second.

A subprocess per engine gives a clean ru_maxrss, prevents warm caches inside a library from carrying over, and removes any chance of one import affecting the other.

3. Report memory as well as time

Time differences are often modest; memory differences are frequently decisive. The measured global join was 2.4ร— faster in DuckDB and used 82ร— less memory โ€” 291 MB against 23,813 MB.

That second number is what decides whether the job runs on a laptop at all. A benchmark that omits it is measuring the less important axis.

4. Control the cache deliberately

Two policies, both defensible, and the important thing is to state which one you used:

  • Warm โ€” run once to warm the page cache, then measure. Reflects a repeated interactive workload.
  • Cold โ€” drop caches between runs (needs root on Linux) or use a file larger than RAM. Reflects a first run.

Also turn off DuckDB's own cache when measuring remote reads: set enable_external_file_cache = false, or the second query reads nothing and appears miraculous.

5. Assert that the results agree

This is the check that catches the embarrassing benchmark. A CRS mismatch in DuckDB returns zero rows with no error, and zero rows is very fast.

Compare the actual outputs: the matched-pair count, the group count, the top few groups. Measured on the global join, both engines returned 12,950,244 pairs across 4,419 groups with the same top three โ€” which is what makes the timing comparison meaningful. On the first run they did not agree on the smallest join, and the cause was the aggregation, not the engines (Example 1).

6. Take the best of several runs, and say how many

Wall-clock timings on a shared machine are noisy. Take the minimum of three to five runs โ€” the minimum is the least contaminated by other load โ€” and report the run count alongside.

Report the machine too: core count, RAM, and whether the data was on SSD or a network mount. Numbers without a machine are not reproducible.

What a defensible engine comparison reports. what to reportwhybest of n runsthe minimum is least polluted by other loadpeak memoryoften the decisive axis, not the timeresult equalitya fast wrong answer is the usual outcomemachinecores, RAM, storage change every numberNumbers without a machine are not reproducible; numbers without a result check are not numbers spatialworkflow.io
State whether the cache was warm. Both choices are valid; silence is not.

A fair benchmark across data sizes

The data used on this page

The points are GeoNames' full allCountries dump โ€” every feature, about 13.5 million, rebuilt daily at download.geonames.org, so counts drift a little between runs โ€” kept both as the 1.79 GB text file and as places.parquet (also linked as geonames.parquet) with name, latitude, longitude, feature class, country and population. The polygons are Natural Earth's 1:10m admin-1 states and provinces, 4,596 of them, as provinces.shp.

import os
import zipfile

import duckdb
import requests


def fetch(url, path):
    if not os.path.exists(path):
        with requests.get(url, stream=True, timeout=600) as reply:
            reply.raise_for_status()
            with open(path, "wb") as out:
                for chunk in reply.iter_content(1 << 20):
                    out.write(chunk)


fetch("https://download.geonames.org/export/dump/allCountries.zip", "allCountries.zip")   # about 420 MB
if not os.path.exists("allCountries.txt"):
    zipfile.ZipFile("allCountries.zip").extract("allCountries.txt")
if not os.path.exists("places.parquet"):
    duckdb.sql("""
        copy (select column01 as name, column04 as lat, column05 as lon, column06 as feature_class,
                     column08 as country, column14 as population
              from read_csv('allCountries.txt', delim = '\\t', header = false, quote = '',
                            all_varchar = false, sample_size = -1))
        to 'places.parquet' (format parquet)""")
if not os.path.exists("geonames.parquet"):
    os.link("places.parquet", "geonames.parquet")
fetch("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_1_states_provinces.zip", "provinces.zip")
if not os.path.exists("provinces.shp"):
    with zipfile.ZipFile("provinces.zip") as z:
        for member in z.namelist():
            if member.startswith("ne_10m_admin_1_states_provinces."):
                open("provinces" + os.path.splitext(member)[1], "wb").write(z.read(member))
n = duckdb.sql("select count(*) from read_parquet('places.parquet')").fetchone()[0]
print(f"{n:,} GeoNames points; places.parquet {os.path.getsize('places.parquet') / 1e6:.0f} MB, "
      f"allCountries.txt {os.path.getsize('allCountries.txt') / 1e9:.2f} GB")
13,472,195 GeoNames points; places.parquet 306 MB, allCountries.txt 1.79 GB

Three pointโ€“polygon pairs are prepared from those: Natural Earth's 7,342 populated places against all 4,596 provinces, the 2.24 million GeoNames points in the United States against the 51 US states (with the District of Columbia), and everything against everything.

import geopandas as gpd

fetch("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_populated_places.zip", "populated_places.zip")
if not os.path.exists("towns.parquet"):
    towns = gpd.read_file("populated_places.zip")
    towns.assign(name=towns.NAME, lon=towns.geometry.x, lat=towns.geometry.y)[["name", "lon", "lat"]].to_parquet("towns.parquet")
if not os.path.exists("us_points.parquet"):
    duckdb.sql("copy (select * from read_parquet('places.parquet') where country = 'US') "
               "to 'us_points.parquet' (format parquet)")
if not os.path.exists("us_states.gpkg"):
    provinces = gpd.read_file("provinces.shp")
    provinces[provinces.adm0_a3 == "USA"][["name", "geometry"]].to_file("us_states.gpkg")
for name, sql in (("towns", "read_parquet('towns.parquet')"), ("US points", "read_parquet('us_points.parquet')")):
    print(f"{name}: {duckdb.sql(f'select count(*) from {sql}').fetchone()[0]:,}")
print("US states:", len(gpd.read_file("us_states.gpkg")))
towns: 7,342
US points: 2,241,404
US states: 51

Example 1 โ€” the benchmark script, one engine per invocation

%%writefile bench.py
#!/usr/bin/env python
"""bench.py <engine> <points.parquet> <polygons.shp>"""
import json
import resource
import sys
import time


def run_duckdb(points, polygons):
    import duckdb
    con = duckdb.connect()
    con.execute("install spatial; load spatial;")
    con.execute("set enable_progress_bar = false")
    rows = con.execute(f"""
        select a.name, count(*) as n
        from read_parquet('{points}') p
        join st_read('{polygons}') a
          on st_intersects(st_point(p.lon, p.lat), a.geom)
        group by 1 order by n desc
    """).fetchall()
    return {"groups": len(rows), "total": sum(r[1] for r in rows),
            "top": [list(r) for r in rows[:3]]}


def run_geopandas(points, polygons):
    import duckdb
    import geopandas as gpd
    frame = duckdb.connect().execute(
        f"select lon, lat from read_parquet('{points}')").df()
    areas = gpd.read_file(polygons)
    pts = gpd.GeoDataFrame(frame,
                           geometry=gpd.points_from_xy(frame.lon, frame.lat),
                           crs=areas.crs)
    joined = gpd.sjoin(pts, areas, predicate="intersects")
    counts = joined.groupby("name", dropna=False).size().sort_values(ascending=False)   # keep unnamed areas, as SQL does
    return {"groups": len(counts), "total": int(counts.sum()),
            "top": [[k, int(v)] for k, v in list(counts.items())[:3]]}


if __name__ == "__main__":
    engine, points, polygons = sys.argv[1], sys.argv[2], sys.argv[3]
    started = time.perf_counter()
    result = run_duckdb(points, polygons) if engine == "duck" \
        else run_geopandas(points, polygons)
    elapsed = time.perf_counter() - started
    peak_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
    print(json.dumps({"engine": engine, "secs": round(elapsed, 2),
                      "peak_mb": round(peak_mb, 1), "result": result}))

dropna=False is there because the first run of this benchmark failed its own assertion: DuckDB counted 7,324 townโ€“province pairs and GeoPandas 7,321. Seven Natural Earth provinces have no name, and pandas' groupby silently drops rows whose key is missing, while SQL's group by keeps them as a NULL group. The join was identical; the aggregation was not.

Note that the GeoPandas path uses DuckDB to read the Parquet file. That is deliberate: it isolates the join difference from the Parquet-reading difference, and it is what a real GeoPandas user would do with a large Parquet input.

Example 2 โ€” the runner, with repeats and assertions

import json
import statistics
import subprocess
import sys


def bench(engine, script, points, polygons, repeats=3):
    runs = []
    for _ in range(repeats):
        out = subprocess.run([sys.executable, script, engine, points, polygons],
                             capture_output=True, text=True, check=True)
        runs.append(json.loads(out.stdout.strip().splitlines()[-1]))
    return {
        "engine": engine,
        "best_secs": min(r["secs"] for r in runs),
        "median_secs": statistics.median(r["secs"] for r in runs),
        "peak_mb": max(r["peak_mb"] for r in runs),
        "result": runs[0]["result"],
        "runs": repeats,
    }


def compare(script, points, polygons, repeats=3):
    duck = bench("duck", script, points, polygons, repeats)
    gpd_ = bench("gpd", script, points, polygons, repeats)

    assert duck["result"]["total"] == gpd_["result"]["total"], (
        f"engines disagree: {duck['result']['total']:,} vs "
        f"{gpd_['result']['total']:,} matched pairs. Check the CRS on both sides.")

    print(f"{'engine':10} {'best':>8} {'median':>8} {'peak MB':>9}  ({repeats} runs)")
    for row in (duck, gpd_):
        print(f"{row['engine']:10} {row['best_secs']:8.2f} "
              f"{row['median_secs']:8.2f} {row['peak_mb']:9,.0f}")
    print(f"\nspeed  {gpd_['best_secs'] / duck['best_secs']:.1f}ร— "
          f"memory {gpd_['peak_mb'] / duck['peak_mb']:.1f}ร—  in DuckDB's favour")
    print(f"matched pairs {duck['result']['total']:,} in {duck['result']['groups']:,} groups; "
          f"same top three: {duck['result']['top'] == gpd_['result']['top']}\n")


for label, points, polygons, repeats in (("7,342 towns ร— 4,596 provinces", "towns.parquet", "provinces.shp", 3),
                                         ("US points ร— 51 states", "us_points.parquet", "us_states.gpkg", 3),
                                         ("all points ร— 4,596 provinces", "places.parquet", "provinces.shp", 1)):
    print(label)
    compare("bench.py", points, polygons, repeats)
7,342 towns ร— 4,596 provinces
engine         best   median   peak MB  (3 runs)
duck           0.26     0.26       215
gpd            0.44     0.44       318

speed  1.7ร— memory 1.5ร—  in DuckDB's favour
matched pairs 7,324 in 2,709 groups; same top three: True

US points ร— 51 states
engine         best   median   peak MB  (3 runs)
duck           3.66     3.67       215
gpd           13.83    13.97       932

speed  3.8ร— memory 4.3ร—  in DuckDB's favour
matched pairs 2,198,712 in 51 groups; same top three: True

all points ร— 4,596 provinces
engine         best   median   peak MB  (1 runs)
duck          50.85    50.85       291
gpd          121.80   121.80    23,813

speed  2.4ร— memory 81.7ร—  in DuckDB's favour
matched pairs 12,950,244 in 4,419 groups; same top three: True

Example 3 โ€” a size sweep, because the answer changes with scale

def scale_sweep(script, points_parquet, polygons, sizes=(10_000, 100_000,
                                                         1_000_000, 10_000_000)):
    """The crossover is the interesting result, not any single number."""
    import duckdb
    import tempfile
    import os

    con = duckdb.connect()
    print(f"{'rows':>12} {'duckdb':>9} {'geopandas':>10} {'winner':>10}")
    for n in sizes:
        with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as handle:
            subset = handle.name
        con.execute(f"""copy (select * from read_parquet('{points_parquet}')
                              limit {n}) to '{subset}' (format parquet)""")
        duck = bench("duck", script, subset, polygons, repeats=3)
        gpd_ = bench("gpd", script, subset, polygons, repeats=3)
        winner = "duckdb" if duck["best_secs"] < gpd_["best_secs"] else "geopandas"
        print(f"{n:12,} {duck['best_secs']:9.2f} {gpd_['best_secs']:10.2f} {winner:>10}")
        os.unlink(subset)


scale_sweep("bench.py", "places.parquet", "provinces.shp")
        rows    duckdb  geopandas     winner
      10,000      0.20       0.41     duckdb
     100,000      0.62       0.98     duckdb
   1,000,000      2.64       9.49     duckdb
  10,000,000     48.15      90.96     duckdb

On this machine there was no crossover: DuckDB was faster at every size, from 0.20 s against 0.41 s at 10,000 rows to 48 s against 91 s at ten million. Below a few hundred thousand rows the difference is a fraction of a second, which is convenience territory; the size at which the memory difference starts to decide whether the job runs is the useful output.

Best-of-three times for the same point-in-province join in DuckDB and GeoPandas at four input sizes. the same join at four sizes, each engine in its own processpointsDuckDBGeoPandasratio10,0000.20 s0.41 s2.1ร—100,0000.62 s0.98 s1.6ร—1,000,0002.64 s9.49 s3.6ร—10,000,00048.15 s90.96 s1.9ร—no crossover on this machine; the memory gap grew far faster than the time gapFaster at every size, by a factor that does not grow steadily spatialworkflow.io
Measured in Example 3. The ratio moves with size in both directions.

Reads inside the timer, results that must agree

Why the file read belongs inside the timer

DuckDB's whole design point is that it reads lazily inside the query. Excluding the read from its timer measures nothing, and excluding GeoPandas' explicit read measures a different task.

Timing the complete task โ€” files in, answer out โ€” is the only comparison that corresponds to what a user experiences. It also means the benchmark automatically credits DuckDB for column pruning, which is a real advantage that a join-only timer hides.

Why memory is the more decisive axis

The measured global join was 2.4ร— faster in DuckDB and used an eighty-second of the memory. On a machine with 16 GB of RAM the GeoPandas version simply does not run, which is a qualitative difference that no speed ratio expresses.

Memory also determines what else can run at the same time. A pipeline stage that peaks at 23.8 GB cannot run at all on a 16 GB machine; one that peaks at 291 MB can run dozens of times over.

Why the equality assertion matters more than any timing

A DuckDB spatial join across mismatched coordinate systems returns zero rows and no error. Zero rows is extremely fast.

Every benchmark should therefore compare outputs before comparing times. In the measured runs both engines produced 12,950,244 matched pairs across 4,419 groups with identical leaders โ€” and that agreement is what licenses the timing comparison. Without it, the fast number could be measuring nothing at all.

Why the scale, not one ratio, is the useful output

"DuckDB is 2ร— faster" is not actionable, because the ratio moves with scale. The measured speed advantage was 1.7ร— at 7,342 points, 3.8ร— at 2.2 million and 2.4ร— at 13.5 million; the memory advantage went 1.5ร—, 4.3ร—, 82ร—.

The number worth extracting from a benchmark is where your workload sits on that curve. While both finish in seconds within the memory you have, use whichever library is more convenient; beyond that, the choice starts to determine whether the job completes.

GeoPandas wins at small sizes and loses at large ones; the crossover is the answer. DuckDB memory advantage over GeoPandas, same joins7,342 points1.5ร— (318 vs 215 MB)2,241,404 points4.3ร— (932 vs 215 MB)13,472,195 points82ร— (23,813 vs 291 MB)045ร—90ร—Speed differed by 1.7โ€“3.8ร—; memory is where scale shows spatialworkflow.io
Time differed by 1.7โ€“3.8ร—; memory by up to 82ร—.

Machines, repeats and units of ru_maxrss

  • Report the machine. Core count, RAM and storage type change every number here.
  • Take the minimum of several runs, not the mean; it is least polluted by other load.
  • ru_maxrss is kilobytes on Linux and bytes on macOS. Normalise it.
  • Disable DuckDB's external file cache when measuring remote reads.
  • A subprocess per engine is the only reliable way to attribute peak memory.
  • Warm the cache or state that you did not. Both are valid; silence is not.
  • Check thread counts. DuckDB uses all cores by default; GeoPandas is largely single-threaded.
  • Compare the outputs first. A fast wrong answer is the most common benchmark result.

FAQ

How should I time a GIS engine comparison?

Time the whole task โ€” files in, answer out โ€” in a separate process per engine, take the minimum of several runs, and record the machine.

Should I measure memory too?

Yes, and it is often the more important number. The measured global join was 2.4ร— faster in DuckDB and used 82ร— less memory: 291 MB against 23,813 MB.

Why do I need a separate process per engine?

Peak resident memory is process-wide, so running both in one process attributes the maximum to whichever ran second.

What is the most common benchmark mistake?

Not comparing the results. A DuckDB join across mismatched CRSs returns zero rows with no error, and zero rows is very fast.

Is DuckDB always faster than GeoPandas?

Not by much at small sizes. On 7,342 points against 4,596 polygons DuckDB took 0.26 s and GeoPandas 0.44 s โ€” a difference nobody waits for. The gap that matters opens with size, and in memory first.

Should the cache be warm or cold?

Either, as long as you say which. Warm reflects repeated interactive use; cold reflects a first run. Silence about it makes the numbers uninterpretable.