How to Query Remote GeoParquet over HTTP with DuckDB

On this page

Problem statement

The dataset is 446 MB and it is on someone else's server. The conventional workflow downloads it, converts it, and queries it โ€” twenty minutes and half a gigabyte of disk before the first question is answered.

DuckDB can query it where it is. With the httpfs extension it issues HTTP range requests and reads only the bytes the query needs. Measured against a byte-counting server, on a 446 MB Parquet file with 13,472,195 rows (Example 1):

query                                   bytes read   share of file   requests   time
count(*)                                    0.26 MB       0.06%             1   0.006 s
count(distinct country)                     0.27 MB       0.06%           110   1.246 s
where country = 'AD'                        0.26 MB       0.06%             3   0.005 s
top 5 by population                         2.76 MB       0.62%            48   1.466 s
group by floor(lon), floor(lat)           108.19 MB      24.24%           110   1.253 s
count + max(length(alternatenames))       116.11 MB      26.01%           110   2.078 s

Three of those six queries read less than one thousandth of the file. That is the difference between downloading a dataset and asking it a question.

Quick answer

Load httpfs and put a URL where a path would go:

import duckdb

con = duckdb.connect()
con.execute("install httpfs; load httpfs;")
con.execute("install spatial; load spatial;")

con.execute("""
    select country, count(*) as n
    from read_parquet('https://example.org/data/places.parquet')
    where country in ('GB', 'FR', 'DE')
    group by 1
""").df()

For S3-compatible storage:

con.execute("set s3_region = 'us-west-2'")
con.execute("""
    select count(*) from read_parquet('s3://bucket/prefix/*.parquet')
    where bbox.xmin between -74.02 and -73.93
""").fetchone()

Measured against a real public bucket โ€” Overture Maps places, theme=places/type=place โ€” a Manhattan bounding-box count returned 224,648 places in 9.1 seconds from the 2026-09-23.1 release, without downloading the dataset (Example 2).

Bytes pulled over HTTP by six queries against one 446 MB Parquet file. measured with a byte-counting servercount(*)0.26 MB ยท 0.06%count(distinct country)0.27 MB ยท 0.06%where country = 'AD'0.26 MB ยท 0.06%top 5 by population2.76 MB ยท 0.62%group by lon, lat108 MB ยท 24.2%count + max(len(alt))116 MB ยท 26.0%0130 MBThree of six read less than a thousandth of the file โ€” that is the whole cloud-native argument spatialworkflow.io
The count needed no column data at all: the row count is in the footer.

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

13,472,195 rows; full.parquet 446 MB, shuffled.parquet 741 MB

Step-by-step solution

1. Install httpfs, and know what it enables

httpfs teaches DuckDB to read http://, https:// and s3:// URLs by issuing HTTP range requests. Without it, a URL is just an unreadable path.

import duckdb

con = duckdb.connect()
con.execute("install httpfs")   # one download per DuckDB version
con.execute("load httpfs")      # every connection

It works with read_parquet, read_csv and DuckDB's own database files. ST_Read โ€” the GDAL path โ€” has its own remote handling through GDAL's virtual file systems, which is a different mechanism with different behaviour.

2. Understand what makes remote reads cheap

Three mechanisms, all of them properties of the Parquet format:

  • The footer holds the schema and statistics. DuckDB reads it first โ€” a couple of hundred kilobytes โ€” and knows the row count, the columns and each row group's minimum and maximum per column.
  • Column pruning. Only the columns the query names are fetched. A count(*) needs none of them.
  • Row-group pruning. A filter whose value falls outside a row group's recorded range skips that group entirely.

The count(*) in the opening table read 0.26 MB in one request: the footer, and nothing else. The row count was already in the metadata.

3. Sort the file if you control it โ€” it changes everything

Row-group pruning only works if the values are clustered. The same 13.5-million-row file, sorted by the filter column and shuffled, answering the same query:

                    sorted     shuffled
bytes read         0.26 MB     10.26 MB
requests                 3          221
time               0.005 s      1.492 s
file size           446 MB       741 MB

Thirty-nine times the bytes, about three hundred times the time, and a 66% larger file โ€” for identical rows (Example 1). Sorting on write is the single most effective thing a publisher can do for their readers.

4. Name your columns

select * fetches every column. On the measured file, the difference between a two-column aggregate and one touching the widest column was 108 MB against 116 MB โ€” but against a count(*) at 0.26 MB, both are enormous.

The discipline is the same as for any columnar file and matters much more over a network: every column named is a network transfer.

5. Filter on the partition or the sort key, not on a derived value

A filter DuckDB can push into the pruning logic must compare a column against a constant:

where country = 'AD'                          -- prunes row groups
where upper(country) = 'AD'                   -- cannot prune; reads everything
where lat between 51 and 52                   -- prunes if sorted or clustered by lat

Wrapping the column in a function defeats the statistics, because the statistics are about the column's values, not the function's. Measured, upper(country) = 'AD' made 110 requests โ€” every row group of the column โ€” where country = 'AD' made 3, and took 1.25 s instead of 0.005 s. The bytes stayed small only because the country column is tiny.

6. Cache deliberately

DuckDB keeps an external file cache within a connection, so a second identical query can read nothing at all. That is helpful interactively and misleading in a benchmark:

con.execute("set enable_external_file_cache = false")   # for honest measurement

For repeated work against a stable remote file, the opposite applies โ€” leave the cache on, or copy the file locally once.

The three mechanisms that make a remote Parquet read cheap. three mechanisms, all in the format1read the footerschema, row count, statistics2prune columnsonly what the query names3prune row groupsmin/max excludes the filter4fetch the rest by range requestthe bytes that survivedA filter wrapped in a function defeats the third step entirely spatialworkflow.io
Statistics describe the columnโ€™s values, not a function of them.

Code examples

The data used on this page

The file is GeoNames' full allCountries dump โ€” every named place on Earth, about 13.5 million rows, rebuilt daily at download.geonames.org โ€” written with all 19 of its columns to full.parquet in the order the dump lists them (which groups rows by country), and again with the rows shuffled to shuffled.parquet. Same rows, same schema; only the order differs.

import os
import zipfile

import duckdb
import requests

if not os.path.exists("allCountries.txt"):
    if not os.path.exists("allCountries.zip"):
        with requests.get("https://download.geonames.org/export/dump/allCountries.zip",
                          stream=True, timeout=600) as reply:          # about 420 MB
            reply.raise_for_status()
            with open("allCountries.zip", "wb") as out:
                for chunk in reply.iter_content(1 << 20):
                    out.write(chunk)
    zipfile.ZipFile("allCountries.zip").extract("allCountries.txt")

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)")
if not os.path.exists("shuffled.parquet"):
    duckdb.sql("select setseed(0.42)")
    duckdb.sql("copy (select * from read_parquet('full.parquet') order by random()) "
               "to 'shuffled.parquet' (format parquet, compression zstd)")
rows = duckdb.sql("select count(*) from read_parquet('full.parquet')").fetchone()[0]
print(f"{rows:,} rows; full.parquet {os.path.getsize('full.parquet') / 1e6:.0f} MB, "
      f"shuffled.parquet {os.path.getsize('shuffled.parquet') / 1e6:.0f} MB")
13,472,195 rows; full.parquet 446 MB, shuffled.parquet 741 MB

Example 1 โ€” measuring what a remote query actually costs

import http.server
import os
import re
import socketserver
import threading

COUNT = {"bytes": 0, "requests": 0}


class ByteCountingHandler(http.server.SimpleHTTPRequestHandler):
    """Serves a directory and counts exactly what each client pulled."""

    def log_message(self, *args):
        pass

    def do_GET(self):
        path = self.translate_path(self.path)
        if not os.path.isfile(path):
            self.send_error(404)
            return
        size = os.path.getsize(path)
        rng = self.headers.get("Range")
        if rng:
            match = re.match(r"bytes=(\d+)-(\d*)", rng)
            start = int(match.group(1))
            end = int(match.group(2)) if match.group(2) else size - 1
            end = min(end, size - 1)
            length = end - start + 1
            self.send_response(206)
            self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
        else:
            start, length = 0, size
            self.send_response(200)
        self.send_header("Content-Length", str(length))
        self.send_header("Accept-Ranges", "bytes")
        self.end_headers()
        with open(path, "rb") as handle:
            handle.seek(start)
            self.wfile.write(handle.read(length))
        COUNT["bytes"] += length
        COUNT["requests"] += 1


class Server(socketserver.ThreadingTCPServer):
    allow_reuse_address = True     # must be set before the socket binds, so on the class
    daemon_threads = True


def serve(directory, port=0):          # 0: let the OS pick a free port
    handler = lambda *a, **kw: ByteCountingHandler(*a, directory=directory, **kw)
    server = Server(("127.0.0.1", port), handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    return server
def measure(sql, url_size):
    import duckdb, time
    con = duckdb.connect()
    con.execute("install httpfs; load httpfs;")
    con.execute("set enable_external_file_cache = false")   # or the numbers lie
    COUNT["bytes"] = COUNT["requests"] = 0
    started = time.perf_counter()
    result = con.execute(sql).fetchall()
    print(f"{COUNT['bytes'] / 1e6:8.2f} MB  {COUNT['requests']:4} requests  "
          f"{time.perf_counter() - started:6.3f}s  "
          f"({100 * COUNT['bytes'] / url_size:5.2f}% of the file)")
    return result

Serving the working directory and running the six queries from the top of the page:

server = serve(".")
BASE = f"http://127.0.0.1:{server.server_address[1]}"
URL = f"{BASE}/full.parquet"
SIZE = os.path.getsize("full.parquet")
SRC = f"read_parquet('{URL}')"
QUERIES = {
    "count(*)": f"select count(*) from {SRC}",
    "count(distinct country)": f"select count(distinct country) from {SRC}",
    "where country = 'AD'": f"select count(*) from {SRC} where country = 'AD'",
    "top 5 by population": f"select name, population from {SRC} order by population desc limit 5",
    "group by floor(lon), floor(lat)": f"select floor(lon), floor(lat), count(*) from {SRC} group by 1, 2",
    "count + max(length(alternatenames))": f"select count(*), max(length(alternatenames)) from {SRC}",
}
spent = {}
for label, sql in QUERIES.items():
    print(f"{label:37}", end=" ")
    measure(sql, SIZE)
    spent[label] = COUNT["bytes"]
count(*)                                  0.26 MB     1 requests   0.006s  ( 0.06% of the file)
count(distinct country)                   0.27 MB   110 requests   1.246s  ( 0.06% of the file)
where country = 'AD'                      0.26 MB     3 requests   0.005s  ( 0.06% of the file)
top 5 by population                       2.76 MB    48 requests   1.466s  ( 0.62% of the file)
group by floor(lon), floor(lat)         108.19 MB   110 requests   1.253s  (24.24% of the file)
count + max(length(alternatenames))     116.11 MB   110 requests   2.078s  (26.01% of the file)

And the same filtered count against the sorted and the shuffled copy โ€” identical rows, different order:

import time

for name in ("full.parquet", "shuffled.parquet"):
    print(f"{name:17}", end=" ")
    measure(f"select count(*) from read_parquet('{BASE}/{name}') where country = 'AD'",
            os.path.getsize(name))
for name in ("full.parquet", "shuffled.parquet"):                 # the same query on local disk
    started = time.perf_counter()
    duckdb.sql(f"select count(*) from read_parquet('{name}') where country = 'AD'").fetchall()
    print(f"{name:17} local {time.perf_counter() - started:.3f}s")
print(f"{'upper(country)':17}", end=" ")
measure(f"select count(*) from read_parquet('{URL}') where upper(country) = 'AD'",
        os.path.getsize("full.parquet"))
full.parquet          0.26 MB     3 requests   0.005s  ( 0.06% of the file)
shuffled.parquet     10.26 MB   221 requests   1.492s  ( 1.38% of the file)
full.parquet      local 0.004s
shuffled.parquet  local 0.008s
upper(country)        0.27 MB   110 requests   1.253s  ( 0.06% of the file)

Running this against your own published files is the only way to know whether they are actually cloud-friendly.

Example 2 โ€” reading from S3-compatible storage

import duckdb


def s3_connection(region="us-east-1", anonymous=True, endpoint=None):
    con = duckdb.connect()
    con.execute("install httpfs; load httpfs;")
    con.execute("install spatial; load spatial;")
    con.execute("set enable_progress_bar = false")
    con.execute(f"set s3_region = '{region}'")
    if endpoint:                                   # MinIO, R2, other S3 APIs
        con.execute(f"set s3_endpoint = '{endpoint}'")
        con.execute("set s3_url_style = 'path'")
    if not anonymous:
        con.execute("set s3_access_key_id = getenv('AWS_ACCESS_KEY_ID')")
        con.execute("set s3_secret_access_key = getenv('AWS_SECRET_ACCESS_KEY')")
    return con


def latest_overture_release():
    """Overture keeps only recent releases in the bucket, so look the newest one up."""
    import re
    import requests
    listing = requests.get("https://overturemaps-us-west-2.s3.amazonaws.com/",
                           params={"list-type": "2", "prefix": "release/", "delimiter": "/"},
                           timeout=60).text
    return sorted(re.findall(r"<Prefix>release/([^<]+)/</Prefix>", listing))[-1]


def overture_places_in_bbox(con, xmin, ymin, xmax, ymax, release=None):
    """A bounding-box query against a public multi-terabyte dataset."""
    release = release or latest_overture_release()
    return con.execute(f"""
        select count(*) from read_parquet(
            's3://overturemaps-us-west-2/release/{release}/theme=places/type=place/*.parquet')
        where bbox.xmin between {xmin} and {xmax}
          and bbox.ymin between {ymin} and {ymax}
    """).fetchone()[0]
import time

con = s3_connection(region="us-west-2")
print("release", latest_overture_release())
started = time.perf_counter()
n = overture_places_in_bbox(con, -74.02, 40.70, -73.93, 40.79)     # Manhattan
print(f"{n:,} places in {time.perf_counter() - started:.1f} s")
release 2026-09-23.1
224,648 places in 9.1 s

The bbox struct column is why this is fast: it is a plain numeric column with row-group statistics, so the filter prunes before any geometry is touched.

Example 3 โ€” deciding whether to download

The footer says how big each column is before anything is fetched, and Example 1 says how many bytes a typical query really pulls. Together they give a break-even: downloading costs the whole file once, reading remotely costs the query's bytes every time.

def should_download(con, url, bytes_per_query):
    """Compare downloading once with reading remotely every time, from the footer alone."""
    columns = con.execute(f"""
        select path_in_schema as col, sum(total_compressed_size) / 1e6 as mb
        from parquet_metadata('{url}') group by 1 order by 2 desc
    """).fetchall()
    groups = con.execute(f"select count(distinct row_group_id) from parquet_metadata('{url}')").fetchone()[0]
    file_mb = sum(mb for _, mb in columns)
    print(f"column data {file_mb:,.0f} MB in {len(columns)} columns and {groups} row groups")
    for col, mb in columns[:3] + columns[-3:]:
        print(f"  {col:15} {mb:8.2f} MB")
    runs = file_mb / (bytes_per_query / 1e6)
    print(f"a query reading {bytes_per_query / 1e6:.2f} MB breaks even with one download "
          f"after about {runs:,.0f} runs")
con = duckdb.connect()
con.execute("install httpfs; load httpfs;")
should_download(con, URL, spent["group by floor(lon), floor(lat)"])
print("distinct countries:", con.execute(f"select count(distinct country) from read_parquet('{URL}')").fetchone()[0])
column data 443 MB in 19 columns and 109 row groups
  alternatenames    115.85 MB
  name               74.60 MB
  asciiname          73.05 MB
  timezone            0.60 MB
  cc2                 0.04 MB
  country             0.01 MB
a query reading 108.19 MB breaks even with one download after about 4 runs
distinct countries: 253

The footer also shows why the opening table looks the way it does: alternatenames is 116 MB of the file's 443 MB of column data, and country is 0.01 MB.

Explanation

A Parquet file ends with a footer containing the schema, the number of rows, and per-column statistics for every row group. DuckDB fetches the last few kilobytes, reads the footer, and for count(*) it already has the answer.

That is the 0.26 MB in two requests. No column data was transferred at all, because none was needed โ€” the count is metadata.

Why one query read 24% of the file and another read 0.06%

group by floor(lon), floor(lat) needs every value of two columns, so it reads both columns in full: 108 MB. count(distinct country) needs every value of one column too โ€” but that column holds 253 distinct codes, stored dictionary-encoded and in long runs because the file is grouped by country, so its entire storage across 109 row groups is 0.01 MB. The query's 0.27 MB is almost all footer; what it paid for was 110 requests, one per row group.

The lesson is that "reads a whole column" and "transfers a lot of data" are not the same thing. Low-cardinality columns are nearly free; high-cardinality ones are not.

Why sorting matters more remotely than locally

Locally, reading 10 MB instead of 0.26 MB costs a few milliseconds of page-cache access โ€” measured, 0.008 s against 0.004 s. Over a network it costs 221 round trips and 1.5 seconds โ€” and on a high-latency link, considerably more.

That is why the sorted-versus-shuffled measurement is a 300ร— time difference over HTTP and a 2ร— one on local disk. Cloud-native performance is dominated by round trips, and pruning is what removes them.

Why this changes how datasets are published

A publisher who sorts by the column readers filter on, uses sensible row-group sizes, and includes a bbox struct for spatial filtering turns a multi-terabyte archive into something a laptop can query in seconds.

The Overture example is exactly that: a public dataset of hundreds of millions of features, queried for a Manhattan bounding box in 9.1 seconds from a Python process, because the file layout was designed for it.

Why sorting matters more for remote reads than for local ones. the same pruning, two settingslocal fileโœ“10 MB instead of 0.26 MBโœ“0.008 s instead of 0.004 sโœ“usually not worth chasingโœ“sorting still shrinks the fileover HTTPโœ•221 requests instead of 3โœ•1.49 s instead of 0.005 sโœ•round trips dominateโœ•sorting is the whole optimisationCloud-native performance is a story about round trips, and pruning removes them spatialworkflow.io
A publisher who sorts their files is optimising every readerโ€™s query.

Edge cases or notes

  • httpfs and spatial are separate extensions โ€” load both for remote spatial work.
  • The server must support range requests. Without Accept-Ranges, DuckDB downloads the whole file.
  • Disable the external file cache when benchmarking, or the second query reads nothing and looks miraculous.
  • ST_Read on a URL goes through GDAL, not httpfs, and behaves differently.
  • Wrapping a filter column in a function defeats pruning.
  • Glob patterns over S3 list the prefix first, which can be slow on very large buckets.
  • Anonymous S3 access needs the region set and nothing else for public buckets.
  • Copy locally if you will scan the whole file repeatedly โ€” remote reads are for selective queries.

FAQ

Can DuckDB query a Parquet file over HTTP?

Yes, with the httpfs extension. It issues range requests and reads only the bytes the query needs โ€” measured, a count(*) on a 446 MB file read 0.26 MB.

How much data does a remote query actually transfer?

It depends entirely on the query. Measured on one file: 0.06% for a count or a filtered count, 0.62% for a top-five query, and 24% for an aggregate over two full columns.

Why is my remote query slow?

Usually because the file is not sorted on the column you filter by, so no row groups can be pruned. The same query read 0.26 MB from a sorted file and 10.26 MB from a shuffled one.

Do I need to download the file first?

Not for selective queries. Download when you will repeatedly scan whole columns โ€” at that point the transfer happens anyway and local access is faster.

Does this work with S3?

Yes. Set s3_region and use an s3:// URL. A bounding-box count against the public Overture places dataset returned 224,648 places in 9.1 seconds.

Why did my second query read nothing?

DuckDB caches remote file chunks within a connection. Set enable_external_file_cache = false when measuring, and leave it on in normal use.