Writing OSM Features to GeoParquet with PyArrow Jump to heading
Turn a stream of normalised OSM features into a GeoParquet file that GeoPandas, DuckDB and GDAL all open as spatial data, and that a bounding-box filter can read a fraction of.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
GeoParquet is Parquet with a convention. There is no new file format, no new encoding, and no new library — just a geo key in the file’s metadata declaring where the geometry is and what coordinate system it is in.
Geometry itself is stored as well-known binary in an ordinary binary column. Everything a spatial reader needs to interpret that column lives in the metadata block, and every interoperability problem people have with GeoParquet is a problem with that block.
The CRS field deserves particular care. A null CRS is not an omission — the specification defines it to mean OGC:CRS84, longitude then latitude in WGS 84, which is exactly what unprojected OSM data is. That makes null the correct value for a file straight out of an OSM pipeline, and a serious error for one that has been reprojected, which is the trap discussed in Coordinate Reference Systems in OSM.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""Write normalised OSM features as GeoParquet that spatial readers recognise."""
from __future__ import annotations
import json
import logging
from typing import Iterable, Sequence
import h3
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq
from shapely import to_wkb
from shapely.geometry.base import BaseGeometry
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
ROW_GROUP_ROWS = 250_000
SORT_RESOLUTION = 6 # coarse H3 cell, used only as a locality-preserving sort key
def geo_metadata(geometry_types: Sequence[str], bbox: Sequence[float]) -> bytes:
"""The `geo` key. A null crs means OGC:CRS84 — correct for unprojected OSM."""
return json.dumps({
"version": "1.1.0",
"primary_column": "geometry",
"columns": {
"geometry": {
"encoding": "WKB",
"geometry_types": list(geometry_types),
"crs": None,
"bbox": list(bbox),
}
},
}).encode()
def build_table(features: Iterable[dict]) -> pa.Table:
"""Typed columns for what we query, a map column for everything else."""
osm_ids, names, kinds, geoms, extra, cells = [], [], [], [], [], []
for f in features:
geom: BaseGeometry = f["geometry"]
tags: dict[str, str] = f["tags"]
osm_ids.append(f["osm_id"])
names.append(tags.get("name"))
kinds.append(tags.get("building"))
geoms.append(to_wkb(geom, output_dimension=2))
# Everything not promoted to a column survives in the map, so nothing is lost.
extra.append([(k, v) for k, v in tags.items() if k not in ("name", "building")])
centroid = geom.representative_point()
cells.append(h3.latlng_to_cell(centroid.y, centroid.x, SORT_RESOLUTION))
return pa.table({
"osm_id": pa.array(osm_ids, pa.int64()),
"name": pa.array(names, pa.string()),
"building": pa.array(kinds, pa.string()),
"tags": pa.array(extra, pa.map_(pa.string(), pa.string())),
"h3_r6": pa.array(cells, pa.string()),
"geometry": pa.array(geoms, pa.binary()),
})
def write_geoparquet(table: pa.Table, path: str, geometry_types: Sequence[str],
bbox: Sequence[float]) -> None:
"""Sort for locality, attach the geo metadata, write with statistics on."""
ordered = table.sort_by([("h3_r6", "ascending"), ("osm_id", "ascending")])
meta = dict(ordered.schema.metadata or {})
meta[b"geo"] = geo_metadata(geometry_types, bbox)
ordered = ordered.replace_schema_metadata(meta)
pq.write_table(
ordered, path,
compression="zstd", compression_level=3,
row_group_size=ROW_GROUP_ROWS,
write_statistics=True,
)
groups = -(-ordered.num_rows // ROW_GROUP_ROWS)
logger.info("wrote %s: %d rows in %d row group(s)", path, ordered.num_rows, groups)
def verify(path: str) -> None:
"""Assert the file is readable as spatial data before anything downstream sees it."""
pf = pq.ParquetFile(path)
meta = pf.schema_arrow.metadata or {}
if b"geo" not in meta:
raise ValueError(f"{path}: no `geo` metadata — readers will not see the geometry")
geo = json.loads(meta[b"geo"])
primary = geo["primary_column"]
if primary not in pf.schema_arrow.names:
raise ValueError(f"{path}: primary_column {primary!r} is not in the schema")
logger.info("%s: %d row group(s), primary column %r, crs %r",
path, pf.num_row_groups, primary, geo["columns"][primary]["crs"])
Step-by-step walkthrough Jump to heading
build_table implements the hybrid schema the parent topic recommends: name and building become real typed columns because they are what queries filter on, and everything else goes into a map<string,string> so the long tail of OSM tagging survives without inflating the schema to thousands of mostly-null columns.
The h3_r6 column is not there to be queried — it is a sort key. A coarse H3 cell groups geographically nearby features into adjacent rows, which is what makes the per-row-group min/max statistics selective. The same job can be done with a quadkey or a Hilbert index; the property that matters is locality preservation, discussed in Spatial Index Selection.
write_geoparquet sorts, then attaches metadata, then writes. The order matters: replace_schema_metadata returns a new table, and sorting after attaching would carry the metadata forward anyway, but writing before sorting would produce a file whose statistics are useless.
verify reads the file back and asserts the two things that actually break readers. It costs milliseconds and it catches the failure that otherwise appears as “this file has no geometry” in someone else’s notebook a week later.
Verification Jump to heading
Open the file with a reader that was not involved in writing it:
import geopandas as gpd
gdf = gpd.read_parquet("buildings.parquet")
print(gdf.crs, len(gdf), gdf.geometry.geom_type.value_counts().to_dict())
Three things should be true. The CRS prints as EPSG:4326 (readers resolve a null CRS to CRS84, which is equivalent). The row count matches what was written. And the geometry types match what the metadata declared — a mismatch means the declaration was copied rather than derived.
Then check the pruning actually works:
import duckdb
duckdb.sql("INSTALL spatial; LOAD spatial;")
duckdb.sql("""
SELECT count(*) FROM 'buildings.parquet'
WHERE h3_r6 BETWEEN '861f8d4ffffffff' AND '861f8d5ffffffff'
""").show()
Compare the bytes read for that query against a full scan. A well-sorted file with quarter-million-row groups should read single-digit percentages of the file for a small area.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| GeoPandas: “no geometry column” | geo metadata missing |
Attach it before writing |
| Everything plots off West Africa | Projected coords with a null CRS | Set the PROJJSON CRS, or write CRS84 |
| Filter reads the whole file | Rows unsorted, or one row group | Sort by a locality key; set row_group_size |
ArrowInvalid on the tags column |
Mixed types in the tag map | Coerce every tag value to str first |
| File much larger than expected | compression=None, the default |
Pass compression="zstd" |
| Geometry column is all nulls | to_wkb given a Shapely 1.x geometry |
Upgrade to Shapely 2.0+, or use geom.wkb |
Frequently Asked Questions Jump to heading
Should I write one file or many?
Many, partitioned by something with a few hundred distinct values — a country, a region, a coarse cell. One enormous file cannot be read in parallel by row-range and forces every consumer through one metadata footer; millions of tiny files make listing the dataset slower than reading it. Aim for partitions in the low hundreds of megabytes.
Is WKB the only geometry encoding allowed?
WKB is the encoding every reader supports and the safe default. GeoParquet 1.1 also permits a native Arrow geometry encoding which is faster to read because it avoids parsing WKB per row, but support is not yet universal. Write WKB unless you control every consumer.
Do I need to write the bbox in the metadata?
It is optional and worth including. Readers use it to skip a file entirely when it cannot intersect a query, which for a partitioned dataset means most files are never opened. Compute it from the data rather than from the boundary you cut with, since the two differ wherever a clipping strategy kept features that spill past the line.
How do I add a column later without rewriting everything?
You cannot, within a file — Parquet is immutable. What you can do is write the new column as a separate dataset keyed on osm_id and join at read time, or accept the rewrite for the partitions that need it. This is the practical argument for keeping the tag map: promoting a tag to a column later becomes a schema change rather than a re-extract from the PBF.
Writing incrementally Jump to heading
Building the whole table in memory before writing works up to a few million features and stops working somewhere below a country-sized layer. pq.ParquetWriter takes the schema up front and accepts batches, which keeps peak memory at one batch rather than one layer:
writer = pq.ParquetWriter(path, schema, compression="zstd",
compression_level=3, write_statistics=True)
for batch in batches:
writer.write_table(batch, row_group_size=ROW_GROUP_ROWS)
writer.close()
There is a real trade here, and it is worth stating plainly. Streaming batches means you cannot sort globally, because sorting needs every row at once — so the locality that makes row-group statistics selective is lost unless the batches already arrive in spatial order. Two ways out: sort each batch and accept coarse, per-batch locality, which recovers most of the pruning when batches are large; or write unsorted and run a compaction pass afterwards that reads, sorts and rewrites, which costs one extra full pass and gives the ideal layout.
Which is right depends on how often the file is read. For a dataset written nightly and queried thousands of times a day, the compaction pass pays for itself before breakfast. For an intermediate artefact read once by the next pipeline stage, per-batch sorting is enough and the extra pass is waste.
The schema must be identical across every batch — a column that is all-null in one batch and typed in another will raise on the second write, which is the usual reason an incremental writer fails halfway through a long run. Build the schema once from the promoted-column list rather than inferring it per batch.
Related Jump to heading
- Exporting OSM to GeoParquet & PostGIS — the topic this writer belongs to.
- Batch Attribute Mapping Strategies — where the promoted-column list is decided.
- Spatial Index Selection: R-tree, H3 or Quadkey — picking the sort key.
- Coordinate Reference Systems in OSM — why the CRS field must match the data.
- Memory-Efficient Chunk Processing — feeding the writer without holding the layer in memory.
Up one level: Exporting OSM to GeoParquet & PostGIS.