How to Export a Static Tile Pyramid for Offline Use
Problem statement
A static pyramid is tiles on disk, served by any file host or bundled into an application β no server process, no database, no runtime rendering.
The constraint is arithmetic. Tile count grows as 4^z, so each level costs more than all previous levels combined. For a city-sized extent:
zoom tiles
8 1
12 6
14 49
16 625
total 8-16 870
Extending to zoom 18 would add roughly 10,000 more. A full world pyramid to zoom 16 is 5,726,623,061 tiles β which is why global static pyramids stop well below that.
Quick answer
Count before you generate:
import mercantile
def plan(bounds, zooms, bytes_per_tile=8_000):
total = 0
for zoom in zooms:
count = len(list(mercantile.tiles(*bounds, zooms=[zoom])))
total += count
print(f" z{zoom:<3} {count:9,} tiles "
f"{count * bytes_per_tile / 1e6:8.1f} MB")
print(f" total {total:,} tiles, {total * bytes_per_tile / 1e6:.0f} MB")
return total
Then decide between loose files and an archive. Loose files are simple and slow to copy β 870 files is fine, 870,000 is not. An MBTiles or PMTiles archive is one file and needs a reader.
Step-by-step solution
1. Choose the zoom range from the data and the use
The maximum zoom should match the data's resolution. At 53.5Β° north, zoom 14 is 5.7 m per pixel and zoom 16 is 1.4 m. Serving 10 m data above zoom 14 produces pixels finer than the information.
The minimum zoom should be where the layer stops being useful β often the point at which everything has been generalised away.
2. Skip empty tiles
Over water, outside the data extent, or in sparse rural areas, most tiles have nothing in them. Writing a transparent PNG or an empty MVT for each multiplies the pyramid for no benefit.
Clients treat a 404 as "nothing here" correctly. Skipping empties routinely removes most of a pyramid over a coastal or rural extent.
3. Deduplicate identical tiles
Where you do write empties β or where large areas are uniform β many tiles are byte-identical.
digest = hashlib.sha256(payload).hexdigest()
if digest in seen:
os.link(seen[digest], path) # hard link, not a copy
Hard links cost a directory entry. In an archive format, several tile ids can point at one blob.
4. Choose loose files or an archive
| loose files | MBTiles | PMTiles | |
|---|---|---|---|
| serving | any file host | needs a process | any host with ranges |
| copying | slow at scale | one file | one file |
| inspecting | trivial | SQL | a reader |
| updating | per tile | per row | rewrite |
Loose files up to a few thousand; an archive beyond that. Copying 100,000 small files to a device takes far longer than copying one 300 MB archive containing them.
5. Generate in parallel, by zoom or by tile range
Tiles are independent, so a process pool scales linearly. Splitting by zoom is simplest; splitting by tile range within a zoom balances better, because the top zoom has hundreds of times more tiles than the bottom.
Code examples
Example 1 β generating with skipping, deduplication and a report
import gzip
import hashlib
import os
import mercantile
def export_pyramid(render, bounds, out_dir, zooms=range(10, 17),
extension="pbf", compress=True, deduplicate=True):
"""render(tile) -> bytes or None. Writes z/x/y.ext, skipping empties."""
os.makedirs(out_dir, exist_ok=True)
seen, written, skipped, linked, total_bytes = {}, 0, 0, 0, 0
for zoom in zooms:
tiles = list(mercantile.tiles(*bounds, zooms=[zoom]))
for tile in tiles:
payload = render(tile)
if payload is None:
skipped += 1
continue
if compress and extension == "pbf":
payload = gzip.compress(payload, 6)
directory = os.path.join(out_dir, str(tile.z), str(tile.x))
os.makedirs(directory, exist_ok=True)
path = os.path.join(directory, f"{tile.y}.{extension}")
if deduplicate:
digest = hashlib.sha256(payload).hexdigest()
if digest in seen:
os.link(seen[digest], path)
linked += 1
continue
seen[digest] = path
with open(path, "wb") as handle:
handle.write(payload)
written += 1
total_bytes += len(payload)
print(f" z{zoom}: {len(tiles):,} tiles considered")
print(f" {written:,} written, {linked:,} hard-linked duplicates, "
f"{skipped:,} empty and skipped")
print(f" {total_bytes / 1e6:.1f} MB on disk, "
f"mean {total_bytes / max(written, 1) / 1024:.1f} kB per tile")
return written
Hard links only work within one filesystem and are invisible to most copying tools, which will expand them. For distribution, an archive format handles duplicates properly.
Example 2 β parallel generation with progress
import os
from concurrent.futures import ProcessPoolExecutor, as_completed
import mercantile
def render_chunk(args):
tiles, source, out_dir, extension = args
written = 0
for tile in tiles:
payload = render_tile_from(source, tile)
if payload is None:
continue
directory = os.path.join(out_dir, str(tile.z), str(tile.x))
os.makedirs(directory, exist_ok=True)
with open(os.path.join(directory, f"{tile.y}.{extension}"), "wb") as f:
f.write(payload)
written += 1
return written
def export_parallel(source, bounds, out_dir, zooms=range(10, 17),
workers=8, chunk=200, extension="png"):
"""Split every zoom into chunks so the work is balanced across workers."""
tasks = []
for zoom in zooms:
tiles = list(mercantile.tiles(*bounds, zooms=[zoom]))
for i in range(0, len(tiles), chunk):
tasks.append((tiles[i:i + chunk], source, out_dir, extension))
print(f" {sum(len(t[0]) for t in tasks):,} tiles in {len(tasks)} chunks")
total, done = 0, 0
with ProcessPoolExecutor(workers) as pool:
futures = [pool.submit(render_chunk, task) for task in tasks]
for future in as_completed(futures):
total += future.result()
done += 1
if done % 20 == 0:
print(f" {done}/{len(tasks)} chunks, {total:,} tiles written")
print(f" {total:,} tiles written")
return total
Chunking across zooms rather than by zoom balances the work: zoom 16 has 625 tiles and zoom 8 has one, so one process per zoom leaves seven workers idle almost immediately.
Example 3 β bundling for offline use
import json
import os
import zipfile
def bundle(tile_dir, out_path, bounds, zooms, name, attribution="",
tile_format="pbf"):
"""A zip with the tiles and a manifest, for shipping to a device."""
manifest = {
"name": name,
"format": tile_format,
"bounds": list(bounds),
"minzoom": min(zooms),
"maxzoom": max(zooms),
"attribution": attribution,
"scheme": "xyz",
}
count, total_bytes = 0, 0
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_STORED) as archive:
archive.writestr("manifest.json", json.dumps(manifest, indent=2))
for root, _, files in os.walk(tile_dir):
for name_ in files:
full = os.path.join(root, name_)
archive.write(full, os.path.relpath(full, tile_dir))
count += 1
total_bytes += os.path.getsize(full)
print(f" {count:,} tiles, {total_bytes / 1e6:.1f} MB raw, "
f"{os.path.getsize(out_path) / 1e6:.1f} MB zipped")
return out_path
ZIP_STORED rather than ZIP_DEFLATED is deliberate: the tiles are already compressed, so re-compressing costs time and saves nothing. It also lets a reader seek to a tile without decompressing.
Recording "scheme": "xyz" in the manifest is the line that prevents the row-numbering confusion on the consuming side.
Explanation
Why the last zoom level is most of the work
At each level the tile count quadruples, so level z has as many tiles as all levels below it combined, plus one.
For the measured extent: zooms 8 through 15 total 245 tiles, and zoom 16 alone is 625. Adding zoom 17 would add roughly 2,500 more.
That makes the maximum zoom the single most consequential parameter. Choosing one level lower cuts the pyramid by roughly 75%.
Why to skip rather than write empty tiles
An empty vector tile is about 20 bytes; a transparent 256-pixel PNG is a few hundred. Neither is large, and there can be very many.
More importantly, they carry a cost at every later stage: copying, uploading, listing, and β on object storage β a request charge each.
Clients handle 404 correctly: MapLibre and Leaflet treat a missing tile as an empty one. The only case for writing empties is a host that returns an HTML error page for 404, which some CDNs do by default.
Why deduplication beats compression on sparse data
A pyramid over a coastal extent contains thousands of identical tiles: the same empty tile, or the same uniform sea colour.
Compression works within a tile and cannot see across them, so each identical tile is stored in full.
Deduplication β by hash, then hard links or archive-level pointers β removes them entirely. On sparse data that is a much larger saving than any compression setting, and the two are complementary.
Why an archive beats loose files at scale
Filesystems handle a few thousand small files well and hundreds of thousands badly. Directory listings slow down, copying takes minutes, and every file carries block-size overhead β a 200-byte tile on a 4 kB block wastes 95% of its space.
An archive is one file: fast to copy, exact in size, and with an internal index that is faster than a directory walk.
The threshold is roughly ten thousand tiles. Below that, loose files are simpler and easier to debug; above it, the archive pays for the extra reader.
Edge cases or notes
- Tile count grows as
4^z. The maximum zoom dominates everything. - Skip empty tiles; clients treat 404 as empty.
- Deduplicate identical tiles β usually a bigger saving than compression.
- Hard links do not survive copying. Use an archive for distribution.
ZIP_STOREDfor already-compressed tiles.- Chunk across zooms when parallelising, or most workers idle.
- Record the scheme (
xyzortms) in a manifest. - Match the maximum zoom to the data's resolution, not to what looks smooth.
Internal links
- Web map tiles explained: XYZ, zoom levels and the tile pyramid β where the counts come from
- PMTiles and MBTiles explained β archive formats
- How to build vector tiles from a GeoDataFrame in Python β generating vector tiles
- How to render raster tiles from a COG in Python β generating raster tiles
- My tiles are offset or in the wrong place β validating the output
- How to speed up batch GIS jobs with parallel processing in Python β the parallel pattern
- How to package GIS deliverables into zipped bundles with Python β bundling for delivery
- Generalisation for zoom levels explained β what goes into each level
FAQ
How many tiles will my pyramid have?
Count them with mercantile.tiles. For a city extent across zooms 8β16 it was 870, of which 625 are at zoom 16 alone.
Should I write empty tiles?
No. Clients treat a 404 as an empty tile, and empties cost copying, uploading and per-request charges.
How do I reduce the pyramid size?
Lower the maximum zoom β each level is most of the total β then skip empties and deduplicate identical tiles.
Loose files or an archive?
Loose files up to a few thousand tiles; an archive beyond that. Filesystems handle hundreds of thousands of small files badly.
How do I parallelise generation?
Chunk tiles across all zooms and hand chunks to a process pool. One process per zoom leaves most workers idle, because the top zoom has hundreds of times more tiles.
Should I zip the bundle with compression?
Use ZIP_STORED. The tiles are already compressed, so deflating again costs time and saves nothing.
What maximum zoom should I choose?
The one whose ground resolution matches the data. Serving 10 m data above zoom 14 gives pixels finer than the information.