MapLibre GL Vector Web Maps
When a project outgrows Folium's static HTML — smooth zooming, data-driven styling, hundreds of thousands of features — the answer is MapLibre GL JS, an open-source WebGL renderer for vector data. Python's job shifts from emitting a whole map to producing the sources MapLibre consumes: GeoJSON for modest layers, vector tiles for large ones. This guide covers how Python feeds MapLibre and sits alongside Interactive Maps with Folium and Vector Tile Pipelines with PMTiles in Web Mapping & Interactive Visualization.
Architecture & Data Structures
MapLibre is configured by a style document: a JSON object with two parts that matter here — sources (where data comes from) and layers (how to draw it). A source can be a GeoJSON object/URL, a vector-tile set (pmtiles:// or mbtiles), or raster tiles. Layers reference a source by name and apply paint and filter expressions. This indirection is what makes vector rendering powerful: one source can drive a fill layer, an outline layer, and a label layer, all restyled client-side without re-fetching data.
import json
import geopandas as gpd
bike_lanes = gpd.read_file("bike_lanes.gpkg").to_crs(epsg=4326)
style = {
"version": 8,
"sources": {
"lanes": {"type": "geojson", "data": json.loads(bike_lanes.to_json())},
},
"layers": [
{
"id": "lanes-line",
"type": "line",
"source": "lanes",
"paint": {"line-color": "#1d2d44", "line-width": 2},
}
],
}
Environment Configuration & Dependency Resolution
MapLibre GL JS runs in the browser; Python only prepares its inputs. The Python side needs GeoPandas to export GeoJSON and, for tiled sources, the pmtiles package plus the tippecanoe binary. There is no maplibre Python package required for serving static sources — you load the JS library from a CDN or bundle it.
conda install -c conda-forge "geopandas=0.14.*" "pmtiles=3.2.*"
# tippecanoe is a system binary, not pip-installable on most platforms:
# macOS: brew install tippecanoe
# Linux: build from felt/tippecanoe, or use a container image
Pin the JS library version in your HTML (maplibre-gl@4.x) so a CDN update doesn't change rendering behavior under you.
Vectorized Operations & Core Workflow
The end-to-end workflow: export a styled GeoJSON source from a GeoDataFrame, write a minimal style document, and load it in an HTML page. The detailed serving recipe is in Serving GeoJSON to MapLibre GL JS.
import geopandas as gpd
parcels = gpd.read_file("parcels.gpkg") # EPSG:25832
parcels["area_ha"] = parcels.geometry.area / 1e4 # metric work first
parcels_web = parcels.to_crs(epsg=4326)[["parcel_id", "area_ha", "geometry"]]
# Trim precision to keep the GeoJSON source small
parcels_web["geometry"] = parcels_web.geometry.set_precision(1e-6)
parcels_web.to_file("parcels.geojson", driver="GeoJSON")
Data-driven styling then lives in the layer paint, using MapLibre expressions that read feature properties:
fill_paint = {
"fill-color": [
"interpolate", ["linear"], ["get", "area_ha"],
0, "#f0ebd8", 5, "#748cab", 20, "#1d2d44",
],
"fill-opacity": 0.8,
}
Geometry / Data Processing Details
MapLibre clips and tessellates geometry on the GPU, but it cannot fix bad input. Invalid polygons render with holes or spikes, and unsimplified geometry wastes bandwidth. Validate and simplify in Python first, reusing the topology tooling from Topology Validation & Repair.
import geopandas as gpd
from shapely.validation import make_valid
coastline = gpd.read_file("coastline.gpkg")
coastline["geometry"] = coastline.geometry.apply(make_valid)
# Simplify in a metric CRS so tolerance is in metres
coastline_m = coastline.to_crs(coastline.estimate_utm_crs())
coastline_m["geometry"] = coastline_m.geometry.simplify(25)
coastline = coastline_m.to_crs(epsg=4326)
CRS Alignment & Projection Pipeline
MapLibre's default projection is Web Mercator (EPSG:3857), and it expects sources in EPSG:4326 longitude/latitude. The contract is identical to Folium's: do metric analysis upstream in a projected CRS via Coordinate Systems with PyProj, then convert to 4326 at export.
import geopandas as gpd
flood_zones = gpd.read_file("flood_zones.gpkg") # EPSG:32633
assert flood_zones.crs.is_projected, "Analyse in a projected CRS first"
flood_zones["risk_area_km2"] = flood_zones.geometry.area / 1e6
flood_web = flood_zones.to_crs(epsg=4326) # for MapLibre
Production Export & Integration
GeoJSON sources are fine up to a few megabytes; beyond that, MapLibre's strength is consuming vector tiles so the client only fetches the current viewport at the current zoom. The cloud-native path packages tiles as PMTiles, served from a static bucket with no tile server, covered in Vector Tile Pipelines with PMTiles. MapLibre reads PMTiles directly with the official protocol shim.
- Inline/URL GeoJSON: smallest setup, best for <5 MB layers.
- PMTiles: single-file vector tiles, ideal for static hosting and large datasets.
- MBTiles via a server: when you need a running endpoint with auth or dynamic filtering — see Serving MBTiles with Python.
Windows / Platform Edge Cases & Debugging
tippecanoewon't build on Windows. Use WSL2 or a Linux container; there is no native Windows build. PMTiles packaging in Python works cross-platform once tiles exist.- Blank WebGL canvas. Check the browser console — a missing
version: 8or a layer referencing an undefined source is the usual cause. - Features off-globe. Source left in a projected CRS; reproject to EPSG:4326.
- Huge initial load. You inlined a multi-megabyte GeoJSON; switch to tiles.
- Labels overlap. MapLibre's symbol layer handles collision only within itself — set
text-allow-overlapdeliberately and order layers so labels draw last. - PMTiles won't load. Register the protocol (
maplibregl.addProtocol("pmtiles", ...)) before constructing the map.