Mapnik vs MapLibre Native for Headless Server-Side Rendering
Choose Mapnik for CPU-based, deterministic raster rendering driven by XML or CartoCSS styles on servers that have no GPU, where its mature font and label engine and byte-stable output are decisive; choose MapLibre Native when your source of truth is a Mapbox GL JSON style over vector tiles and you can supply a GL/EGL context, trading a heavier runtime for GPU rasterization and design parity with browser maps. Everything else — throughput, container size, reproducibility — follows from that rasterization-model split.
Core Algorithm and Workflow
Both engines answer the same question — “given a bounding box, a zoom, and a style, return a raster tile” — but they take structurally different paths from input to pixels. Mapnik walks its object model on the CPU and hands geometry to the Anti-Grain Geometry (AGG) scanline rasterizer. MapLibre Native compiles a GL JSON style into shader programs and draw calls, then rasterizes on the GPU (or a software GL emulation). The diagram below places the two stacks side by side with their required inputs and their execution context.
Five dimensions decide the choice, and they all trace back to that split:
- Rasterization model. Mapnik uses AGG on the CPU — no accelerator, no driver, fully in-process. MapLibre Native issues GLES draw calls that need a real or emulated GL context. On a GPU-less server, Mapnik simply runs; MapLibre Native must fall back to Mesa’s
llvmpipe, which erases most of the GPU advantage. - Style format. Mapnik reads its own XML schema, typically generated from CartoCSS. MapLibre Native reads Mapbox GL JSON with data-driven expressions and zoom stops. Converting either way is lossy, which is why picking the engine that matches your existing styles matters more than raw speed.
- Font handling. Mapnik loads TrueType fontsets at render time and shapes text with a mature label engine (dot-density, shield placement, along-line labels). MapLibre Native consumes pre-rendered PBF glyph ranges (SDF glyphs) and places labels with the GL symbol layer, which excels at smooth rotation and collision on vector tiles.
- Throughput and determinism. Mapnik parallelizes cleanly across CPU cores and produces byte-identical output run to run. MapLibre Native can be faster per tile on a real GPU but its pixels depend on the driver, so a content-addressed cache may see hash drift across a mixed fleet.
- Container deployment. A Mapnik image ships CPU libraries only. A MapLibre Native image must add Mesa/EGL or a headless GL stack and bake in glyphs and sprites, producing a larger, more fragile image.
The style-format axis connects directly to the portability questions covered in Rule-Based Styling Engines and, more concretely, to the format trade-offs in QML vs Mapbox GL JSON for Style Portability.
Production-Ready Python Implementation
The script below wraps both engines behind one interface so you can benchmark them on identical inputs and assert non-empty, deterministic output. Mapnik exposes official Python bindings; MapLibre Native has no first-class Python API, so the common production pattern is to drive a small Node or native sidecar and shell out to it. Here the MapLibre path calls a maplibre-gl-native render CLI, and both paths return a PNG byte string plus a stable hash.
"""
Headless render bench: Mapnik (CPU/AGG) vs MapLibre Native (GPU/GL).
Pinned versions used in the reference container:
python-mapnik == 3.1.0 (Mapnik 3.1.x, https://mapnik.org)
@maplibre/maplibre-gl-native == 5.4.0 (https://maplibre.org)
mercantile == 1.2.1 (XYZ <-> bbox conversion)
"""
from __future__ import annotations
import hashlib
import json
import subprocess
import time
from dataclasses import dataclass
import mercantile # XYZ tile -> lon/lat bbox
TILE_PX = 256
@dataclass
class RenderResult:
engine: str
png: bytes
pixel_hash: str
seconds: float
@property
def is_blank(self) -> bool:
# A transparent 256x256 PNG hashes to a small, well-known set of values;
# here we simply flag suspiciously tiny payloads as likely-empty.
return len(self.png) < 512
def _hash(png: bytes) -> str:
return hashlib.sha256(png).hexdigest()[:16]
def render_mapnik(xml_style: str, z: int, x: int, y: int) -> RenderResult:
"""
Render one XYZ tile with Mapnik's AGG CPU rasterizer.
Deterministic: identical inputs yield byte-identical PNGs.
"""
import mapnik # imported lazily so the module loads without Mapnik present
bounds = mercantile.xy_bounds(x, y, z) # web-mercator metres (EPSG:3857)
m = mapnik.Map(TILE_PX, TILE_PX, "+init=epsg:3857")
mapnik.load_map(m, xml_style)
m.zoom_to_box(mapnik.Box2d(bounds.left, bounds.bottom, bounds.right, bounds.top))
image = mapnik.Image(TILE_PX, TILE_PX)
t0 = time.perf_counter()
mapnik.render(m, image)
elapsed = time.perf_counter() - t0
png = image.tostring("png32")
return RenderResult("mapnik", png, _hash(png), elapsed)
def render_maplibre(gl_style_path: str, z: int, x: int, y: int,
render_cli: str = "maplibre-render") -> RenderResult:
"""
Render one XYZ tile with MapLibre Native via a sidecar CLI.
The sidecar must run against a GL/EGL context. In a GPU-less container
that means Mesa llvmpipe with, e.g.:
EGL_PLATFORM=surfaceless
MESA_GL_VERSION_OVERRIDE=3.3
LIBGL_ALWAYS_SOFTWARE=1
"""
tile = mercantile.Tile(x, y, z)
center = mercantile.ul(*mercantile.parent(tile, zoom=max(z - 1, 0)))
request = {
"style": gl_style_path,
"zoom": z,
"center": [center.lng, center.lat],
"width": TILE_PX,
"height": TILE_PX,
"ratio": 1,
}
t0 = time.perf_counter()
proc = subprocess.run(
[render_cli, "--stdin-json"],
input=json.dumps(request).encode("utf-8"),
capture_output=True,
check=True,
)
elapsed = time.perf_counter() - t0
png = proc.stdout
return RenderResult("maplibre-native", png, _hash(png), elapsed)
def assert_renderable(result: RenderResult) -> RenderResult:
"""Fail loudly on the silent-blank failure mode both engines can hit."""
if result.is_blank:
raise RuntimeError(
f"{result.engine} produced a blank/empty tile — check the GL context "
f"(MapLibre) or the fontset/data paths (Mapnik)."
)
return result
if __name__ == "__main__":
# z/x/y for a dense central London tile.
z, x, y = 14, 8185, 5449
mapnik_a = assert_renderable(render_mapnik("styles/osm.xml", z, x, y))
mapnik_b = assert_renderable(render_mapnik("styles/osm.xml", z, x, y))
# Determinism check: AGG output must be byte-stable across runs.
assert mapnik_a.pixel_hash == mapnik_b.pixel_hash, "Mapnik output drifted!"
mlbr = assert_renderable(render_maplibre("styles/osm-gl.json", z, x, y))
for r in (mapnik_a, mlbr):
print(f"{r.engine:16s} {r.seconds * 1000:7.1f} ms hash={r.pixel_hash} "
f"bytes={len(r.png)}")
The assert_renderable guard exists because both engines fail silently in different ways: MapLibre Native emits a transparent framebuffer when it cannot acquire a GL context, and Mapnik emits an empty tile when a <Style> references a missing fontset or an unreachable PostGIS layer. A smoke test that only checks the process exit code will pass on both broken cases — you must assert on pixel content.
Performance Tuning and Cartographic Best Practices
- Pin the rasterization stack, not just the engine. For deterministic caches, Mapnik plus a fixed AGG build gives byte-identical PNGs, which pairs cleanly with content-addressed Vector Tile Generation outputs. If you must use MapLibre Native across a mixed fleet, standardize on one software GL (
llvmpipe) so every worker hashes identically rather than by GPU vendor. - Bake fonts into the image, never mount them at runtime. Mapnik needs the exact TrueType fontset named in the XML; MapLibre Native needs the PBF glyph ranges (
0-255.pbf,256-511.pbf, …) referenced byglyphsin the style. A missing range renders blank boxes with no error, so package fonts and sprites in the container and fail the build if they are absent. - Scale Mapnik by process, MapLibre Native by context. Mapnik releases the GIL during
render, so a thread pool or amultiprocessingpool across cores scales near-linearly on CPU-bound tiles. MapLibre Native throughput is bounded by how many GL contexts you can hold; onllvmpipethat is CPU-bound too, so oversubscribing contexts past core count only adds scheduler overhead. - Match the engine to the style source of truth. If your cartography lives in CartoCSS or Mapnik XML, rendering it with MapLibre Native means a lossy conversion of expressions and label rules; if it lives in Mapbox GL JSON authored for the browser, Mapnik cannot reproduce data-driven
interpolate/stepexpressions faithfully. Render each format with its native engine. - Buffer vector tiles to avoid clipped labels. MapLibre Native reads vector tiles that must carry a geometry buffer (typically 64 units) so labels and lines near tile edges are not clipped mid-glyph. Set the buffer when you generate tiles rather than trying to patch it at render time.
Integration and Next Steps
Wrap either engine behind the RenderResult contract above and the rest of a tile service — routing, caching, invalidation — stays engine-agnostic. Two integration patterns dominate:
- Deterministic raster cache (Mapnik). Because AGG output is byte-stable, use the
pixel_hashas the cache key. Two servers rendering the samez/x/ywith the same pinned style produce the same object, so a CDN or object store deduplicates for free and invalidation reduces to purging by key. - GL parity service (MapLibre Native). When the browser client already runs a MapLibre GL JSON style, rendering server-side previews, social cards, or print snapshots with MapLibre Native guarantees the static image matches the interactive map. Drive the sidecar from a queue and cap concurrency to the number of GL contexts the host can hold.
From here, the natural next steps are generating the vector tiles that feed a MapLibre Native service — covered in Vector Tile Generation — and deciding how portable your styles need to be before you commit to one engine, which is exactly the question examined in QML vs Mapbox GL JSON for Style Portability. For the broader menu of headless engines and where these two fit, return to Headless Rendering Engines.
Frequently Asked Questions
Why does MapLibre Native render blank tiles inside a Docker container?
MapLibre Native needs an OpenGL or GLES context, which a bare container does not provide. Without a GPU you must supply a software GL implementation such as Mesa’s llvmpipe via EGL, and set environment variables like MESA_GL_VERSION_OVERRIDE and EGL_PLATFORM=surfaceless. If the process cannot create a context it silently produces transparent tiles rather than raising, so always assert a non-empty pixel hash in a smoke test.
Can I reuse a Mapbox GL JSON style with Mapnik?
Not directly. Mapnik consumes its own XML schema (or CartoCSS compiled to that XML), while Mapbox GL JSON is a different model with data-driven expressions, zoom-interpolated stops, and sprite references that have no one-to-one Mapnik equivalent. Tools exist to approximate a conversion, but expressions, symbol placement, and text-halo semantics rarely survive intact. Keep one format as the source of truth and render it with its native engine.
Which engine gives more deterministic, reproducible tiles?
Mapnik’s AGG CPU rasterizer is fully deterministic: the same input, style, and version produce byte-identical PNGs across machines, which is ideal for content-addressed tile caches. MapLibre Native depends on the GL driver, and antialiasing or subpixel results can differ between GPU vendors and between hardware and llvmpipe. If pixel-hash stability across a heterogeneous fleet matters, pin a single software GL stack or prefer Mapnik.
Related
- Headless Rendering Engines — the parent overview of server-side render engines, where CPU and GPU stacks are compared across the full field.
- Rule-Based Styling Engines — how style rules compile to the XML, CartoCSS, and GL JSON formats these engines consume.
- QML vs Mapbox GL JSON for Style Portability — the format-portability trade-off that often decides which engine you can adopt.
Back to Headless Rendering Engines