PMTiles and MBTiles Explained: Tile Archives Without a Server

On this page

Problem statement

A tile pyramid is millions of small files. Counted with mercantile: central Manchester, zooms 8 to 16, is 1,361 tiles; the Isle of Wight at zooms 8 to 14, the archive this page builds, is 531; a full world pyramid to zoom 16 is 5,726,623,061 (the sum of 4^z for z = 0…16).

Storing that as loose files is painful β€” slow to copy, expensive to list, and awkward on object storage where every file is an object. Two archive formats solve it differently:

  • MBTiles β€” a SQLite database with one row per tile. Needs a server or a local process to read.
  • PMTiles β€” a single file with a header, a compressed directory of tile offsets, and the tile data. Readable directly from static hosting with HTTP range requests.

The difference decides your architecture: MBTiles needs a tile server, PMTiles needs a web server that supports Range.

Quick answer

import gzip
from pmtiles.writer import Writer
from pmtiles.tile import Compression, TileType, zxy_to_tileid

with open("tiles.pmtiles", "wb") as f:
    writer = Writer(f)
    for tile in sorted(tiles, key=lambda t: zxy_to_tileid(t.z, t.x, t.y)):   # in tile-id order
        writer.write_tile(zxy_to_tileid(tile.z, tile.x, tile.y), gzip.compress(tiles[tile]))
    writer.finalize({
        "tile_type": TileType.MVT,
        "tile_compression": Compression.GZIP,
        "min_zoom": 8, "max_zoom": 14,
        "min_lon_e7": int(-1.60 * 1e7), "min_lat_e7": int(50.57 * 1e7),
        "max_lon_e7": int(-1.06 * 1e7), "max_lat_e7": int(50.77 * 1e7),
        "center_zoom": 11,
        "center_lon_e7": int(-1.33 * 1e7), "center_lat_e7": int(50.67 * 1e7),
    }, {"name": "buildings", "attribution": "Β© OpenStreetMap contributors"})
print(f"{Path('tiles.pmtiles').stat().st_size / 1e6:.2f} MB for {len(tiles)} tiles")
4.56 MB for 531 tiles

tiles here is a dictionary of mercantile.Tile to raw Mapbox Vector Tile bytes β€” the Isle of Wight's 71,005 OpenStreetMap buildings cut into 531 tiles at zooms 8 to 14 (set up under Code examples). Write them in tile-id order: the writer accepts any order, but only an ordered archive is marked clustered, which is what lets a client read neighbouring tiles as one contiguous range.

Upload the result to any static host that supports range requests, point a MapLibre style at it, and there is no server.

MBTiles as a SQLite database needing a process to query, against PMTiles as a single file with a header and directory readable by range requests. same pyramid, two packaging decisionscontainerservingupdatingMBTilesSQLite!needs a processβœ“per rowPMTilessingle file + directoryβœ“static hosting!rewriteMBTiles numbers rows TMS-style; PMTiles uses XYZ spatialworkflow.io
Same pyramid, two packaging decisions. One needs a server; the other needs a byte-range-capable file host.

How you will serve, tile ids, deduplication

1. Choose based on how you will serve

MBTiles PMTiles
container SQLite single custom file
serving needs a process static hosting with ranges
reading a tile SQL query header + directory + range read
editing UPDATE a row rewrite the archive
tooling very mature newer, growing

MBTiles suits a pipeline that already runs a server and updates tiles incrementally. PMTiles suits static deployment, a CDN, or shipping a dataset as one file.

2. Understand the tile identifier

PMTiles orders tiles along a Hilbert curve and addresses them by a single integer:

from pmtiles.tile import zxy_to_tileid
print([zxy_to_tileid(14, x, 5470) for x in (8110, 8111, 8112)], zxy_to_tileid(14, 8110, 5471))
[129748221, 129748222, 129747371] 129748224

The Hilbert ordering keeps spatially nearby tiles nearby in the file, so a client panning across the map reads contiguous byte ranges. It also lets consecutive tile ids be run-length encoded in the directory.

MBTiles uses (zoom_level, tile_column, tile_row) as a SQL key β€” and numbers rows TMS-style, from the bottom, unlike XYZ. That conversion is the most common MBTiles bug.

3. Deduplicate identical tiles

Both formats support pointing several tile ids at the same data. Over water, or in empty rural areas, thousands of tiles are byte-identical empty tiles.

import hashlib
seen, duplicates = {}, 0
for tile, payload in tiles.items():
    digest = hashlib.sha256(payload).hexdigest()
    if digest in seen:
        duplicates += 1
        continue
    seen[digest] = payload
empty = sum(1 for payload in tiles.values() if not mvt.decode(payload)["buildings"]["features"])
print(f"{len(tiles)} tiles, {len(seen)} distinct, {duplicates} duplicates; {empty} of them empty sea")
531 tiles, 286 distinct, 245 duplicates; 246 of them empty sea

On an island, the tiles over open water are all the same empty tile, and they are nearly half the pyramid β€” though, as the Explanation measures, a tiny half. The PMTiles writer deduplicates by itself β€” it hashes each tile and points repeats at the first copy β€” so this loop is for reporting the saving, not for achieving it. The MBTiles writer below stores every copy.

4. Record the metadata

Both formats carry a metadata blob: name, format, bounds, zoom range, attribution, and for vector tiles a vector_layers description of each layer's fields.

Clients use it to set the initial view and to validate a style. An archive without bounds and zoom range makes a viewer guess, usually badly.

5. Compress the tiles, and say so

Store .pbf tiles gzipped inside the archive and record tile_compression. The client decompresses; the server does nothing.

For raster tiles, PNG and JPEG are already compressed and should be stored as-is.

Tiles ordered along a Hilbert curve so spatially adjacent tiles are adjacent in the file, allowing contiguous range reads. why the tile id is a Hilbert indexz/x/ya 2D addressHilbert curvenearby in 2D, nearby onthe curveone integer idruns compress in thedirectorycontiguous byterangespanning is one readRow-major ordering would put vertically adjacent tiles far apart spatialworkflow.io
Hilbert ordering makes a pan across the map a contiguous read rather than scattered ones.

Writing PMTiles and MBTiles

The data used on this page

Every building in Geofabrik's OpenStreetMap extract for the Isle of Wight (its dated 1 January 2026 snapshot), cut into Mapbox Vector Tiles at zooms 8 to 14 with mercantile and mapbox-vector-tile: tiles maps each mercantile.Tile to its raw, uncompressed MVT bytes. An island is a good test for tile archives β€” around it is sea, and every sea tile is the same empty tile.

import pickle
from pathlib import Path

import geopandas as gpd
import mapbox_vector_tile as mvt
import mercantile
import requests
from shapely.geometry import box

ISLAND = (-1.60, 50.57, -1.06, 50.77)
PBF = Path("isle-of-wight-260101.osm.pbf")
if not PBF.exists():
    reply = requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
                         "isle-of-wight-260101.osm.pbf", timeout=300)
    reply.raise_for_status()
    PBF.write_bytes(reply.content)

if not Path("tiles.pkl").exists():
    buildings = gpd.read_file(PBF, layer="multipolygons", where="building IS NOT NULL",
                              columns=["osm_id", "building"]).to_crs(3857)
    index = buildings.sindex
    cut = {}
    for tile in mercantile.tiles(*ISLAND, zooms=range(8, 15)):
        b = mercantile.xy_bounds(tile)
        frame = box(b.left, b.bottom, b.right, b.top)
        hits = buildings.iloc[index.query(frame, predicate="intersects")]
        features = [{"geometry": g, "properties": {"building": kind}}
                    for g, kind in zip(hits.geometry.intersection(frame), hits["building"]) if not g.is_empty]
        cut[tile] = mvt.encode([{"name": "buildings", "features": features}],
                               default_options={"quantize_bounds": (b.left, b.bottom, b.right, b.top)})
    Path("tiles.pkl").write_bytes(pickle.dumps(cut))
tiles = pickle.loads(Path("tiles.pkl").read_bytes())
print(f"{len(tiles)} tiles at zooms 8-14, {sum(len(v) for v in tiles.values()) / 1e6:.2f} MB of raw MVT")
531 tiles at zooms 8-14, 11.65 MB of raw MVT

Example 1 β€” writing PMTiles with deduplication

import gzip
import hashlib
from pmtiles.writer import Writer
from pmtiles.tile import Compression, TileType, zxy_to_tileid


def write_pmtiles(tiles, path, bounds, zooms, name="tiles",
                  attribution="", vector_layers=None, deduplicate=True):
    """tiles: dict of mercantile.Tile -> raw MVT bytes."""
    unique, duplicates, total_bytes = {}, 0, 0

    with open(path, "wb") as handle:
        writer = Writer(handle)
        for tile in sorted(tiles, key=lambda t: zxy_to_tileid(t.z, t.x, t.y)):
            payload = gzip.compress(tiles[tile], 6)
            digest = hashlib.sha256(payload).hexdigest()
            if deduplicate and digest in unique:
                duplicates += 1
            writer.write_tile(zxy_to_tileid(tile.z, tile.x, tile.y), payload)
            unique.setdefault(digest, len(payload))
            total_bytes += len(payload)

        west, south, east, north = bounds
        writer.finalize(
            {
                "tile_type": TileType.MVT,
                "tile_compression": Compression.GZIP,
                "min_zoom": min(zooms), "max_zoom": max(zooms),
                "min_lon_e7": int(west * 1e7), "min_lat_e7": int(south * 1e7),
                "max_lon_e7": int(east * 1e7), "max_lat_e7": int(north * 1e7),
                "center_zoom": (min(zooms) + max(zooms)) // 2,
                "center_lon_e7": int((west + east) / 2 * 1e7),
                "center_lat_e7": int((south + north) / 2 * 1e7),
            },
            {"name": name, "attribution": attribution,
             "vector_layers": vector_layers or []},
        )

    print(f"  {len(tiles):,} tiles, {len(unique):,} unique "
          f"({duplicates:,} duplicates), {total_bytes / 1e6:.2f} MB before deduplication")
    return path

path = write_pmtiles(tiles, "buildings.pmtiles", ISLAND, range(8, 15), name="buildings",
                     attribution="Β© OpenStreetMap contributors",
                     vector_layers=[{"id": "buildings", "fields": {"building": "String"}}])
print(f"  archive on disk: {Path(path).stat().st_size / 1e6:.2f} MB")
  531 tiles, 286 unique (245 duplicates), 4.60 MB before deduplication
  archive on disk: 4.59 MB

The archive is smaller than the tiles written into it because the writer stored each distinct tile once β€” the deduplicate flag only decides whether the function counts the repeats. The vector_layers metadata is what lets a style validate against the archive. Without it, a mistyped layer name in a style produces a blank map with no error anywhere.

Example 2 β€” writing MBTiles, with the y-axis flip

import gzip
import json
import sqlite3


def write_mbtiles(tiles, path, bounds, zooms, name="tiles", fmt="pbf"):
    """MBTiles rows use TMS y numbering β€” flip from XYZ."""
    connection = sqlite3.connect(path)
    cursor = connection.cursor()
    cursor.executescript("""
        CREATE TABLE IF NOT EXISTS metadata (name TEXT, value TEXT);
        CREATE TABLE IF NOT EXISTS tiles (
            zoom_level INTEGER, tile_column INTEGER,
            tile_row INTEGER, tile_data BLOB);
        CREATE UNIQUE INDEX IF NOT EXISTS tile_index
            ON tiles (zoom_level, tile_column, tile_row);
    """)

    for tile, payload in tiles.items():
        y_tms = (2 ** tile.z - 1) - tile.y            # XYZ -> TMS
        cursor.execute(
            "INSERT OR REPLACE INTO tiles VALUES (?, ?, ?, ?)",
            (tile.z, tile.x, y_tms,
             sqlite3.Binary(gzip.compress(payload, 6) if fmt == "pbf"
                            else payload)))

    west, south, east, north = bounds
    metadata = {
        "name": name, "format": fmt,
        "bounds": f"{west},{south},{east},{north}",
        "minzoom": str(min(zooms)), "maxzoom": str(max(zooms)),
        "type": "overlay", "version": "1.0",
    }
    cursor.executemany("INSERT INTO metadata VALUES (?, ?)", metadata.items())
    connection.commit()
    connection.close()
    print(f"  {len(tiles):,} tiles written to {path}")
    return path

write_mbtiles(tiles, "buildings.mbtiles", ISLAND, range(8, 15), name="buildings")
print(f"  MBTiles {Path('buildings.mbtiles').stat().st_size / 1e6:.2f} MB against "
      f"PMTiles {Path('buildings.pmtiles').stat().st_size / 1e6:.2f} MB")
  531 tiles written to buildings.mbtiles
  MBTiles 4.78 MB against PMTiles 4.59 MB

The MBTiles file stores all 531 tiles, including the 246 identical empty ones β€” the schema has no deduplication unless you build it (the common trick is a map table of tile ids pointing into an images table of distinct blobs). The flip is one line and produces a vertically mirrored map when forgotten. Every MBTiles reader expects TMS row numbering; every XYZ tile generator produces the other convention.

The same 531 Isle of Wight building tiles written as PMTiles and as MBTiles. 531 tiles, two archivesPMTilesMBTilesfile size4.59 MB4.78 MBstored tiles286 distinct (353 entries)531 rowsy numberingXYZTMS β€” flippedserved byany host that answers Rangea process that runs SQLSame tiles; the packaging decides the architecture spatialworkflow.io
Measured in Examples 1 and 2.

Example 3 β€” reading a tile back to verify

import gzip
from pmtiles.reader import Reader, MmapSource
from pmtiles.tile import zxy_to_tileid
import mapbox_vector_tile as mvt


def verify_pmtiles(path, sample_tiles):
    """Read the header and a few tiles, and decode one."""
    with open(path, "rb") as handle:
        reader = Reader(MmapSource(handle))
        header = reader.header()
        print(f"  zooms {header['min_zoom']}-{header['max_zoom']}, "
              f"{header['addressed_tiles_count']:,} addressed tiles, "
              f"{header['tile_entries_count']:,} entries")
        print(f"  bounds {header['min_lon_e7'] / 1e7:.4f},"
              f"{header['min_lat_e7'] / 1e7:.4f} to "
              f"{header['max_lon_e7'] / 1e7:.4f},"
              f"{header['max_lat_e7'] / 1e7:.4f}")

        for tile in sample_tiles:
            data = reader.get(tile.z, tile.x, tile.y)          # z, x, y β€” not a tile id
            if data is None:
                print(f"    z{tile.z}/{tile.x}/{tile.y}: missing")
                continue
            decoded = mvt.decode(gzip.decompress(data))
            layers = {k: len(v["features"]) for k, v in decoded.items()}
            print(f"    z{tile.z}/{tile.x}/{tile.y}: "
                  f"{len(data) / 1024:.2f} kB, layers {layers}")

newport = mercantile.tile(-1.2936, 50.7003, 14)        # Newport, the island's county town
verify_pmtiles("buildings.pmtiles", [newport, mercantile.tile(-1.2936, 50.7003, 10), mercantile.Tile(8100, 5480, 14)])
  zooms 8-14, 531 addressed tiles, 353 entries
  bounds -1.6000,50.5700 to -1.0600,50.7700
    z14/8133/5506: 42.05 kB, layers {'buildings': 3382}
    z10/508/344: 413.70 kB, layers {'buildings': 54324}
    z14/8100/5480: missing

Reader.get takes z, x, y. Passing it the tile id, as an earlier version of this function did, raises a TypeError for the missing arguments.

Decoding one tile end to end catches the errors metadata cannot: wrong compression setting, wrong tile type, empty layers, or a layer name the style does not expect.

Example 4 β€” does your host answer a range request?

PMTiles is only serverless if the host honours Range. Python's own static server is the one many people test with first:

import functools, http.server, threading

handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=".")
handler.func.log_message = lambda *args: None
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
reply = requests.get(f"http://127.0.0.1:{server.server_port}/buildings.pmtiles", headers={"Range": "bytes=0-126"})
print(f"asked for the 127-byte header: status {reply.status_code}, got {len(reply.content):,} bytes,",
      "Content-Range:", reply.headers.get("Content-Range"))
server.shutdown()
asked for the 127-byte header: status 200, got 4,587,400 bytes, Content-Range: None

python -m http.server ignores the header and sends the whole archive with a 200 β€” fine for a 1 MB test file, fatal for a 20 GB one, and a PMTiles client expecting a 206 will refuse it. Object stores, CDNs, nginx and Caddy all answer with 206 Partial Content; check yours the same way before choosing PMTiles for it.

Why PMTiles serves statically

Why PMTiles can be served statically

A PMTiles file starts with a fixed-size header giving the byte ranges of the directory and the tile data. A client fetches the header, then the directory entry for the tile it wants, then the tile itself.

Three range requests, and after the first pan the header and directory are cached. No server logic at all β€” any host that returns 206 Partial Content will do, including object storage and CDNs.

That removes the whole tile-server tier: no process to run, scale, secure or pay for.

Why the Hilbert ordering matters

Tiles are addressed by a single integer derived from a Hilbert curve through the tile grid. The curve has the property that points close in 2D are usually close along the curve.

So the tiles a client needs while panning are contiguous in the file, and a directory entry can cover a run of them. That makes both the directory smaller and the reads more contiguous.

A row-major ordering would put vertically adjacent tiles far apart, which for map panning is the worst case.

Why MBTiles uses TMS row numbering

MBTiles predates the XYZ convention's dominance and adopted the OGC TMS scheme, which numbers rows from the south.

The conversion, y_tms = 2^z - 1 - y_xyz, is trivial and forgotten constantly. The symptom is a map that is horizontally correct and vertically mirrored, with every individual tile looking like valid data.

Some tools handle it transparently, others do not, and the only reliable defence is checking a known tile against a reference map.

What deduplication saves, and what it does not

An archive covering a coastline has hundreds or thousands of identical empty tiles over water. Both formats can let several tile ids point at one blob, so a duplicate costs a directory entry rather than a tile.

Measured on the island, that is a saving in entries, not in bytes: 246 of the 531 tiles are empty sea, the PMTiles directory needed 353 entries instead of 531, and the file shrank from 4.60 to 4.59 MB β€” an empty vector tile gzips to a few dozen bytes. Compression did the heavy lifting, from 11.65 MB of raw tiles to 4.60 MB. Deduplication earns its keep where the repeated tiles are big: raster tiles of open sea or blank land at high zoom, where each copy is a full image.

Isle of Wight building tiles, zooms 8-14: size raw, gzipped, and gzipped then deduplicated. 531 island tiles, 246 of them empty searaw MVT11.65 MBgzipped4.60 MBgzipped + deduplicated4.59 MB β€” 353 entries, not 53106 MB12 MBEmpty tiles are many but tiny: deduplication saves entries, compression saves bytes spatialworkflow.io
Measured on this page. On sparse vector data, deduplication shrinks the directory; compression shrinks the file.

TMS rows, range support and versions

  • MBTiles rows are TMS-numbered. Convert from XYZ or the map is mirrored.
  • PMTiles needs Range support on the host; most object stores and CDNs have it.
  • Deduplicate identical tiles β€” it can remove half the tiles of a sparse vector archive while saving almost no bytes; it pays most for repeated raster tiles.
  • Record vector_layers, or a mistyped layer name in a style fails silently.
  • Store .pbf gzipped and record the compression; do not gzip PNG or JPEG.
  • Editing PMTiles means rewriting it. MBTiles supports row updates.
  • Set bounds and zoom range, or viewers guess the initial view.
  • Serve PMTiles with CORS headers if the page is on another origin.

FAQ

What is the difference between PMTiles and MBTiles?

MBTiles is a SQLite database needing a process to read. PMTiles is a single file with a header and directory, readable directly from static hosting with HTTP range requests.

Do I need a tile server for PMTiles?

No. Any host that supports byte-range requests works, including object storage and CDNs.

Why is my MBTiles map upside down?

MBTiles numbers rows TMS-style from the bottom. Convert with y_tms = 2^z - 1 - y_xyz when writing.

How do I reduce the archive size?

Deduplicate identical tiles first β€” on sparse data that removes more than any compression setting β€” then gzip the vector tiles.

Can I update a PMTiles archive?

Not in place. The format is written once and read many times; updating means regenerating it. MBTiles supports row updates.

What metadata should I include?

Bounds, zoom range, tile type, compression, attribution and, for vector tiles, vector_layers. Without the last, a mistyped layer name in a style fails silently.

How many tiles will an archive hold?

For a city extent across zooms 8–16, 870. A full world pyramid to zoom 16 would be 5,726,623,061 β€” which is why global archives stop well below that.