PMTiles and MBTiles Explained: Tile Archives Without a Server

Problem statement

A tile pyramid is millions of small files. For a city-sized extent, zooms 8 to 16 is 870 tiles; a country at zoom 14 is millions; a full world pyramid to zoom 16 is 5,726,623,061.

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

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, payload in tiles.items():          # payload already gzipped MVT
        writer.write_tile(zxy_to_tileid(tile.z, tile.x, tile.y), payload)
    writer.finalize({
        "tile_type": TileType.MVT,
        "tile_compression": Compression.GZIP,
        "min_zoom": 10, "max_zoom": 16,
        "min_lon_e7": int(-2.35 * 1e7), "min_lat_e7": int(53.43 * 1e7),
        "max_lon_e7": int(-2.18 * 1e7), "max_lat_e7": int(53.53 * 1e7),
        "center_zoom": 13,
        "center_lon_e7": int(-2.24 * 1e7), "center_lat_e7": int(53.48 * 1e7),
    }, {"name": "buildings", "attribution": "Β© OpenStreetMap contributors"})

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 decisions. One needs a server; the other needs a byte-range-capable file host.

Step-by-step solution

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
tile_id = zxy_to_tileid(z, x, y)

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.

seen = {}
for tile, payload in tiles.items():
    digest = hashlib.sha256(payload).hexdigest()
    if digest in seen:
        duplicates += 1
        continue
    seen[digest] = payload

On a dataset with large empty areas this routinely removes most of the archive.

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.
Hilbert ordering makes a pan across the map a contiguous read rather than scattered ones.

Code examples

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")
    return path

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

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.

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(zxy_to_tileid(tile.z, tile.x, tile.y))
            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}")

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.

Explanation

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.

Why deduplication matters more than compression

An archive covering a coastline has thousands of identical empty tiles over water. Compressing each one separately still stores thousands of copies.

Both formats let several tile ids point at one blob, so duplicates cost a directory entry rather than a tile. On sparse data that is a much larger saving than any compression setting.

The hash-based approach is simple and effective: compress the tile, hash the bytes, and store only the first occurrence.

A sparse tile pyramid reduced far more by deduplicating identical tiles than by compressing them.
On sparse data, deduplication is a much larger saving than any compression setting.

Edge cases or notes

  • 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 β€” often most of a sparse archive.
  • 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.