GeoPackage Tile Matrix Sets Explained

gpkgtilematrixset holds one row per tile table giving the pyramid's extent and reference system; gpkgtilematrix holds one row per zoom level giving that…

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

Primary Method

python
# 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.

The identity that ties the two matrix tables togetherThe set row gives the pyramid's extent width. The matrix row for a zoom level gives the grid width in tiles, the tile width in pixels, and the pixel size on the ground. Multiplying those three must reproduce the extent width. The same identity holds in the vertical direction. A violation means the two tables describe different geometries, which renders as an offset or a scale error rather than as a failure.matrix_widthtiles acrossfrom gpkg_tile_matrix×tile_widthpixels per tileusually 256×pixel_x_sizeground units per pixelhalves each level down=max_x − min_xfrom the set rowthe extent widthand the same identity verticallya violation renders as an offset or a scale errornever as a failure, because every row is individually valid
One multiplication per level, and it catches every class of matrix inconsistency there is.

Step-by-Step Walkthrough

1. Read the set row

sql
-- 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

sql
-- 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.

python
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

ColumnWhat it decidesWhat breaks if it is wrong
min_xmax_yWhere the grid sits on the groundThe whole pyramid is offset
srs_idWhich reference system those bounds are inThe pyramid lands in the wrong place entirely
matrix_width / matrix_heightHow many tiles the grid hasAddresses map to the wrong tiles
tile_width / tile_heightPixels per tileRendering is scaled wrongly
pixel_x_size / pixel_y_sizeGround units per pixelScale 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.

How each kind of matrix error presentsA wrong reference system puts the pyramid in the wrong part of the world and is noticed immediately. A wrong extent shifts the whole basemap by a consistent amount, which looks like a datum problem. Wrong grid dimensions map addresses to the wrong tiles, which shows as a scrambled or repeating image. A wrong pixel size makes the client choose the wrong zoom level, which shows as a blurry or overly detailed render at a given scale.wrong srs_idlands in the wrong part of the world — noticed in a minutewrong extenta consistent shift — looks like a datum problemwrong grid dimensionsaddresses hit the wrong tiles — scrambled or repeatingwrong pixel sizethe client picks the wrong level — blurry, or too detailed
Only the top row announces itself. The three below it are the reason the arithmetic check belongs in a build gate.

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.

sql
-- 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.

sql
-- 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

python
problems = check_matrices("basemap.gpkg", "basemap")
assert not problems, "matrix inconsistency:\n  " + "\n  ".join(problems)

And prove the check fires, by corrupting a copy:

python
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.

How the matrix rows progress between levelsAcross successive zoom levels of a conventional pyramid, the grid width and height double while the pixel size halves, so the product of grid size and pixel size stays constant and equal to the extent. Level ten has a four by four grid at eight metres per pixel; level eleven an eight by eight grid at four metres; level twelve a sixteen by sixteen grid at two metres. The extent each covers is identical.Grid doubles, pixel size halves, extent unchangedzoom 10grid 4 × 48 m per pixel4 × 256 × 8 = extentzoom 11grid 8 × 84 m per pixel8 × 256 × 4 = extentzoom 12grid 16 × 162 m per pixel16 × 256 × 2 = extentthe specification does not require this progression — but a pyramid that breaks it is worth understanding
The right-hand expression is the same number in all three columns, which is the identity the check asserts.

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.