GeoPackage Tile Matrix Sets Explained
gpkg_tile_matrix_set holds one row per tile table giving the pyramid’s extent and reference system; gpkg_tile_matrix holds one row per zoom level giving that level’s grid size, tile size and pixel size. The two must agree arithmetically — grid width times tile width times pixel size must equal the extent width — and when they do not, the pyramid renders offset or scaled rather than failing.
This page belongs to the Raster Tiles & Coverage in GeoPackage guide.
Why This Matters
Everything that makes a stack of images into a map lives in these two tables. The tiles themselves are just PNGs; the matrix rows are what say where they go. A container whose tiles are perfect and whose matrix rows are inconsistent produces a basemap in the wrong place, at the wrong scale, or shifted by a fraction of a tile at every level — and no structural check notices, because every row is individually valid.
The inconsistency is easy to introduce. Anything that writes these rows by hand, or that rebuilds a pyramid at a different extent without updating the set row, or that clips a pyramid and forgets the grid dimensions, produces exactly this.
Prerequisites
- A GeoPackage containing a tile table
sqlite3— nothing here needs GDAL or the spatial extension- The pyramid’s intended extent and reference system, for comparison
- Familiarity with tile addressing from How to Read a Raster Tile from a GeoPackage in Python
Primary Method
# Check the two matrix tables agree, level by level
import sqlite3
TOL = 1e-6 # relative tolerance for the arithmetic
def check_matrices(path: str, table: str) -> list[str]:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
try:
s = conn.execute("""
SELECT min_x, min_y, max_x, max_y, srs_id
FROM gpkg_tile_matrix_set WHERE table_name = ?
""", (table,)).fetchone()
if s is None:
return [f"{table}: no gpkg_tile_matrix_set row"]
min_x, min_y, max_x, max_y, srs_id = s
extent_w, extent_h = max_x - min_x, max_y - min_y
problems = []
for (z, mw, mh, tw, th, px, py) in conn.execute("""
SELECT zoom_level, matrix_width, matrix_height,
tile_width, tile_height, pixel_x_size, pixel_y_size
FROM gpkg_tile_matrix WHERE table_name = ? ORDER BY zoom_level
""", (table,)):
covered_w = mw * tw * px
covered_h = mh * th * py
if abs(covered_w - extent_w) > TOL * extent_w:
problems.append(
f"{table} z{z}: grid covers {covered_w:.4f} but the extent "
f"is {extent_w:.4f} wide"
)
if abs(covered_h - extent_h) > TOL * extent_h:
problems.append(
f"{table} z{z}: grid covers {covered_h:.4f} but the extent "
f"is {extent_h:.4f} tall"
)
return problems
finally:
conn.close()
That single arithmetic identity — grid tiles times pixels per tile times metres per pixel equals the extent — is what ties the two tables together. Every inconsistency this page is about shows up as a violation of it.
Step-by-Step Walkthrough
1. Read the set row
-- One row per tile table: the pyramid's extent and reference system
SELECT table_name, srs_id, min_x, min_y, max_x, max_y
FROM gpkg_tile_matrix_set;
The extent here is the pyramid’s extent — the area the grid covers — which is not necessarily the extent of the data inside it. A pyramid built on the standard web-tile scheme covers the whole world at every level, and stores tiles only where there is imagery.
2. Read the matrix rows
-- One row per zoom level, per tile table
SELECT zoom_level, matrix_width, matrix_height,
tile_width, tile_height, pixel_x_size, pixel_y_size
FROM gpkg_tile_matrix
WHERE table_name = 'basemap'
ORDER BY zoom_level;
Two regularities should hold across the levels, and both are worth checking. matrix_width and matrix_height double from each level to the next; pixel_x_size and pixel_y_size halve. A pyramid where they do not follow that pattern is not necessarily wrong — the specification permits arbitrary levels — but it is unusual enough to be worth understanding.
rows = conn.execute("""
SELECT zoom_level, matrix_width, pixel_x_size FROM gpkg_tile_matrix
WHERE table_name = 'basemap' ORDER BY zoom_level
""").fetchall()
for (z0, w0, p0), (z1, w1, p1) in zip(rows, rows[1:]):
if z1 == z0 + 1:
assert w1 == w0 * 2, f"z{z0}->z{z1}: grid width {w0} -> {w1}, expected doubling"
assert abs(p1 - p0 / 2) < 1e-9, f"z{z0}->z{z1}: pixel size not halved"
3. Understand what each column controls
| Column | What it decides | What breaks if it is wrong |
|---|---|---|
min_x … max_y | Where the grid sits on the ground | The whole pyramid is offset |
srs_id | Which reference system those bounds are in | The pyramid lands in the wrong place entirely |
matrix_width / matrix_height | How many tiles the grid has | Addresses map to the wrong tiles |
tile_width / tile_height | Pixels per tile | Rendering is scaled wrongly |
pixel_x_size / pixel_y_size | Ground units per pixel | Scale selection picks the wrong level |
The interesting property of that table is that only the second row produces an obviously wrong result. The others produce a map that is subtly displaced — which is why the arithmetic check matters more than inspection.
4. Check the tiles fall inside the grid they claim
The matrix says how large the grid is; the tile table should contain nothing outside it.
-- Any tile whose address is outside its level's declared grid
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 < 0 OR t.tile_column >= m.matrix_width
OR t.tile_row < 0 OR t.tile_row >= m.matrix_height
GROUP BY t.zoom_level;
A non-zero count usually means the pyramid was written with the TMS row convention, where rows count upward — which puts every address in the top half of the grid outside the bottom half’s range.
5. Report coverage rather than guessing at gaps
Sparse pyramids are normal, so “tiles missing” is not itself a defect. What is worth tracking is the fraction present per level, compared against the previous build.
-- Coverage per level: stored tiles against the grid's capacity
SELECT m.zoom_level,
m.matrix_width * m.matrix_height AS capacity,
(SELECT count(*) FROM basemap t WHERE t.zoom_level = m.zoom_level) AS stored
FROM gpkg_tile_matrix m
WHERE m.table_name = 'basemap'
ORDER BY m.zoom_level;
Verification
problems = check_matrices("basemap.gpkg", "basemap")
assert not problems, "matrix inconsistency:\n " + "\n ".join(problems)
And prove the check fires, by corrupting a copy:
import shutil, sqlite3
shutil.copy("basemap.gpkg", "broken.gpkg")
conn = sqlite3.connect("broken.gpkg")
conn.execute(
"UPDATE gpkg_tile_matrix SET matrix_width = matrix_width + 1 "
"WHERE table_name = 'basemap' AND zoom_level = 12"
)
conn.commit()
conn.close()
assert check_matrices("broken.gpkg", "basemap"), (
"the check did not notice an inconsistent grid width"
)
Incrementing one grid dimension by one is the smallest possible corruption, and a check that misses it will miss everything.
Alternative Approaches or Edge Cases
Non-standard tiling schemes. The specification does not require the grid to double per level, or tiles to be 256 pixels, or the pyramid to start at zoom zero. A pyramid in a national grid with its own level progression is perfectly conforming, and the arithmetic identity still holds — which is why checking the identity is more robust than checking for the web-tile pattern.
gpkg_2d_gridded_coverage_ancillary. Elevation and other continuous coverages use an extension that adds scaling and offset metadata alongside the tile tables. The matrix tables work identically; what changes is that the tile payload is 16-bit or float data rather than a displayable image, so Image.open will not decode it.
Rebuilding at a different extent. Changing the extent means every matrix row’s grid dimensions change too, and updating one without the other is the most common way this inconsistency is introduced. Rebuild rather than edit, or regenerate both tables from a single computation.
Troubleshooting
The basemap is shifted by a consistent amount
Cause: The set row’s extent does not match the grid the tiles were written against. Fix: Run the arithmetic check; a uniform shift is the signature of an extent that is right in size and wrong in origin.
Tiles repeat or appear scrambled
Cause: matrix_width or matrix_height disagrees with the addresses actually used, so the client’s arithmetic maps a ground position to a different tile than the writer did. Fix: The out-of-range query in step 4 identifies which levels are affected.
A client reports the layer as having no valid tiles
Cause: The tile table has no row in gpkg_tile_matrix_set, so there is no extent to place it. Fix: Insert the set row. This is the tile equivalent of an unregistered feature table — the data is fine and invisible.
Frequently Asked Questions
Why are there two tables rather than one?
Because one property belongs to the pyramid and the rest belong to each level. The extent and reference system are the same at every zoom, so repeating them per level would be redundant and would allow them to disagree. Splitting them means the only way to make the container inconsistent is arithmetically, which is exactly what a check can detect.
Must every zoom level have a matrix row?
Every level that has tiles must, and a level with a matrix row and no tiles is legal — it simply describes an empty grid. What is not legal is a tile whose zoom_level has no matrix row, because nothing then says where that tile belongs. The out-of-range query catches that as a join that drops rows, which is worth checking with a LEFT JOIN if you suspect it.
Does the extent have to match the data?
No, and usually it does not. A pyramid on the standard web scheme declares a world extent and stores tiles only over the imagery, which is both conforming and normal. The extent describes the grid; coverage describes what is in it. Confusing the two leads to a “the extent is wrong” diagnosis for a container that is behaving correctly.
Related
- Raster Tiles & Coverage in GeoPackage — parent guide: the pyramid model
- How to Read a Raster Tile from a GeoPackage in Python — the arithmetic these rows drive
- Writing a Tile Pyramid into a GeoPackage — producing rows that are consistent by construction
- Asserting OGC Compliance in Continuous Integration — where the arithmetic check belongs in a build