Raster Tiles & Coverage in GeoPackage
Most work with GeoPackage stops at feature tables, and that leaves half the format unused. The same container can hold a raster basemap as a pyramid of image tiles — which is what makes a single .gpkg a complete offline map rather than a layer that needs a basemap from somewhere else. On a field device with no network, that difference is the whole product.
This guide is part of the Python Integration & Database Workflows section. It covers the tile side of the specification: how a pyramid is addressed, which registry tables describe it, and how to read and write tiles from Python with and without GDAL.
Prerequisites
Concept & Specification Reference
A tile pyramid is a stack of grids. Each zoom level covers the same ground with more, smaller tiles, and every tile is addressed by three numbers: its zoom level, its column, and its row. Four registry tables describe the arrangement.
| Table | What it records |
|---|---|
gpkg_contents | That the table exists and its data type is tiles |
gpkg_tile_matrix_set | The pyramid’s extent and reference system — one row per tile table |
gpkg_tile_matrix | One row per zoom level: grid dimensions, tile size, pixel size |
| The tile table itself | zoom_level, tile_column, tile_row, tile_data |
The addressing detail that catches everyone is row direction. GeoPackage numbers tile_row from the top of the extent downward, which matches the common web-tile convention but is the opposite of the TMS convention some tools emit. A pyramid built with the wrong convention renders as a vertically mirrored map, level by level — visually obvious once you know to look for it, and easy to mistake for a projection problem if you do not.
Step-by-Step Implementation
1. Discover what the container actually holds
A container can hold feature layers, tile layers, or both. gpkg_contents distinguishes them by data_type, and reading it first avoids opening a tile table as though it were features.
# List feature and tile layers, and the zoom range of each pyramid
import sqlite3
conn = sqlite3.connect("file:basemap.gpkg?mode=ro", uri=True)
for name, dtype in conn.execute(
"SELECT table_name, data_type FROM gpkg_contents ORDER BY data_type, table_name"
):
print(f"{dtype:>8} {name}")
for name, zmin, zmax, n in conn.execute("""
SELECT c.table_name,
min(m.zoom_level), max(m.zoom_level), count(*)
FROM gpkg_contents c
JOIN gpkg_tile_matrix m ON m.table_name = c.table_name
WHERE c.data_type = 'tiles'
GROUP BY c.table_name
"""):
print(f"{name}: zoom {zmin}–{zmax} across {n} matrix levels")
2. Read the tile matrix for a zoom level
Everything needed to locate a tile geographically lives in gpkg_tile_matrix_set and gpkg_tile_matrix. The set gives the pyramid’s extent and reference system; the matrix gives the grid at each level.
# Everything needed to map a ground coordinate to a tile address
row = conn.execute("""
SELECT s.min_x, s.min_y, s.max_x, s.max_y, s.srs_id,
m.matrix_width, m.matrix_height,
m.tile_width, m.tile_height,
m.pixel_x_size, m.pixel_y_size
FROM gpkg_tile_matrix_set s
JOIN gpkg_tile_matrix m ON m.table_name = s.table_name
WHERE s.table_name = ? AND m.zoom_level = ?
""", ("basemap", 12)).fetchone()
min_x, min_y, max_x, max_y, srs_id, mw, mh, tw, th, px, py = row
print(f"zoom 12: {mw}×{mh} tiles of {tw}×{th} px at {px} units/px in EPSG:{srs_id}")
3. Convert a ground coordinate to a tile address
With the matrix in hand, the mapping is arithmetic — and the only subtlety is the downward row direction.
def tile_address(x: float, y: float, zoom: int, matrix) -> tuple[int, int]:
"""Map a ground coordinate to (tile_column, tile_row) at a zoom level."""
min_x, min_y, max_x, max_y, mw, mh = matrix
span_x = (max_x - min_x) / mw
span_y = (max_y - min_y) / mh
col = int((x - min_x) // span_x)
# tile_row counts DOWN from max_y — this is the line people get wrong
row = int((max_y - y) // span_y)
if not (0 <= col < mw and 0 <= row < mh):
raise ValueError(f"({x}, {y}) is outside the pyramid extent at zoom {zoom}")
return col, row
4. Fetch and decode a tile
Tiles are ordinary BLOBs holding PNG or JPEG data, so reading one needs nothing beyond the standard library plus an image decoder.
# Fetch one tile and decode it
import io
from PIL import Image
blob = conn.execute("""
SELECT tile_data FROM basemap
WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?
""", (12, col, row)).fetchone()
if blob is None:
print("no tile stored at that address — the pyramid may be sparse")
else:
img = Image.open(io.BytesIO(blob[0]))
print(img.format, img.size, img.mode)
A missing tile is normal rather than exceptional. Pyramids are commonly sparse: a container covering a coastal survey area stores no tiles for the open sea, and a reader that treats None as an error will fail constantly on perfectly good data.
5. Write a pyramid
Building a pyramid by hand is possible and rarely worth it. gdal_translate and gdaladdo produce a conforming container in two commands, and getting the matrix rows right by hand is fiddly enough that the driver route is the sensible default.
# Build a GeoPackage tile pyramid from a georeferenced raster
gdal_translate -of GPKG orthophoto.tif basemap.gpkg \
-co RASTER_TABLE=basemap \
-co TILE_FORMAT=JPEG \
-co QUALITY=80 \
-co TILING_SCHEME=GoogleMapsCompatible
# Add the coarser levels of the pyramid
gdaladdo -r average basemap.gpkg 2 4 8 16 32
TILING_SCHEME=GoogleMapsCompatible is worth choosing deliberately: it commits the pyramid to Web Mercator and the standard web-tile grid, which is what most mobile map libraries expect. Omitting it produces a pyramid in the source raster’s own reference system, which is more faithful and less portable.
6. Append feature layers into the same container
The point of tiles in GeoPackage is that they share a container with features. Adding a feature layer to a container that already holds a pyramid is an ordinary append, following the pattern in How to Append Layers to an Existing GeoPackage with Fiona.
# One container: a basemap pyramid plus the survey layer over it
ogr2ogr -f GPKG -update basemap.gpkg parcels.shp -nln parcels \
-t_srs EPSG:3857 -lco SPATIAL_INDEX=YES
Reprojecting the features to match the pyramid’s reference system is not required by the format — each layer records its own — but it saves the client transforming every geometry at render time, which on a field device is the difference between a map that pans smoothly and one that does not.
Validation & Verification
A pyramid can be structurally valid and still unusable, so check both the registry and the tiles.
-- Every tile table must have a matrix set row and at least one matrix level
SELECT c.table_name,
(SELECT count(*) FROM gpkg_tile_matrix_set s WHERE s.table_name = c.table_name) AS has_set,
(SELECT count(*) FROM gpkg_tile_matrix m WHERE m.table_name = c.table_name) AS levels
FROM gpkg_contents c
WHERE c.data_type = 'tiles';
-- No tile may sit outside the grid its matrix level declares
SELECT t.zoom_level, count(*) AS out_of_range
FROM basemap t
JOIN gpkg_tile_matrix m
ON m.table_name = 'basemap' AND m.zoom_level = t.zoom_level
WHERE t.tile_column >= m.matrix_width OR t.tile_row >= m.matrix_height
GROUP BY t.zoom_level;
A count above zero in the second query means the pyramid was written with a different row convention or a mismatched extent, and it is the check that catches the mirrored-map problem before anyone opens the file.
Sampling the tiles themselves is worth doing too, because a BLOB that is not a decodable image passes every structural check:
# Sample a handful of tiles per level and confirm they decode at the declared size
import io, random
from PIL import Image, UnidentifiedImageError
for (z, tw, th) in conn.execute(
"SELECT zoom_level, tile_width, tile_height FROM gpkg_tile_matrix "
"WHERE table_name='basemap' ORDER BY zoom_level"
):
sample = conn.execute(
"SELECT tile_data FROM basemap WHERE zoom_level=? LIMIT 5", (z,)
).fetchall()
for (blob,) in sample:
try:
img = Image.open(io.BytesIO(blob))
except UnidentifiedImageError:
raise AssertionError(f"undecodable tile at zoom {z}")
assert img.size == (tw, th), f"zoom {z}: {img.size} != declared ({tw}, {th})"
Common Failure Modes & Fixes
The map renders upside down, level by level
Diagnosis: The pyramid was written with TMS row numbering, which counts upward from the bottom, while GeoPackage counts downward from the top. Fix: Recompute tile_row as matrix_height - 1 - tile_row for every tile at every level. This is a pure key rewrite — no image data changes — but it must be applied consistently across all levels, since matrix_height differs per level.
Tiles are missing over part of the extent
Diagnosis: Usually not a fault. Pyramids are sparse, and a container built from a raster with nodata regions stores no tiles there. Fix: Confirm against the source raster’s coverage before treating it as a defect; render a missing tile as transparent rather than failing.
The container is enormous relative to the source raster
Diagnosis: The tile format is PNG where JPEG would do. Lossless PNG on aerial imagery commonly produces containers several times larger than JPEG at a quality nobody can distinguish on a phone screen. Fix: Rebuild with TILE_FORMAT=JPEG for imagery, keeping PNG for anything with sharp edges or transparency — cartographic basemaps, overlays, hillshades with an alpha channel.
A conforming client sees no tile layer at all
Diagnosis: The tile table exists but gpkg_contents has no row with data_type = 'tiles', or the matrix set row is missing. This is the tile equivalent of an unregistered feature table. Fix: Insert the missing registry rows; the table itself is fine, and no image data needs rewriting.
Zoom levels beyond a point show no more detail
Diagnosis: The pyramid was built with overviews below the source resolution, so the deepest levels are upsampled from the same pixels. Fix: Stop the pyramid at the level whose pixel_x_size matches the source ground sample distance. Levels beyond it quadruple storage per step and add no information — commonly the single largest avoidable component of a container’s size.
Performance Notes
Storage in a pyramid is dominated by its deepest level. Because each level holds four times the tiles of the one above, the last level alone is about three quarters of the total and the last two are roughly nine tenths. That makes the maximum zoom the most consequential decision in the whole build: dropping one level from a pyramid removes most of its size, and adding one below the source resolution adds size for no detail.
Reading is fast for a different reason. A tile lookup is a primary-key hit on (zoom_level, tile_column, tile_row), which is an ordinary B-tree index — no spatial index is involved, and none is needed. What does hurt is a query pattern that scans: fetching “all tiles at zoom 15” to find the ones in view reads the whole level, where computing the address range and fetching by key reads only what is drawn.
Finally, tile blobs are already compressed. Storing them in a container that is itself compressed, or copying them through a compressing transport, spends CPU for a percent or two. Keep the compression in the image format where it belongs.
Child Pages
Pages in this section go deeper on individual tile tasks:
- How to Read a Raster Tile from a GeoPackage in Python — address a tile and decode it with no GDAL dependency
- Writing a Tile Pyramid into a GeoPackage — building the pyramid and its registry rows
- GeoPackage Tile Matrix Sets Explained — the two matrix tables in detail
- How to Serve GeoPackage Tiles Offline — a minimal local tile endpoint over a container
- Comparing MBTiles and GeoPackage for Basemaps — two SQLite tile formats, and when each wins
Frequently Asked Questions
Can one container hold both a basemap and the survey layers over it?
Yes, and that is the main reason to use GeoPackage tiles at all. gpkg_contents distinguishes tile tables from feature tables by data_type, so a single file can carry an imagery pyramid, a vector parcel layer and a point observation layer, each with its own reference system. For an offline field deployment this collapses the whole map into one artefact to build, validate, sign and sync — which is considerably easier to get right than three files that must stay in step.
Which tile format should I choose?
JPEG for photographic imagery, PNG for anything with sharp edges or transparency. Aerial and satellite imagery compresses enormously well as JPEG and the artefacts are invisible at the scale a tile is displayed; the same imagery as PNG can multiply container size several times over. Cartographic basemaps, hillshades with alpha, and any overlay with hard boundaries should stay PNG, where lossless compression is both smaller and correct.
Do tiles need a spatial index?
No. A tile is addressed by an exact key — zoom level, column, row — and that primary key is already an efficient index. The R-tree exists to answer “which geometries might overlap this rectangle”, a question tiles never ask, because the client computes the tile addresses it wants arithmetically before touching the database.
How deep should a pyramid go?
To the level whose ground pixel size matches the source data, and no deeper. Every additional level quadruples the tile count while showing upsampled pixels that carry no new information. Working out the right maximum from the source ground sample distance takes a minute and is frequently the difference between a container a field device can hold and one it cannot.
Can I update part of a pyramid without rebuilding it?
Yes — tiles are rows, and replacing the tiles covering a changed area is an ordinary transaction. The work is in the bookkeeping: a change at the deepest level should propagate upward, since every coarser tile above it is a downsampled composite that is now stale. Recomputing that chain for the affected addresses is straightforward and considerably cheaper than rebuilding the pyramid.
How do I check that a pyramid actually covers the area it claims?
Compare the tiles present against the extent declared in gpkg_tile_matrix_set. At the coarsest level the grid is small enough to enumerate directly, so a query that counts stored tiles against the declared matrix_width × matrix_height gives a coverage fraction immediately. Sparse pyramids will legitimately be below one; what matters is whether the gaps line up with the nodata regions of the source. A coverage figure that drops between builds is the same relative signal a feature count gives for vector layers.
Can tiles and features in one container use different reference systems?
Yes — each registers its own — and it is a common arrangement, because pyramids are usually built in Web Mercator while survey data arrives in a national grid. The cost is paid at render time, where the client transforms every geometry to match the basemap on every frame. On a field device that is measurable, and reprojecting the feature layers once at build time to match the pyramid removes it entirely.
Does adding a pyramid slow down feature queries?
No. Tile tables and feature tables are separate tables with separate indexes, and a query against one never touches the other. What does change is the size of the file, and with it the working set the page cache has to cover — a container that grew from forty megabytes to two gigabytes because of imagery will see more cache misses on the feature side simply because the file no longer fits in memory. That is a caching effect rather than a query-planning one, and raising the page cache is the lever.
Related
- Python Integration & Database Workflows — parent section: the full Python spatial SQLite stack
- GeoPackage Specification Deep Dive — the registry model tile tables share with feature tables
- How to Append Layers to an Existing GeoPackage with Fiona — adding feature layers beside a pyramid
- Offline-First Sync Strategies — distributing the resulting container to field devices