MapLibre GL
Web Maps
Visualization
Frontend
Performance

GeoParquet to MapLibre: GeoJSON and PMTiles, Done Correctly

Turn analytical GeoParquet into bounded GeoJSON or tiled PMTiles delivery, with runnable MapLibre examples, a correct protocol registration, and measurable limits.

By 12 min read

MapLibre GL JS does not read an arbitrary GeoParquet feature table as a map source. A web map needs either a bounded GeoJSON payload or map tiles. Keep GeoParquet as the analytical source, then publish one of those delivery representations:

Delivery pathUse it whenMain limit
Inline or fetched GeoJSONA bounded, already-filtered feature set is small enough to parse and renderPayload and client parsing grow with every returned feature
Viewport GeoJSON APIUsers need current features and server-side filtersYou operate an API and must cap each response
Vector tilesMany zoom levels or large feature collectionsRequires tiling/generalization and a style source-layer contract
PMTilesVector/raster tiles can live as one immutable archive on object storageBrowser host needs range requests/CORS; updates usually publish a new archive

This guide provides two copyable browser examples: an inline GeoJSON smoke test and a correctly registered PMTiles source. It then defines the missing GeoParquet-to-delivery boundary.

Smoke-test MapLibre with two controlled features

Save this as map.html and open it from a local HTTP server. The example pins the same library versions used by the current upstream PMTiles example as checked on August 21, 2026: MapLibre GL JS 5.13.0. It uses no basemap, so the two circles are the only data dependency.

example.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>MapLibre GeoJSON smoke test</title>
  <link
    rel="stylesheet"
    href="https://unpkg.com/maplibre-gl@5.13.0/dist/maplibre-gl.css"
    crossorigin="anonymous">
  <script
    src="https://unpkg.com/maplibre-gl@5.13.0/dist/maplibre-gl.js"
    crossorigin="anonymous"></script>
  <style>
    html, body, #map { height: 100%; margin: 0; }
  </style>
</head>
<body>
<div id="map" aria-label="Example point map"></div>
<script>
const features = {
  type: "FeatureCollection",
  features: [
    {
      type: "Feature",
      id: "monaco",
      geometry: { type: "Point", coordinates: [7.4246, 43.7384] },
      properties: { name: "Monaco" }
    },
    {
      type: "Feature",
      id: "nice",
      geometry: { type: "Point", coordinates: [7.2620, 43.7102] },
      properties: { name: "Nice" }
    }
  ]
};
 
const map = new maplibregl.Map({
  container: "map",
  center: [7.34, 43.725],
  zoom: 9,
  style: {
    version: 8,
    sources: {
      places: { type: "geojson", data: features }
    },
    layers: [
      {
        id: "background",
        type: "background",
        paint: { "background-color": "#f4f1ea" }
      },
      {
        id: "places",
        type: "circle",
        source: "places",
        paint: {
          "circle-radius": 8,
          "circle-color": "#14532d",
          "circle-stroke-color": "#ffffff",
          "circle-stroke-width": 2
        }
      }
    ]
  }
});
 
map.on("click", "places", (event) => {
  const feature = event.features && event.features[0];
  if (!feature) return;
 
  const label = document.createElement("strong");
  label.textContent = String(feature.properties.name);
 
  new maplibregl.Popup()
    .setLngLat(event.lngLat)
    .setDOMContent(label)
    .addTo(map);
});
</script>
</body>
</html>

Expected behavior: a beige canvas shows two green circles near Monaco and Nice; clicking either displays its name. setDOMContent plus textContent avoids treating a data value as HTML.

If this does not work, debug the MapLibre/CDN/CSP path before involving GeoParquet.

Define the GeoJSON API contract

For viewport delivery, the browser can request:

notes.txt
GET /features?bbox=minLon,minLat,maxLon,maxLat&limit=5000

The API should:

  • validate that bbox contains four finite WGS84 numbers in valid order;
  • enforce a maximum area, feature count, and response bytes;
  • select an allowlist of properties rather than SELECT *;
  • transform to OGC:CRS84 before emitting GeoJSON;
  • return a valid FeatureCollection and a truncation/cursor signal;
  • cache by immutable dataset version plus normalized query;
  • cancel superseded viewport requests.

A native DuckDB service can do the spatial part after loading the spatial extension:

query.sql
WITH bounded AS (
  SELECT
    feature_id,
    name,
    geometry
  FROM read_parquet('features.parquet')
  WHERE geometry && ST_MakeEnvelope(?, ?, ?, ?)
  ORDER BY feature_id
  LIMIT 5001
)
SELECT
  feature_id,
  name,
  ST_AsGeoJSON(geometry) AS geometry_json
FROM bounded
LIMIT 5000;

This assumes the file exposes native GEOMETRY in the same CRS as the envelope. For older WKB/BLOB GeoParquet, decode through ST_GeomFromWKB first. The service layer should JSON-parse geometry_json and build the FeatureCollection; ST_AsGeoJSON returns a geometry fragment, not a complete feature collection.

Returning 5,001 rows internally lets the service say that the 5,000-row response was truncated. The number is an example policy, not a universal rendering limit; choose it from measured payload and device tests.

Update a viewport source without stale responses

Once the API contract exists:

example.js
let requestController;
 
map.on("moveend", async () => {
  if (requestController) requestController.abort();
  requestController = new AbortController();
 
  try {
    const b = map.getBounds();
    const bbox = [
      b.getWest(), b.getSouth(), b.getEast(), b.getNorth()
    ].join(",");
 
    const response = await fetch(
      "/features?bbox=" + encodeURIComponent(bbox) + "&limit=5000",
      { signal: requestController.signal }
    );
    if (!response.ok) throw new Error("Feature request failed");
 
    const collection = await response.json();
    const source = map.getSource("places");
    if (source) source.setData(collection);
  } catch (error) {
    if (error.name !== "AbortError") console.error(error);
  }
});

The MapLibre GeoJSONSource API (opens in a new tab) documents setData. Production code should debounce rapid movements, handle AbortError, expose truncation, and validate the response before replacing the current source.

Register PMTiles before using a pmtiles URL

A pmtiles:// source is not built into MapLibre. The PMTiles library must register the protocol first. This omission makes many short examples fail.

The following source/layer block follows the current official PMTiles MapLibre example (opens in a new tab) and pins PMTiles 4.4.1 with MapLibre 5.13.0:

example.html
<link
  rel="stylesheet"
  href="https://unpkg.com/maplibre-gl@5.13.0/dist/maplibre-gl.css"
  crossorigin="anonymous">
<script
  src="https://unpkg.com/maplibre-gl@5.13.0/dist/maplibre-gl.js"
  crossorigin="anonymous"></script>
<script src="https://unpkg.com/pmtiles@4.4.1/dist/pmtiles.js"></script>
 
<div id="map" style="height: 520px"></div>
<script>
const protocol = new pmtiles.Protocol({ metadata: true });
maplibregl.addProtocol("pmtiles", protocol.tile);
 
const archiveUrl =
  "https://pmtiles.io/protomaps(vector)ODbL_firenze.pmtiles";
 
const map = new maplibregl.Map({
  container: "map",
  center: [11.2543435, 43.7672134],
  zoom: 13,
  style: {
    version: 8,
    sources: {
      firenze: {
        type: "vector",
        url: "pmtiles://" + archiveUrl
      }
    },
    layers: [
      {
        id: "background",
        type: "background",
        paint: { "background-color": "#f8f4f0" }
      },
      {
        id: "water",
        type: "fill",
        source: "firenze",
        "source-layer": "water",
        filter: ["==", ["geometry-type"], "Polygon"],
        paint: { "fill-color": "#80b1d3" }
      },
      {
        id: "roads",
        type: "line",
        source: "firenze",
        "source-layer": "roads",
        paint: { "line-color": "#d6604d", "line-width": 1.2 }
      }
    ]
  }
});
 
map.showTileBoundaries = true;
</script>

Expected behavior while the upstream demo archive is available: water polygons and road lines around Florence render, with tile boundaries visible. If a source-layer name does not exist in a different archive, the map loads but that layer draws nothing. Inspect the archive metadata instead of guessing.

The PMTiles host must support browser CORS and HTTP range requests. A full 200 response to a range request can cause unexpectedly large transfers. Use DevTools Network to confirm 206 responses and Content-Range.

Convert GeoParquet into a tile product

GeoParquet stores features; PMTiles stores tiles. A production conversion decides:

  • target CRS and axis order;
  • minimum and maximum zoom;
  • simplification/generalization per zoom;
  • feature IDs;
  • property allowlist;
  • clustering or density representation;
  • layer names;
  • tile-size limits;
  • attribution and license text;
  • immutable archive version.

One possible CLI pipeline is:

command.sh
# Inspect and validate the source first.
gpq validate features.parquet
 
# Convert to newline-delimited GeoJSON in WGS84.
ogr2ogr \
  -f GeoJSONSeq \
  -t_srs OGC:CRS84 \
  features.geojsonseq \
  features.parquet
 
# Tile and package. Choose zooms and simplification from your data.
tippecanoe \
  -o features.pmtiles \
  -l features \
  -Z 0 \
  -z 12 \
  features.geojsonseq

Pin GDAL, GPQ, and tippecanoe versions and review the exact tool documentation. Validate geometry and counts before conversion, inspect the PMTiles archive afterward, and compare representative features at multiple zooms. Generalization can intentionally remove vertices or features; that is a product decision, not lossless format conversion.

Choose with measurements

For a representative viewport and device, record:

  • source feature count and geometry type;
  • response/archive version and bytes transferred;
  • parse time and time until the MapLibre source reports loaded;
  • rendered feature count at each zoom;
  • panning/zooming frame behavior;
  • memory peak;
  • cache-hit behavior;
  • any truncation or generalization.

Do not claim that PMTiles or MapLibre “handles billions of features smoothly.” The archive may contain many source features, but a map displays bounded tiles and styled layers for the current view. Performance depends on tile density, style complexity, device, network, and browser.

Use the DuckDB spatial guide for analytical filtering and the GeoParquet version guide before building the delivery artifact. viewparquet can inspect the source table, but it does not currently render this map pipeline.

Primary references