Computing Bounds and Center From a Tileset

Derive bounds from the tiles the build actually produced, not from the source extent. The two disagree whenever a feature was dropped, a zoom threshold excluded a region, or a single stray geometry sat at the origin — and the tileset’s own extent is the one a client should be told about, because it is the one that has tiles behind it.

When to Use This

Any time a tileset is published without a tile server to compute the extent for you. A static PMTiles or MBTiles deployment carries whatever bounds the build wrote, and a wrong value there produces a map that opens on the wrong part of the world, or a fitBounds call that zooms out to the whole globe because one feature landed at 0, 0.

It also comes up after a filtered rebuild. Tiling only the features inside a bounding box, or dropping a region that no longer has a licence, changes the tileset’s extent and nothing updates the metadata automatically.

Specification Detail: Two Different Extents

Extent Derived from Used for Typical discrepancy
Source extent The input geometries Sanity-checking the input Includes features the build dropped
Tileset extent The tiles that exist bounds in the metadata The honest answer for a client

The difference is not academic. A build with --maximum-zoom 14 and a zoom threshold that excludes a sparse region will produce no tiles there, and a client told those bounds will request tiles that do not exist. Conversely a stray null geometry coerced to 0, 0 widens the source extent to include the Gulf of Guinea while producing no tiles there at all.

Why the source extent and the tileset extent disagreeTwo panels contrasting the extent computed from source geometries with the extent computed from the tiles that were actually generated.TWO EXTENTSSource extentUnion of every input geometryIncludes features the build droppedWidened by a single stray point at 0,0Cheap to compute with ogrinfoDescribes the input, not the outputTileset extentUnion of the tiles that existReflects zoom thresholds and filtersImmune to geometries that produced notileRequires a scan of the tile indexDescribes what a client can fetch
Publish the right-hand one. It is the extent for which requests will succeed.

Production Command

The tileset extent is a query over tile coordinates, converted back to longitude and latitude. Compute it at the tileset’s maximum zoom, where the grid is finest and the extent tightest:

python
import math
import sqlite3

def tile_to_lonlat(x: int, y: int, z: int) -> tuple[float, float]:
    """North-west corner of tile (z, x, y) in EPSG:4326."""
    n = 2.0 ** z
    lon = x / n * 360.0 - 180.0
    lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
    return lon, lat


def tileset_bounds(path: str) -> tuple[float, float, float, float]:
    conn = sqlite3.connect(path)
    z = conn.execute("SELECT MAX(zoom_level) FROM tiles").fetchone()[0]
    # MBTiles rows are TMS-ordered: y counts north from the south edge.
    min_x, max_x, min_row, max_row = conn.execute(
        "SELECT MIN(tile_column), MAX(tile_column), "
        "       MIN(tile_row), MAX(tile_row) "
        "FROM tiles WHERE zoom_level = ?", (z,)).fetchone()
    conn.close()

    n = 2 ** z
    # Convert TMS rows to XYZ y, remembering that XYZ y grows southward.
    top_y = n - 1 - max_row
    bottom_y = n - 1 - min_row

    west, north = tile_to_lonlat(min_x, top_y, z)
    east, south = tile_to_lonlat(max_x + 1, bottom_y + 1, z)
    return (round(west, 6), round(south, 6), round(east, 6), round(north, 6))


print(tileset_bounds("basemap.mbtiles"))
# (-0.516357, 51.279208, 0.335083, 51.694279)

Two details in that code are where most implementations go wrong. The + 1 on the eastern and southern edges is because a tile’s coordinates address its north-west corner, so the extent’s far edge is the near edge of the tile after the last one. And the TMS-to-XYZ conversion is required because MBTiles stores tile_row counting north while the conversion formula expects y counting south — the same y-axis flip that reflects maps vertically when it is missed elsewhere.

For a PMTiles archive the header already carries the values, and the correct move is to read them rather than recompute:

bash
pmtiles show basemap.pmtiles | jq '{bounds: .bounds, center: .center}'
From tile indices to a bounding boxThe minimum and maximum tile column and row at the maximum zoom are read from the index, TMS rows are converted to XYZ, the far edges are extended by one tile, and the result is converted to longitude and latitude.DERIVATIONIndex querymin/max columnmin/max rowTMS to XYZy = n - 1 - rowMBTiles onlyExtend far edgesx+1, y+1tiles address their NW cornerTo lon/latinverse Mercator
Every step here has a sign or an off-by-one that silently produces a plausible but wrong box — which is why the sanity check afterwards matters.

Computing a Useful Center

center is [longitude, latitude, zoom] and, unlike bounds, it is a design decision rather than a measurement. The geometric centre of the bounding box is the obvious default and frequently the wrong answer: for a tileset covering a country with one dense city, the centroid lands in a field.

Two better defaults, both cheap:

The densest tile at a mid zoom. The tile with the largest byte size is a good proxy for where the data is, and its centre is usually somewhere a reader wants to be:

sql
SELECT zoom_level, tile_column, tile_row, length(tile_data) AS bytes
FROM tiles WHERE zoom_level = 11
ORDER BY bytes DESC LIMIT 1;

A deliberate value. For a tileset with a known subject — one city, one region — hard-coding the centre is honest and stable, and it is one of the few metadata values that genuinely should be authored rather than derived.

For the zoom component, the tileset’s minzoom opens on the whole extent, which is safe but rarely interesting. Two or three levels in usually shows something.

What a wrong bounds value looks like on the mapFour symptoms of an incorrect bounds array mapped to the specific mistake behind each.DIAGNOSEThe map opens somewhere unexpected — which mistakeproduced it?Opens on the wholeglobeSource extent used,widened by a stray pointat 0,0Opens in the wronghemisphereTMS rows not converted toXYZEdges of the dataare clippedRounded to too few decimalplacesTiles that existare never requestedBounds narrower than thetileset

Interaction Effects

With fitBounds. MapLibre’s fitBounds uses the source’s bounds, so an over-wide box produces an over-zoomed-out initial view. This is the most visible consequence of getting bounds from the source rather than the tiles.

With request suppression. MapLibre will not request tiles outside a source’s declared bounds. That makes an over-narrow box worse than an over-wide one: it suppresses requests for tiles that genuinely exist, and the map has a hard edge with no error to explain it.

With incremental rebuilds. A partitioned build that regenerates one region must recompute the bounds of the merged output, not carry the bounds of the region it rebuilt. Merging with tile-join and inheriting one input’s metadata is the usual way this goes wrong.

Checking the Answer

Two sanity checks catch nearly every implementation error in the conversion above, and both are quick enough to run on every build.

Round-trip a corner. Take the computed north-west corner, convert it forward to a tile number at the same zoom, and confirm it lands on the tile the query reported. A mismatch of one tile means an off-by-one in the edge extension; a mismatch mirrored about the equator means the TMS flip.

Compare the area to the source, loosely. The tileset extent should be equal to or smaller than the source extent, and usually within a few percent of it. A tileset extent that is larger is impossible and means a sign error. One that is dramatically smaller means the build dropped a region — which is worth knowing about regardless of the metadata.

bash
# The source extent, for comparison only
ogrinfo -al -so source.geojson | grep Extent
# Extent: (-0.510000, 51.280000) - (0.330000, 51.690000)

If the two agree closely, either is publishable and the tileset one is still the better habit. If they disagree substantially, the disagreement is the interesting finding, and it usually points at a zoom threshold or a filter doing more than intended.

Common Mistakes

Using the source extent because it is easier to get. ogrinfo -al -so is one command and answers a different question. It is a useful sanity check on the input and the wrong value to publish.

Forgetting the TMS flip on MBTiles. The resulting box is mirrored about the equator, which for a northern-hemisphere dataset places it convincingly in the southern one.

Rounding to too few decimal places. Six decimal places is roughly 10 cm and is plenty; two decimal places is about a kilometre and will visibly clip a city-scale tileset at its edges.

Publishing [-180, -85, 180, 85] as a default. It is never wrong enough to fail a check and never right enough to be useful. If the extent is genuinely global, say so deliberately; if it is not, compute it.

FAQ

Why not use the source data’s extent?

Because it answers a different question. The source extent includes features the build dropped and is widened by any stray geometry at the origin; the tileset extent describes what a client can actually fetch.

What is the most common mistake in the conversion?

Forgetting that MBTiles rows are TMS-ordered. The resulting box is mirrored about the equator, which places a northern dataset convincingly in the southern hemisphere.

Is an over-wide bounds worse than a narrow one?

The other way round. MapLibre will not request tiles outside a source’s declared bounds, so a narrow box suppresses requests for tiles that genuinely exist and leaves the map with a hard edge.

How should center be chosen?

Deliberately. The geometric centre of the bounding box is often a field; the densest tile at a mid zoom is a better default, and a hand-picked value is entirely reasonable.