Exporting OSM to GeoParquet & PostGIS Jump to heading

Every stage documented in Parsing & Tag Normalization Workflows ends in the same place: a stream of normalised records that has to be written somewhere a consumer can query. That last stage gets far less attention than parsing does, and it decides more about how the data is used than any earlier choice. A schema that buries every tag in a JSON blob makes the fastest export and the slowest analytics. A partitioning scheme chosen without reference to the queries makes every filter a full scan. And a sink chosen without asking whether the data will be updated commits the pipeline to either full rebuilds or a middle-table layout, for its entire life.

This topic covers the two sinks that account for most OSM pipelines. GeoParquet is the columnar, immutable, object-storage-friendly option that analytics engines read directly. PostGIS is the mutable, indexed, transactional option that can be kept current with minutely diffs. They are not alternatives so much as different answers to “what happens on the next run”, and a large fraction of mature pipelines run both.

Where a normalised OSM stream forks into its two common sinks A four-stage chain. Normalised records carrying kind, identifier, tags and geometry reach a schema decision that is made once per sink: fixed columns or a tag map. One branch writes GeoParquet as immutable partitioned files with row groups. The other loads PostGIS through osm2pgsql flex or COPY, producing a mutable database. One normalised stream, two sinks, two different contracts normalised records kind · id · tags · geom the pipeline output schema decision columns vs a tag map made once, per sink GeoParquet writer row groups · partitions immutable files PostGIS loader osm2pgsql flex · COPY a mutable database The fork is not really about file formats. It is about whether the result is rewritten wholesale each run or updated in place by diffs.
Choosing a sink is choosing an update model. GeoParquet is rewritten; PostGIS is amended. Everything else follows from that.

Prerequisite concepts Jump to heading

Three things need to be settled before the export stage is designed. The tag vocabulary has to be stable, because the columns you promote out of the tag map are a schema commitment — the material in Tag Taxonomy & Key-Value Standards is what makes that commitment safe. The geometry has to be assembled and valid, which is the subject of Geometry Validation & Repair; writing invalid geometry to either sink pushes the problem to whoever reads it. And the coordinate reference system has to be decided, because both formats record a CRS and neither will convert for you — see Coordinate Reference Systems in OSM.

Choosing between the sinks Jump to heading

GeoParquet against PostGIS across six operational properties A grid comparing the two sinks. GeoParquet is very fast for full-scan analytics, needs a scan or a separate index for point lookups by identifier, has no built-in spatial index, requires rewriting a partition to apply diffs, supports unlimited concurrent readers because files are immutable, and costs almost nothing at rest in object storage. PostGIS is adequate for full scans, immediate for point lookups, has GiST spatial indexing built in, applies diffs with osm2pgsql append, is bounded by connection count, and requires a running server. The two sinks answer different questions well GeoParquet PostGIS full-scan analytics columnar, very fast row store, fine point queries by id needs a full scan or an index file index lookup, immediate spatial joins no index — bring your own engine GiST, built in applying minutely diffs rewrite the partition osm2pgsql --append concurrent readers unlimited, files are immutable bounded by connections cost at rest object storage, pennies a running server Many teams run both: PostGIS as the diff-updated system of record, GeoParquet exported from it nightly for analytics.
Neither is the better sink. The pairing that works is PostGIS as the diff-updated system of record with GeoParquet exported from it for analytics.

The comparison resolves into one question: does this dataset get updated, or rebuilt? A dataset rebuilt nightly from a fresh extract has no use for the middle tables that make PostGIS updatable, and gains a great deal from immutable files that any number of readers can open at once without a server. A dataset that must reflect upstream edits within minutes needs the update path described in Applying .osc Change Files with osmium, and that path leads to a database.

GeoParquet: what the specification actually requires Jump to heading

GeoParquet is ordinary Apache Parquet plus a metadata convention. The requirements are modest and worth knowing exactly, because most interoperability failures come from omitting one of them.

Geometry lives in a binary column encoded as well-known binary. The file’s key-value metadata carries a geo key whose value is a JSON document declaring the version, the name of the primary geometry column, and, per geometry column, the encoding, the geometry types present, and the CRS as a PROJJSON object. Omit the geo metadata and you have a Parquet file with a blob column that no spatial tool will recognise; get the column name wrong in the metadata and readers will not find the geometry they are looking at.

python
import json
import logging
import pyarrow as pa
import pyarrow.parquet as pq
from shapely import to_wkb

logger = logging.getLogger(__name__)

GEO_META = {
    "version": "1.1.0",
    "primary_column": "geometry",
    "columns": {
        "geometry": {
            "encoding": "WKB",
            "geometry_types": ["Polygon", "MultiPolygon"],
            "crs": None,  # null means OGC:CRS84 — longitude, latitude in WGS 84
            "bbox": [-10.6, 51.4, -5.3, 55.5],
        }
    },
}

def write_geoparquet(table: pa.Table, path: str, row_group_rows: int = 250_000) -> None:
    """Write an Arrow table as GeoParquet with the metadata spatial readers expect."""
    meta = dict(table.schema.metadata or {})
    meta[b"geo"] = json.dumps(GEO_META).encode()
    table = table.replace_schema_metadata(meta)
    pq.write_table(
        table, path,
        compression="zstd", compression_level=3,
        row_group_size=row_group_rows,
        write_statistics=True,     # per-row-group min/max is what enables pruning
    )
    logger.info("wrote %s: %d rows, %d row groups", path, table.num_rows,
                -(-table.num_rows // row_group_rows))

A null CRS in the metadata is not an omission — it is the specification’s way of saying OGC:CRS84, longitude then latitude in WGS 84, which is what unprojected OSM data is. Writing projected coordinates without replacing that null is the most common CRS mistake in OSM exports, and it produces a file every reader misinterprets confidently.

Schema shape Jump to heading

Output size for five GeoParquet schema shapes over the same layer A bar chart of file size for 14.2 million German building features. Storing all tags as one JSON string column takes 3.10 gigabytes and forces every query to parse JSON. A map of string to string takes 1.45 gigabytes. Twenty typed columns plus a tag map takes 980 megabytes and lets typed reads touch a single column. Twenty typed columns with tags dropped takes 610 megabytes but is lossy. Typed columns plus a map, sorted by H3 cell before writing, takes 890 megabytes and prunes better on spatial filters. Schema shape decides file size and query speed together German buildings layer, 14.2 M features, written five ways one JSON string column of tags 3.10 GB · every query parses JSON MAP tag column 1.45 GB · scan the map, no parse 20 typed columns + tag map 980 MB · typed reads hit one column 20 typed columns, tags dropped 610 MB · lossy, cannot re-derive typed + map, sorted by H3 cell 890 MB · better compression, prunes Sorting before writing costs one shuffle at export and pays back on every spatially filtered query afterwards.
The typed-columns-plus-map shape is the one to reach for: the columns you query become real columns, and nothing is thrown away.

The measurements point clearly at a hybrid: promote the tags you actually filter and group by into real typed columns, and keep the remainder in a map column so nothing is lost. Typed columns compress well and let a reader touch one column instead of parsing a blob; the map column preserves the long tail described in Tag Taxonomy & Key-Value Standards without inflating the schema to thousands of mostly-null columns.

Two details make a large difference. Row-group size sets the granularity of statistics-based pruning: too large and a filter reads more than it needs, too small and metadata overhead grows and compression suffers. A quarter of a million rows is a reasonable default for OSM features. And sorting rows before writing — by an H3 cell, a quadkey, or any locality-preserving key from Spatial Index Selection — clusters spatially near features into the same row group, so a bounding-box filter skips most of the file on statistics alone.

PostGIS: the flex output Jump to heading

osm2pgsql has two output backends and the difference matters. The legacy pgsql output writes a fixed set of tables with a fixed column choice and a hstore catch-all. The flex output lets you define tables and the mapping from OSM objects to rows in Lua, which means the schema is yours rather than the tool’s.

lua
local buildings = osm2pgsql.define_table({
  name = 'buildings',
  ids = { type = 'area', id_column = 'osm_id' },
  columns = {
    { column = 'name',        type = 'text' },
    { column = 'building',    type = 'text', not_null = true },
    { column = 'levels',      type = 'int' },
    { column = 'height_m',    type = 'real' },
    { column = 'tags',        type = 'jsonb' },
    { column = 'geom',        type = 'multipolygon', projection = 4326, not_null = true },
  }
})

function osm2pgsql.process_way(object)
  if not object.is_closed or not object.tags.building then return end
  buildings:insert({
    name     = object.tags.name,
    building = object.tags.building,
    levels   = tonumber(object.tags['building:levels']),
    height_m = tonumber((object.tags.height or ''):match('^%d+%.?%d*')),
    tags     = object.tags,
    geom     = object:as_multipolygon(),
  })
end

The ids declaration is what makes the table updatable: it tells osm2pgsql how to find the rows belonging to an OSM object when a diff modifies it. A flex table without an ids block is write-once, and --append will not touch it.

For data that does not come through osm2pgsql at all — a derived table your own pipeline produces — bulk loading goes through COPY rather than inserts, by roughly two orders of magnitude:

python
import io
import logging
import psycopg

logger = logging.getLogger(__name__)

def copy_features(conn: psycopg.Connection, rows: list[tuple[int, str, bytes]]) -> int:
    """Bulk-load (osm_id, name, wkb_geometry) rows into a prepared table."""
    with conn.cursor() as cur, cur.copy(
        "COPY features (osm_id, name, geom) FROM STDIN (FORMAT BINARY)"
    ) as copy:
        copy.set_types(["int8", "text", "bytea"])
        for row in rows:
            copy.write_row(row)
    logger.info("copied %d rows", len(rows))
    return len(rows)

Build the spatial index after the load, not before. A GiST index maintained during a bulk load costs roughly three times what building it once at the end does.

Validation and error-handling matrix Jump to heading

Condition Root cause Detection Action
Spatial tools see no geometry in the Parquet geo metadata missing or names the wrong column Read the file’s key-value metadata Write the metadata block explicitly
Coordinates plot in the Gulf of Guinea Projected coordinates with a null CRS declared Bounding box near 0,0 Set the PROJJSON CRS, or reproject to CRS84
Every filtered query reads the whole file No statistics, or rows unsorted Row-group count of 1, or min/max spans everything Sort before writing; set a row-group size
osm2pgsql --append reports nothing to update Flex table declared without ids Row counts unchanged after a diff Add the ids block and reimport
Load takes hours on a country extract Index present during load, or row-by-row inserts pg_stat_activity shows index maintenance COPY, then create the index
Parquet and PostGIS row counts disagree One sink filtered invalid geometry, the other did not Compare counts per feature class Apply the validity gate before the fork, not after

That last row is the systemic one. When two sinks are fed by the same pipeline, every filtering decision must happen upstream of the fork, or the sinks drift apart and nobody can say which is right.

Performance and scale considerations Jump to heading

Export cost is dominated by serialisation and compression, not by the sink. Writing 14 million features to GeoParquet with zstd level 3 runs at roughly 380 000 rows per second on one core; raising compression to level 9 costs three times the CPU for around eight percent more compression, which is rarely a good trade for data that is read often. Geometry encoding is the other large term: shapely.to_wkb over an array is vectorised and fast, while calling it per row in a Python loop is not, in the same way and for the same reason as the regex work in Value Standardization & Regex Cleaning.

For PostGIS, the dominant term is index construction and the write-ahead log. Loading with COPY into an unindexed table, then creating indexes, then running ANALYZE, is between five and ten times faster end to end than loading into an indexed table. On a bulk initial load where the data can be re-created, an UNLOGGED table during the load and a switch to logged afterwards removes the WAL cost as well.

Failure modes and gotchas Jump to heading

Parquet has no schema evolution rules of its own, so two runs of the same pipeline that promote different tag columns produce files that a reader cannot union. Pin the promoted-column list in the mapping registry described in Batch Attribute Mapping Strategies and version it with the data.

Partition cardinality is the other trap. Partitioning by country gives roughly two hundred directories and works well; partitioning by H3 resolution 8 gives millions of tiny files and makes every query slower than no partitioning at all. Aim for partitions of a few hundred megabytes.

Finally, osm2pgsql in flex mode silently drops objects your Lua returns without inserting. That is the intended behaviour and it makes a typo in a tag name look exactly like an area with no buildings. Count insertions in the Lua and log them.

Keeping two sinks honest Jump to heading

Running both sinks is common and it introduces a failure the single-sink case does not have: the two can disagree, and neither knows it. The discipline that prevents it is to treat one as the system of record and derive the other from it, rather than feeding both from the pipeline in parallel.

Deriving GeoParquet from PostGIS costs a query and guarantees the two agree by construction, because there is only one copy of the filtering and normalisation logic. Feeding both in parallel means every validity gate, every tag rule and every fallback chain exists twice, and the moment one is changed without the other the exports diverge in a way that only shows up as a row-count difference somebody notices weeks later.

Where parallel feeding is genuinely necessary — usually because the export must not wait for the database — the mitigation is a reconciliation job: count rows by feature class in both sinks on a schedule and alert on any divergence beyond a small tolerance. It does not prevent the drift, but it bounds how long it can go unnoticed.

In this section Jump to heading

Frequently Asked Questions Jump to heading

Should I store tags as JSON, as a map, or as columns?

As columns for the tags you query, plus a map column for the rest. A JSON string column is the worst of the options because every reader pays a parse; a map column avoids the parse but still scans; typed columns let a columnar engine read one column and skip the rest. Keeping the map alongside the columns means promoting another tag later is a schema change rather than a re-extract.

Can GeoParquet be updated with minutely diffs?

Not in place. Parquet files are immutable, so applying a diff means rewriting the partitions the changed objects fall into, and identifying those partitions requires an identifier-to-partition index you have to maintain yourself. If the dataset must track upstream within minutes, use PostGIS as the system of record and export GeoParquet from it on a schedule.

What row-group size should I use?

Around a quarter of a million rows for OSM features is a reasonable starting point. The number that actually matters is the compressed row-group size — aim for roughly 64 to 256 megabytes. Smaller groups give finer pruning and more metadata overhead; larger groups compress better and read more than a selective filter needs.

Why does my PostGIS load slow down as it progresses?

Almost always index maintenance, sometimes compounded by autovacuum. Every inserted row updates every index on the table, and a GiST index on geometry is expensive to maintain incrementally. Drop or defer the indexes, load with COPY, then create the indexes and run ANALYZE.

Do I need PostGIS at all if I have GeoParquet?

Only if you need one of three things: updates applied in place from the diff stream, transactional reads and writes, or an indexed point lookup by identifier at low latency. Analytics over whole layers is a job GeoParquet does better and far more cheaply.

Up one level: Parsing & Tag Normalization Workflows.