PMTiles Writer
Package a tile pyramid ((z, x, y, bytes)) into PMTiles
archives. The lightweight pmtiles_gbx writer (pure-Python, Serverless-safe,
distributed spatial sharding) and the heavyweight pmtiles writer take the
same input and produce decoded-tile-identical archives (verified in the
benchmark).
The lightweight (*_gbx) writers need no JAR or init script and are the only option
on Serverless, standard (shared), and ARM clusters. The heavyweight raster/PMTiles writers
require a classic x86 cluster (JAR + GDAL init script); where available they use native
GDAL on the JVM. So your compute usually decides the tier — then data scale. See the
Benchmarking page for timings and methodology.
At 1,000 tiles the lightweight pmtiles_gbx writer runs comparably to the heavyweight encoder (~18.4 s vs ~20.7 s).
Schema
Input schema — exactly (z, x, y, bytes):
root
|-- z: int
|-- x: int
|-- y: int
|-- bytes: binary
A tile pyramid: one row per tile, bytes being the already-encoded tile payload (PNG/JPEG/WebP/MVT/…). The writer requires exactly these four columns, in this order — extra, missing, or mis-ordered columns raise an error.
What each column means:
z— zoom level.0is the whole world in one tile; each level up subdivides every tile into four.x,y— the tile's column and row in the XYZ ("slippy-map") grid, addressed from the upper-left (north-west) origin:xincreases eastward,yincreases southward. Put differently,(x, y)identifies the tile by its upper-left corner. This is the Web-Mercator/XYZ convention PMTiles uses — the opposite of the TMS scheme, whereyincreases northward. So(z, x, y) = (0, 0, 0)is the single top-level tile, and at zoom 1(1, 0, 0)is the north-west quadrant.bytes— the already-encoded tile payload; its type (PNG/JPEG/WebP/MVT) is auto-detected from the leading magic bytes.
If your tiles already live in a DataFrame under different names, project them to the exact shape and order:
tiles.select(
tiles["zoom"].alias("z"),
tiles["tile_x"].alias("x"), # column index from the NW origin (east is +)
tiles["tile_y"].alias("y"), # row index from the NW origin (south is +)
tiles["payload"].alias("bytes"),
).write.format("pmtiles_gbx").mode("overwrite").save("/Volumes/cat/sch/vol/out")
Or generate the pyramid straight from rasters with GeoBrix RasterX. gbx_rst_xyzpyramid explodes a raster into one row per intersecting XYZ tile (rendering each as PNG/JPEG/WebP) and already emits a tile: STRUCT<z, x, y, bytes> — exactly the PMTiles schema, no manual aliasing of the tile coordinates needed:
from pyspark.sql import functions as F
from databricks.labs.gbx.rasterx.functions import rst_xyzpyramid
# One row per (z, x, y) tile across zooms 0..5, as 256px PNG bytes. rst_xyzpyramid
# emits XYZ tiles addressed from the NW origin -- the convention described above.
tiles = (
rasters
.select(F.explode(rst_xyzpyramid("tile", F.lit(0), F.lit(5))).alias("t"))
.selectExpr(
"t.tile.z AS z",
"t.tile.x AS x",
"t.tile.y AS y",
"t.tile.bytes AS bytes",
)
)
tiles.write.format("pmtiles_gbx").mode("overwrite").save("/Volumes/cat/sch/vol/out")
Output: by default, spatially-sharded .pmtiles archives plus a catalog under the target directory; with shardZoom=0, a single .pmtiles archive file.
Options
Both tiers are write-only and require .mode("overwrite") — a finalized archive cannot be appended to.
Lightweight (pmtiles_gbx)
| Option | Default | Description |
|---|---|---|
shardZoom | "6" | Grid zoom that partitions the world into one bounded .pmtiles per parent tile. 0 = a single merged archive. |
targetTilesPerShard | unset | Optional cap on tiles per shard; when set, used to size shards instead of a fixed shardZoom. |
catalog | "stac" | Catalog written over the shards: stac, tilejson, or none. |
tileType | auto-detect | Override the PMTile tile_type: png, jpeg/jpg, webp, avif, or mvt. |
tileCompression | "none" | PMTile tile_compression advertised in the header: none, gzip, brotli, or zstd. Tile bytes pass through unchanged. |
metadata | "{}" | JSON metadata string written into the PMTile header (e.g. '{"name":"my_tileset","attribution":"..."}'). |
fileName | (none) | Name the output unit explicitly. When set, .save(path) treats path as the parent directory (created if missing). In single-archive mode (shardZoom=0): output = path/<fileName>.pmtiles (extension auto-completed). In sharded mode (default): output directory = path/<fileName> (no extension appended). See Output naming. |
# Knobs (sensible defaults):
# shardZoom 6 -> sharded; 0 -> single archive
# targetTilesPerShard adaptive sharding (subdivide dense cells)
# catalog stac (default) | tilejson | none
# tileType auto-sniff (png/jpeg/webp/mvt); override if needed
# tileCompression none (default) | gzip | brotli | zstd
# metadata JSON string -> archive metadata
Heavyweight (pmtiles)
| Option | Default | Description |
|---|---|---|
metadataJson | "{}" | JSON metadata string written into the PMTile header (e.g. '{"name":"my_tileset","attribution":"..."}'). |
tileType | auto-detect | Override the auto-detected PMTile tile_type byte: 1 = MVT, 2 = PNG, 3 = JPEG, 4 = WebP. Useful when emitting via a custom encoder that doesn't carry the standard magic bytes. |
tileCompression | 1 (none) | PMTile tile_compression byte advertised in the header: 1 = none, 2 = gzip, 3 = brotli, 4 = zstd. GeoBrix passes tile bytes through unchanged; set this only if you have pre-compressed your tiles upstream. |
fileName | (none) | Name the output .pmtiles file explicitly. When set, .save(path) treats path as the parent directory (created if missing) and writes path/<fileName>.pmtiles (extension auto-completed). The heavyweight writer is always single-archive. See Output naming. |
Output naming
Both tiers apply the same 3-case naming contract. The output unit differs by mode:
- Single-archive (
shardZoom=0forpmtiles_gbx; always forpmtiles) — the unit is a single.pmtilesfile. Extension auto-completion applies:tiles→tiles.pmtiles;tiles.pmtiles→ unchanged. Passing a name ending in a different recognized geo extension raises a clear error. - Sharded (default
pmtiles_gbxwithshardZoom ≥ 1) — the unit is the output directory that holds the shard archives,overview.pmtiles, and the catalog. No.pmtilesextension is appended to the directory name.
Rules evaluated in order:
| Case | .save(path) / fileName | Single-archive resolved output | Sharded resolved output |
|---|---|---|---|
fileName given | .option("fileName","tiles").save("/out/exports") | /out/exports/tiles.pmtiles | /out/exports/tiles/ (directory) |
No fileName; path is an existing directory | .save("/out/exports") | /out/exports/exports.pmtiles | /out/exports/exports/ (directory under the given path) |
No fileName; path is a stem | .save("/out/exports/tiles") | /out/exports/tiles.pmtiles | /out/exports/tiles/ (directory) |
The heavyweight pmtiles writer is always single-archive (the sharded column does not apply). Both tiers behave identically for single-archive mode.
- Lightweight · pmtiles_gbx
- Heavyweight · pmtiles
Pure-Python, JAR-free, Serverless-safe writer that packages a tile pyramid
((z, x, y, bytes)) into PMTiles archives
using distributed spatial sharding: each populated parent tile becomes one
bounded, non-overlapping .pmtiles shard, plus a global overview.pmtiles and a
catalog over the shards.
Sharded output (default)
# Lightweight PMTiles writer - distributed spatial sharding (default).
# Input is a tile pyramid: (z, x, y, bytes). shardZoom=6 emits one
# tileset/{z}/{x}/{y}.pmtiles per populated parent + overview.pmtiles + a
# STAC catalog.json.
from databricks.labs.gbx.ds.register import register
register(spark)
df.write.format("pmtiles_gbx").mode("overwrite").option("shardZoom", "6").save(OUT_DIR)
Output layout:
OUT_DIR/tileset/{z}/{x}/{y}.pmtiles # one per populated parent (Z >= shardZoom)
OUT_DIR/tileset/overview.pmtiles # Z < shardZoom global overview
OUT_DIR/tileset/catalog.json # STAC/GeoJSON manifest
Single archive
# Single-archive PMTiles: shardZoom=0 packs every tile into one .pmtiles file.
from databricks.labs.gbx.ds.register import register
register(spark)
df.write.format("pmtiles_gbx").mode("overwrite").option("shardZoom", "0").save(OUT_FILE)
Spatial sharding
The writer treats tiled output as immutable, spatially-indexed shards: partition
the world by a grid, emit one bounded .pmtiles per parent tile, and deliver a
catalog over the shards rather than one merged file. This keeps shards
independently regenerable and lets a browser fetch only the shard for the area in
view. Set shardZoom=0 for a single merged archive.
It is the lightweight counterpart of the heavyweight pmtiles writer, supporting Python and SQL bindings (not Scala).
The PMTiles writer streams a per-tile (z, x, y, bytes) row set into a single PMTiles v3 archive file. It is the write-only counterpart of the gbx_pmtiles_agg UDAF — both share the same native-Scala encoder, but the DataSource avoids the Spark cell-size ceiling by performing a partitioned streaming commit with no in-memory consolidation.
Format Name
pmtiles
The DataSource is registered automatically when the GeoBrix JAR is on the Spark classpath (via META-INF/services) — no register(spark) call is required.
Required Conventions
1. Input schema must be exactly (z, x, y, bytes)
The writer enforces an exact write schema. Missing columns, extra columns, or wrong types all raise a single IllegalArgumentException that names the canonical schema (mirrors the GDAL writer's policy — predictable failure mode).
z INT — tile zoom level (0..31)
x INT — tile x within the zoom
y INT — tile y within the zoom
bytes BINARY — tile payload (PNG / JPEG / WebP / MVT)
Project to exactly these columns before writing:
tiles_df = (
raster_df
.select(explode(rst_xyzpyramid("tile", lit(0), lit(5))).alias("t"))
.selectExpr("t.tile.z AS z", "t.tile.x AS x", "t.tile.y AS y", "t.tile.bytes AS bytes")
)
2. .mode("overwrite") is required
The PMTiles DataSource is single-file — append semantics do not apply. The default ErrorIfExists mode is rejected upstream by Spark with a loud error that points you at .mode("overwrite"):
tiles_df.write.format("pmtiles")... # ❌ ErrorIfExists rejected
tiles_df.write.mode("overwrite").format("pmtiles")... # ✅
append and ignore are not implemented.
3. Output path and naming
By default, .save(path) resolves path as the final .pmtiles file (extension auto-completed). With .option("fileName", name), path is instead treated as the parent directory and name.pmtiles is written inside it. See Output naming for the full 3-case contract.
# stem path — extension auto-completed
.save("/Volumes/main/default/tiles/out") # -> out.pmtiles
# explicit fileName — path is the parent directory
.option("fileName", "out").save("/Volumes/main/default/tiles") # -> tiles/out.pmtiles
Scratch _part_*.tdata and _part_*.entries files are written alongside the target path during the commit phase and deleted on success.
Basic Usage
Python
(
tiles_df
.write
.format("pmtiles")
.option("metadataJson", '{"name":"my_tileset"}')
.mode("overwrite")
.save("/Volumes/main/default/tiles/out.pmtiles")
)
Scala
tilesDf.write
.format("pmtiles")
.option("metadataJson", "{\"name\":\"my_tileset\"}")
.mode("overwrite")
.save("/Volumes/main/default/tiles/out.pmtiles")
Tile-Type Detection
The encoder reads the first 12 bytes of the first non-empty tile payload and sets the PMTile header's tile_type byte:
| Magic bytes | tile_type | Meaning |
|---|---|---|
89 50 4E 47 | 2 (PNG) | PNG raster |
FF D8 | 3 (JPEG) | JPEG raster |
RIFF????WEBP | 4 (WebP) | WebP raster |
| anything else | 1 (MVT) | Mapbox Vector Tile (protobuf) |
Override via .option("tileType", "<byte>") when auto-detection isn't appropriate.
Reading PMTiles
Reading PMTiles is not supported in GeoBrix 0.4.0 — spark.read.format("pmtiles") raises a friendly "Reading PMTiles archives is not supported in GeoBrix 0.4.0" error. Use one of the client libraries instead:
- pmtiles JS library for MapLibre / browser rendering.
- The Python
pmtilespackage for tile inspection and extraction.
Serving from Object Storage
PMTiles is designed to be served as a single static file with HTTP Range requests. After uploading the output .pmtiles to S3 / ABFS / GCS:
- CORS: enable
GET, HEAD, OPTIONSfor your map host; allowRangeandIf-Matchheaders. - Content-Type: serve as
application/vnd.pmtiles. - Browse: drop the URL into pmtiles.io for a visual sanity check.
- Embed in MapLibre GL JS via the pmtiles protocol — see the PMTiles functions page for a worked HTML snippet.
Limits
- No leaf directories. If the global root directory would exceed 16,257 bytes (PMTiles spec § 4), the encoder errors out and asks you to split your input. In practice this only happens with very large pyramids (tens of millions of tiles); the limit will be relaxed in a future release.
- No cross-task dedup. Identical tiles across partitions are stored multiple times in the final file. The
gbx_pmtiles_aggUDAF does per-blob SHA-256 dedup, so for known-redundant pyramids prefer the UDAF when your data fits a single Spark cell.
Next Steps
- PMTiles Function Reference —
gbx_pmtiles_aggUDAF (the in-cell counterpart). - PMTiles Function Reference — Concepts, schema contract, and serving notes.
- Raster Functions — Generate per-tile PNG bytes with
gbx_rst_xyzpyramid. - Helios notebooks — worked end-to-end example: the PMTiles writer packages raster XYZ pyramids (NB02) and terrain hillshade (NB03) over a San Francisco AOI.
- VectorX Function Reference — Generate per-tile MVT bytes with
gbx_st_asmvt_pyramid.