Web Map Tiles Explained: XYZ, Zoom Levels and the Tile Pyramid
Problem statement
Every web map is a pyramid of small square images or vector packets, addressed by three integers: zoom, x and y. That scheme decides the resolution available at each zoom, the number of files, and the projection you are stuck with.
The arithmetic is worth seeing before designing anything. For an area covering central Manchester:
zoom tiles ground resolution tile covers
8 1 363.9 m/px 93 km
10 1 91.0 m/px 23 km
12 6 22.7 m/px 5.8 km
14 49 5.7 m/px 1.5 km
16 625 1.4 m/px 364 m
Two zoom levels is a factor of 16 in tile count. A full world pyramid to zoom 16 is 5,726,623,061 tiles.
Quick answer
import math
import mercantile
def tile_info(lat, zoom, tile_px=256):
"""Ground resolution and coverage of one tile at this zoom and latitude."""
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
return {"zoom": zoom,
"m_per_px": round(m_per_px, 3),
"tile_m": round(m_per_px * tile_px),
"tiles_worldwide": 4 ** zoom}
tiles = list(mercantile.tiles(west, south, east, north, zooms=[14]))
print(f"{len(tiles)} tiles at zoom 14")
The cos(latitude) term is the whole reason a "zoom 14 tile" is a different size in Manchester than in Nairobi.
Step-by-step solution
1. Understand the addressing scheme
Zoom 0 is one tile covering the world. Each level splits every tile into four. At zoom z there are 4^z tiles, addressed (z, x, y) with x increasing east and y increasing south from the top-left.
That last detail is the classic bug. The TMS scheme numbers y from the bottom, and XYZ from the top, so a TMS tile server serving XYZ requests produces a map that is correct left to right and flipped top to bottom.
y_xyz = (2 ** zoom - 1) - y_tms
2. Work out the resolution each zoom gives you
m_per_px = 156543.03392 * cos(latitude) / 2^zoom
The constant is the Web Mercator world circumference divided by 256. At Manchester's latitude:
zoom 12 22.744 m/px
zoom 14 5.686 m/px
zoom 16 1.421 m/px
zoom 18 0.355 m/px
Match the maximum zoom to the data's real resolution. Serving 10 m imagery at zoom 18 gives 0.355 m pixels containing 10 m information β a claim the data cannot support.
3. Count the tiles before generating them
tiles = list(mercantile.tiles(w, s, e, n, zooms=range(min_z, max_z + 1)))
For the Manchester extent, going from zoom 14 to 16 takes the count from 49 to 625 at that level, and 870 in total across zooms 8β16. Extending to zoom 18 would add roughly 10,000 more.
The growth is 4^z, so each extra level costs more than every previous level combined.
4. Accept Web Mercator, or leave the ecosystem
Effectively every web mapping library assumes EPSG:3857. It is conformal, so shapes are locally correct, and it distorts area by 1/cosΒ²(latitude) β a factor of 2.6 at 50Β° north and much worse further poleward.
Alternatives exist for polar and equal-area work, and they mean writing your own tile grid and losing basemap compatibility.
5. Choose raster or vector tiles
Raster tiles are pre-rendered images: simple to serve, styling fixed at generation time, and one set per style.
Vector tiles carry geometry and attributes, so styling happens in the browser and one set serves every style. Measured on 182 buildings in one zoom-15 tile:
MVT, no attributes 5.80 kB (4.54 kB gzipped)
MVT, two attributes 8.02 kB (5.73 kB gzipped)
the same as GeoJSON 95.47 kB (14.72 kB gzipped)
Twelve times smaller than GeoJSON raw, and 2.6 times smaller gzipped.
Code examples
Example 1 β planning a pyramid before building it
import math
import mercantile
def plan_pyramid(bounds, zooms=range(8, 17), tile_px=256,
bytes_per_tile=8_000):
"""Tile counts, resolutions and a size estimate per zoom level."""
west, south, east, north = bounds
lat = (south + north) / 2
total = 0
print(f" {'zoom':>5} {'tiles':>9} {'m/px':>10} {'tile covers':>13} "
f"{'estimate':>10}")
for zoom in zooms:
count = len(list(mercantile.tiles(west, south, east, north,
zooms=[zoom])))
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
total += count
print(f" {zoom:5d} {count:9,} {m_per_px:10.3f} "
f"{m_per_px * tile_px:11.0f} m "
f"{count * bytes_per_tile / 1e6:8.1f} MB")
print(f" total {total:,} tiles, "
f"{total * bytes_per_tile / 1e6:.0f} MB estimated")
print(f" a full world pyramid to zoom {max(zooms)} would be "
f"{sum(4 ** z for z in range(max(zooms) + 1)):,} tiles")
return total
Printing the world-pyramid figure alongside your own is a useful corrective. It is why nobody generates global tiles above about zoom 14 without on-demand rendering.
Example 2 β converting between tile and geographic coordinates
import math
def tile_from_lonlat(lon, lat, zoom):
"""XYZ tile containing this point."""
n = 2 ** zoom
x = int((lon + 180.0) / 360.0 * n)
lat_rad = math.radians(lat)
y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return zoom, min(max(x, 0), n - 1), min(max(y, 0), n - 1)
def bounds_from_tile(zoom, x, y):
"""Geographic bounds of an XYZ tile."""
n = 2 ** zoom
def lat_at(row):
return math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * row / n))))
return (x / n * 360.0 - 180.0, lat_at(y + 1),
(x + 1) / n * 360.0 - 180.0, lat_at(y))
def tms_to_xyz(zoom, x, y):
"""TMS numbers y from the bottom; XYZ from the top."""
return zoom, x, (2 ** zoom - 1) - y
The asinh(tan(lat)) is the Mercator projection in one term. Implementing it directly rather than through a projection library is worth doing once, because it makes the latitude dependence obvious.
Example 3 β checking that a zoom range matches the data
import math
def sensible_zoom_range(data_resolution_m, lat, min_ground_m=None):
"""Which zooms this data can honestly support."""
zooms = []
for zoom in range(0, 23):
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
zooms.append((zoom, m_per_px))
max_zoom = max(z for z, m in zooms if m >= data_resolution_m / 2)
min_zoom = min(z for z, m in zooms
if m <= (min_ground_m or 100_000) / 256)
print(f" data resolution {data_resolution_m} m at latitude {lat:.1f}")
print(f" honest zoom range: {min_zoom} to {max_zoom}")
for zoom, m in zooms[min_zoom:max_zoom + 3]:
flag = " <- beyond the data" if m < data_resolution_m / 2 else ""
print(f" z{zoom:<3} {m:8.3f} m/px{flag}")
return min_zoom, max_zoom
data resolution 10 m at latitude 53.5
honest zoom range: 8 to 14
z8 363.903 m/px
z12 22.744 m/px
z14 5.686 m/px
z15 2.843 m/px <- beyond the data
z16 1.421 m/px <- beyond the data
Generating tiles beyond the data's resolution is not harmful in itself β the renderer interpolates β but it invites readers to make measurements the data cannot support, and it multiplies the tile count by four per level.
Explanation
Why the pyramid is powers of four
Each zoom level doubles the linear resolution, so it quadruples the area count. That geometric growth is what makes the scheme work and what limits it.
It works because a viewport shows a roughly constant number of tiles β about 6 to 20 β at any zoom. Panning at zoom 16 loads the same number of tiles as panning at zoom 6.
It limits because storage grows as 4^z. Zoom 16 worldwide is 4.3 billion tiles; zoom 18 is 68 billion. Beyond about zoom 14, global coverage stops being a static asset and becomes an on-demand service.
Why the resolution depends on latitude
Web Mercator maps the whole world onto a square, which requires stretching as latitude increases. The stretch factor is 1/cos(latitude).
So a zoom-14 tile is 1,456 m across in Manchester at 53.5Β° north and 2,447 m at the equator. The pixel count is the same; the ground each pixel covers is not.
Two consequences. Ground resolution figures must state a latitude. And area comparisons across latitudes are wrong by cosΒ², which at 53.5Β° is a factor of 2.8.
Why the y axis convention causes so much trouble
XYZ, used by every common web map library, numbers y from the north. TMS, the older OGC scheme, numbers it from the south.
The two agree on zoom and x and disagree on y, so a mismatch produces a map that is horizontally correct and vertically mirrored β and every tile individually looks like real data.
The conversion is one line, y_xyz = 2^z - 1 - y_tms, and knowing which convention a source uses is the only difficult part.
Why vector tiles won for most vector data
The measured comparison is decisive for anything with attributes: 5.73 kB gzipped as MVT against 14.72 kB as GeoJSON for the same 182 buildings, with styling still changeable in the browser.
Raster tiles remain right for imagery, hillshades and anything whose rendering is expensive or whose source is not vector. They are also simpler: an image is an image, and no client library is required.
For vector data, one MVT set serves every style, updates by regenerating one archive, and lets the client change colours without touching the server.
Edge cases or notes
- XYZ numbers y from the top; TMS from the bottom. Mixing them mirrors the map.
- Ground resolution depends on latitude through
cos(latitude). - Tile count grows as
4^z. Each level costs more than all previous levels combined. - Match the maximum zoom to the data's resolution.
- Web Mercator distorts area by
1/cosΒ²(latitude)β 2.8Γ at 53.5Β° north. - 512-pixel tiles halve the tile count for the same detail and are widely supported.
- Tiles are conventionally cached forever; version the URL when the data changes.
- A full world pyramid to zoom 16 is 5.7 billion tiles.
Internal links
- Vector tiles explained: MVT, layers and why they are not GeoJSON β the format inside a vector tile
- PMTiles and MBTiles explained β packaging a pyramid as one file
- Generalisation for zoom levels explained β what to draw at each zoom
- How to export a static tile pyramid for offline use β generating the tiles
- How to render raster tiles from a COG in Python β the raster path
- Choosing a map projection for display: what Web Mercator distorts β the projection you are stuck with
- My tiles are offset or in the wrong place β the y-axis bug in practice
- How to build vector tiles from a GeoDataFrame in Python β producing MVT
FAQ
What is an XYZ tile?
A square image or vector packet addressed by zoom, column and row, where zoom 0 is one world tile and each level splits every tile into four.
How do I work out the ground resolution at a zoom level?
156543.03392 * cos(latitude) / 2^zoom metres per pixel for 256-pixel tiles. At zoom 14 and 53.5Β° north that is 5.686 m.
Why is my map upside down?
You are mixing XYZ and TMS. TMS numbers y from the bottom; convert with y_xyz = 2^z - 1 - y_tms.
How many tiles will I need?
Count them with mercantile.tiles. For a city-sized extent, zoom 8 to 16 was 870 tiles; each extra level quadruples the top level.
What maximum zoom should I use?
The one whose ground resolution is about half your data's. Serving 10 m data above zoom 14 provides pixels finer than the information.
Should I use raster or vector tiles?
Vector for vector data β 5.73 kB gzipped against 14.72 kB for GeoJSON, with client-side styling. Raster for imagery and expensive renderings.
Do I have to use Web Mercator?
Effectively yes, if you want to work with standard basemaps and libraries. Custom grids are possible and cost you the ecosystem.