MapLibre GL Vector Web Maps

When a project outgrows the inlined-HTML approach of Interactive Maps with Folium — you need smooth continuous zoom, data-driven styling, or hundreds of thousands of features — the renderer to reach for is MapLibre GL JS, an open-source WebGL engine for vector data (the community fork of Mapbox GL). This guide sits inside Web Mapping & Interactive Visualization alongside Vector Tile Pipelines with PMTiles, and its scope is the Python side of the boundary: your code stops emitting a whole map and instead produces the sources MapLibre consumes — GeoJSON for modest layers, vector tiles for large ones — plus the JSON style document that tells the GPU how to draw them.

MapLibre source and layer model Python produces a GeoJSON or PMTiles source; the MapLibre style document references that source from one or more styled layers, which the WebGL renderer draws. Python output GeoJSON / PMTiles source data reference layer paint + filter WebGL canvas rendered map A style document binds many layers to one source; the renderer draws them with GPU acceleration
MapLibre separates the data source from the styled layers that reference it — Python supplies the source.

Architecture & Data Structures

MapLibre is configured by a single style document: a JSON object that conforms to the MapLibre Style Specification. Two top-level keys carry the weight here — sources (where data comes from) and layers (how to draw it) — with version: 8 as a mandatory sentinel and optional glyphs and sprite URLs that supply fonts for labels and icons for symbols. A source can be a GeoJSON object or URL, a vector-tile set (pmtiles://, mbtiles, or a {z}/{x}/{y} template), or raster tiles. Layers reference a source by name and apply paint, layout, and filter properties. This indirection is the whole point of vector rendering: one source can drive a fill layer, an outline layer, and a label layer at once, and every one of them can be restyled client-side without re-fetching a byte of data.

import json
import geopandas as gpd

# GeoPandas is the encoder; MapLibre is the renderer. See the GeoDataFrame model.
bike_lanes = gpd.read_file("bike_lanes.gpkg").to_crs(epsg=4326)

style = {
    "version": 8,
    "glyphs": "https://your-cdn.example/fonts/{fontstack}/{range}.pbf",
    "sources": {
        "lanes": {"type": "geojson", "data": json.loads(bike_lanes.to_json())},
    },
    "layers": [
        {
            "id": "lanes-line",
            "type": "line",
            "source": "lanes",
            "layout": {"line-cap": "round", "line-join": "round"},
            "paint": {"line-color": "#1d2d44", "line-width": 2},
        }
    ],
}

The GeoDataFrame you serialize here is the same structure covered in GeoPandas DataFrames Explained; MapLibre only ever sees its GeoJSON projection, so every attribute you want available for styling or interaction must survive the to_json() call. Two features of the layer model are worth internalising early. First, layers are ordered: later layers paint on top, so outlines and labels belong after fills. Second, paint values can be expressions — small JSON-encoded programs evaluated per feature against its properties and the current zoom — which is what makes a single vector source capable of a full choropleth, graduated line widths, or zoom-dependent detail with no round trip to Python.

Nine layer types cover everything the renderer can draw, and each accepts its own paint vocabulary: fill and line for polygons and linework, circle for cheap point symbols, symbol for icons and text, fill-extrusion for extruded footprints, heatmap for density surfaces, raster for tiled imagery, hillshade for terrain-derived relief, and background for the flat colour beneath everything. The choice constrains what Python has to supply. A symbol layer is inert without a glyphs URL, and without a sprite it cannot draw icons at all. A fill-extrusion layer needs a numeric height attribute in metres, which means you compute it in a projected CRS before the layer ever exists. A heatmap layer needs nothing but points and silently ignores any polygon you hand it — an easy way to spend an hour debugging an empty map.

Alongside the declarative document sits a small imperative API, and four calls carry most of the weight in a Python-fed map. map.addSource and map.addLayer register things after the style has loaded, and map.addLayer(layer, beforeId) inserts the new layer below an existing one — that second argument is how you slide a data layer under a basemap's road labels instead of painting over them. map.setPaintProperty swaps a single paint value, including a whole expression, without touching the source. map.getSource(id).setData(...) replaces a GeoJSON source's contents in place, which is the cheapest way to push a freshly computed Python result into a map that is already on screen. Two query functions then look interchangeable and are not: queryRenderedFeatures returns only what is currently drawn in the viewport, clipped and deduplicated, while querySourceFeatures returns everything held in the loaded tiles for a source, including features scrolled off screen and features duplicated across tile boundaries. Counting features with the first and wondering why the total changes as you pan is a rite of passage.

Two structural rules bite late in a project. Layer id values must be unique across the entire style, so merging a generated layer list into a third-party basemap style can silently discard the later duplicate; prefix your generated ids with the dataset name. And a layer whose source does not exist throws at addLayer time rather than rendering empty, which is a useful failure — generate the sources dictionary and the layers list from the same Python object so the two can never disagree.

Environment Configuration & Dependency Resolution

MapLibre GL JS runs in the browser; Python only prepares its inputs, so there is no maplibre Python package to install for serving static sources. The Python side needs GeoPandas to export GeoJSON and, for tiled sources, the pmtiles package plus the tippecanoe binary that cuts vector tiles. Pin the versions so a silent upgrade never changes tile geometry or rendering under you.

# conda-forge carries the GDAL/PROJ/GEOS stack GeoPandas depends on
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

On the browser side, pin the JS library version in your HTML (maplibre-gl@4.x) so a CDN update does not shift rendering behaviour, and load the matching stylesheet — MapLibre's controls and attribution break visually without it.

<link href="https://unpkg.com/maplibre-gl@4.5.0/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/maplibre-gl@4.5.0/dist/maplibre-gl.js"></script>

If you plan to read PMTiles directly (the recommended path for anything large), you also load the pmtiles protocol shim and register it before constructing the map — a step whose omission is the single most common "my tiles won't load" report, addressed again in the debugging section below.

Renderer versions matter more here than in most Python stacks, because the thing consuming your output is a moving target. MapLibre GL JS forked from Mapbox GL JS 1.13 when that project left its open licence, so MapLibre 1.x is API-compatible with the last open Mapbox release and tutorials from that era still apply verbatim. The 2.x line rewrote large parts of the render loop, 3.x brought 3D terrain and a sky layer out of beta, 4.x tightened style-specification validation so documents that used to load with a warning now fail outright, and 5.x introduced alternative projections including a globe view. The practical fallout for Python code that emits style JSON: an expression or layout property you copied from a recent Mapbox example may simply not exist in MapLibre, and a style that loaded silently on 3.x can throw a validation error on 4.x. Pin the exact patch version in your HTML, and record it next to your Python pins — the style your code generates is only correct with respect to a stated renderer version.

The glyphs and sprite URLs are the other half of the environment, and neither is a Python package. glyphs points at a template such as https://host/fonts/{fontstack}/{range}.pbf, where {range} is a 256-codepoint block; MapLibre fetches only the blocks a label actually needs, so one font stack is hundreds of small static files that you generate once and host beside your data. sprite points at a base path with no extension: the renderer appends .json and .png itself, and on a high-DPI display it appends @2x to both. A missing sprite@2x.png therefore produces icons that vanish on a Retina laptop and render perfectly on the reviewer's external monitor — one of the more maddening bug reports in web mapping. The public demo tile and font endpoints exist to make examples runnable, not to carry your traffic; budget for hosting glyphs and sprites alongside your tiles from the first production deploy.

If you intend to screenshot maps from Python for reports or visual regression tests, remember that the renderer needs a real GL context. Headless Chromium driven by Playwright works only when software rendering is forced (--use-gl=swiftshader or the equivalent ANGLE flag); without it the canvas stays transparent, no exception is raised, and your test suite happily asserts on a blank image.

Vectorized Operations & Core Workflow

The end-to-end workflow is: export a trimmed GeoJSON source from a GeoDataFrame, write a minimal style document, and load it in an HTML page. The detailed serving recipe — MIME types, CORS, and inline-versus-URL trade-offs — lives in Serving GeoJSON to MapLibre GL JS.

import geopandas as gpd

parcels = gpd.read_file("parcels.gpkg")            # EPSG:25832 (ETRS89 / UTM 32N)
parcels["area_ha"] = parcels.geometry.area / 1e4   # metric work first, in the projected CRS
parcels_web = parcels.to_crs(epsg=4326)[["parcel_id", "area_ha", "geometry"]]

# Trim coordinate 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 entirely in the layer paint, using MapLibre expressions that read feature properties. An interpolate expression maps a numeric attribute onto a colour ramp — the vector equivalent of a Folium choropleth, but recomputed on the GPU as the user zooms:

fill_paint = {
    "fill-color": [
        "interpolate", ["linear"], ["get", "area_ha"],
        0, "#f0ebd8", 5, "#748cab", 20, "#1d2d44",
    ],
    "fill-opacity": 0.8,
}

To boot the map in the browser, hand MapLibre the style you built and add the layers over your source. A minimal, self-contained page looks like this:

<div id="map" style="position:absolute;inset:0"></div>
<script>
  const map = new maplibregl.Map({
    container: "map",
    style: {
      version: 8,
      sources: { parcels: { type: "geojson", data: "parcels.geojson" } },
      layers: [
        { id: "parcels-fill", type: "fill", source: "parcels",
          paint: { "fill-color": ["interpolate", ["linear"], ["get", "area_ha"],
                                   0, "#f0ebd8", 20, "#1d2d44"], "fill-opacity": 0.8 } },
        { id: "parcels-line", type: "line", source: "parcels",
          paint: { "line-color": "#3e5c76", "line-width": 0.5 } }
      ]
    },
    center: [7.69, 45.07],   // [lon, lat] — GeoJSON axis order, not Folium's [lat, lon]
    zoom: 11
  });
</script>

Note the centre coordinate: MapLibre takes [longitude, latitude], the (x, y) order of GeoJSON and Shapely, the opposite of Folium's [lat, lon]. Mixing the two is the fastest way to send your map to the Gulf of Guinea.

Once a map has more than one layer, hand-editing the style stops paying. Generate the whole document from the same GeoDataFrame you exported, so the source name, the layer ids, the property names in the expressions and the initial view are all derived from one object and cannot drift apart:

import json
import geopandas as gpd

neighborhoods = gpd.read_file("neighborhoods.gpkg")             # EPSG:26910 (NAD83 / UTM 10N)
neighborhoods["area_km2"] = neighborhoods.geometry.area / 1e6   # metric attribute, projected CRS
neighborhoods = neighborhoods.to_crs(epsg=4326)                 # delivery hand-off, last step

minx, miny, maxx, maxy = neighborhoods.total_bounds             # degrees, lon/lat order

style = {
    "version": 8,
    "glyphs": "https://cdn.example/fonts/{fontstack}/{range}.pbf",
    "center": [(minx + maxx) / 2, (miny + maxy) / 2],
    "zoom": 10,
    "sources": {
        "hoods": {
            "type": "geojson",
            "data": "neighborhoods.geojson",
            "promoteId": "hood_id",      # stable identity for feature-state
        }
    },
    "layers": [
        {"id": "hoods-fill", "type": "fill", "source": "hoods",
         "paint": {"fill-color": "#c3d0e4", "fill-opacity": 0.55}},
        {"id": "hoods-outline", "type": "line", "source": "hoods",
         "paint": {"line-color": "#1d2d44", "line-width": 1.2}},
        {"id": "hoods-label", "type": "symbol", "source": "hoods",
         "layout": {
             "text-field": ["get", "name"],
             "text-font": ["Noto Sans Regular"],        # must exist in the glyph stack
             "text-size": 12,
             "symbol-sort-key": ["-", 0, ["get", "area_km2"]],   # big areas label first
         },
         "paint": {"text-color": "#1d2d44",
                   "text-halo-color": "#ffffff", "text-halo-width": 1.4}},
    ],
}

with open("public/style.json", "w", encoding="utf-8") as fh:
    json.dump(style, fh, indent=2)

print(f"bbox {minx:.4f},{miny:.4f},{maxx:.4f},{maxy:.4f}")
# bbox -122.4360,47.4955,-122.2360,47.7341

Three details in that document are worth calling out. The layer order — fill, then outline, then labels — is the order they paint, so reversing it hides the outlines under the fills. symbol-sort-key decides which labels win a collision: lower values are placed first, so negating the area makes the largest neighbourhood claim its label before its smaller neighbours compete for space. And text-font names a font stack that must physically exist under your glyphs URL; a typo there produces a symbol layer that renders nothing at all and logs nothing useful.

The fixed zoom: 10 in the style is a guess. The bounding box you just computed is not, so hand it to the client and let the map frame the data exactly:

bounds = [[minx, miny], [maxx, maxy]]   # MapLibre wants [[west, south], [east, north]]
# In the page JS:
#   map.fitBounds([[-122.4360, 47.4955], [-122.2360, 47.7341]],
#                 { padding: 32, animate: false });

fitBounds is also the honest way to detect a projection mistake: if the map zooms out to the whole world, the bounding box is in metres, not degrees, and the reprojection never happened.

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 on vertices no screen pixel will ever distinguish. Validate and simplify in Python first, reusing the repair tooling from Topology Validation & Repairmake_valid from Shapely Geometry Operations is the workhorse:

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 the tolerance is honest metres, not degrees
coastline_m = coastline.to_crs(coastline.estimate_utm_crs())
coastline_m["geometry"] = coastline_m.geometry.simplify(25)   # 25 m
coastline = coastline_m.to_crs(epsg=4326)

Beyond geometry, MapLibre's feature-state API lets you drive paint from runtime state rather than baked-in properties — the basis of hover highlighting and click selection without redrawing the source. For that to work each feature needs a stable identity: either a top-level GeoJSON id, or a promoteId on the source pointing at a unique attribute. Emit one deliberately from Python so the client can address features by key:

# Promote a real business key to the GeoJSON feature id
parcels_web = parcels_web.reset_index(drop=True)
geojson = parcels_web.to_json()   # then set  source.promoteId = "parcel_id"  in the style

Symbol (label) layers add one more processing concern: collision. MapLibre only resolves overlap within a single symbol layer, dropping lower-priority labels that would collide. Keep labels in one layer, order it last so it draws over the fills, and reach for text-allow-overlap only when you genuinely want every label regardless of crowding.

Three geometry shapes that GeoPandas tolerates happily will misdraw or disappear once they cross into a browser. Null and empty geometries serialize to "geometry": null, which MapLibre skips without a word — the map looks fine, and only a feature count taken in the console disagrees with len(gdf). GeometryCollections are legal GeoJSON, rejected outright by tippecanoe, and rendered inconsistently by the GeoJSON source; explode them into their parts or filter them out. Ring winding is the subtle one: RFC 7946 asks for counter-clockwise exterior rings and clockwise holes, and while MapLibre's GeoJSON path is forgiving, the same geometry routed through a tiling step can come back with holes filled in as solid shapes. Normalising all three costs four lines:

import geopandas as gpd
from shapely.geometry import MultiPolygon
from shapely.geometry.polygon import orient

admin = gpd.read_file("admin_areas.gpkg").to_crs(epsg=4326)

admin = admin[admin.geometry.notna() & ~admin.geometry.is_empty]
admin = admin[admin.geom_type.isin(["Polygon", "MultiPolygon"])]   # no GeometryCollections

def orient_rfc7946(geom):
    """Exterior rings counter-clockwise, interior rings clockwise."""
    if geom.geom_type == "Polygon":
        return orient(geom, sign=1.0)
    return MultiPolygon([orient(part, sign=1.0) for part in geom.geoms])

admin["geometry"] = admin.geometry.apply(orient_rfc7946)

The antimeridian is the remaining trap, and it produces an unmistakable symptom: a feature that should sit in Fiji or the Chukchi Sea instead draws as a band stretched across the entire width of the map. The cause is that a ring whose vertices run from 179.6 to -179.8 is read as travelling the long way round, westward across the whole globe, because GeoJSON has no notion of which side of the line you meant. Detect it before you export — anything whose longitude span exceeds 180° is almost certainly wrapped rather than genuinely hemispheric:

spans = admin.bounds["maxx"] - admin.bounds["minx"]
wrapped = admin[spans > 180]
print(f"{len(wrapped)} features appear to cross the antimeridian")   # 3 features ...

The fix is to split those features at ±180 into two polygons before export (shapely.ops.split against a meridian line), or, if the dataset is genuinely Pacific-centred, to accept that a Web Mercator map cut at 180° is the wrong canvas and produce a static map in a Pacific-centred projection instead.

CRS Alignment & Projection Pipeline

MapLibre's default projection is Web Mercator, and it expects every source in EPSG:4326 longitude/latitude. The contract is identical to Folium's, and it is the recurring discipline of the whole visualization layer: do all metric analysis upstream in an appropriate projected CRS — a local UTM zone, not Web Mercator, whose distortion makes areas and distances meaningless away from the equator — then convert to 4326 as the single, deliberate step at the delivery boundary. The mechanics of that transform, including the axis-order and always_xy traps, are covered in Coordinate Systems with PyProj.

import geopandas as gpd

flood_zones = gpd.read_file("flood_zones.gpkg")   # EPSG:32633 (WGS 84 / UTM 33N)
assert flood_zones.crs.is_projected, "Analyse in a projected CRS before measuring"

flood_zones["risk_area_km2"] = flood_zones.geometry.area / 1e6   # metric area, correct here
flood_web = flood_zones.to_crs(epsg=4326)                        # reproject only for MapLibre

One subtlety separates MapLibre from Folium: because MapLibre reprojects EPSG:4326 to screen Mercator on the GPU, a straight line between two lon/lat vertices is drawn as a straight Mercator line, not a great circle. For most thematic layers this is invisible, but long geodesic features (flight paths, submarine cables) need densified vertices in Python before export or they will visibly cut corners.

A two-vertex line drawn as a Mercator chord versus a densified line Two panels compare how MapLibre draws a long route between the same pair of points. On the left the exported geometry has only two vertices, so the renderer joins them with a straight screen line that runs well south of the dashed true geodesic path, leaving a visible gap at the midpoint. On the right the same route has been densified in Python before export, so nine of its sixty-four vertices trace the poleward bow of the geodesic and the drawn line follows the true path. A north arrow in each panel shows that the geodesic bows toward the pole. A long line between two vertices is a Mercator chord Two vertices — drawn as a chord Densified before export — follows the path N N A B the chord cuts inside the true path MapLibre draws a straight screen line between vertices A B 9 of 64 vertices shown densify in Python before the reprojection and export true geodesic path exported geometry (2 vertices) densified geometry (64 vertices)
Screen-space interpolation between vertices is what bends the result: only extra vertices, added in Python, make a long line hold its geodesic shape after the GPU reprojects it.

Web Mercator is undefined at the poles, so the tile grid — and therefore MapLibre — stops at ±85.051129° of latitude, the value that makes the world square. Data beyond that line is clipped, and the clip is silent: an Arctic sea-ice polygon or a research-station buffer simply loses its northern edge. Check for it explicitly whenever the data reaches high latitudes:

import geopandas as gpd

MERCATOR_MAX_LAT = 85.051129

sea_ice = gpd.read_file("sea_ice_extent.gpkg").to_crs(epsg=4326)
bounds = sea_ice.bounds
clipped = sea_ice[(bounds["maxy"] > MERCATOR_MAX_LAT) | (bounds["miny"] < -MERCATOR_MAX_LAT)]
print(f"{len(clipped)} features extend past the Mercator limit and will be truncated")

If that count is anything but zero, a Web Mercator web map is arguably the wrong deliverable: areas near the limit are inflated by more than a factor of a hundred, so any visual comparison of polar features is meaningless regardless of clipping. The globe projection in recent MapLibre releases fixes the appearance but not the underlying tile grid or the analysis, which still belongs in a polar stereographic or equal-area CRS.

One more transformation trap belongs here because it only shows up at the map boundary. GeoPandas' to_crs always returns x/y ordering, so the axis-order question never arises inside a GeoDataFrame. Drop to raw PyProj to convert a single centre point or a bounding box for the map — which is exactly what people do when wiring up center or fitBounds — and EPSG:4326's formal latitude-first axis order reappears:

from pyproj import Transformer

# Wrong for a MapLibre center: EPSG:4326 declares latitude first
transformer_default = Transformer.from_crs("EPSG:25832", "EPSG:4326")
print(transformer_default.transform(500000, 5000000))     # (45.1279..., 9.0000...) -> lat, lon

# Right: always_xy keeps (x, y) == (lon, lat), the order MapLibre expects
transformer = Transformer.from_crs("EPSG:25832", "EPSG:4326", always_xy=True)
lon, lat = transformer.transform(500000, 5000000)
print([round(lon, 4), round(lat, 4)])                     # [9.0, 45.1279]

Both results are plausible coordinates, which is why the mistake survives code review and only surfaces as a map centred in the wrong country.

Production Export & Integration

Inlined or URL-referenced GeoJSON is fine up to a few megabytes; past that, MapLibre's real strength is consuming vector tiles so the client only ever fetches the current viewport at the current zoom. The cloud-native path packages those tiles as a single PMTiles archive served straight from a static bucket with no tile server, covered in Vector Tile Pipelines with PMTiles and, from the storage side, in Cloud-Native Geospatial Formats. MapLibre reads PMTiles directly once you register the protocol shim:

<script src="https://unpkg.com/pmtiles@3.2.0/dist/pmtiles.js"></script>
<script>
  const protocol = new pmtiles.Protocol();
  maplibregl.addProtocol("pmtiles", protocol.tile);   // MUST run before new maplibregl.Map(...)

  const map = new maplibregl.Map({
    container: "map",
    style: {
      version: 8,
      sources: {
        parcels: { type: "vector", url: "pmtiles://https://cdn.example/parcels.pmtiles" }
      },
      layers: [{
        id: "parcels-fill", type: "fill",
        source: "parcels", "source-layer": "parcels",   // must match the tile layer name
        paint: { "fill-color": "#748cab", "fill-opacity": 0.6 }
      }]
    }
  });
</script>

Choose the source type against dataset size and hosting:

For dense point layers, MapLibre can cluster client-side by setting cluster: true on a GeoJSON source, then styling the aggregated point_count with a step expression — the vector analogue of Folium's MarkerCluster, but re-clustered live as you zoom rather than baked at export time.

Before concluding that a layer is too big for a GeoJSON source, tune the source itself. MapLibre pushes GeoJSON through an in-browser tiler and exposes its knobs as source properties that most people never touch:

sensors_source = {
    "type": "geojson",
    "data": "sensors.geojson",
    "tolerance": 0.75,        # simplification in tile units; default 0.375, higher = cheaper
    "buffer": 64,             # tile edge buffer of 4096; default 128, lower = less work
    "maxzoom": 14,            # stop subdividing here; default 18
    "generateId": True,       # sequential ids for feature-state when there is no key column
    "cluster": True,
    "clusterRadius": 60,
    "clusterMaxZoom": 13,
    "clusterProperties": {
        "pm25_sum": ["+", ["get", "pm25"]],      # summed across the cluster's members
        "pm25_max": ["max", ["get", "pm25"]],
    },
}

tolerance and buffer trade fidelity for memory: raising tolerance to 0.75 visibly cheapens dense linework at low zoom and costs nothing at high zoom, while dropping buffer to 64 halves the geometry duplicated into neighbouring tiles at the price of the occasional label clipped at a tile seam. clusterProperties is the underused one — it aggregates member attributes as the browser clusters, so a bubble can be coloured by its members' mean PM2.5 by dividing pm25_sum by the built-in point_count inside a paint expression. That grouping happens per zoom level in the client, on data Python never had to pre-aggregate.

Where the ceiling actually sits is a question about vertices, not rows. For point layers carrying a handful of properties each, nothing you do matters below about 10,000 features; 10,000–50,000 is comfortable with the defaults; 50,000–150,000 needs clustering or a lowered maxzoom; and past roughly 200,000 features the initial JSON parse alone costs seconds and tab memory climbs into the hundreds of megabytes. Polygon and line layers hit the wall far earlier, because a 5,000-feature coastline carrying a million vertices behaves like a 200,000-point layer. Measure the number that predicts render cost rather than the one in len():

import shapely
import geopandas as gpd

coastline = gpd.read_file("coastline.gpkg")
n_vertices = int(shapely.get_num_coordinates(coastline.geometry.values).sum())
print(f"{len(coastline)} features, {n_vertices:,} vertices")   # 5,120 features, 1,043,887 vertices

Cross a few hundred thousand vertices and simplification stops being enough; that is the point at which the pipeline should be producing tiles. In the browser, map.showTileBoundaries = true and the map.on("data", ...) event tell you whether the renderer is re-tiling on every pan, which is the usual symptom of an oversized GeoJSON source that has not yet been diagnosed as one.

Choosing a MapLibre source type by dataset size and hosting model A decision flow: if the source is under roughly five megabytes, serve inline or URL GeoJSON; otherwise, if you need auth, dynamic filtering, or a live endpoint, serve MBTiles behind a tile server; if not, serve a single PMTiles archive from a static bucket that the client range-reads by viewport. Vector source for MapLibre how should Python deliver it? Total under ~5 MB? Need a live endpoint (auth or filtering)? Inline / URL GeoJSON downloads whole, up front MBTiles + tile server auth / dynamic filtering PMTiles on a static bucket client range-reads the viewport yes no yes no The ~5 MB line is the payload ceiling: past it, tiles beat one big download
Pick the source type by size first, then by hosting: small layers ship as GeoJSON, large static data as PMTiles, and only a live-endpoint requirement justifies an MBTiles tile server.

Windows / Platform Edge Cases & Debugging

Frequently Asked Questions

Should Python generate the whole style document, or should it only produce data? Generate it whenever any part of the style depends on the data — class breaks, the initial extent, layer names derived from a table, or a legend that has to agree with the map. The style is plain JSON, so emitting it from the same script that writes the source keeps the property names in the expressions and the columns in the export provably identical. Hand-written styles are the better choice only for the parts that never change with the data: basemap layers, sprite and glyph URLs, and cartographic decisions a designer owns. A common split is a checked-in base style that Python loads, mutates, and re-serializes.

When is MapLibre the wrong tool and Folium the right one? When the deliverable is a notebook cell, an emailed HTML file, or a one-off exploratory look at a few thousand features, Interactive Maps with Folium gets you there in three lines and no build step. MapLibre earns its extra ceremony when you need continuous zoom without redrawing, styling driven by attributes on the GPU, feature counts that make a Leaflet DOM layer stutter, or a map that is a product surface rather than a report figure. The honest test: if nobody will ever restyle the map without re-running Python, you probably do not need the style document.

Do I need a basemap, and can I get one without a commercial key? No layer in a MapLibre style is mandatory except the ones you add, so a data-only map on a plain background layer is perfectly valid and often clearer for thematic work. When you do want context, the open options are a raster tile layer from an OpenStreetMap-based provider (cheapest to wire, no vector styling) or a vector basemap you host yourself as a PMTiles archive built from OpenStreetMap extracts. The second costs a build pipeline but removes the external dependency entirely and lets you restyle the basemap to sit behind your data rather than fight it.

How do I keep a map in sync with a Python model that recomputes every few seconds? Keep the source as GeoJSON and call map.getSource("model").setData(newGeoJson) on each update rather than removing and re-adding the layer — the layer, its paint expressions, and the user's viewport all survive. Push the payload over a WebSocket or poll a small endpoint that returns only the changed features. Tiles are the wrong container for anything that changes faster than you can rebuild them; the crossover point is roughly when a rebuild takes longer than the update interval.

Can MapLibre read GeoParquet or a PostGIS table directly? No. The renderer understands GeoJSON, vector tiles (MVT), raster tiles, images, and video, and nothing else. GeoParquet is an analytical store, not a wire format for a browser, so the pipeline is always store → derived rendering artifact: GeoJSON for small layers, tiles for large ones. The same applies to PostGIS — you either export a snapshot or put a service in front of it that emits GeoJSON or MVT per request.

Are Mapbox GL styles and plugins still compatible? Style documents written for Mapbox GL JS 1.x load in MapLibre essentially unchanged, and that covers the vast majority of published style JSON, including most open basemap styles. Anything using properties Mapbox added after the fork — newer projections, some 3D and lighting features, and Mapbox-hosted mapbox:// source URLs — will not resolve. Plugins are hit and miss for the same reason: those that only touch the map's public API usually work, those that reach into internals do not. Check for a MapLibre-specific fork before assuming a plugin is unmaintained.