The Web Map Tile Service (WMTS) standard was engineered to solve the latency and scalability bottlenecks inherent in dynamic map rendering. Rather than generating a custom map image per request the way a WMS service does, WMTS pre-computes a fixed pyramid of cached tiles and serves them by address. The structure that makes this addressing deterministic is the Tile Matrix Set — a mathematical framework specifying how continuous geographic space is partitioned across zoom levels, which CRS governs the grid, and exactly where each tile sits within it. For GIS platform engineers and spatial data publishers who need reliable, standards-compliant tiled map delivery, understanding this structure in depth is the difference between a service that works and one that silently mis-registers tiles at scale.
This guide assumes working familiarity with HTTP request/response mechanics and the Python 3.10+ standard library. You will need the following packages installed:
pip install requests lxml pyproj
Before diving into Tile Matrix Set internals, you should understand how WMTS fits within the broader OGC Standards Architecture & Service Fundamentals — specifically the role of GetCapabilities responses in advertising a service’s capabilities contract, and how the SRS and Coordinate Reference System Handling guide’s axis-order rules propagate into WMTS grid definitions. A mis-declared SupportedCRS in a Tile Matrix Set produces a subtly broken service that passes basic connectivity tests but renders tiles offset by hundreds of kilometres in production.
The diagram below shows how a client resolves a tile address from user viewport coordinates:
A TileMatrixSet element in a WMTS Capabilities document contains four mandatory components. Each one is a specific XML element with a defined OGC name — the exact spelling matters because lxml XPath queries are case-sensitive.
Identifier is a string that uniquely names the set within the service. Common values include WebMercatorQuad and GoogleMapsCompatible. Clients reference this name in every tile request via the TileMatrixSet KVP parameter.
SupportedCRS declares the coordinate reference system as a URN. The distinction between urn:ogc:def:crs:EPSG::3857 and the shorthand EPSG:3857 is not cosmetic: the full URN form specifies axis order unambiguously, while the short form leaves axis interpretation to the client library. Always use the full URN in published services.
TileMatrix is an array — one element per zoom level — each containing:
| Child element | Type | Role |
|---|---|---|
Identifier |
string | Zoom-level label, typically "0" through "18" |
ScaleDenominator |
double | Representative fraction; OGC assumes 0.28 mm/px display |
TopLeftCorner |
two doubles | Grid origin in native CRS axis order |
TileWidth / TileHeight |
integer | Tile dimensions in pixels (256 or 512) |
MatrixWidth / MatrixHeight |
integer | Tile count along X and Y axes |
WellKnownScaleSet (optional but strongly recommended) is a URN referencing a predefined scale progression registered with OGC. The value urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible guarantees that scale denominators match the progression expected by OpenLayers, Leaflet, and ESRI clients without per-client negotiation.
The OGC resolution formula is the foundation of all tile index computation:
Resolution (m/px) = ScaleDenominator × 0.00028
The constant 0.00028 metres represents the standardised physical pixel size OGC adopts across all implementations. Ground resolution doubles at each coarser zoom level, and MatrixWidth × MatrixHeight halves accordingly. A globally valid Web Mercator grid (WebMercatorQuad) spans 1 × 1 tiles at zoom 0, 2 × 2 at zoom 1, and 2^n × 2^n at zoom n.
Given a projected coordinate (x, y) in CRS units, tile indices are:
TileCol = floor( (x − TopLeftCorner[0]) / (Resolution × TileWidth) )
TileRow = floor( (TopLeftCorner[1] − y) / (Resolution × TileHeight) )
Note the sign inversion on TileRow: the grid origin sits at the top-left, so Y decreases as row number increases. This matches PNG raster conventions but is opposite to most geographic CRS conventions where northing increases upward.
WMTS has one published version: 1.0.0 (2010). However, three encoding variants exist and behave differently:
| Variant | GetCapabilities path | Tile request encoding | Notes |
|---|---|---|---|
| KVP (Key-Value Pair) | ?SERVICE=WMTS&REQUEST=GetCapabilities |
?SERVICE=WMTS&REQUEST=GetTile&... |
Most compatible; GeoServer and MapServer default |
| RESTful | /1.0.0/WMTSCapabilities.xml |
/{Layer}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}.png |
Highest cache hit rate; requires URL template in Capabilities |
| SOAP | SOAP envelope over HTTP POST | Not used in practice | Avoid; no major server implements it reliably |
The RESTful encoding produces the cleanest CDN cache keys because the full tile address is in the URL path, not the query string, making Cache-Control and surrogate-key invalidation straightforward.
The following class parses a WMTS Capabilities document, extracts a named Tile Matrix Set, and computes valid tile indices from geographic coordinates. All XML access uses explicit OGC namespace prefixes to avoid silent namespace-mismatch failures.
import math
import requests
from typing import Any
from lxml import etree
from pyproj import Transformer, CRS
NS = {
"wmts": "http://www.opengis.net/wmts/1.0",
"ows": "http://www.opengis.net/ows/1.1",
}
# OGC standard: physical pixel size assumed for ScaleDenominator calculations.
OGC_PIXEL_SIZE_M = 0.00028
class WMTSTileMatrixSet:
"""Parse and validate a WMTS Tile Matrix Set from a Capabilities document."""
def __init__(self, capabilities_url: str, timeout: int = 15) -> None:
self.capabilities_url = capabilities_url
self.timeout = timeout
self._root: etree._Element | None = None
# ------------------------------------------------------------------
# Capabilities fetch
# ------------------------------------------------------------------
def fetch_capabilities(self) -> etree._Element:
"""Retrieve and parse WMTS Capabilities XML.
Raises requests.HTTPError on non-2xx responses, and
lxml.etree.XMLSyntaxError on malformed XML.
"""
resp = requests.get(self.capabilities_url, timeout=self.timeout)
resp.raise_for_status()
self._root = etree.fromstring(resp.content)
return self._root
# ------------------------------------------------------------------
# Matrix set extraction
# ------------------------------------------------------------------
def extract_matrix_set(self, matrix_set_id: str) -> dict[str, Any]:
"""Return a structured dict of TMS parameters for *matrix_set_id*.
Raises ValueError if the identifier is absent from Capabilities.
"""
if self._root is None:
self.fetch_capabilities()
xpath = f".//wmts:TileMatrixSet[wmts:Identifier='{matrix_set_id}']"
node = self._root.find(xpath, namespaces=NS)
if node is None:
raise ValueError(
f"TileMatrixSet '{matrix_set_id}' not found in Capabilities."
)
crs_urn: str = node.find("wmts:SupportedCRS", NS).text.strip()
matrices: list[dict[str, Any]] = []
for tm in node.findall("wmts:TileMatrix", NS):
raw_top_left = tm.find("wmts:TopLeftCorner", NS).text.split()
matrices.append({
"identifier": tm.find("wmts:Identifier", NS).text.strip(),
"scale_denominator": float(tm.find("wmts:ScaleDenominator", NS).text),
"top_left_corner": (float(raw_top_left[0]), float(raw_top_left[1])),
"tile_width": int(tm.find("wmts:TileWidth", NS).text),
"tile_height": int(tm.find("wmts:TileHeight", NS).text),
"matrix_width": int(tm.find("wmts:MatrixWidth", NS).text),
"matrix_height": int(tm.find("wmts:MatrixHeight", NS).text),
})
return {
"identifier": matrix_set_id,
"crs": crs_urn,
"matrices": matrices,
}
# ------------------------------------------------------------------
# Grid validation
# ------------------------------------------------------------------
def validate_progression(self, matrix_data: dict[str, Any]) -> list[str]:
"""Check that MatrixWidth and MatrixHeight double at each zoom step.
Returns a list of warning strings (empty list = valid).
Partial grids (TileMatrixSetLimits) are common for regional layers;
this validation applies only to the declared zoom range.
"""
warnings: list[str] = []
matrices = sorted(matrix_data["matrices"], key=lambda m: float(m["identifier"]))
for i in range(1, len(matrices)):
prev, curr = matrices[i - 1], matrices[i]
expected_w = prev["matrix_width"] * 2
expected_h = prev["matrix_height"] * 2
if curr["matrix_width"] != expected_w:
warnings.append(
f"MatrixWidth mismatch at zoom {curr['identifier']}: "
f"expected {expected_w}, got {curr['matrix_width']}"
)
if curr["matrix_height"] != expected_h:
warnings.append(
f"MatrixHeight mismatch at zoom {curr['identifier']}: "
f"expected {expected_h}, got {curr['matrix_height']}"
)
return warnings
# ------------------------------------------------------------------
# Tile index computation
# ------------------------------------------------------------------
def compute_tile_index(
self,
lon: float,
lat: float,
zoom_identifier: str,
matrix_data: dict[str, Any],
) -> tuple[int, int]:
"""Convert WGS84 lon/lat to (TileCol, TileRow) for *zoom_identifier*.
*lon* and *lat* are always in longitude-then-latitude order regardless
of the target CRS axis order — pyproj handles the swap internally when
always_xy=True.
Raises ValueError for unknown zoom identifiers.
Raises IndexError when the coordinate falls outside the grid extent.
"""
target = next(
(m for m in matrix_data["matrices"] if m["identifier"] == zoom_identifier),
None,
)
if target is None:
raise ValueError(
f"Zoom identifier '{zoom_identifier}' not in TileMatrixSet "
f"'{matrix_data['identifier']}'."
)
crs = CRS.from_user_input(matrix_data["crs"])
transformer = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
# always_xy=True ensures transformer.transform(lon, lat) → (easting, northing)
# regardless of the native axis order of the target CRS.
x, y = transformer.transform(lon, lat)
resolution = target["scale_denominator"] * OGC_PIXEL_SIZE_M
tile_col = int((x - target["top_left_corner"][0]) / (resolution * target["tile_width"]))
tile_row = int((target["top_left_corner"][1] - y) / (resolution * target["tile_height"]))
if not (0 <= tile_col < target["matrix_width"]):
raise IndexError(
f"TileCol {tile_col} out of bounds [0, {target['matrix_width']}) "
f"at zoom {zoom_identifier}."
)
if not (0 <= tile_row < target["matrix_height"]):
raise IndexError(
f"TileRow {tile_row} out of bounds [0, {target['matrix_height']}) "
f"at zoom {zoom_identifier}."
)
return tile_col, tile_row
Namespace dict (NS): Every element path in WMTS Capabilities lives under http://www.opengis.net/wmts/1.0 or http://www.opengis.net/ows/1.1. Passing the NS dict to every find() and findall() call avoids the silent-failure mode where an XPath expression matches nothing because a namespace prefix is absent.
fetch_capabilities: The method calls resp.raise_for_status() before parsing. Without this guard, a 200 OK carrying a server-side error HTML page reaches etree.fromstring() and raises a confusing XMLSyntaxError instead of the real HTTP error.
extract_matrix_set: The TopLeftCorner text is split on whitespace and converted to floats. In EPSG:4326 CRS (axis order latitude, longitude), the correct value is "90 -180" — latitude first. In Web Mercator (EPSG:3857, axis order easting, northing), it is "-20037508.3428 20037508.3428". The code stores these as a tuple without re-interpreting axis order; callers must be consistent with the target CRS convention.
validate_progression: Sorting by float-cast identifier handles services that label zoom levels as "0", "1", …, "22" but store them in arbitrary XML order. The check compares MatrixWidth at zoom N against twice the value at zoom N−1 — a deviation indicates either a non-standard scale set or a server configuration bug.
compute_tile_index: The always_xy=True flag on Transformer.from_crs is mandatory. Without it, pyproj follows the native CRS axis order, which for urn:ogc:def:crs:EPSG::4326 is latitude-first, causing x and y to be silently swapped and producing a wildly incorrect tile address.
A WMTS server that receives a malformed GetTile request must return an OWS ExceptionReport XML document rather than an HTTP error code. The exception codes relevant to Tile Matrix Set issues are:
InvalidParameterValue — the TileMatrixSet, TileMatrix, TileRow, or TileCol parameter does not match the Capabilities advertisement.MissingParameterValue — a required KVP parameter was omitted.PointNotDefined — the requested tile lies within a declared TileMatrixSetLimits gap (regional layers only).Always inspect the response Content-Type header before parsing. A tile request that returns text/xml instead of image/png is almost certainly an ExceptionReport.
def safe_tile_fetch(url: str, timeout: int = 10) -> bytes:
"""Fetch a tile, raising a descriptive error if the server returns XML."""
resp = requests.get(url, timeout=timeout)
resp.raise_for_status()
ct = resp.headers.get("Content-Type", "")
if "xml" in ct:
# Parse the ExceptionReport to surface the OGC exception code.
root = etree.fromstring(resp.content)
codes = root.findall(".//{http://www.opengis.net/ows/1.1}ExceptionCode")
code_text = ", ".join(el.text for el in codes) if codes else "unknown"
raise RuntimeError(f"WMTS ExceptionReport (code: {code_text}) from {url}")
return resp.content
The SRS and Coordinate Reference System Handling guide covers axis order in full, but the WMTS-specific manifestation deserves emphasis. Three places in a WMTS workflow each have an independent axis-order rule:
TopLeftCorner in Capabilities: follows the native axis order of SupportedCRS per OGC WMTS 1.0.0 §6.1.BoundingBox in layer metadata: follows the same SupportedCRS axis order.Transformer: follows the always_xy flag — setting it True overrides native CRS order so you can safely pass (lon, lat) regardless of CRS.Treating all three as (lon, lat) is the most common mistake. The symptom is tiles that render correctly within the CRS extent but are displaced by exactly the distance that a lat/lon swap would produce.
Regional layers often declare TileMatrixSetLink with a TileMatrixSetLimits child that restricts valid MinTileRow, MaxTileRow, MinTileCol, MaxTileCol per zoom level. Requesting a tile outside these limits returns PointNotDefined. Your bounds-checking logic must incorporate these limits rather than relying solely on the full MatrixWidth / MatrixHeight.
def parse_limits(
tms_link_node: etree._Element,
) -> dict[str, dict[str, int]]:
"""Return {zoom_id: {min_row, max_row, min_col, max_col}} from a TileMatrixSetLink."""
limits: dict[str, dict[str, int]] = {}
for lim in tms_link_node.findall(
".//wmts:TileMatrixLimits", NS
):
zm = lim.find("wmts:TileMatrix", NS).text.strip()
limits[zm] = {
"min_row": int(lim.find("wmts:MinTileRow", NS).text),
"max_row": int(lim.find("wmts:MaxTileRow", NS).text),
"min_col": int(lim.find("wmts:MinTileCol", NS).text),
"max_col": int(lim.find("wmts:MaxTileCol", NS).text),
}
return limits
A minimal unit-test suite should cover three scenarios: a valid tile index computation, an out-of-bounds coordinate, and an axis-order swap detection.
import pytest
from unittest.mock import MagicMock, patch
SAMPLE_MATRIX_DATA = {
"identifier": "WebMercatorQuad",
"crs": "urn:ogc:def:crs:EPSG::3857",
"matrices": [
{
"identifier": "0",
"scale_denominator": 559082264.0287178,
"top_left_corner": (-20037508.3428, 20037508.3428),
"tile_width": 256,
"tile_height": 256,
"matrix_width": 1,
"matrix_height": 1,
},
{
"identifier": "1",
"scale_denominator": 279541132.0143589,
"top_left_corner": (-20037508.3428, 20037508.3428),
"tile_width": 256,
"tile_height": 256,
"matrix_width": 2,
"matrix_height": 2,
},
],
}
def get_parser() -> "WMTSTileMatrixSet": # noqa: F821 (class defined earlier)
from your_module import WMTSTileMatrixSet
p = WMTSTileMatrixSet.__new__(WMTSTileMatrixSet)
return p
def test_valid_tile_index() -> None:
p = get_parser()
col, row = p.compute_tile_index(0.0, 0.0, "1", SAMPLE_MATRIX_DATA)
assert col == 1
assert row == 1
def test_out_of_bounds_raises() -> None:
p = get_parser()
with pytest.raises(IndexError):
p.compute_tile_index(200.0, 0.0, "1", SAMPLE_MATRIX_DATA)
def test_progression_valid() -> None:
p = get_parser()
warnings = p.validate_progression(SAMPLE_MATRIX_DATA)
assert warnings == []
For OGC CITE compliance, the OGC Compliance & Interoperability Testing and Evaluation suite includes a WMTS 1.0.0 test module. Running it against a GeoServer deployment requires posting a test session configuration that specifies the Capabilities URL and the TileMatrixSet identifier under test. A passing CITE run confirms that GetCapabilities, GetTile, and GetFeatureInfo (if implemented) all comply with the schema and semantic rules of the standard.
RESTful encoding for CDN efficiency. Switch from KVP to the RESTful tile URL template whenever your CDN supports path-based cache keys. A URL like /tiles/WebMercatorQuad/10/512/341.png creates a natural one-to-one mapping between URL and cached object; KVP query strings require the CDN to be configured to include query parameters in cache keys, which is error-prone.
Pre-generation vs on-demand. For layers covering large geographic extents at zoom levels above 10, pre-generating the tile pyramid with MapServer’s mapcache_seed or GeoServer’s GeoWebCache seeder eliminates render latency entirely. At zoom 14 a global Web Mercator pyramid contains roughly 268 million tiles — seed selectively by bounding box or by geometry to avoid generating ocean tiles.
Connection pooling. Re-use a requests.Session with a HTTPAdapter configured for the expected concurrency. A session pool of 10 connections handles most production read workloads; increase to 20–50 for tile proxy scenarios where your Python service is relaying client requests upstream.
import requests
from requests.adapters import HTTPAdapter
session = requests.Session()
adapter = HTTPAdapter(pool_connections=10, pool_maxsize=20, max_retries=3)
session.mount("https://", adapter)
session.mount("http://", adapter)
CDN Cache-Control headers. Tiles are immutable once seeded for a given data version. Set Cache-Control: public, max-age=86400, immutable on the tile response. When layer data changes, increment a version segment in the URL path (/v2/WebMercatorQuad/...) rather than busting the cache with a query parameter — path-based versioning is compatible with all CDN providers.
Memory management during seeding. If you invoke GDAL-based rendering pipelines for on-demand tile generation, ensure raster sources are opened with GDAL_CACHEMAX tuned to available RAM and that each worker opens its own dataset handle rather than sharing one across threads. Shared dataset handles are not thread-safe in GDAL’s Python bindings.
A WellKnownScaleSet is an OGC-registered URI that identifies a predefined progression of ScaleDenominator values. Using one (e.g. urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible) guarantees that any client recognising the same URI can request tiles without needing to parse and match individual ScaleDenominator values. It is required when you need interoperability with web mapping libraries such as OpenLayers or Leaflet that hard-code scale progressions.
OGC WMTS 1.0.0 mandates that TopLeftCorner follows the native axis order of the declared SupportedCRS. For urn:ogc:def:crs:EPSG::4326, the OGC axis order is latitude then longitude, so TopLeftCorner is written as "90 -180", not "-180 90". Confusing the two is the single most common cause of tile row and column being silently swapped — the service returns tiles, they just cover the wrong location.
512-pixel tiles reduce the number of HTTP round trips by a factor of four at equivalent coverage, which benefits high-latency mobile connections. However, they increase per-tile payload and server memory during generation. For CDN-served raster tiles, 256 px remains the safe default because client-side caches and CDN edge nodes are optimised for it. Use 512 px when serving vector tiles or high-DPI displays where the extra resolution eliminates the need for overzooming.
Yes. A single Capabilities document can declare an arbitrary number of TileMatrixSet elements, each with a different SupportedCRS, scale progression, or tile size. Layers then reference the sets they support via TileMatrixSetLink elements, which may also include TileMatrixSetLimits to advertise that only a subset of zoom levels or tile ranges actually contains data for that layer.
Compare the ScaleDenominator values in the Capabilities XML against the expected WellKnownScaleSet progression. A deviation greater than one part in ten thousand (relative error > 1e-4) at any zoom level indicates either a rounding error in the server configuration or a genuinely custom scale set. In either case, clients that rely on hard-coded progressions will silently request incorrect tiles, so the mismatch must be surfaced at service startup, not discovered from user reports.
Back to OGC Standards Architecture & Service Fundamentals
Related