How to Query Hive-Partitioned GeoParquet in DuckDB

On this page

Problem statement

One 446 MB Parquet file is convenient until every query filters on the same column. Then the engine reads the file, prunes what it can from the row-group statistics, and still touches more than it needs.

Partitioning splits the data into a directory tree whose path encodes a column value:

places/
  country=AD/data_0.parquet
  country=AE/data_0.parquet
  country=US/data_0.parquet
  โ€ฆ

A query with where country = 'US' then reads one directory and ignores every other one โ€” before opening anything. Measured: partitioning 13,472,195 rows by country produced 271 files totalling 490 MB, 10% more than the single 446 MB file, and 190 of the files were under 1 MB (Example 1).

The cost is that partitioning is a commitment. It optimises one access pattern and can make every other one worse โ€” and on this data it did not even win its own: a filtered count took 1.9 ms on the single file and 6.0 ms across the partitions (Example 2).

Quick answer

Write with partition_by, read with hive_partitioning:

import duckdb

con = duckdb.connect()
# write
con.execute("""
    copy (select * from read_parquet('full.parquet'))
    to 'places_by_country'
    (format parquet, partition_by (country), compression zstd, overwrite_or_ignore)
""")
# read: the partition column comes back as a normal column
print(con.execute("""
    select country, count(*)
    from read_parquet('places_by_country/**/*.parquet', hive_partitioning = 1)
    where country = 'NO'
    group by 1
""").fetchall())
[('NO', 608564)]

The partition column does not exist inside the files โ€” it is reconstructed from the directory names, which is why filtering on it costs nothing at all.

A Hive-partitioned directory tree where the path encodes a column value. the path is the columnone fileplaces.parquet446 MB ยท 109 row groupsrow-group statistics prune insidepartitionedcountry=ADcountry=GBcountry=US271 files ยท 490 MB totalwhole files skipped before openingThe partition column is not inside the files โ€” hive_partitioning = 1 reconstructs it spatialworkflow.io
Partitioning prunes files; row-group statistics prune inside them.

Step-by-step solution

1. Choose the partition column from the queries, not from the data

A good partition column is one that almost every query filters on, and whose values divide the data into manageable pieces.

Good candidates: country, region, year, month, product line. Bad candidates: anything with thousands of distinct values (one file per value means thousands of tiny files), anything with two values (the pruning saves half at best), and anything queries rarely filter on.

2. Aim for the right file size

Partitioning by a high-cardinality column produces the small-files problem: thousands of files, each a few kilobytes, where per-file overhead dominates.

Target roughly 100 MB to 1 GB per file. Measured on the 13.5-million-row dataset partitioned by country, the largest partition was the United States and the smallest were tiny territories โ€” a spread of several orders of magnitude, which is typical and usually acceptable when the small ones are rarely queried.

3. Write the partitioned dataset

copy (select * from source)
to 'output_directory'
(format parquet, partition_by (country, year), compression zstd,
 overwrite_or_ignore);

Two partition columns nest: country=US/year=2025/data_0.parquet. Each additional level multiplies the file count, so two levels is usually the practical limit.

Measured: partitioning 13.5 million rows by country took 5.5 s and produced 271 files โ€” more than the 253 countries, because DuckDB splits a large partition across several files (the United States got three).

4. Read with hive_partitioning = 1

select * from read_parquet('output/**/*.parquet', hive_partitioning = 1);

Without the flag, the partition column is missing from the result โ€” the values live only in the paths. With it, DuckDB parses the directory names into columns and uses them for pruning.

5. Sort within each partition too

Partitioning prunes at the file level; row-group statistics prune within a file. Both are worth having:

copy (select * from source order by country, lat)
to 'output' (format parquet, partition_by (country), compression zstd);

The measured effect of within-file sorting is large: on a single file, a filtered query read 0.26 MB from a sorted file and 10.25 MB from a shuffled one โ€” 39ร— the bytes for the same answer (measured here).

6. Know what partitioning costs

  • Queries that do not filter on the partition column now read many files instead of one, with per-file overhead on each.
  • Listing the directory is itself work, and on object storage with tens of thousands of prefixes it can dominate a small query.
  • Rewriting is expensive. Changing the partition column means rewriting the whole dataset.
  • The partition column disappears from the file contents, so a file read without hive_partitioning is missing a column.
What makes a good partition column and what makes a bad one. choose from the queries, not the dataalmost every query filters on itcountry, year, regionenough values to divide the datatens to hundredsfew enough to keep files large100 MB โ€“ 1 GB eachthousands of distinct valuesthe small-files problema column queries rarely filter onall cost, no benefitPartitioning optimises one access pattern and can make every other one worse spatialworkflow.io
Changing the scheme later means rewriting the whole dataset.

Code examples

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

The single-file baseline is all 19 GeoNames columns written with zstd, full.parquet, in the dump's own order.

COLUMNS = ("geonameid BIGINT, name VARCHAR, asciiname VARCHAR, alternatenames VARCHAR, lat DOUBLE, lon DOUBLE, "
           "feature_class VARCHAR, feature_code VARCHAR, country VARCHAR, cc2 VARCHAR, admin1 VARCHAR, "
           "admin2 VARCHAR, admin3 VARCHAR, admin4 VARCHAR, population BIGINT, elevation VARCHAR, dem INTEGER, "
           "timezone VARCHAR, modified DATE")
TSV = ("read_csv('allCountries.txt', delim = '\\t', header = false, quote = '', columns = {"
       + ", ".join(f"'{c.split()[0]}': '{c.split()[1]}'" for c in COLUMNS.split(", ")) + "})")
if not os.path.exists("full.parquet"):
    duckdb.sql(f"copy (select * from {TSV}) to 'full.parquet' (format parquet, compression zstd)")
print(f"full.parquet {os.path.getsize('full.parquet') / 1e6:.0f} MB")
full.parquet 446 MB

Example 1 โ€” writing a partitioned dataset with a report

import duckdb
import os
import time


def write_partitioned(con, source, target, partition_columns, sort_within=None,
                      compression="zstd"):
    """Partition, then report the file-size distribution โ€” the thing that
    decides whether the layout will work."""
    order = f"order by {', '.join(list(partition_columns) + list(sort_within or []))}"
    started = time.perf_counter()
    con.execute(f"""
        copy (select * from {source} {order})
        to '{target}'
        (format parquet, partition_by ({', '.join(partition_columns)}),
         compression {compression}, overwrite_or_ignore)
    """)
    elapsed = time.perf_counter() - started

    sizes = []
    for root, _, files in os.walk(target):
        for name in files:
            sizes.append(os.path.getsize(os.path.join(root, name)))

    sizes.sort()
    total = sum(sizes)
    print(f"wrote {len(sizes):,} files, {total / 1e6:,.0f} MB, in {elapsed:.1f}s")
    print(f"  smallest {sizes[0] / 1e6:8.3f} MB")
    print(f"  median   {sizes[len(sizes) // 2] / 1e6:8.3f} MB")
    print(f"  largest  {sizes[-1] / 1e6:8.3f} MB")
    tiny = sum(1 for s in sizes if s < 1e6)
    if tiny > len(sizes) * 0.5:
        print(f"  ! {tiny} files under 1 MB โ€” the partition column may be too "
              f"high-cardinality")


import shutil

shutil.rmtree("sorted_by_country", ignore_errors=True)
write_partitioned(con, "read_parquet('full.parquet')", "sorted_by_country", ["country"], sort_within=["lat"])
wrote 271 files, 490 MB, in 5.5s
  smallest    0.004 MB
  median      0.389 MB
  largest    39.950 MB
  ! 190 files under 1 MB โ€” the partition column may be too high-cardinality

Example 2 โ€” measuring how much a partition actually prunes

def pruning_benefit(con, partitioned_glob, single_file, filter_sql):
    """Same query, two layouts. The difference is what partitioning bought."""
    import time

    def timed(sql):
        best = None
        for _ in range(3):
            started = time.perf_counter()
            con.execute(sql).fetchall()
            elapsed = time.perf_counter() - started
            best = elapsed if best is None else min(best, elapsed)
        return best

    single = timed(f"select count(*) from read_parquet('{single_file}') "
                   f"where {filter_sql}")
    partitioned = timed(f"select count(*) from read_parquet('{partitioned_glob}', "
                        f"hive_partitioning = 1) where {filter_sql}")

    print(f"single file  {single * 1000:8.1f} ms")
    print(f"partitioned  {partitioned * 1000:8.1f} ms  "
          f"({single / max(partitioned, 1e-9):.1f}ร—)")
    print("  a small difference means the single file's row-group statistics "
          "were already doing the work")


pruning_benefit(con, "sorted_by_country/**/*.parquet", "full.parquet", "country = 'NO'")
single file       1.9 ms
partitioned       6.0 ms  (0.3ร—)
  a small difference means the single file's row-group statistics were already doing the work

That last line is the important one. Measured here, the single file won: 1.9 ms against 6.0 ms, because the GeoNames dump is already grouped by country โ€” only 6 of the file's 109 row groups have a country range that includes NO โ€” so its row-group statistics prune almost as precisely as the directories, and listing and opening a partition costs more than the groups it skips. Partitioning pays when the single file is not clustered on the filter column, when the data is remote, or when whole partitions are rewritten independently.

How the 271 files of a country-partitioned GeoNames dataset are spread across file sizes. 271 partition files by sizeunder 0.1 MB800.1 โ€“ 1 MB1101 โ€“ 10 MB7010 โ€“ 100 MB110110 files190 of 271 files are under 1 MB โ€” far below the 100 MB โ€“ 1 GB a file should be spatialworkflow.io
Measured in Example 1. Partitioning by country gives a handful of large files and a long tail of tiny ones.

Example 3 โ€” listing what the partitions contain, without reading them

def partition_summary(con, glob, partition_column):
    """Row counts per partition, from metadata rather than from the data."""
    frame = con.execute(f"""
        with files as (select file_name, num_rows from parquet_file_metadata('{glob}')),
             sizes as (select file_name, sum(total_compressed_size) as bytes
                       from parquet_metadata('{glob}') group by 1)
        select regexp_extract(file_name, '{partition_column}=([^/]+)', 1) as {partition_column},
               count(*)                         as files,
               sum(num_rows)                    as rows,
               round(sum(bytes) / 1e6, 1)       as mb
        from files join sizes using (file_name)
        group by 1 order by rows desc
    """).df()
    print(frame.head(10).to_string(index=False))
    return frame


summary = partition_summary(con, "sorted_by_country/**/*.parquet", "country")
country  files      rows   mb
     US      3 2241404.0 78.3
     CN      1  961888.0 37.3
     IN      2  660026.0 17.7
     NO      1  608564.0 19.5
     FI      1  552800.0 17.4
     MX      2  521520.0 18.2
     ID      1  452093.0 13.9
     RU      1  412812.0 18.1
     CA      1  317026.0  8.5
     TH      1  264777.0 12.0

Reading the metadata rather than the rows makes this instant even on a large dataset, and it is the fastest way to spot a partition that has grown out of proportion. Two details make it correct: the partition value is not in the metadata โ€” it lives only in the path, so it is parsed from the file name โ€” and parquet_metadata has one row per column chunk, so sizes are summed per file before joining, or every row count would be multiplied by the number of columns.

Explanation

Why partition pruning is free

The partition column's values are in the directory names. DuckDB parses country=US from the path and evaluates where country = 'US' against it before opening the file.

That is qualitatively different from row-group pruning, which requires reading the footer of each file. On a dataset of thousands of files, skipping most of them without any I/O is the whole point.

Why the small-files problem is real

Every Parquet file has a footer, a schema and per-column metadata. At 100 MB per file that overhead is negligible; at 50 kB per file it can exceed the data.

Worse, each file is a separate open โ€” and on object storage, a separate HTTP request with its latency. A query over ten thousand tiny files spends its time on round trips.

The guidance to target 100 MB to 1 GB per file comes from that arithmetic, not from any property of Parquet itself.

Why sorting inside partitions still matters

Partitioning answers "which files do I need?" Sorting answers "which parts of this file do I need?" They compose, and neither replaces the other.

The measured single-file numbers show how much the second is worth: a filter on a sorted file read 0.26 MB in 4 requests; on a shuffled file with identical rows, 10.25 MB in 222 requests. If your partitions are large, sorting within them recovers most of that.

Why partitioning is a commitment

A partitioned layout is optimised for one filter. Queries that do not use it pay the per-file cost of every partition, and changing the scheme means rewriting the dataset.

That is why the first step is to look at the queries. If half the queries filter by country and half by year, partitioning by country makes half of them faster and half of them slower โ€” and a single sorted file may serve both better than either partitioning would.

Partitioning and sorting prune at different levels and compose. two levels of pruningpartitionwhich files do I open?skipped with no I/O at allrow groupwhich parts of this file?skipped after reading a footercolumnwhich columns of those?only what the query namesrowsthe exact filterwhat finally gets decodedNeither replaces the other: a large partition still needs to be sorted inside spatialworkflow.io
Each level removes work the level below never has to do.

Edge cases or notes

  • hive_partitioning = 1 is required or the partition column is missing from the result.
  • The partition column is not stored inside the files โ€” it exists only in the paths.
  • overwrite_or_ignore is needed to write into a directory that already exists.
  • Nesting multiplies files. Two levels is usually the practical maximum.
  • Partition values are strings in the path โ€” a numeric column comes back needing a cast.
  • Directory listing costs time on object storage, especially with many prefixes.
  • parquet_file_metadata() and parquet_metadata() read only footers, which makes dataset inspection instant.
  • A single well-sorted file is often enough. Measure before committing to a layout.

FAQ

What is Hive partitioning?

A directory layout where the path encodes a column value โ€” country=US/data_0.parquet. Queries filtering on that column skip whole directories without opening any file.

How do I read a partitioned dataset in DuckDB?

read_parquet('dir/**/*.parquet', hive_partitioning = 1). Without the flag the partition column is missing, because its values live only in the paths.

What should I partition by?

A column almost every query filters on, with enough distinct values to divide the data and few enough to keep files large. Country and year are typical; an id column is not.

How large should each partition file be?

Roughly 100 MB to 1 GB. Thousands of small files spend their time on per-file overhead and, on object storage, on round trips.

Do I still need to sort within partitions?

Yes. Partitioning prunes files; sorting prunes row groups inside them. On a single file the measured difference was 0.26 MB against 10.25 MB for the same filter.

Is partitioning always worth it?

No. It optimises one filter and makes other queries read more files. A single well-sorted Parquet file is frequently enough โ€” measure both before committing.