Skip to content

GeoJSON Conversion

gpio can convert GeoParquet files to GeoJSON format, with a focus on streaming output for vector tile generation workflows.

Overview

The gpio convert geojson command supports two modes:

  1. Streaming Mode (default): Outputs newline-delimited GeoJSON (GeoJSONSeq) to stdout, designed for piping to tools like tippecanoe
  2. File Mode: Writes a standard GeoJSON FeatureCollection to a file

Streaming to tippecanoe for PMTiles

The primary use case is generating PMTiles or MBTiles from GeoParquet data by piping to tippecanoe:

# Basic PMTiles generation
gpio convert geojson buildings.parquet | tippecanoe -P -o buildings.pmtiles

# With layer name
gpio convert geojson roads.parquet | tippecanoe -P -l roads -o roads.pmtiles

# Generate MBTiles instead
gpio convert geojson data.parquet | tippecanoe -P -o tiles.mbtiles

Why Streaming Works with tippecanoe

The streaming output includes RFC 8142 record separators by default. These special characters (\x1e) enable tippecanoe's parallel mode (-P flag), which significantly speeds up tile generation by allowing tippecanoe to process features in parallel.

# The -P flag tells tippecanoe to read in parallel mode
gpio convert geojson data.parquet | tippecanoe -P -o output.pmtiles

If you're piping to a tool that doesn't support RFC 8142, disable the separators:

gpio convert geojson data.parquet --no-rs | some-other-tool

Using gpio pmtiles (Built-in)

For a simpler PMTiles workflow, use the built-in gpio pmtiles command. It provides integrated PMTiles generation with production-quality defaults and built-in CRS handling.

Requires tippecanoe

Install tippecanoe first: brew install tippecanoe (macOS) or sudo apt install tippecanoe (Ubuntu)

# Basic usage
gpio pmtiles create buildings.parquet buildings.pmtiles

# With CRS override (for incorrect metadata)
gpio pmtiles create data.parquet tiles.pmtiles --src-crs EPSG:3857

# Add layer metadata to the output PMTiles based on the values of column 'owner'
gpio pmtiles create --layer-by-column owner data.parquet tiles.pmtiles

# With filtering (no manual piping needed)
gpio pmtiles create data.parquet tiles.pmtiles \
  --bbox "-122.5,37.5,-122.0,38.0" \
  --where "population > 10000" \
  --include-cols name,type,height

from geoparquet_io.api import ops

# Basic usage
ops.create_pmtiles(
    input_path="buildings.parquet",
    output_path="buildings.pmtiles"
)

# With filtering (no manual piping needed)
ops.create_pmtiles(
    input_path="data.parquet",
    output_path="tiles.pmtiles",
    bbox="-122.5,37.5,-122.0,38.0",
    where="population > 10000",
    include_cols="name,type,height"
)

# With CRS override (for incorrect metadata)
ops.create_pmtiles(
    input_path="data.parquet",
    output_path="tiles.pmtiles",
    src_crs="EPSG:3857"
)

# Add layer metadata to the output PMTiles based on the values of column 'owner'
ops.create_pmtiles(
    input_path="data.parquet",
    output_path="tiles.pmtiles",
    layer_by_column="owner"
)

The command handles the entire pipeline internally (reprojection → filtering → conversion → tippecanoe) with optimal settings.

Tuning tile generation (dense data)

By default gpio pmtiles create runs tippecanoe with --no-tile-size-limit and --drop-densest-as-needed. This is a max-fidelity setting: nothing is dropped, so tiles can grow without bound. --drop-densest-as-needed only drops features to bring a tile back under the size limit, so while the limit is off it never fires.

For national- or global-overview maps over dense data, re-enable the size limit so feature dropping actually happens. Each flag is individually toggleable; the defaults reproduce the historical behavior.

--no-tile-size-limit defeats --drop-densest-as-needed

These two ship on by default and interact: with no size limit, there is nothing for drop-densest to drop against. Pass --tile-size-limit (or set an explicit --maximum-tile-bytes) to make dropping take effect.

# Re-enable tippecanoe's size limit so drop-densest actually drops
gpio pmtiles create dense.parquet tiles.pmtiles \
  --tile-size-limit \
  --max-zoom 14

# Or set an explicit per-tile byte cap (takes precedence over
# --no-tile-size-limit)
gpio pmtiles create dense.parquet tiles.pmtiles \
  --maximum-tile-bytes 500000

# Disable individual production-quality flags
gpio pmtiles create data.parquet tiles.pmtiles \
  --no-simplify-only-low-zooms \
  --no-drop-densest-as-needed

# Overwrite an existing output file
gpio pmtiles create data.parquet tiles.pmtiles --force

from geoparquet_io.api import ops

# Re-enable tippecanoe's size limit so drop-densest actually drops
ops.create_pmtiles(
    input_path="dense.parquet",
    output_path="tiles.pmtiles",
    no_tile_size_limit=False,
    max_zoom=14,
)

# Or set an explicit per-tile byte cap (takes precedence over
# no_tile_size_limit)
ops.create_pmtiles(
    input_path="dense.parquet",
    output_path="tiles.pmtiles",
    maximum_tile_bytes=500000,
)

Chunking very large inputs (--chunks)

--temporary-directory puts the scratch somewhere it fits. --chunks stops it being enormous in the first place: gpio splits the input into an NxM grid of disjoint spatial chunks, tiles each one, and merges them with tile-join, so peak scratch is bounded by the chunk rather than the dataset. --max-zoom is required with it (see below).

gpio pmtiles create buildings.parquet out.pmtiles --chunks 2x2 --max-zoom 14

from geoparquet_io.api import ops

ops.create_pmtiles('buildings.parquet', 'out.pmtiles', chunks='2x2', max_zoom=14)

The input is split once, on disk. One DuckDB pass — the same single-scan partitioned write gpio partition uses — writes each non-empty chunk to <output>.parts/chunk_<row>_<col>.parquet. Each chunk is then streamed to tippecanoe the way a whole file is, so memory stays flat; an ocean chunk with no features is simply absent and never fails the join.

Features are assigned by centroid, not by bounding box. --bbox selects features that intersect a box, so a polygon straddling a chunk edge would be tiled by both neighbours and appear twice in the joined archive — a visible seam under translucent fills. Each feature lands in exactly one chunk by its centroid, and the grid index is clamped so a centroid that a rounded bbox covering places just outside the dataset bounds still tiles. For the same reason --bbox cannot be combined with --chunks: filter first with gpio extract --bbox, then chunk the result. --where and --include-cols are applied while splitting.

Failed runs resume. These runs take hours; losing the last chunk should not cost the first eleven. A chunk's archive gets its final name only once tippecanoe has finished it, so a re-run reuses exactly the complete parts, and a manifest.json records the input, grid, filters and tiling flags the parts were built with — a re-run with different options is refused rather than joining a stale grid into the archive. The directory is removed once the join succeeds.

Why --max-zoom is required

Without it tippecanoe guesses a maximum zoom per run (-zg), and it would guess differently for a dense chunk and a sparse one. The joined archive would claim the dense chunk's maximum while the sparse regions had no tiles above their own guess — blank map past that zoom.

Chunking is not bit-identical to a single pass

Every source feature survives and none is duplicated, but --simplify-only-low-zooms generalizes each chunk against only its own neighbours, so low-zoom geometry can differ slightly from a whole-dataset run. Use the smallest grid that fits your disk rather than the largest.

A levelled overview GeoParquet (one carrying the geo:overviews footer key) is rejected: in duplicating mode every feature appears at every level, so chunking would stack all levels into the same tiles. Tile those with tylertoo export-pmtiles, which reads the levels as written.

Scratch space (--temporary-directory)

tippecanoe sorts the whole input on disk before it writes a tile, and that scratch is several times the input, not the output. Tiling 168M polygons at -Z 12 -z 14 (~12 GB of GeoParquet in, ~34 GB of PMTiles out) accumulated ~270 GB of scratch. Budget for it.

By default it goes to the OS temp directory ($TMPDIR, or /tmp when that is unset) — on macOS the boot volume, which is usually not where your data lives. Point the run at an existing directory with room; tippecanoe's scratch and the temp files of the gpio processes feeding it all follow:

gpio pmtiles create buildings.parquet out.pmtiles --temporary-directory scratch

# Same flag on pyramid, where it also parents the intermediate band archives
gpio pmtiles pyramid cells.parquet pyramid.pmtiles -t scratch

from geoparquet_io.api import ops

ops.create_pmtiles('buildings.parquet', 'out.pmtiles', temporary_directory='scratch')
ops.create_pmtiles_pyramid(
    'cells.parquet', 'pyramid.pmtiles', temporary_directory='scratch'
)

Without the flag, TMPDIR is the knob — the same one every other gpio command follows, described under Where Spilled Data Goes. tippecanoe does not read that variable itself; gpio passes the resolved directory to it.

A full scratch volume is hard to diagnose

tippecanoe creates these files with mkstemp + unlink, so they have no directory entry: they are invisible to ls and du, and rm cannot reclaim them. From outside, the disk simply fills with nothing visible consuming it, and only killing the process releases the space.

tile-join, used by gpio pmtiles pyramid, accepts no equivalent flag upstream, so its own spill stays wherever it puts it.

PMTiles Pyramids

A fine-grained aggregate (say A5 resolution 10 over billions of buildings) renders beautifully zoomed in but overflows tile limits at low zooms, forcing tippecanoe to drop the densest cells — exactly the hotspots you care about. gpio pmtiles pyramid fixes this by building a banded archive: coarser aggregate levels serve low zooms, finer levels take over as you zoom in, and (optionally) the raw features appear at the highest zooms.

Levels come from gpio process overview — existing _r* siblings are reused, missing ones are built automatically — and each level is pinned to the zoom band where its worst tile fits --max-tile-kb (default 500 KB). One tippecanoe run per band, merged with tile-join (ships with tippecanoe), with the bands recorded in the archive metadata under gpio:pyramid.

# Auto bands from the tile budget (builds temp overviews if missing)
gpio pmtiles pyramid cells.parquet cells.pmtiles

# Explicit overview levels and a capped base band
gpio pmtiles pyramid cells.parquet out.pmtiles --levels 5 --max-zoom 10

# Country cells -> region cells -> raw polygons in one archive
gpio pmtiles pyramid by_region.parquet out.pmtiles \
    --include-features --features-source buildings.parquet --max-zoom 8

from geoparquet_io.api import ops

ops.create_pmtiles_pyramid('cells.parquet', 'cells.pmtiles')

ops.create_pmtiles_pyramid(
    'cells.parquet',
    'pyramid.pmtiles',
    include_features=True,
    features_source='buildings.parquet',
    max_zoom=8,
)

Layer Modes

--layer-mode controls how bands map to vector tile layers, which drives how you style them in MapLibre:

Mode Layers MapLibre styling
single one layer named after the output file One source-layer for everything. Bands never overlap zooms, so a single style layer renders seamlessly across the whole zoom range.
grouped (default) aggregate + features Two style layers: one for source-layer: "aggregate" (all cell levels share the schema, so one fill-color ramp on count just works), one for source-layer: "features" (raw attributes differ).
per-level r5, r10, … (or country, region) + features One style layer per source-layer — style each resolution independently, or toggle levels client-side for comparison.

For example, with the default grouped mode:

map.addLayer({
  id: 'cells',
  type: 'fill',
  source: 'pyramid',            // your pmtiles source
  'source-layer': 'aggregate',  // every aggregate band lands here
  paint: {
    'fill-color': ['interpolate', ['linear'], ['get', 'count'],
                   1, '#ffffcc', 10000, '#800026'],
  },
});

Because each zoom is served by exactly one band, no zoom-range filtering is needed in the style. The gpio:pyramid metadata key lists every band (level, layer, minzoom, maxzoom) if a client wants to introspect the archive.

Common Workflows

Filter Before Converting

Use gpio extract to filter data before conversion to reduce output size:

# Limit rows for testing
gpio extract data.parquet --limit 1000 | \
  gpio convert geojson - | \
  tippecanoe -P -o sample.pmtiles
# Filter by bounding box
gpio extract data.parquet --bbox "-122.5,37.5,-122,38" | \
  gpio convert geojson - | \
  tippecanoe -P -o sf.pmtiles

# Filter by column values
gpio extract data.parquet --where "population > 10000" | \
  gpio convert geojson - | \
  tippecanoe -P -o cities.pmtiles
# End the pipeline in a GeoJSON file instead of a pipe
gpio extract data.parquet --limit 1000 | \
  gpio convert geojson - sample.geojson

Reading from stdin with - works in both modes: pipe onward without an output file to get newline-delimited GeoJSON, or name an output file to get a FeatureCollection written there.

Select Specific Columns

Reduce output size by selecting only needed columns:

gpio extract data.parquet --include-cols name,type,population | \
  gpio convert geojson - | \
  tippecanoe -P -o output.pmtiles

Transform Before Converting

Apply spatial operations before conversion:

# Add bbox and sort, then convert
gpio add bbox data.parquet | \
  gpio sort hilbert - | \
  gpio convert geojson - | \
  tippecanoe -P -o output.pmtiles

# Reproject before converting
gpio convert reproject data.parquet - --dst-crs EPSG:4326 | \
  gpio convert geojson - | \
  tippecanoe -P -o output.pmtiles

Tip: The built-in gpio pmtiles command handles reprojection automatically:

# Automatically reproject from EPSG:3857 to WGS84
gpio pmtiles create data.parquet tiles.pmtiles --src-crs EPSG:3857

from geoparquet_io.api import ops

# Automatically reproject from EPSG:3857 to WGS84
ops.create_pmtiles(
    input_path="data.parquet",
    output_path="tiles.pmtiles",
    src_crs="EPSG:3857"
)

Writing to File

To write a standard GeoJSON FeatureCollection, specify an output file:

# Write to GeoJSON file
gpio convert geojson data.parquet output.geojson
# With options
gpio convert geojson data.parquet output.geojson --precision 5 --write-bbox
import geoparquet_io as gpio

# Write to GeoJSON file
gpio.read('data.parquet').to_geojson('output.geojson')

# With options
gpio.read('data.parquet').to_geojson(
    'output.geojson',
    precision=5,
    write_bbox=True
)

# Get as string (no file output)
geojson_str = gpio.read('data.parquet').to_geojson()

File output is generated directly from query results as RFC 7946-compliant GeoJSON; no GDAL is involved.

Options Reference

Option Default Description
--no-rs false Disable RFC 8142 record separators (streaming only)
--precision N 7 Coordinate decimal precision (RFC 7946 recommends 7)
--write-bbox false Include bbox property for each feature
--id-field COLUMN none Use this column as the GeoJSON feature id
--description TEXT none Add a description to the FeatureCollection
--feature-collection false Output a FeatureCollection instead of GeoJSONSeq (streaming only)
--pretty false Pretty-print the JSON output with indentation
--keep-crs false Keep the original CRS instead of reprojecting to WGS84
--no-repair-geometry false Preserve invalid geometry instead of repairing it with ST_MakeValid
--verbose false Show debug output
--aws-profile NAME none AWS profile for S3 files

Coordinate Precision

The --precision option controls decimal places for coordinates. Lower precision reduces output size but decreases accuracy:

Precision Accuracy Use Case
7 (default) ~1cm High accuracy, RFC 7946 default
6 ~10cm Most mapping applications
5 ~1m City-level visualization
4 ~10m Regional maps
# Reduce precision for smaller output
gpio convert geojson data.parquet --precision 5 | tippecanoe -P -o output.pmtiles

Feature ID Field

Use --id-field to specify which column should become the GeoJSON feature id:

gpio convert geojson buildings.parquet --id-field osm_id | tippecanoe -P -o output.pmtiles

This is useful for feature state in map rendering or for joining data.

Bounding Box

Include per-feature bounding boxes with --write-bbox:

gpio convert geojson data.parquet output.geojson --write-bbox

Description

Add a description to the FeatureCollection:

gpio convert geojson data.parquet output.geojson --description "My dataset"

Pretty Print

For human-readable output with indentation:

gpio convert geojson data.parquet output.geojson --pretty

FeatureCollection Mode (Streaming)

By default, streaming outputs newline-delimited GeoJSONSeq. To output a complete FeatureCollection instead:

gpio convert geojson data.parquet --feature-collection > output.geojson

No GDAL Layer Creation Options

gpio convert geojson does not use GDAL, so there are no layer creation options to pass: the writer generates GeoJSON directly from query results. The settings that matter for GeoJSON output are available as dedicated flags (--precision, --write-bbox, --id-field, --feature-collection, --pretty). If you need a GDAL driver option that has no dedicated flag, run ogr2ogr on the output file.

Performance Tips

  1. Filter first: Use gpio extract to reduce row count before conversion
  2. Select columns: Only include columns needed for visualization
  3. Lower precision: Use --precision 5 or --precision 6 for smaller output
  4. Pipeline processing: Chain commands to avoid intermediate files

Large File Example

# Efficient pipeline for large files
gpio extract large.parquet \
  --bbox "-122.5,37.5,-122,38" \
  --include-cols name,type,height | \
  gpio convert geojson - --precision 5 | \
  tippecanoe -P -z14 -o sf_buildings.pmtiles

Remote Files

Read from S3, GCS, or Azure:

# From S3 with profile
gpio convert geojson s3://bucket/data.parquet --aws-profile my-aws | tippecanoe -P -o output.pmtiles

# From public URL
gpio convert geojson https://example.com/data.parquet | tippecanoe -P -o output.pmtiles

See Also