Converting XYZ Tile URLs to WMTS GetTile Requests in Python

An XYZ address maps onto WMTS almost directly — x becomes TILECOL, y becomes TILEROW, and both count downward from the top-left, same as slippy maps. The two things you cannot assume are the tile matrix identifier, which is frequently EPSG:3857:9 rather than 9, and the row direction of the source, because the older TMS convention counts upward and produces a perfectly plausible vertically mirrored map.

The Core Challenge: Three Tile Conventions, Two of Which Agree

Slippy-map XYZ, as used by every web mapping library, places tile (0,0) at the top-left of the world and counts rows downward. WMTS does the same: TILEROW increases southward from the declared TopLeftCorner. TMS — the older OSGeo specification whose URLs look identical — counts rows upward from the bottom.

The mapping is nearly one-to-one, and the exceptions bite A six-row grid mapping slippy-map XYZ tile concepts onto WMTS parameters. Zoom becomes the tile matrix identifier, which is not always the bare number; column and row map directly and both count downward from the top-left; the implicit Web Mercator grid becomes a named tile matrix set with a declared origin; and the row direction is the trap, because the older TMS convention counts upward from the bottom. Concept XYZ slippy map WMTS Zoom z TILEMATRIX (often 'z', sometimes 'EPSG:3857:z') Column x TILECOL Row y (top-down) TILEROW (top-down) Grid implicit Web Mercator named TILEMATRIXSET Origin top-left TopLeftCorner, declared Y direction down down — but TMS counts up

Because the three URL forms are visually indistinguishable, a source labelled only “tile server” can be any of them, and the failure is not an error but a map that is mirrored about the equator. At low zoom that is obvious; at high zoom over featureless terrain it is not, and it has shipped to production more than once. The conversion y_tms = 2**z - 1 - y_xyz is trivial — knowing which side of it you are on is the actual work.

Production-Ready Code

from __future__ import annotations

from dataclasses import dataclass
from functools import lru_cache
from xml.etree import ElementTree as ET

import requests

NS = {
    "wmts": "http://www.opengis.net/wmts/1.0",
    "ows": "http://www.opengis.net/ows/1.1",
}

@dataclass(frozen=True)
class MatrixLevel:
    identifier: str            # what TILEMATRIX must literally contain
    matrix_width: int
    matrix_height: int

    def contains(self, col: int, row: int) -> bool:
        return 0 <= col < self.matrix_width and 0 <= row < self.matrix_height

@dataclass(frozen=True)
class TileMatrixSet:
    identifier: str
    levels: dict[int, MatrixLevel]     # zoom -> level

    def level(self, zoom: int) -> MatrixLevel:
        try:
            return self.levels[zoom]
        except KeyError:
            raise KeyError(
                f"zoom {zoom} is not in tile matrix set {self.identifier!r}; "
                f"available: {sorted(self.levels)}") from None

@lru_cache(maxsize=16)
def read_matrix_set(capabilities_url: str, set_id: str) -> TileMatrixSet:
    resp = requests.get(capabilities_url, timeout=60, params={
        "SERVICE": "WMTS", "VERSION": "1.0.0", "REQUEST": "GetCapabilities"})
    resp.raise_for_status()
    root = ET.fromstring(resp.content)

    for tms in root.iter(f"{{{NS['wmts']}}}TileMatrixSet"):
        identifier = tms.findtext("ows:Identifier", "", NS)
        if identifier != set_id:
            continue
        levels: dict[int, MatrixLevel] = {}
        for matrix in tms.findall("wmts:TileMatrix", NS):
            raw = matrix.findtext("ows:Identifier", "", NS)
            # The identifier may be "9" or "EPSG:3857:9" — the zoom is the
            # trailing integer, but TILEMATRIX must carry the whole string.
            zoom = int(raw.rsplit(":", 1)[-1])
            levels[zoom] = MatrixLevel(
                identifier=raw,
                matrix_width=int(matrix.findtext("wmts:MatrixWidth", "0", NS)),
                matrix_height=int(matrix.findtext("wmts:MatrixHeight", "0", NS)))
        return TileMatrixSet(identifier, levels)

    raise KeyError(f"tile matrix set {set_id!r} is not advertised")

def tms_to_xyz(y: int, zoom: int) -> int:
    """Flip a TMS row index into the XYZ/WMTS convention."""
    return (1 << zoom) - 1 - y

def gettile_params(matrix_set: TileMatrixSet, layer: str, zoom: int,
                   x: int, y: int, style: str = "default",
                   fmt: str = "image/png", source: str = "xyz") -> dict[str, str]:
    """Build KVP GetTile parameters from a slippy-map address.

    `source` states the convention of the incoming y: 'xyz' counts down
    from the top, 'tms' counts up from the bottom.
    """
    if source not in {"xyz", "tms"}:
        raise ValueError("source must be 'xyz' or 'tms'")
    row = y if source == "xyz" else tms_to_xyz(y, zoom)

    level = matrix_set.level(zoom)
    if not level.contains(x, row):
        raise IndexError(
            f"tile {zoom}/{x}/{row} is outside matrix {level.identifier} "
            f"({level.matrix_width}x{level.matrix_height})")

    return {
        "SERVICE": "WMTS", "VERSION": "1.0.0", "REQUEST": "GetTile",
        "LAYER": layer, "STYLE": style, "FORMAT": fmt,
        "TILEMATRIXSET": matrix_set.identifier,
        "TILEMATRIX": level.identifier,     # the full string, not the zoom
        "TILEROW": str(row), "TILECOL": str(x),
    }

def restful_url(template: str, matrix_set: TileMatrixSet, layer: str,
                zoom: int, x: int, y: int, style: str = "default",
                source: str = "xyz") -> str:
    """Fill a ResourceURL template — the CDN-friendly binding."""
    row = y if source == "xyz" else tms_to_xyz(y, zoom)
    level = matrix_set.level(zoom)
    if not level.contains(x, row):
        raise IndexError(f"tile {zoom}/{x}/{row} is outside {level.identifier}")
    return (template
            .replace("{TileMatrixSet}", matrix_set.identifier)
            .replace("{TileMatrix}", level.identifier)
            .replace("{TileRow}", str(row))
            .replace("{TileCol}", str(x))
            .replace("{Layer}", layer)
            .replace("{Style}", style))

def fetch(url: str, params: dict[str, str] | None = None) -> bytes | None:
    """None means the tile is legitimately absent, not that a call failed."""
    resp = requests.get(url, params=params, timeout=30)
    if resp.status_code == 404:
        return None
    resp.raise_for_status()
    if "xml" in resp.headers.get("content-type", ""):
        raise RuntimeError(f"service exception: {resp.text[:300]}")
    return resp.content

Step-by-Step Walkthrough

TILEMATRIX takes the identifier, not the zoom. This is the mistake that costs the most time, because a service using bare numeric identifiers works perfectly with a client that sends the zoom, and the same client fails completely against a service using EPSG:3857:9. Reading the identifiers from capabilities and keeping the zoom only as a lookup key handles both without a special case.

Five steps from a slippy-map address to a WMTS tile A five-stage chain converting an XYZ tile address into a WMTS request. The zoom, column and row arrive from the map client; the tile matrix identifier is resolved from capabilities because it is not always the bare zoom number; the column and row are bounds-checked against the matrix dimensions; the request is built in either the keyword-value or the RESTful binding; and the response is tile bytes or a 404 that means the tile is legitimately empty. z / x / y from the map client Resolve the matrix id not always the bare number Bounds check 0 <= col < MatrixWidth Build the request KVP or RESTful Tile bytes or a 404 that means empty input capabilities assert template

The source argument makes the convention explicit. A function that silently assumes XYZ is a function that will one day be handed TMS indices. Naming the convention at the call site turns an invisible assumption into a visible argument, and tms_to_xyz documents the flip in one place.

Bounds-check before requesting. A column or row outside the matrix produces a 404 or a TileOutOfRange exception depending on the server, and both are indistinguishable from a legitimately empty tile. Checking locally against MatrixWidth and MatrixHeight — which the tile matrix set guide explains are 2**z for a quad pyramid — separates the two cases cleanly.

Prefer the RESTful binding for anything cached. A path-based tile URL carries no query string, so a CDN caches it on the path alone with no configuration. The ResourceURL template is advertised in capabilities exactly for this, and filling it is a string substitution.

Verification

Confirm the identifiers and that a known tile resolves:

matrix_set = read_matrix_set(caps_url, "WebMercatorQuad")
print({z: lvl.identifier for z, lvl in sorted(matrix_set.levels.items())[:4]})

params = gettile_params(matrix_set, "orthophoto", zoom=12, x=2145, y=1436)
data = fetch(base_url, params)
print(params["TILEMATRIX"], len(data) if data else "empty")
{0: 'EPSG:3857:0', 1: 'EPSG:3857:1', 2: 'EPSG:3857:2', 3: 'EPSG:3857:3'}
EPSG:3857:12 24518

Then check the orientation deliberately: fetch tile (1,0) and (1,1) at zoom 1 and confirm the northern hemisphere is the one with TILEROW=0. It is the only check that catches a flip.

Gotchas & Edge Cases

A 404 is a normal response. Sparse tile caches return 404 for tiles that were never seeded, and treating that as an error turns a partially seeded layer into a stream of exceptions. Returning None and letting the caller decide — draw nothing, fall back to a lower zoom, trigger a render — is the useful behaviour.

Three conventions, one of which is upside down A decision diamond for offset or missing WMTS tiles. A vertically mirrored map means the TMS convention, which counts rows upward from the bottom, was assumed instead of the XYZ and WMTS convention that counts downward from the top; a wrong tile matrix identifier means the declared identifiers were not read; an out-of-range index produces a 404 that is not an error; and when all three are right the layer genuinely has no data at that address. Tiles are offset or missing. Which convention was assumed? TMS counts up XYZ and WMTS count down Read the identifiers 'EPSG:3857:9' not '9' Bounds-check first a 404 is not an error here No data there the layer's limits exclude it y flipped matrix id wrong out of range all correct

TileMatrixSetLimits narrow the valid range further. A layer may support a tile matrix set globally while only holding data in one country, and it advertises that as per-level row and column limits inside its TileMatrixSetLink. Bounds-checking against the matrix alone therefore over-estimates what exists; reading the limits removes most of the 404s in advance.

Not every service offers WebMercatorQuad. A national service may publish only its own grid, in which case there is no XYZ equivalent at all and the conversion is meaningless — the client has to work in the service’s grid. Checking that the requested set is advertised, rather than assuming, is what read_matrix_set raising a KeyError is for.

Cache the capabilities document. It is large, it changes rarely, and fetching it per tile turns a fast tile proxy into a slow one. The lru_cache above is process-local; a shared cache with a short TTL is the equivalent for a multi-process deployment.

Frequently Asked Questions

Is WMTS TILEROW the same direction as XYZ y?

Yes. Both count downward from the top-left origin, so an XYZ y maps to TILEROW unchanged. The convention that differs is TMS, which counts upward from the bottom. Since TMS and XYZ URLs look identical, the only safe approach is to state which convention a source uses rather than infer it.

Why is my TILEMATRIX rejected when I send the zoom number?

Because the tile matrix identifier is a string chosen by the service, and many implementations qualify it with the reference system — ‘EPSG:3857:9’ rather than ‘9’. The parameter must carry that string verbatim. Reading the identifiers from capabilities and indexing them by the trailing integer handles both styles without branching.

Should I use the KVP or the RESTful binding?

RESTful whenever the service advertises a ResourceURL template, especially behind a CDN, because a path-based URL is cacheable on the path with no cache-key configuration. KVP is the fallback and is fine for a server-side client that is not caching.

How do I avoid requesting tiles that do not exist?

Read the TileMatrixSetLimits from the layer’s TileMatrixSetLink and bounds-check against those rather than against the full matrix dimensions. That removes almost all of the 404s for a layer with a limited footprint, which matters when a viewport at high zoom would otherwise fire dozens of requests for empty ocean.


Back to WMTS Tile Matrix Sets Explained

Related