Skip to content

GeoParquet

A GeoParquet (.parquet or .geoparquet) file stores features as a columnar table, the format GeoPandas, DuckDB and GDAL write. maplibre-yaml reads it into an ordinary GeoJSON source, and reads only the columns the map uses, so a table with dozens of columns downloads only the few you show. Popups, classify:, clustering and fitTo work as for a .geojson file. It is one of the large-data formats; for which one suits your data’s size, see Choose a format for large data.

Keep type: geojson and point url: at the file:

type: map
id: geoparquet-wells
config:
center: [10.2, 42.15]
zoom: 10
mapStyle: "https://tiles.openfreemap.org/styles/positron"
fitTo: { source: wells, padding: 20 }
sources:
wells:
type: geojson
url: /gallery-assets/cloud-native/points.parquet
layers:
- id: wells
type: circle
source: wells
paint:
circle-radius: 4
circle-color: ["match", ["get", "kind"], "well", "#e63946", "station", "#457b9d", "#2a9d8f"]
interactive:
click:
popup:
- h3: [{ property: kind }]
- p: [{ str: "Value: " }, { property: value }]

The file is on this documentation site; to run the document on yours, download points.parquet into gallery-assets/cloud-native/. You should see red and blue points; click one for its kind and value. The gallery’s GeoParquet source shows it live.

maplibre-yaml reads the geometry plus the properties the document uses for that source: here kind and value, not the rest of the file. It finds them in ["get", …] and ["has", …] in paint, layout and filter, {name} tokens in text, popup and legend property: items, classify: field and normalize, the source’s promoteId, filter and clusterProperties, and a story’s setFilter, setPaintProperty and setLayoutProperty actions.

To choose yourself, set columns::

sources:
wells:
type: geojson
url: data/wells.parquet
columns: [kind, value, owner] # or: columns: all

A clicked feature (ml-map:layer-click, queryRenderedFeatures) carries only the columns that were read. If your page’s own JavaScript reads other properties, list them in columns:, or write columns: all.

By default a file is read whole when it is small and by view when it is large. load: changes that:

load:What it readsNeeds
none, or all (default)Small files whole (the columns the map uses, from every row). Large ones by view (see Large files), when the file and server allow it; otherwise the map says why and how to fix itAny web server for a small file
viewportOnly the rows in view, read again as the map moves; nothing below minzoom (default 10), and nothing for a view holding more than maxFeatures features (default 150,000)A bbox column, rows sorted by location, and a server with range requests

Reading by view works because a GeoParquet file stores rows in groups, and its footer records each group’s extent from the bbox column; groups outside the view are never downloaded. That only pays off when nearby rows sit in the same groups, in a file sorted by location. A file whose groups each span most of the area is read whole instead, with a console note; one without a bbox column is refused with a notice that says how to add it. With load: viewport, fitTo frames the extent recorded in the file’s metadata, at the outermost zoom whose view is read (never below minzoom nor over maxFeatures), so the map opens with features drawn. classify: with method: works too: the breaks come from one column of every row, read once when the source loads (Class breaks on large data).

A whole read costs memory and time in the browser, and compressed bytes understate it, so everything is decided from the file’s footer before any data is downloaded:

  • Read whole up to 400,000 rows, 50 MB of the columns the map reads (compressed), and 100 MB once those columns are decoded (NYC’s citywide parcels decode to 142 MB and took over 4 GB to draw). Between 10 and 50 MB a console note says it may take a few seconds. Brooklyn’s 275,628 parcels read whole in 13–17 s on a desktop and pan at 42–50 fps.
  • Over any of those, the file is read by view when it has a bbox column and sorted row groups and the server supports range requests, with a note for the author. Otherwise it is refused, and the notice names what stops it being read by view and how to fix that.
  • By view, a view isn’t read when it would hold more than maxFeatures features (default 150,000), estimated from the footer: each row group’s rows times the share of the group’s bbox extent that the view covers. Nor is it read when the row groups it touches hold more than 3 × maxFeatures rows: a row group is decoded whole, so a file in 100,000-row groups decoded about 600,000 rows for a view of 145,000 features (3.8 GB, 10 fps). Readers see “Too many features to show here — zoom in to see them”, also below minzoom, until they zoom in.

A server without range requests (python -m http.server) sends the whole file at once: fine for a small file, refused before download for one over 50 MB.

Each note and notice names the way that applies; here they all are.

  1. Say load: viewport. A large file is read by view anyway; saying so drops the note.

  2. Raise maxFeatures. It is the one override: the most features one read takes, by view, and above 400,000 it raises the whole-file limit too. It costs memory, so the notes suggest it only up to 600,000 features loaded whole, or 250,000 in a view, and say how much; the 100 MB decoded limit still applies.

    sources:
    lots:
    type: geojson
    url: data/lots.parquet
    load: { maxFeatures: 600000 } # a 550,000-feature file, read whole
  3. Make the file readable by view: a bbox column, rows sorted along a Hilbert curve, about 10,000 rows per row group. With geoparquet-io, nothing to install but uv:

    Terminal window
    uvx --from geoparquet-io==1.7.0 gpio sort hilbert lots.parquet lots-hilbert.parquet \
    --row-group-size 10000 --add-bbox
    # from a FlatGeobuf, GeoJSON or GeoPackage file (sorted the same way)
    uvx --from geoparquet-io==1.7.0 gpio convert geoparquet lots.fgb lots.parquet --row-group-size 10000

    GDAL’s ogr2ogr -f Parquet -lco WRITE_COVERING_BBOX=YES -lco SORT_BY_BBOX=YES -lco ROW_GROUP_SIZE=10000 works too, but packs row groups less tightly: on NYC’s parcels a borough view touched 1.5–2 times the rows of a Hilbert sort, which can be enough to keep a view unread.

  4. For a citywide view, convert it to PMTiles. PMTiles draws at every zoom (1–3 s and 58–60 fps on a desktop for NYC’s 856,687 parcels). The PMTiles guide has the one-line tylertoo command.

  5. Or write overview levels into the file, so a wide view reads a simplified level instead of asking to zoom in (Overview files, experimental).

npx maplibre-yaml@latest prepare lots.parquet reads the file’s footer and prints the step that applies, with the YAML to paste (Choose a format for large data).

An overview file carries its own zoom pyramid: tylertoo’s overview writes a simplified copy of the data per zoom level as extra rows of the same file, listed in its geo:overviews key. Read by view, the map reads the level that fits the zoom, with no YAML change:

Terminal window
uv run --with tylertoo==0.8.0 python -c "import tylertoo as t; t.overview('lots.parquet', 'lots-ov.parquet', min_zoom=0, max_zoom=14, collapse_square=True)"
sources:
lots:
type: geojson
url: lots-ov.parquet
load: viewport # the overview level that fits the zoom (experimental)
  • Which level. At each zoom the map reads the finest level built for that zoom or a coarser one: at zoom 11.4, the zoom-11 level, or zoom 10’s when there is none. Below the coarsest level it reads the coarsest. A level that declares no zoom gets one from its ground resolution (gsd).
  • minzoom. By view, a plain file reads nothing below zoom 10. An overview file with no minzoom of yours reads its coarsest level at every wider zoom instead; a minzoom you write still wins.
  • The budget applies to the level read. A view of that level holding more than maxFeatures (default 150,000) isn’t read, and readers see “Too many features to show here — zoom in”, with an author hint about the level. The map doesn’t fall back to a coarser level.
  • Whole reads use full detail. A file small enough to read whole is read at its full-detail level, and so are classify: breaks, mlym emit and mlym breaks. Each feature is drawn once, never once per level.
  • Popups and hover work at every level: each row of each level is a real feature with all its columns, and promoteId works within a level. When the zoom crosses to another level, the map redraws from that level and resets hover and other feature state for the source’s layers. A popup opened at a coarse level stays as it was until you close it.
  • load: { mode: viewport, overviews: false } reads only the full-detail level, by view: for debugging, or for a file whose coarse levels look wrong.
  • Versions. The geo:overviews spec is a draft (0.2.0). A file declaring another major version is read at its full-detail level, with an author hint.

What the coarse levels look like is the producer’s choice. With tylertoo 0.8.0 on a parcel fabric they drop most small lots, or, with collapse_square=True, draw them as squares that fill a citywide view past the budget; Overview GeoParquet has the measurements, and why PMTiles stays the route for parcels and building footprints seen citywide.

geoparquet-io (gpio; no GDAL needed): uvx --from geoparquet-io==1.7.0 gpio convert geoparquet in.geojson out.parquet --row-group-size 10000 writes a Hilbert-sorted file with a bbox column, ready for load: viewport.

GDAL (ogr2ogr, also inside QGIS). Writing Parquet needs GDAL built with its Arrow/Parquet driver (3.9 or later for the bbox options): many distro packages leave it out. conda-forge’s gdal with libgdal-arrow-parquet, or the ghcr.io/osgeo/gdal Docker image, have it; ogrinfo --formats | grep Parquet tells.

Terminal window
ogr2ogr -f Parquet out.parquet in.geojson -lco COMPRESSION=SNAPPY
# for load: viewport: a bbox column, rows sorted by location, smaller row groups
# (a Hilbert sort, above, packs them tighter)
ogr2ogr -f Parquet out.parquet in.geojson -lco WRITE_COVERING_BBOX=YES \
-lco SORT_BY_BBOX=YES -lco ROW_GROUP_SIZE=10000

Add -t_srs EPSG:4326 when the source data is in a projected CRS.

GeoPandas (Python):

import geopandas as gpd
gdf = gpd.read_file("in.geojson").to_crs(4326)
gdf.to_parquet("out.parquet") # WKB geometry, snappy
# for load: viewport: sort by location, then write the bbox column
gdf = gdf.iloc[gdf.geometry.hilbert_distance().argsort()]
gdf.to_parquet("out.parquet", write_covering_bbox=True, row_group_size=10_000)

DuckDB (with its spatial extension):

INSTALL spatial; LOAD spatial;
COPY (SELECT * FROM ST_Read('in.geojson')) TO 'out.parquet' (FORMAT parquet);

Whatever writes it, keep the file to the area and columns you show.

  • Geometry must be WKB, the default of GeoPandas, GDAL and DuckDB. GeoArrow-encoded geometry isn’t read.
  • Coordinates must be longitude/latitude (OGC:CRS84, the GeoParquet default, or EPSG:4326).
  • Compression: snappy, zstd, gzip, lz4 and brotli all work.
  • Live data (refresh:, stream:) isn’t available for GeoParquet; validation says so (Live data).
  • “…stores its geometry as GeoArrow”: rewrite it as WKB, ogr2ogr -f Parquet -lco GEOMETRY_ENCODING=WKB out.parquet x.parquet, or GeoPandas to_parquet(..., geometry_encoding="WKB").
  • “…is a Parquet file but not GeoParquet”: it has no geometry metadata, such as a table with a WKB column written by hand. Write it with GeoPandas to_parquet, ogr2ogr -f Parquet or DuckDB’s spatial extension.
  • “…is in EPSG:…”: the notice gives the ogr2ogr command that reprojects it.
  • Your page’s script finds a property missing on a clicked feature: that column wasn’t read. Add it to columns:.
  • Hosting problems (range requests, CORS): see Large data › When something goes wrong.

Export to style.json: mlym emit writes the file’s features (every column, or the columns: you listed) into the style as GeoJSON (Large-data files in an exported style).