Generating MBTiles from GeoPackage with Tippecanoe

Generating MBTiles from a GeoPackage with Tippecanoe is a two-hop pipeline: convert each GeoPackage layer to newline-delimited GeoJSON with ogr2ogr (the GeoJSONSeq driver), feed those streams to tippecanoe with an explicit minimum and maximum zoom plus --drop-densest-as-needed, and package a single .mbtiles file you verify by reading its metadata table with sqlite3. Tippecanoe has no GDAL driver, so the intermediate GeoJSONSeq step is mandatory — it is also what keeps memory flat, because Tippecanoe streams the newline-delimited records instead of loading a whole FeatureCollection.

Core Algorithm and Workflow

A GeoPackage is a SQLite container that can hold several vector layers, each with its own schema and geometry type. Tippecanoe wants one input per tile layer, in EPSG:4326, as a feature stream. The workflow therefore enumerates the GPKG layers, reprojects and serializes each one, then hands the batch to Tippecanoe under a single fixed zoom range so the resulting tileset is reproducible.

GeoPackage to MBTiles pipeline with annotated zoom range A four-stage flow: a GeoPackage with three named layers is streamed by ogr2ogr into three GeoJSONSeq files reprojected to EPSG:4326, fed into Tippecanoe running from zoom 5 to zoom 14 with drop-densest-as-needed, producing one MBTiles file whose SQLite metadata table is read for verification. GeoPackage roads buildings landuse GeoJSONSeq one .geojsonl per layer, EPSG:4326 Tippecanoe -L per layer drop-densest- as-needed MBTiles SQLite: tiles + metadata ogr2ogr stream -o --force -Z 5 -z 14 zoom range sqlite3 verify
  1. Enumerate layers. Read the layer names from the GeoPackage so each becomes a distinct, named tile layer. Merging everything into one layer destroys the per-feature-class styling a vector tile renderer relies on.
  2. Stream to GeoJSONSeq. For every layer, run ogr2ogr with the GeoJSONSeq driver and -t_srs EPSG:4326. Newline-delimited output means Tippecanoe reads a record at a time and memory stays flat regardless of layer size.
  3. Tile with a fixed zoom range. Pass -Z (minzoom) and -z (maxzoom) explicitly rather than letting Tippecanoe guess, add one -L per input so layer names are preserved, and use --drop-densest-as-needed so only overfull tiles shed features.
  4. Verify the MBTiles. Open the output as SQLite and read the metadata table. The minzoom, maxzoom, format, and json (with its vector_layers) values are your contract check before the tileset ships.

Choosing that zoom range is a cartographic decision as much as a technical one — the mapping between web zoom levels and real-world scale is covered in Scale Mapping for Web and Print, and pre-thinning dense geometry before it reaches Tippecanoe is the subject of Generalization and Simplification.

Production-Ready Python Implementation

The script below drives both hops with subprocess, then verifies the result. It uses pyogrio (bundled with recent geopandas) to list layers, ogr2ogr from GDAL 3.8+, and tippecanoe 2.x. Feature filtering — dropping tiny buildings, say — is done with the -where SQL clause in the ogr2ogr step so Tippecanoe never sees the discarded rows.

import json
import shutil
import sqlite3
import subprocess
from pathlib import Path

import pyogrio  # ships with geopandas >= 0.13

MIN_ZOOM = 5
MAX_ZOOM = 14

# Optional per-layer attribute filter applied during extraction.
# Keys are GeoPackage layer names; values are OGR SQL WHERE clauses.
LAYER_FILTERS = {
    "buildings": "area_m2 >= 40",   # drop sheds/garages below 40 m^2
    "landuse":   "kind <> 'grass'",  # skip low-value grass polygons
}


def list_gpkg_layers(gpkg: Path) -> list[str]:
    """Return the vector layer names inside a GeoPackage."""
    # pyogrio.list_layers returns an array of [name, geometry_type] rows.
    return [row[0] for row in pyogrio.list_layers(gpkg)]


def extract_layer_to_geojsonseq(gpkg: Path, layer: str, out_dir: Path) -> Path:
    """Reproject one GPKG layer to newline-delimited GeoJSON in EPSG:4326."""
    out_path = out_dir / f"{layer}.geojsonl"
    cmd = [
        "ogr2ogr",
        "-f", "GeoJSONSeq",          # newline-delimited -> Tippecanoe streams it
        "-t_srs", "EPSG:4326",       # Tippecanoe requires lon/lat input
        "-nlt", "PROMOTE_TO_MULTI",  # keep mixed single/multi geometries valid
        str(out_path),
        str(gpkg),
        layer,
    ]
    where = LAYER_FILTERS.get(layer)
    if where:
        cmd[7:7] = ["-where", where]  # insert before positional args
    subprocess.run(cmd, check=True)
    return out_path


def build_mbtiles(geojsonl_paths: dict[str, Path], mbtiles: Path,
                  min_zoom: int = MIN_ZOOM, max_zoom: int = MAX_ZOOM) -> None:
    """Tile a set of named GeoJSONSeq files into a single MBTiles."""
    cmd = [
        "tippecanoe",
        "-o", str(mbtiles),
        "--force",                            # overwrite on re-run -> deterministic
        "-Z", str(min_zoom),                  # minimum zoom
        "-z", str(max_zoom),                  # maximum zoom
        "--drop-densest-as-needed",           # thin only tiles that overflow
        "--extend-zooms-if-still-dropping",   # add zooms where thinning isn't enough
        "--no-tile-size-limit",               # optional: allow >500 KB tiles if intended
        "--name", mbtiles.stem,
        "--attribution", "(c) contributors",
    ]
    # One -L "<layername>:<file>" pair per input keeps layer identity.
    for layer, path in geojsonl_paths.items():
        cmd += ["-L", f"{layer}:{path}"]
    subprocess.run(cmd, check=True)


def verify_mbtiles(mbtiles: Path) -> dict:
    """Read and validate the MBTiles metadata table with sqlite3."""
    with sqlite3.connect(mbtiles) as conn:
        meta = dict(conn.execute("SELECT name, value FROM metadata").fetchall())
        tile_count = conn.execute("SELECT COUNT(*) FROM tiles").fetchone()[0]

    assert meta.get("format") == "pbf", f"unexpected format: {meta.get('format')}"
    assert int(meta["minzoom"]) == MIN_ZOOM, "minzoom mismatch"
    assert int(meta["maxzoom"]) == MAX_ZOOM, "maxzoom mismatch"

    # The 'json' value holds vector_layers (TileJSON-style layer manifest).
    layer_meta = json.loads(meta.get("json", "{}"))
    layer_ids = [lyr["id"] for lyr in layer_meta.get("vector_layers", [])]

    print(f"format={meta['format']} "
          f"zoom={meta['minzoom']}-{meta['maxzoom']} "
          f"tiles={tile_count} layers={layer_ids}")
    return {"metadata": meta, "layers": layer_ids, "tiles": tile_count}


def main(gpkg_path: str, mbtiles_path: str) -> None:
    gpkg = Path(gpkg_path)
    mbtiles = Path(mbtiles_path)
    work = mbtiles.parent / "_geojsonseq"
    work.mkdir(parents=True, exist_ok=True)

    outputs: dict[str, Path] = {}
    for layer in list_gpkg_layers(gpkg):
        outputs[layer] = extract_layer_to_geojsonseq(gpkg, layer, work)

    build_mbtiles(outputs, mbtiles)
    result = verify_mbtiles(mbtiles)

    shutil.rmtree(work)  # remove the intermediate GeoJSONSeq scratch files
    if not result["layers"]:
        raise SystemExit("MBTiles has no vector_layers — check the -L arguments")


if __name__ == "__main__":
    main("city.gpkg", "city.mbtiles")

If you prefer to see the raw commands, the two hops for a single roads layer are exactly:

ogr2ogr -f GeoJSONSeq -t_srs EPSG:4326 roads.geojsonl city.gpkg roads
tippecanoe -o city.mbtiles --force -Z 5 -z 14 \
  --drop-densest-as-needed --extend-zooms-if-still-dropping \
  -L roads:roads.geojsonl

Performance Tuning and Cartographic Best Practices

  • Prefer GeoJSONSeq over a single FeatureCollection. The streaming driver lets both ogr2ogr and tippecanoe process one feature at a time. On a national roads layer this is the difference between a flat few hundred megabytes of RAM and an out-of-memory kill on the JSON parse.
  • Set the zoom range from intent, not defaults. -Z/-z bound the tile pyramid; every extra maxzoom level roughly quadruples tile count and build time. Derive the maxzoom from the smallest feature you must resolve, using the zoom-to-scale relationship in Scale Mapping for Web and Print, rather than accepting Tippecanoe’s inferred guess.
  • Let --drop-densest-as-needed do the thinning, and pair it with --extend-zooms-if-still-dropping. Together they keep sparse tiles complete while dense downtown tiles stay under the size limit and gain extra high zooms instead of losing detail. Reach for --coalesce-densest-as-needed when you would rather merge adjacent small polygons than drop them.
  • Pre-simplify geometry upstream when it is heavy. Tippecanoe simplifies per zoom, but feeding it already-generalized coastlines or boundaries cuts build time and produces cleaner low zooms; the Visvalingam-Whyatt approach in Generalization and Simplification is a good pre-pass.
  • Filter attributes at extraction, not after tiling. Use -where in ogr2ogr (or -select to keep only the columns you style on) so discarded rows never enter the tiler. Trimming attributes also shrinks each tile, which directly raises the feature budget before --drop-densest-as-needed engages.

Integration and Next Steps

The verifier returns the layer manifest and tile count, which makes this pipeline safe to run unattended in CI: assert on minzoom, maxzoom, and the expected vector_layers ids, and fail the build if a layer went missing because a -L argument was mistyped. Because --force overwrites the output, re-runs are idempotent and the .mbtiles artifact is reproducible from the same GeoPackage.

Once the tileset exists, serving it from Martin, pg_tileserv, or a static host puts the emphasis on cache behavior — when a source layer changes you must rebuild and then invalidate the edge copies, which is exactly the failure mode covered in Tile Cache and Invalidation. The full menu of tiler options, zoom strategies, and driver choices lives in the Vector Tile Generation overview, which frames where this GeoPackage-to-Tippecanoe recipe fits among the alternatives.


Frequently Asked Questions

Why does Tippecanoe reject my GeoPackage directly?

Tippecanoe reads GeoJSON, newline-delimited GeoJSON (GeoJSONSeq), CSV, and FlatGeobuf — it has no OGR/GDAL driver, so it cannot open a .gpkg at all. Convert each layer with ogr2ogr first. Choose the GeoJSONSeq driver over a single FeatureCollection: Tippecanoe reads the newline-delimited form as a stream and never holds the whole layer in memory, which matters once a layer runs to millions of features.

What is the difference between --drop-densest-as-needed and -r1?

--drop-densest-as-needed drops features only in tiles that exceed the 500 KB / 200k-feature limit, and only as many as required to fit, so sparse tiles keep every feature. -r1 is a different lever: it sets the point dot-dropping rate to 1, effectively disabling the radius-based thinning of dense point layers. Combine --drop-densest-as-needed with --extend-zooms-if-still-dropping so that persistently dense areas gain extra high zoom levels instead of silently losing detail at maxzoom.

How do I confirm which layers ended up in the MBTiles?

Read the metadata table’s json value with the sqlite3 module and parse it. The vector_layers array lists every layer id, its minzoom/maxzoom, and its attribute fields with inferred types — the same TileJSON-style manifest a tile server exposes. Asserting on those ids in Python catches a missing or misnamed -L layer before deployment, which a visual check of the rendered map often would not.


  • Vector Tile Generation — the parent overview covering tiler choices, zoom strategy, and where the GeoPackage-to-Tippecanoe path fits among the alternatives.
  • Generalization and Simplification — pre-thin heavy coastlines and boundaries before tiling so low zooms build faster and read cleaner.
  • Tile Cache and Invalidation — what to invalidate after you rebuild the MBTiles so edge caches do not serve stale tiles.

Back to Vector Tile Generation