/ geopbf

GeoPBF β€” Lightweight Binary GIS Format

Protocol Buffers-based geospatial binary format.
Lightweight Β· Universal converter Β· ArrayBuffer-native β€” the wire format for browser GIS

Table of Contents
  1. Why GeoPBF?
  2. Binary Format Structure
  3. Delta Encoding Γ— Varint
  4. GIS Conversion Hub
  5. Antimeridian Handling
  6. ArrayBuffer & Worker Architecture
  7. Gint Extension β€” 64-bit Coordinate Packing
  8. Comparison with Other Formats
  9. API Reference
  10. Map Library Integrations
  11. Untrusted HTML & Sanitizing
  12. COG β€” Rasters, the Same Way
  13. PMTiles & GeoParquet β€” the Way Out

1. Why GeoPBF?

GeoJSON is readable and convenient, but has critical limitations at scale. As a text format it is expensive to transfer, slow to parse, and blocks the browser's main thread. Shapefile is widely adopted but its multi-file structure is ill-suited for the Web. Tiled MVT fragments data by zoom level, increasing round-trip overhead.

GeoPBF is a binary format designed as the communication layer for browser GIS. Protocol Buffers compression efficiency, coordinate delta-encoding, and an ArrayBuffer-native design make transfer, decoding, and inter-Worker handoff uniformly fast.

2. Binary Format Structure

A GeoPBF file consists of two parts: a Header Section and a Body Section (FARRAY).

Header
fixed-length metadata
NAME Β· KEYS (global attribute dictionary) Β· PRECISION Β· BUFS (binary pool)
DESCRIPTION Β· LICENSE Β· ATTRIBUTION
Metadata can be updated O(1) via binary slice β€” no re-encoding required
↓
FARRAY
feature array
FEATURE Γ— N
  β”” GEOMETRY: GTYPE Β· LENGTH Β· COORDS (delta-compressed)
  β”” PROPERTIES: INDEX (KEYS ref) Β· VALUE (typed)
FieldTagTypeDescription
GTYPE8Varint0:Point 1:MPoint 2:Line 3:MLine 4:Poly 5:MPoly 6:Collection
LENGTH9Packed VarintVertex counts for rings and multi-part geometries
COORDS10Packed SVarintDelta-compressed coordinate stream Xβ‚€,Yβ‚€,Ξ”X₁,Ξ”Y₁,... (see below)
INDEX12Packed VarintIndices into the global KEYS dictionary
VALUE11Repeated MessageNULL/BOOL/INTEGER/FLOAT/STRING/DATE/COLOR/JSON/BLOB/IMAGE
O(1) Header Updates

Metadata such as NAME, DESCRIPTION, and LICENSE is stored independently in the header, allowing direct binary-slice writes without touching the feature body. Renaming a dataset or updating license information on a multi-gigabyte file completes instantly, with no re-encoding required.

3. Delta Encoding Γ— Varint

Storing coordinates as floating-point text like GeoJSON means the longitude 139.7412345 alone costs 11 bytes. GeoPBF reduces this in two stages.

β‘  Raw float
139.74000
35.68000
139.74123
35.68123
139.74246
β‘‘ Delta
139.74000
35.68000
+0.00123
+0.00123
+0.00123
β‘’ Γ— 10⁢ (int)
139740000
35680000
+1234
+1234
+1234
β‘£ Varint bytes
4 bytes
4 bytes
2 bytes
2 bytes
2 bytes

The coordinate difference between adjacent vertices is typically just a few hundred metres, so after integer scaling the delta values are small. Varint encodes small integers in fewer bytes, giving GeoPBF a property that is ideal for geographic data: denser vertex clusters compress more aggressively.

Comparison with GeoJSON

A GeoJSON "coordinates":[139.741234,35.681234] pair is roughly 30 bytes (text). GeoPBF's delta-Varint representation costs 8 bytes for the first vertex and 2–3 bytes for every subsequent one. Across the three datasets measured in Β§8.1 that is a 75–92% reduction against raw GeoJSON. Layering gzip via CompressionStream removes a further 18–29% β€” comparatively little, precisely because the redundancy gzip looks for has already been squeezed out by the encoding.

4. GIS Conversion Hub

GeoPBF is not merely a storage format β€” it acts as a conversion hub among major GIS formats, with decoding (import) and encoding (export) paths for all of them. Placing GeoPBF at the center enables any-to-any format conversion in a single step.

FormatReadWriteNotes
GeoJSONβœ“βœ“File / Blob / Object all accepted
Shapefileβœ“ (.zip)βœ“ (.zip)Multi-file bundle handled as a single .zip
KMZ / KMLβœ“βœ“Google Earth compatible
GMLβœ“βœ“OGC standard
GPXβœ“βœ“GPS tracks & waypoints
FlatGeoBufβœ“βœ“Streaming-friendly binary
TopoJSONβœ“βœ“Topology-preserving I/O
MOJ η™»θ¨˜ζ‰€ε‚™δ»˜εœ°ε›³βœ“ (.zip)β€”Japanese cadastral parcels; JGD2011 plane-rectangular (19 zones) β†’ WGS84 on decode. Opt in with { format: "moj" }, since the extension is also .zip
GeoParquet (v1.5)βœ“βœ“WKB + bbox covering column, spatially ordered rows. Dependency-free reader accepts geopandas / pyarrow / DuckDB output β€” Β§13
PMTiles (MVT) (v1.5)β€”βœ“Tiles are cut from Gint, so per-zoom simplification is a rank filter β€” Β§13. No inverse: tiles are simplified and quantized
GeoPBFβœ“ (native)βœ“ (native)Native fast-load
Gintβœ“ (decode)βœ“ (encode)Morton + VW extension (see Β§6)

The first eight rows go through the auto-detecting entry point geopbf(input). GeoParquet and PMTiles are large enough to be worth loading on demand, so they live behind their own subpath exports (geopbf/geoparquet, geopbf/pmtiles) and are covered in Β§13. Rasters travel a separate path again: geopbf/cog reads Cloud Optimized GeoTIFF by HTTP Range without converting it at all (Β§12). Everything here also has a CLI equivalent β€” npx geopbf enc / dec / pmtiles / parquet / parquet2pbf β€” running on plain Node with no GDAL.

Automatic Format Detection

Whatever you pass to geopbf(input) β€” a File, Blob, ArrayBuffer, or plain Object β€” the library auto-detects the format from the file extension, MIME type, or magic bytes and routes to the correct decoder. Accepted: .geopbf .pbf .geojson .json .topojson .fgb .zip .kml .kmz .gpx .gml .xml, each optionally .gz (gzip is recognised by signature, not extension). The caller never has to think about format. The one exception is the MOJ cadastral archive, whose .zip extension collides with Shapefile and so is selected with { format: "moj" }. A File that cannot be decoded raises an explicit error rather than silently yielding an empty FeatureCollection.

// Any format, the same API
const pbf = await geopbf(file);          // File (Shapefile .zip, GeoJSON, KMZ ...)
const pbf = await geopbf(arrayBuffer);    // ArrayBuffer (fetch response, etc.)
const pbf = await geopbf(geojsonObject);  // GeoJSON object directly
const pbf = await geopbf(zip, { format: "moj" });   // cadastral .zip β€” plane-rectangular β†’ WGS84

// Export to any format
const geojson  = pbf.geojson;    // β†’ GeoJSON object
const topojson = pbf.topojson;   // β†’ TopoJSON (topology-preserving)
const shp      = pbf.shape();    // β†’ Shapefile (.zip Blob)
const kmz      = pbf.kmz();      // β†’ KMZ Blob
const gml      = pbf.gml();      // β†’ GML string
const fgb      = await pbf.fgb(); // β†’ FlatGeoBuf ArrayBuffer

// v1.5 β€” the two large formats, behind their own subpath exports (Β§13)
import { toPMTiles } from "geopbf/pmtiles";
import { toGeoParquet, fromGeoParquet } from "geopbf/geoparquet";
const { buffer } = await toPMTiles(pbf, { maxZoom: 12 });  // β†’ PMTiles (MVT) bytes
const pq  = await toGeoParquet(pbf);                    // β†’ GeoParquet bytes
const back = await fromGeoParquet(u8);                  // ← GeoParquet, incl. geopandas/DuckDB output

// Save as native .geopbf (gzip-compressed)
await pbf.save('coastline');    // β†’ downloads coastline.geopbf

5. Antimeridian Handling

The antimeridian (Β±180Β° longitude) is a well-known trap in geographic data. A polygon that straddles it β€” for example a geometry spanning the Bering Sea β€” is stored as a single ring in most formats, with coordinates jumping from +179Β° to βˆ’179Β°. Renderers, spatial indexes, and clipping operations all break silently on such data.

GeoPBF treats antimeridian correctness as a format invariant: every geometry stored in GeoPBF is guaranteed to be antimeridian-cut. No straddling polygon can exist inside the format.

Automatic at Encode Time β€” All Import Paths

setFeature() calls antimeridianFeature(q) on every feature before writing it to the buffer β€” regardless of the source format. GeoJSON, Shapefile, KMZ, GML, GPX, FlatGeoBuf, TopoJSON β€” all go through the same path. The caller never needs to pre-process geometries.

5.1 Spherical Great-Circle Intersection

The cut is not a naive Β±180Β° coordinate clamp. antimeridianCut() computes the precise latitude at which each crossing segment intersects the antimeridian using the spherical formula β€” essential near the poles where meridian convergence is significant. After cutting, each sub-polygon is a valid closed ring with vertices inserted at the exact intersection latitude.

// Crossing detection: sign change + span > 180Β° means antimeridian crossing (not date line jump)
// p[i][0] * p[i+1][0] < 0  &&  abs(p[i][0] - p[i+1][0]) > 180

// Spherical intersection latitude (great-circle formula)
// x = sin(Ξ”lat/2)Β·sin(avgLng)Β·cos(halfDeltaLng) - sin(avgLat)Β·cos(avgLng)Β·sin(halfDeltaLng)
// z = cos(lat0)Β·cos(lat1)Β·sin(Ξ”lng)
// intersectLat = atan2(x, |z|)   ← geometrically exact on the sphere
Why This Matters for Gint

Morton codes require coordinates in a bounded domain. A geometry that straddles Β±180Β° would produce Morton codes that jump across the entire integer space, breaking binary search and spatial proximity guarantees. By cutting at encode time, all Morton codes in a GeoPBF tile are spatially coherent with no special-casing needed downstream.

6. ArrayBuffer & Worker Architecture

GeoPBF's internal representation is consistently an ArrayBuffer. This is a deliberate design choice for deep integration with the browser's parallel processing layer (Web Workers), yielding three important properties.

Main Thread
geopbf(file)
pbf.arrayBuffer
worker.postMessage(buf, [buf])
β€” buffer detached after transfer β€”
β†’
Encoder Worker
gint encode
topology analysis
postMessage(result, [result])
zero-copy return
β†’
Render Worker
SharedArrayBuffer
concurrent multi-worker reads
LOD filter
Canvas / WebGL draw

6.1 Transferable β€” Zero-Copy Transfer

// ArrayBuffer moves from main thread β†’ Worker with zero copy
// (ownership transfer, not a copy β€” no extra memory consumed)
worker.postMessage(pbf.arrayBuffer, [pbf.arrayBuffer]);
// After transfer pbf.arrayBuffer is detached (main thread can no longer access it)

// Worker side
onmessage = ({ data: buf }) => {
	const view = new DataView(buf);  // begin decoding immediately
	postMessage(result, [result]);    // return zero-copy
};

6.2 SharedArrayBuffer β€” Concurrent Multi-Worker Reads

// Expand decoded gint coordinate data into a SharedArrayBuffer
// β†’ multiple render Workers can read concurrently with no copies
const sab = new SharedArrayBuffer(buf.byteLength);
new Uint8Array(sab).set(new Uint8Array(buf));

// N tile workers run LOD filter + draw in parallel
tileWorkers.forEach(w => w.postMessage({ sab, zoom, viewport }));

6.3 CompressionStream β€” Native gzip

// On save: browser-native gzip (no external library needed)
let blob = new Blob([buf]);
blob = await (new Response(
	blob.stream().pipeThrough(new CompressionStream("gzip"))
)).blob();
postMessage(new File([blob], `${name}.geopbf`, { type: "application/x-geopbf" }));
Why Workers Fit So Well

GeoJSON is an object graph (JSON). Before a Worker can touch it you need JSON.stringify β†’ transfer β†’ JSON.parse β€” a full serialize/deserialize cycle that costs hundreds of milliseconds on large datasets. GeoPBF is an ArrayBuffer from the very start, so it moves between Workers via zero-copy transfer, and the receiver decodes by advancing a pointer. The main thread is never blocked.

7. Gint Extension β€” 64-bit Coordinate Packing

Applying gint encoding to GeoPBF packs each vertex's coordinates into a single 64-bit integer that simultaneously stores both the Morton code and the VW weight.

L1 node
(anchor)
1
Morton code (63 bits) β€” fixed precision 10⁻⁷ deg
bit 63 = 1 β†’ L1 (anchor vertex, always retained)
L2 node
(detail)
0
Morton code (58 bits)
VW rank
(6 bits)
bit 63 = 0 β†’ L2 (detail vertex, LOD-filtered)  Β·  bits 0-5 = VW weight rank (0–63)

Packing where (Morton) and which LOD (VW rank) into a single 64-bit integer means both spatial queries and LOD filtering reduce to simple integer arithmetic.

// L1 / L2 check (most-significant bit)
const isAnchor = (code >> 63n) === 1n;

// Extract Morton code (L2: upper 58 bits)
const mortonCode = code & 0x7FFFFFFFFFFFFFF8n;  // bits 3-62

// Extract VW weight rank (L2: lower 6 bits)
const vwRank = Number(code & 0x3Fn);  // bits 0-5 β†’ 0–63

// LOD filter at zoom z (rank threshold corresponding to 1pxΒ²)
const minRank = rankFromZoom(z);  // lower threshold at higher zoom (more vertices)
const visible = isAnchor || vwRank >= minRank;

8. Comparison with Other Formats

8.1 Measured File Size

Three public datasets of different shape and scale, each converted and measured end to end on one machine. Every row holds the same features; sizes are decimal MB.

Format NE 10m admin_0
countries, v5.1.1
KSJ N03 (2026)
Japanese municipalities
TIGER 2024 ZCTA5
US ZIP code areas
features258125,13033,791
vertices548,46915,669,41651,258,797
coordinate grid10⁻⁢° (0.11 m)10⁻⁢° (0.11 m)10⁻⁡° (1.1 m)
Shapefile (.shp+.dbf+.shx)9.7282.8825.3
  β”” +.prj, zipped βˆ’94.9174.8530.5
GML (.xml)β€”580.7β€”
  β”” + gzip βˆ’9β€”123.2β€”
GeoJSON13.3579.61,089.6
  β”” + gzip βˆ’94.7138.6265.8
FlatGeobuf9.3266.9826.8
  β”” + gzip βˆ’95.4156.3421.9
GeoParquet (WKB, zstd, bbox)4.5124.5333.7
GeoPBF3.348.6117.8
GeoPBF + gzip βˆ’9 (distribution form)2.734.495.2

Per vertex, raw β†’ compressed. This is where the formats separate:

Bytes per vertexNE 10m admin_0KSJ N03TIGER ZCTA5
Shapefile17.7 β†’ 9.018.1 β†’ 11.216.1 β†’ 10.3
GeoJSON24.2 β†’ 8.637.0 β†’ 8.821.3 β†’ 5.2
FlatGeobuf17.0 β†’ 9.817.0 β†’ 10.016.1 β†’ 8.2
GeoPBF6.0 β†’ 4.93.1 β†’ 2.22.3 β†’ 1.9
Shapefile and FlatGeobuf land in the same place

Both spend 16–18 bytes per vertex, for the same reason: two IEEE-754 doubles per point, whatever the geometry looks like. The spread between them is record headers and attribute encoding, not coordinates β€” two decades of format design separate the two, and the geometry section did not move. GeoPBF stores a delta on an integer grid instead, so its cost falls with vertex density: 2–3 bytes per vertex on a boundary sampled every few metres, more on sparse geometry with many attribute columns.

Compare like with like

Gzipped against gzipped, GeoPBF is 1.8–4.0Γ— smaller than GeoJSON β€” not the 5–17Γ— the raw rows suggest, and every format here is normally shipped compressed. Compression does most of its work on text (GeoJSON keeps 24–36% of its bytes, GML 21%), a fair amount on the double-based binaries (shapefile 51–64%, FlatGeobuf 51–59%: consecutive coordinates share the high-order bytes of their doubles, even though the low mantissa bits are incompressible noise), and least on GeoPBF (71–82%), which has already removed by construction the redundancy the compressor goes looking for. The uncompressed rows are what a decoder walks through in memory; the compressed rows are what crosses the network.

Reproduce with npx geopbf enc and npx geopbf parquet; the FlatGeobuf rows use the official flatgeobuf npm writer on the same features, and the zipped-shapefile row is .shp+.dbf+.shx+.prj packed at deflate βˆ’9 so that all three datasets are measured the same way β€” the shipped archives differ in what else they carry (Natural Earth 4.9 MB with an HTML README, TIGER 528.8 MB with ISO metadata XML, KSJ N03 803.2 MB with GML and shapefile and GeoJSON in one archive) and are not directly comparable. The shapefiles hold the original full-precision doubles, but a shapefile spends a fixed 16 bytes per point whatever the precision, so its size rows are unaffected.

8.2 Capability Matrix

Metric GeoJSON Shapefile MVT (tiles) GeoPBF
Encoding Text UTF-8 Binary (multi-file) PBF binary PBF + delta + Varint
File count 1 .shp + .dbf + .shx + … zoom Γ— tile count 1
Transfer efficiency Low (large text) Medium Medium (zoom-split) High (binary + gzip)
Worker transfer JSON serialize required Conversion required ArrayBuffer ArrayBuffer zero-copy
LOD support None None Per-zoom files VW weight built-in
Spatial index None .shx (linear) Tile coords None in the file (Morton 64-bit built in memory on load)
Format conversion External library needed External library needed Limited 12 formats built-in
Topology None None None Arc sharing Β· boundary integrity

9. API Reference

// ── Load ───────────────────────────────────────────────
const pbf = await geopbf(input, options);
// input:   File | Blob | ArrayBuffer | GeoJSON object | URL string
// options: { name, gint, nocache, clean }
//   gint:    false β†’ skip topology encode (gint conversion)
//   nocache: true  β†’ bypass IndexedDB cache and re-fetch
//   clean:   true  β†’ run topology integrity clean pass

// ── Data access ────────────────────────────────────────
pbf.length          // feature count
pbf.keys            // attribute name array ['name', 'rank', ...]
pbf.arrayBuffer     // raw ArrayBuffer (for Worker transfer)
pbf.geojson         // convert to GeoJSON FeatureCollection
pbf.topojson        // convert to TopoJSON (topology-preserving)
pbf.getFeature(i)   // decode only the i-th feature

// ── Export ─────────────────────────────────────────────
await pbf.save('name')      // download as .geopbf (gzip)
pbf.shape()               // Shapefile .zip
pbf.kmz()                 // KMZ
pbf.gml()                 // GML string
pbf.gpx()                 // GPX string
await pbf.fgb()           // FlatGeoBuf ArrayBuffer

// ── O(1) metadata update ───────────────────────────────
pbf.header({ name: 'New Name', description: '...', license: 'MIT' });

// ── Render ─────────────────────────────────────────────
pbf.draw(canvasCtx, { fill: '#5a8050', stroke: '#a8d47e' });

10. Map Library Integrations (v1.1)

Since v1.1.0 the npm package ships ready-made adapters for the major web map libraries. None of them import the host library β€” each is a zero-dependency subpath export built on one shared loader: whole-file gzip is detected by magic bytes, decoding avoids new Function (CSP-safe noeval mode), and properties are mapped to be JSON-safe (Date β†’ ISO string, bbox β†’ plain array, binary values dropped) so they survive each library's worker handoff.

Import Shape For
geopbf/maplibre addProtocol handler MapLibre GL JS (v4+)
geopbf/leaflet L.GeoJSON subclass Leaflet (1.x)
geopbf/openlayers VectorSource loader factory OpenLayers (6+)
geopbf/loaders loaders.gl Loader object deck.gl Β· kepler.gl
geopbf/load plain loadGeopbf(url) Cesium Β· D3 Β· anything that eats GeoJSON
// ── MapLibre β€” protocol source ─────────────────────────
import { geopbfProtocol } from "geopbf/maplibre";
maplibregl.addProtocol("geopbf", geopbfProtocol);
map.addSource("rail", { type: "geojson", data: "geopbf://https://…/N02-25_RailroadSection" });

// ── Leaflet β€” layer plugin ─────────────────────────────
import { extendLeaflet } from "geopbf/leaflet";
extendLeaflet(L);
L.geoPBF("geopbf://https://…").on("load", e => console.log(e.meta)).addTo(map);

// ── OpenLayers β€” source loader ─────────────────────────
import { makeGeopbfLoader } from "geopbf/openlayers";
const source = new VectorSource({ loader: makeGeopbfLoader(url, new GeoJSON()) });

// ── deck.gl β€” loaders.gl ───────────────────────────────
import { GeoPBFLoader } from "geopbf/loaders";
new GeoJsonLayer({ data: url, loaders: [GeoPBFLoader] });

// ── Everything else β€” plain loader ─────────────────────
import { loadGeopbf } from "geopbf/load";
const r = await loadGeopbf(url);   // { geojson, name, license, attribution, minZoom, maxZoom, … }
viewer.dataSources.add(await Cesium.GeoJsonDataSource.load(r.geojson));

Attribution travels inside the file (Β§2), and the adapters honor it: Leaflet and OpenLayers wire the header's attribution into the map credit automatically (an explicit option wins), and loadGeopbf hands back all header metadata for manual wiring. One contract to know: with MapLibre, write the inner URL absolute (geopbf://https://…, pmtiles-style) β€” MapLibre normalizes source URLs through new URL(), which corrupts relative forms.

11. Untrusted HTML & Sanitizing (v1.2)

GeoPBF is an open format: anyone can produce a file and hand you a URL. Display properties β€” @tip, @pop, or any property your viewer renders as HTML β€” must therefore be treated as untrusted input. A crafted file could otherwise run script in your page (XSS) the moment a tooltip or popup opens. The rule is the classic one: sanitize at the output boundary, right before the string reaches innerHTML β€” never assume a file was cleaned upstream, because files reach viewers without passing through any editor.

// Sanitize at the output boundary β€” right before innerHTML
import { sanitizeHTML } from "geopbf/sanitize";
el.innerHTML = sanitizeHTML(feature.properties["@pop"]);

sanitizeHTML is a dependency-free allowlist filter: formatting tags, lists, tables, images and links pass through; script, iframe, svg and the like are dropped with their content; unknown tags are unwrapped, keeping their children; event handlers and style never survive. href accepts http(s)/mailto, img src accepts http(s)/data:image, and every link gets target="_blank" rel="noopener noreferrer". It touches the DOM only when called, so importing it in a worker is safe. Note the division of labor with Β§10: sanitizeProperties makes values JSON-safe (types), sanitizeHTML makes them display-safe (markup). Use both.

12. COG β€” Rasters, the Same Way (v1.4)

A COG (Cloud Optimized GeoTIFF) is a static file read by HTTP Range requests β€” the same no-server philosophy as GeoPBF, applied to rasters. v1.4's geopbf/cog is a hand-written reader with zero new dependencies: the header is fetched in one 16 KB range request, tile requests are sorted and adjacent ranges coalesced into a single request, decode and reprojection run in a worker pool, and JPEG/WebP tiles go straight to the browser's native decoder. cog.metrics() reports the numbers at any time (range requests, coalesce ratio, decode time).

import { openCog } from "geopbf/cog";
const cog = await openCog("https://…/TCI.tif");   // header in one range request
const bm  = await cog.renderXYZ(14, x, y);          // ImageBitmap, warped to Web Mercator

The supported subset follows what public COG feeds actually serve: tiled and stripped layouts, BigTIFF, compression none/deflate/LZW/JPEG/WebP, predictor 2, uint8 RGB(A)/palette/single-band uint16/int16/float32 (auto percentile stretch, GDAL_NODATA β†’ transparent), CRS EPSG:4326/3857/UTM (hand-written KrΓΌger n-series, nm accuracy). Anything outside fails with an explicit error pointing to gdal_translate -of COG. One line each for MapLibre (cog:// protocol via geopbf/maplibre-cog) and Leaflet (geopbf/leaflet-cog). The CLI prints structure and measured numbers: npx geopbf cog info <url> --bench.

13. PMTiles & GeoParquet β€” the Way Out (v1.5)

Through v1.4 the hub read formats. v1.5 opens the other direction: a GeoPBF leaves as a PMTiles archive of Mapbox Vector Tiles or as a GeoParquet file, and a GeoParquet comes back in. Still no new dependencies β€” in fact pako was removed. Every codec now goes through the platform (CompressionStream in browsers and workers, node:zlib in Node), leaving pbf as the only runtime dependency β€” and 1.6 dropped that one too, by bringing the protobuf wire reader/writer in-house. The dependency list is now empty.

# CLI β€” plain Node, no native build
npx geopbf pmtiles countries.geopbf countries.pmtiles --maxzoom 10
npx geopbf parquet countries.geopbf countries.parquet
npx geopbf parquet2pbf countries.parquet back.geopbf     # and back again
import { toPMTiles } from "geopbf/pmtiles";
import { toGeoParquet } from "geopbf/geoparquet";

const pbf = await geopbf(file, { gint: true });
const { buffer, stats } = await toPMTiles(pbf, { maxZoom: 12 });  // stats.engine β†’ "gpu" | "cpu"
const pq = await toGeoParquet(pbf);                      // pq.buffer, pq.geo

Tiles are cut from Gint, not from the raw features. That is the same derived buffer ortho-earth draws on the GPU: a shared border exists once, as a single arc, and every vertex carries its Visvalingam–Whyatt rank. Per-zoom simplification is therefore a rank >= threshold filter, which means two neighbouring polygons are necessarily reduced to the identical vertex list β€” no slivers, no gaps, at any zoom. The thresholds are calibrated per arc against a Douglas–Peucker decomposition, so vertex counts land within roughly 10 % of tippecanoe's while which vertices survive is still decided by rank, keeping both sides of a border in step.

On Natural Earth 10m countries (258 polygons, 480k vertices, z0–10, 4-core Node) that is about 6.5 s from GeoJSON for 573,896 tiles / 151.0 MB, against tippecanoe v2.82 at 58 s for 573,885 tiles / 147 MB β€” the same tile set within a handful, with interior tiles byte-for-byte the same size apart from the layer name and the feature id. GDAL's PMTiles driver (3.12) took 780 s for the same job. On US Census ZCTA5 (33,092 ZIP areas, 52M vertices, z0–12): 47 s for 238,322 tiles / 251 MB against tippecanoe's 251 s for 238,319 tiles / 246 MB, indistinguishable in MapLibre at z3/z7/z11.

Parquet rows come out spatially ordered. Feature bbox centres are packed Sort-Tile-Recursive style (order: "str", the default), so each row group's bbox statistics cover a disjoint patch and any reader that pushes an area filter down β€” DuckDB, pyarrow datasets, GeoPandas β€” fetches only the row groups that actually intersect it. On 1,000,000 points in 50 row groups the row-group overlap ratio runs 24.5 unsorted β†’ 0.87 Morton β†’ 0.28 Hilbert β†’ 0.00 STR, and a city-sized query touches 1–2 row groups instead of all 50. This follows Kanahiro Iguchi's Spatial sort for well-packed GeoParquet (CNG Japan 2026).

Where the GPU is, honestly. Projection, the per-zoom rank filter, the integerβ†’double conversion and the bbox reduction run on WebGPU when it is available, with a CPU path performing the same integer arithmetic when it is not β€” the output is byte-identical either way, verified kernel by kernel in headless Chromium. But those stages are only 3–6 % of the job on every dataset measured so far; ring assembly, clipping, MVT encoding and gzip dominate. The GPU keeps the browser's main thread free. It is not the reason the converter is fast β€” that comes from Gint and from the assembly stage.

And back again. fromGeoParquet() turns a GeoParquet into a GeoPBF using a dependency-free Parquet reader β€” Thrift compact footer, DataPage v1/v2, PLAIN and dictionary encodings, codecs none / snappy / gzip / zstd β€” plus a WKB parser. A file geopbf wrote comes back bit-identical, and files written by GeoPandas, pyarrow and DuckDB read back to the same features. The CRS must be lon/lat (CRS84 / EPSG:4326) unless ignoreCrs is passed. PMTiles has no such inverse: its tiles are simplified and quantized, so only an approximate reassembly would be possible, and this package does not pretend otherwise.


geopbf v1.5.0 Β· Kenji Yoshida Β· MIT License Β· 2026