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.

A tile pyramid with one tile at zoom 0 quadrupling at each level, and the ground resolution halving.
Each zoom quadruples the tile count and halves the ground size. The pyramid is the whole data model.

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.

XYZ numbering y from the top and TMS from the bottom, producing a vertically mirrored map when mixed.
XYZ counts y downward, TMS upward. Mixing them mirrors the map vertically and nothing errors.

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.

Zoom levels 12 to 18 giving 22.7 to 0.355 metres per pixel against 10 m source data.
Above zoom 14, a 10 m source is being rendered finer than it was measured.

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.

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.