Web Map Tiles Explained: XYZ, Zoom Levels and the Tile Pyramid
On this page
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;", tile_info(53.48, 14))
49 tiles at zoom 14; {'zoom': 14, 'm_per_px': 5.686, 'tile_m': 1456, 'tiles_worldwide': 268435456}
The cos(latitude) term is the whole reason a "zoom 14 tile" is a different size in Manchester than in Nairobi.
Addressing, resolution per zoom, tile counts
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 (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 11,954 more (Example 1).
The growth is
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.42 at 50Β° north, 2.83 at Manchester, 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 the 401 buildings in the zoom-15 tile containing Manchester Town Hall (Example 4):
MVT, no attributes 12.64 kB ( 9.50 kB gzipped)
MVT, two attributes 19.91 kB (12.90 kB gzipped)
the same as GeoJSON 162.33 kB (26.98 kB gzipped)
Eight times smaller than GeoJSON raw, and 2.1 times smaller gzipped.
Planning a pyramid for central Manchester
The data used on this page
The extent is central Manchester and Salford, β2.31 to β2.18Β° E, 53.44 to 53.52Β° N. For the vector-tile comparison the setup fetches every building polygon in OpenStreetMap inside it with OSMnx, keeping the building type and a footprint area, as buildings.parquet. The setup reads Overpass's answer as saved on 9 October 2026.
import io
import os
import requests
import time
import zipfile
import geopandas as gpd
import osmnx as ox
# OpenStreetMap as Overpass returned it on 9 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-manchester-buildings-2026-10-09.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.requests_timeout = 600
BOX = (-2.31, 53.44, -2.18, 53.52) # west, south, east, north
west, south, east, north = BOX
if not os.path.exists("buildings.parquet"):
for attempt in range(5): # the Overpass mirror is sometimes busy
try:
raw = ox.features_from_bbox(BOX, tags={"building": True})
break
except Exception:
time.sleep(30)
raw = raw[raw.geom_type.isin(["Polygon", "MultiPolygon"])].reset_index()
b = raw[["id", "building", "geometry"]].copy()
b["area_m2"] = b.to_crs(27700).area.round(1)
b.to_parquet("buildings.parquet")
buildings = gpd.read_parquet("buildings.parquet")
print(f"{len(buildings):,} buildings")
63,517 buildings
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
plan_pyramid(BOX)
extra = sum(len(list(mercantile.tiles(*BOX, zooms=[z]))) for z in (17, 18))
print(f" zooms 17 and 18 would add {extra:,} tiles")
zoom tiles m/px tile covers estimate
8 1 363.903 93159 m 0.0 MB
9 1 181.952 46580 m 0.0 MB
10 1 90.976 23290 m 0.0 MB
11 2 45.488 11645 m 0.0 MB
12 6 22.744 5822 m 0.0 MB
13 16 11.372 2911 m 0.1 MB
14 49 5.686 1456 m 0.4 MB
15 169 2.843 728 m 1.4 MB
16 625 1.421 364 m 5.0 MB
total 870 tiles, 7 MB estimated
a full world pyramid to zoom 16 would be 5,726,623,061 tiles
zooms 17 and 18 would add 11,954 tiles
The box needs 870 tiles to zoom 16, about 7 MB at a nominal 8 kB a tile; zooms 17 and 18 would add 11,954 more. 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
town_hall = (-2.2446, 53.4794)
z, x, y = tile_from_lonlat(*town_hall, 14)
print("Manchester Town Hall is in XYZ tile", (z, x, y), "- mercantile agrees:", tuple(mercantile.tile(*town_hall, 14)) == (x, y, z))
print("its bounds:", tuple(round(v, 5) for v in bounds_from_tile(z, x, y)))
print("the same tile in TMS numbering:", tms_to_xyz(z, x, y), "- and back:", tms_to_xyz(*tms_to_xyz(z, x, y)))
Manchester Town Hall is in XYZ tile (14, 8089, 5300) - mercantile agrees: True
its bounds: (-2.26318, 53.47497, -2.24121, 53.48805)
the same tile in TMS numbering: (14, 8089, 11083) - and back: (14, 8089, 5300)
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
sensible_zoom_range(10, 53.5)
data resolution 10 m at latitude 53.5
honest zoom range: 8 to 14
z8 363.732 m/px
z9 181.866 m/px
z10 90.933 m/px
z11 45.466 m/px
z12 22.733 m/px
z13 11.367 m/px
z14 5.683 m/px
z15 2.842 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.
Example 4 β the latitude factors and one vector tile
import gzip
import mapbox_vector_tile
import shapely
for lat in (0, 50, 53.5):
c = math.cos(math.radians(lat))
print(f"latitude {lat:4}: zoom-14 tile {156543.03392 * c / 2 ** 14 * 256:6,.0f} m across, "
f"Mercator area exaggeration {1 / c ** 2:.2f}")
z, x, y = tile_from_lonlat(*town_hall, 15)
w, s_, e, n = bounds_from_tile(z, x, y)
inside = buildings.clip((w, s_, e, n))
mercator = inside.to_crs(3857)
b = mercantile.xy_bounds(x, y, z)
sx, sy = 4096 / (b.right - b.left), 4096 / (b.top - b.bottom)
def mvt(properties):
feats = [{"geometry": shapely.affinity.affine_transform(g, [sx, 0, 0, sy, -b.left * sx, -b.bottom * sy]).wkt,
"properties": p} for g, p in zip(mercator.geometry, properties)]
return mapbox_vector_tile.encode([{"name": "buildings", "features": feats}])
bare = mvt([{}] * len(inside))
two = mvt(inside[["building", "area_m2"]].to_dict("records"))
geojson = inside[["building", "area_m2", "geometry"]].to_json().encode()
print(f"tile {(z, x, y)}: {len(inside)} buildings")
for label, body in (("MVT, no attributes", bare), ("MVT, two attributes", two), ("the same as GeoJSON", geojson)):
print(f" {label:22} {len(body) / 1000:7.2f} kB ({len(gzip.compress(body)) / 1000:6.2f} kB gzipped)")
latitude 0: zoom-14 tile 2,446 m across, Mercator area exaggeration 1.00
latitude 50: zoom-14 tile 1,572 m across, Mercator area exaggeration 2.42
latitude 53.5: zoom-14 tile 1,455 m across, Mercator area exaggeration 2.83
tile (15, 16179, 10601): 401 buildings
MVT, no attributes 12.64 kB ( 9.50 kB gzipped)
MVT, two attributes 19.91 kB ( 12.90 kB gzipped)
the same as GeoJSON 162.33 kB ( 26.98 kB gzipped)
Powers of four, latitude and the y axis
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
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,455 m across in Manchester at 53.5Β° north and 2,446 m at the equator (Example 4). 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
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: 12.90 kB gzipped as MVT against 26.98 kB as GeoJSON for the same 401 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.
XYZ against TMS and ground resolution
- 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
. 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 cut the tile count to a quarter for the same detail β one 512-pixel tile covers four 256-pixel ones β 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
- 3D Tiles and the web delivery of 3D city models explained β the 3D equivalent
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 β 12.90 kB gzipped against 26.98 kB for GeoJSON in the measured tile, 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.