Configuring Tile Matrix Sets for Global WMTS Deployments
TL;DR: Generate an OGC-compliant TileMatrixSet by anchoring a deterministic hierarchical grid to EPSG:3857 or EPSG:4326, halving the ScaleDenominator at each zoom level from the GoogleMapsCompatible base value of 559082264.0287178, and deriving MatrixWidth/MatrixHeight from the CRS extent divided by the tile’s ground coverage. Declare TopLeftCorner at the exact top-left of the CRS extent, enforce axis-order rules, and register the result in your GetCapabilities document before publishing. Deviating from these rules by even a single decimal in a scale denominator breaks client-side tile caching and request routing silently.
Core Challenge: Locked Grid Mathematics Across Three Interacting Dimensions
The non-obvious difficulty in global WMTS configuration is that ScaleDenominator, TopLeftCorner, MatrixWidth, MatrixHeight, and tile pixel size are not independent inputs — they are a closed mathematical system. Changing any one value without recomputing the others produces a grid that is internally inconsistent. Clients such as OpenLayers, Leaflet, and QGIS hardcode expected tile counts per zoom level for the GoogleMapsCompatible well-known scale set; any deviation causes silent tile misregistration rather than a visible error.
The foundational grid model — including how TileMatrix entries nest inside a TileMatrixSet and how WellKnownScaleSet URNs are declared — is explained in depth in WMTS Tile Matrix Sets Explained. That page also covers the SupportedCRS parameter and axis-order conventions, which directly constrain the TopLeftCorner values you must supply.
The diagram below shows how the three configuration dimensions — scale, grid size, and tile coverage — interlock at each zoom level for a global EPSG:3857 deployment:
Core Configuration Parameters
Every TileMatrix entry inside a TileMatrixSet element — as it appears in a GetCapabilities response — must declare five mandatory parameters. Understanding what each one encodes before writing a single line of configuration prevents the class of silent misregistration errors that are otherwise nearly impossible to diagnose at runtime:
ScaleDenominator— the ground distance per display pixel expressed as a unit-less ratio, calculated using the OGC standard pixel size of 0.28 mm (0.00028 m). This is not DPI-dependent; it is a fixed physical constant in the specification.TopLeftCorner— the grid origin in CRS units: meters for EPSG:3857, degrees for EPSG:4326. This must be the exact top-left corner of the full CRS extent, not the data extent.TileWidth/TileHeight— tile pixel dimensions, uniform across all zoom levels within the set. The two dominant choices are256(standard web mapping) and512(higher-resolution displays and retina screens).MatrixWidth/MatrixHeight— total tile count along X and Y at that zoom level, derived from the CRS extent and the tile’s ground coverage.Identifier— a zoom-level string (e.g.,"0","1","18") used in tile request URLs and client-side tile index lookups.
The SRS and Coordinate Reference System Handling guide explains how axis-order rules for EPSG:4326 versus EPSG:3857 affect these coordinates and why the CRS URN format matters for SupportedCRS declarations.
Grid Mathematics and Scale Progression
The OGC WMTS 1.0.0 specification defines a standard display pixel size of 0.28 mm, which corresponds to approximately 90.7 DPI. Ground resolution in meters per pixel at any zoom level is:
Resolution (m/px) = ScaleDenominator × 0.00028
At zoom level z, the scale denominator halves relative to the previous level:
ScaleDenominator(z) = BaseScaleDenominator / (2^z)
Matrix dimensions follow directly from the CRS extent and the tile’s ground coverage:
MatrixWidth = ceil(ExtentX / (Resolution × TileWidth))
MatrixHeight = ceil(ExtentY / (Resolution × TileHeight))
For EPSG:3857 the full extent spans [-20037508.342789244, 20037508.342789244] meters in both X and Y — a square of approximately 40,075,017 m per side. The GoogleMapsCompatible base scale denominator of 559082264.0287178 places exactly one 256×256 tile at zoom level 0, resolving to 156543.034 m/px. At zoom 1 both MatrixWidth and MatrixHeight become 2, at zoom 2 they become 4, and so on — the grid doubles in each dimension with every zoom step.
For EPSG:4326 the extent is asymmetric: [-180, 90] to [180, -90] in degrees. The GlobalCRS84Scale well-known scale set’s base denominator of 279541132.01435887 produces a 2×1 grid at zoom 0 because the CRS spans 360° of longitude but only 180° of latitude. This is the single most common misconfiguration for engineers migrating from a Web Mercator-only stack.
Production-Ready Code
The following self-contained generator produces an OGC-compliant TileMatrixSet dictionary for EPSG:3857 or EPSG:4326. It calculates every required parameter from first principles, handles both CRS extents, and outputs a structure ready for serialization into GetCapabilities XML or a GeoWebCache JSON configuration.
import math
import json
from typing import Literal
# OGC standard display pixel size in metres (fixed constant, not DPI-dependent)
OGC_PIXEL_SIZE_M = 0.00028
# Well-known scale set base denominators and their CRS extents
CRS_CONFIGS = {
"EPSG:3857": {
"wkss": "urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible",
"base_scale": 559082264.0287178,
"origin_x": -20037508.342789244,
"origin_y": 20037508.342789244,
"extent_x": 40075016.685578488,
"extent_y": 40075016.685578488,
# EPSG:3857 uses x,y axis order
"top_left_order": "xy",
},
"EPSG:4326": {
"wkss": "urn:ogc:def:wkss:OGC:1.0:GlobalCRS84Scale",
"base_scale": 279541132.01435887,
"origin_x": -180.0, # longitude (x-axis)
"origin_y": 90.0, # latitude (y-axis, OGC 1.3+ lat-first)
"extent_x": 360.0, # degrees of longitude
"extent_y": 180.0, # degrees of latitude
# EPSG:4326 requires lat,lon ordering for TopLeftCorner in OGC 1.3+
"top_left_order": "yx",
},
}
def generate_tile_matrix_set(
crs: Literal["EPSG:3857", "EPSG:4326"] = "EPSG:3857",
tile_size: int = 256,
min_zoom: int = 0,
max_zoom: int = 18,
) -> dict:
"""
Generate an OGC-compliant TileMatrixSet for the given CRS.
Returns a dict suitable for JSON serialisation into a GeoWebCache
gridset or for inclusion in a WMTS GetCapabilities document.
Args:
crs: Target CRS identifier. Supports "EPSG:3857" and "EPSG:4326".
tile_size: Tile pixel size in both dimensions (256 or 512).
min_zoom: First zoom level (usually 0).
max_zoom: Last zoom level (0–28 for EPSG:3857; 0–21 practical max).
Raises:
ValueError: If crs is not a supported identifier.
"""
if crs not in CRS_CONFIGS:
raise ValueError(
f"Unsupported CRS '{crs}'. Choose from: {list(CRS_CONFIGS)}"
)
cfg = CRS_CONFIGS[crs]
matrices: list[dict] = []
for z in range(min_zoom, max_zoom + 1):
# Scale denominator halves at every zoom level (base-2 progression)
scale_denom = cfg["base_scale"] / (2 ** z)
# Ground resolution in CRS units per pixel (metres for 3857, degrees for 4326)
resolution = scale_denom * OGC_PIXEL_SIZE_M
# Tile counts: ceil ensures full CRS extent is covered at every level
matrix_width = math.ceil(cfg["extent_x"] / (resolution * tile_size))
matrix_height = math.ceil(cfg["extent_y"] / (resolution * tile_size))
# TopLeftCorner axis order: EPSG:4326 requires lat,lon (y,x) in OGC 1.3+
if cfg["top_left_order"] == "yx":
top_left = [cfg["origin_y"], cfg["origin_x"]] # lat first
else:
top_left = [cfg["origin_x"], cfg["origin_y"]] # lon/easting first
matrices.append({
"Identifier": str(z),
"ScaleDenominator": round(scale_denom, 10),
"TopLeftCorner": top_left,
"TileWidth": tile_size,
"TileHeight": tile_size,
"MatrixWidth": matrix_width,
"MatrixHeight": matrix_height,
})
return {
"TileMatrixSet": {
"Identifier": f"{crs}_Global_{tile_size}x{tile_size}",
"SupportedCRS": crs,
"WellKnownScaleSet": cfg["wkss"],
"TileMatrix": matrices,
}
}
if __name__ == "__main__":
# Generate the standard Web Mercator set at zoom levels 0–18
mercator_set = generate_tile_matrix_set("EPSG:3857", tile_size=256, max_zoom=18)
print(json.dumps(mercator_set, indent=2))
# Generate a WGS84 geographic set for OGC-strict deployments
geographic_set = generate_tile_matrix_set("EPSG:4326", tile_size=256, max_zoom=18)
print(json.dumps(geographic_set, indent=2))
Step-by-Step Walkthrough
CRS_CONFIGS dictionary. Embedding both CRS configurations in a dictionary rather than hard-coding them inline makes the generator straightforward to extend. The top_left_order key encodes the axis-order rule: EPSG:3857 publishes [x, y] (easting, northing), while EPSG:4326 must publish [lat, lon] (y, x) to comply with OGC 1.3+ axis conventions. This asymmetry between the two most common global CRS choices is the single most common source of deployment errors.
Scale denominator loop. Dividing base_scale by 2^z at each iteration ensures a strict geometric progression. Rounding to 10 decimal places avoids floating-point noise without sacrificing OGC-required precision (the spec mandates at least 6 significant figures).
Resolution and matrix size. Multiplying scale_denom by OGC_PIXEL_SIZE_M (0.00028 m) converts the abstract ratio into the ground distance one display pixel covers. Dividing the CRS extent by resolution × tile_size then gives the number of tiles needed in each direction. math.ceil is mandatory: flooring would leave a sub-pixel gap at the edge of the CRS extent, producing 404 tile requests from clients requesting the last tile column or row.
TopLeftCorner axis order. The top_left_order branch applies the axis swap for EPSG:4326. The OGC WMTS 1.0.0 specification requires that TopLeftCorner values follow the declared CRS’s axis order. For EPSG:4326 as defined by the EPSG geodetic parameter dataset, the first axis is latitude (north) and the second is longitude (east). Clients that parse GetCapabilities strictly — including recent versions of OpenLayers — will misplace tiles if this order is reversed.
Output structure. The returned dictionary maps directly to the TileMatrixSet XML element in a WMTS GetCapabilities document. Identifier becomes the <Identifier> child element. WellKnownScaleSet is the URN published under <ows:Identifier> within the well-known scale set reference block.
Verification
Run the generator and inspect the zoom 0 and zoom 1 entries to confirm mathematical consistency:
python generate_tms.py | python3 -c "
import json, sys
data = json.load(sys.stdin)
matrices = data['TileMatrixSet']['TileMatrix']
for m in matrices[:3]:
res = m['ScaleDenominator'] * 0.00028
print(f\"z{m['Identifier']:>2}: scale={m['ScaleDenominator']:.4f} \
res={res:.3f} m/px grid={m['MatrixWidth']}x{m['MatrixHeight']}\")
"
Expected output for EPSG:3857:
z 0: scale=559082264.0287 res=156543.034 m/px grid=1x1
z 1: scale=279541132.0144 res=78271.517 m/px grid=2x2
z 2: scale=139770566.0072 res=39135.758 m/px grid=4x4
If grid=1x1 at zoom 0 and grid=2x2 at zoom 1 the output matches the GoogleMapsCompatible well-known scale set exactly. Any deviation indicates a floating-point error in the base scale denominator or a rounding step applied before the division.
Gotchas and Edge Cases
EPSG:4326 axis order in TopLeftCorner. The OGC WMTS specification requires TopLeftCorner values to follow the declared CRS’s native axis order. For EPSG:4326 this means [90.0, -180.0] (latitude first), not [-180.0, 90.0] (longitude first). The SRS and Coordinate Reference System Handling guide documents how to detect axis-order inversion programmatically using pyproj. Swapping the values silently rotates the tile grid 90 degrees — clients render tiles, but every tile is in the wrong geographic position.
WellKnownScaleSet URN precision. The URN urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible is case-sensitive. Some GeoServer versions accept a lowercase variant and silently normalise it; MapProxy does not. Always use the exact URN from the OGC definition. Omitting the WellKnownScaleSet element entirely is valid but forces every client to parse ScaleDenominator values individually rather than resolving them against a known set — this increases GetCapabilities parse time and breaks clients that shortcut by matching the well-known set identifier.
Integer overflow at high zoom levels. At zoom 28 for EPSG:3857, MatrixWidth and MatrixHeight exceed 268 million. Python handles arbitrary-precision integers natively, but if you serialize to JSON and parse in a language with 32-bit integers (JavaScript’s |0 operator, some C parsers), values above 2,147,483,647 will overflow silently. Cap max_zoom at 22 for production deployments unless you have verified your entire toolchain handles 64-bit integers.
GeoServer GetCapabilities caching. GeoServer caches its GetCapabilities document for up to 60 seconds after a gridset change via the REST API. Registering a new TileMatrixSet and immediately querying capabilities may return the stale document. Wait for the cache TTL or issue a POST /gwc/rest/reload request to force a reload before validating the output.
Server Integration
GeoServer and GeoWebCache. Import the configuration via the GeoWebCache REST API by posting the generated JSON to /gwc/rest/gridsets/{name} with Content-Type: application/json. To verify registration: GET /gwc/rest/gridsets.json should list the new identifier. Then assign the gridset to a tile layer via POST /gwc/rest/layers/{layerName}, referencing the new identifier in the gridSubsets array. Alternatively, use the Web UI under Tile Caching → Tile Layers → Edit → Tile Matrix Sets.
MapProxy. Declare the matrices under grids: in mapproxy.yaml. Set origin: nw to match the WMTS top-left convention — MapProxy’s default is sw (south-west), which inverts the Y axis and produces tile rows in reverse order. Set srs: EPSG:3857 and bbox: [-20037508.342789244, -20037508.342789244, 20037508.342789244, 20037508.342789244] to match the generated extent exactly.
QGIS Server. Declare the TILEMATRIXSET in your project’s OWS server properties panel. QGIS Server reads GetCapabilities at startup and does not auto-detect custom gridsets from upstream proxies; the configuration must be present in the project file before the server process starts.
Back to WMTS Tile Matrix Sets Explained
Related
- WMTS Tile Matrix Sets Explained — foundational
TileMatrixstructure,ScaleDenominatormathematics, and production parsing patterns - SRS and Coordinate Reference System Handling — axis-order rules for EPSG:4326 and EPSG:3857, and how CRS URN format affects
TopLeftCornervalues - Handling Spatial Reference Mismatches in OGC Requests — diagnosing and fixing CRS mismatch errors that surface as tile offset or misregistration
- OGC Standards Architecture & Service Fundamentals — service registration,
GetCapabilitiesmetadata compliance, and discovery layer integration