How to Read a Raster Tile from a GeoPackage in Python
Read the pyramid’s extent and grid dimensions from gpkg_tile_matrix_set and gpkg_tile_matrix, convert your ground coordinate into a (tile_column, tile_row) pair — remembering that rows count downward from the top of the extent — and SELECT tile_data from the tile table. The BLOB is a PNG or JPEG, so any image decoder opens it and no GDAL is involved anywhere.
This page belongs to the Raster Tiles & Coverage in GeoPackage guide.
Why This Matters
A field application that renders a basemap needs one thing from the container: the image covering the area on screen. Going through GDAL for that pulls in a large dependency to do work that is two queries and a decoder call — and on a mobile or embedded target, the dependency is frequently the reason the basemap ends up in a separate file instead of the GeoPackage where it belongs.
The arithmetic is also worth understanding rather than delegating. The one detail that catches everyone is row direction: GeoPackage numbers tile_row from the top of the extent downward, matching the common web-tile convention and inverting the TMS one. Getting it wrong produces a map that is mirrored vertically at every zoom level, which reads as a projection problem and is not.
Prerequisites
- Python 3.9+ with
sqlite3— nothing else is required to fetch the BLOB Pillow10+, or any PNG/JPEG decoder, to turn the bytes into pixels- A GeoPackage containing a tile table, per Writing a Tile Pyramid into a GeoPackage
- The tile table’s name, or the enumeration query that finds it
Primary Method
# Ground coordinate -> tile address -> decoded image, with no GDAL
import io
import sqlite3
from dataclasses import dataclass
from PIL import Image
@dataclass(frozen=True)
class Matrix:
min_x: float; min_y: float; max_x: float; max_y: float
srs_id: int
width: int; height: int # grid dimensions, in tiles
tile_w: int; tile_h: int # tile dimensions, in pixels
def read_matrix(conn, table: str, zoom: int) -> Matrix:
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
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 = ?
""", (table, zoom)).fetchone()
if row is None:
raise LookupError(f"no tile matrix for {table!r} at zoom {zoom}")
return Matrix(*row)
def tile_address(m: Matrix, x: float, y: float) -> tuple[int, int]:
"""Ground coordinate -> (tile_column, tile_row). Rows count DOWN from max_y."""
span_x = (m.max_x - m.min_x) / m.width
span_y = (m.max_y - m.min_y) / m.height
col = int((x - m.min_x) // span_x)
row = int((m.max_y - y) // span_y) # the line people get wrong
if not (0 <= col < m.width and 0 <= row < m.height):
raise ValueError(f"({x}, {y}) is outside the pyramid extent")
return col, row
def read_tile(conn, table: str, zoom: int, x: float, y: float):
m = read_matrix(conn, table, zoom)
col, row = tile_address(m, x, y)
blob = conn.execute(
f'SELECT tile_data FROM "{table}" '
"WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?",
(zoom, col, row),
).fetchone()
if blob is None:
return None, (col, row) # sparse pyramids are normal
return Image.open(io.BytesIO(blob[0])), (col, row)
Returning None rather than raising for a missing tile is deliberate. Pyramids are commonly sparse — a container covering a coastal survey stores nothing for open water — and a reader that treats absence as an error fails constantly on perfectly good data.
Step-by-Step Walkthrough
1. Find the tile tables
-- Tile layers, with the zoom range each covers
SELECT c.table_name,
min(m.zoom_level) AS zmin,
max(m.zoom_level) AS zmax,
count(*) AS levels
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;
2. Open read-only
Nothing here writes, and opening read-only means the reader cannot be the cause of a lock or a stray modification to a container a field application is also using.
conn = sqlite3.connect("file:basemap.gpkg?mode=ro", uri=True)
3. Fetch a whole window rather than a tile at a time
Rendering a viewport needs several tiles, and one query for the range beats one query per tile. The addresses are contiguous, so the range is expressible directly.
def read_window(conn, table, zoom, x0, y0, x1, y1):
"""Every tile covering a ground rectangle, in one query."""
m = read_matrix(conn, table, zoom)
c0, r1 = tile_address(m, min(x0, x1), min(y0, y1)) # bottom-left -> max row
c1, r0 = tile_address(m, max(x0, x1), max(y0, y1)) # top-right -> min row
rows = conn.execute(
f'SELECT tile_column, tile_row, tile_data FROM "{table}" '
"WHERE zoom_level = ? AND tile_column BETWEEN ? AND ? "
" AND tile_row BETWEEN ? AND ?",
(zoom, c0, c1, r0, r1),
).fetchall()
return {(c, r): blob for c, r, blob in rows}
The row bounds swap relative to the ground coordinates, because the ground y increases upward and tile_row increases downward. Deriving both from tile_address rather than computing them separately keeps that inversion in one place.
4. Decode, and handle the absent tile
tiles = read_window(conn, "basemap", 14, 400_000, 300_000, 402_000, 302_000)
for (col, row) in expected_addresses:
blob = tiles.get((col, row))
if blob is None:
canvas.paste(TRANSPARENT, position_of(col, row)) # normal, not an error
continue
canvas.paste(Image.open(io.BytesIO(blob)), position_of(col, row))
5. Read the pixel size when you need scale
gpkg_tile_matrix records pixel_x_size and pixel_y_size per level, which is what tells you the ground resolution — and therefore which zoom level to request for a given screen scale.
def best_zoom(conn, table: str, target_m_per_px: float) -> int:
"""The deepest level whose pixels are no coarser than the target."""
rows = conn.execute(
"SELECT zoom_level, pixel_x_size FROM gpkg_tile_matrix "
"WHERE table_name = ? ORDER BY zoom_level", (table,)
).fetchall()
candidates = [z for z, px in rows if px <= target_m_per_px]
return candidates[0] if candidates else rows[-1][0]
Verification
Confirm the address arithmetic against the extent, and confirm the tiles decode at the size the matrix declares.
# Corners of the extent must map to the corner tiles
m = read_matrix(conn, "basemap", 14)
assert tile_address(m, m.min_x + 0.001, m.max_y - 0.001) == (0, 0)
assert tile_address(m, m.max_x - 0.001, m.min_y + 0.001) == (m.width - 1, m.height - 1)
# Sampled tiles must decode, at the declared dimensions
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"
):
for (blob,) in conn.execute(
"SELECT tile_data FROM basemap WHERE zoom_level = ? LIMIT 3", (z,)
):
img = Image.open(io.BytesIO(blob))
assert img.size == (tw, th), f"zoom {z}: {img.size} != declared ({tw}, {th})"
The first assertion is the one that catches an inverted row calculation, because a mirrored implementation maps the top-left corner to (0, height - 1) instead of (0, 0).
Alternative Approaches or Edge Cases
Reading through GDAL. gdal.Open("GPKG:basemap.gpkg:basemap") gives a raster dataset with the usual windowed-read interface, handles the addressing, and is the right choice when you need resampling, reprojection or a mosaic. For “give me the tile at this address”, it is a large dependency for two queries.
Tiles in a different reference system than the features. Perfectly legal — each registers its own — and it means the client transforms geometry to match the basemap on every frame. Reprojecting the feature layers at build time to match the pyramid removes that, at the cost of storing them in a system that may be poor for measurement.
Web Mercator pyramids and the standard scheme. A pyramid built with TILING_SCHEME=GoogleMapsCompatible uses the grid every web map library expects, so the addressing above reduces to the familiar z/x/y arithmetic. Pyramids in a national grid do not, which is exactly why the matrix tables exist rather than a formula.
Troubleshooting
The map is mirrored vertically
Cause: The row calculation counts up from min_y instead of down from max_y. Fix: Use (max_y - y) // span_y. The corner assertion in the verification section catches this immediately.
Every tile query returns nothing
Cause: The wrong zoom level, or the coordinate is outside the pyramid extent. Fix: Print the matrix bounds and the computed address; an address outside the grid raises from tile_address, but a valid address at a zoom level with no stored tiles returns None silently.
UnidentifiedImageError on a tile
Cause: The BLOB is not a decodable image — a truncated write, or a container built by a tool that stored something else. Fix: Sample tiles across every level as in the verification step; an undecodable tile passes every structural check the format defines.
Frequently Asked Questions
Do I need the spatial extension to read tiles?
No. Tile tables hold ordinary integers and BLOBs, the matrix tables are ordinary metadata, and nothing in the read path calls a spatial function. This is one of the few parts of a GeoPackage that is fully accessible from a plain sqlite3 connection, which makes it well suited to restricted environments.
Is there an index on the tile table?
Yes — the primary key on (zoom_level, tile_column, tile_row), which is what makes both the single-tile lookup and the range query efficient. No R-tree is involved, and none would help: the client computes the addresses it wants arithmetically rather than asking which tiles overlap a rectangle.
How should a missing tile be rendered?
As transparent, in almost every case. A sparse pyramid is a design decision — the producer stored nothing where there was nothing to show — so a gap is information rather than an error. Rendering a placeholder or an error tile turns a correct container into one that looks broken.
Related
- Raster Tiles & Coverage in GeoPackage — parent guide: the pyramid model and its registry tables
- GeoPackage Tile Matrix Sets Explained — the two matrix tables in detail
- Writing a Tile Pyramid into a GeoPackage — producing the container this reads
- How to Serve GeoPackage Tiles Offline — putting this read path behind a local endpoint