My Tiles Are Offset or in the Wrong Place
Problem statement
Tiles render, and they are in the wrong place. The four ways this happens look different and have completely different causes:
- vertically mirrored β TMS row numbering used as XYZ
- shifted by a constant β wrong CRS, or a wrong origin
- stretched or squashed β reprojection missing, or the wrong tile grid
- offset by a fraction of a tile β pixel-centre against pixel-corner
The first is by far the most common, because two conventions differ in exactly one integer and every tool assumes the other one.
Quick answer
def tms_to_xyz(z, x, y):
"""TMS numbers rows from the south; XYZ from the north."""
return z, x, (2 ** z - 1) - y
Diagnose by rendering one tile whose contents you can recognise:
import mercantile
tile = mercantile.tile(-2.2426, 53.4808, 14) # a known location
print(f"XYZ {tile.z}/{tile.x}/{tile.y}")
print(f"TMS {tile.z}/{tile.x}/{2 ** tile.z - 1 - tile.y}")
print(f"bounds {mercantile.bounds(tile)}")
If your data appears in the TMS tile rather than the XYZ one, the row numbering is flipped.
Step-by-step solution
1. Vertically mirrored: XYZ against TMS
XYZ, used by every common web map library, numbers rows from the north. TMS, the older OGC scheme, numbers them from the south.
They agree on zoom and column and disagree on row, so a mismatch produces a map that is correct left to right and flipped top to bottom β with every individual tile looking like valid data.
MBTiles stores rows TMS-style. Most generators produce XYZ. The conversion belongs at the write step:
y_tms = (2 ** zoom - 1) - y_xyz
2. Shifted by a constant: the wrong CRS
Web tiles are in EPSG:3857. Data in EPSG:4326 treated as Web Mercator lands within a few metres of the equator and hundreds of kilometres away elsewhere.
The tell is that the offset grows with latitude and is zero at the equator.
print(gdf.crs) # source
print("tiles assume EPSG:3857")
3. Stretched north-south: latitude treated linearly
Web Mercator's y coordinate is R * ln(tan(Ο/4 + Ο/2)), not proportional to latitude. Using a linear mapping produces tiles that are correct at the equator and increasingly stretched toward the poles.
def lat_to_mercator_y(lat_deg):
import math
return math.log(math.tan(math.pi / 4 + math.radians(lat_deg) / 2))
The symptom is data that lines up at low latitude and drifts north or south as you pan away from the equator.
4. Offset by half a pixel: centre against corner
A raster's transform gives the corner of the top-left pixel. Code that treats it as the pixel centre shifts everything by half a cell.
At 10 m resolution that is 5 m β invisible at low zoom, obvious at zoom 18 where one pixel is 0.36 m.
x_centre = transform.c + (col + 0.5) * transform.a
x_corner = transform.c + col * transform.a
5. Verify against a known point
The reliable test is a landmark. Render the tile containing a coordinate you can identify and check that the feature is where it should be within the tile.
Code examples
Example 1 β the conversions, written once
import math
def lonlat_to_tile(lon, lat, zoom):
"""XYZ tile containing a point."""
n = 2 ** zoom
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n)
return zoom, min(max(x, 0), n - 1), min(max(y, 0), n - 1)
def tile_to_bounds(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 tile_to_mercator_bounds(zoom, x, y):
"""Web Mercator metre bounds of an XYZ tile."""
circumference = 20037508.342789244
size = 2 * circumference / (2 ** zoom)
return (-circumference + x * size,
circumference - (y + 1) * size,
-circumference + (x + 1) * size,
circumference - y * size)
def flip_y(zoom, y):
"""Between XYZ and TMS row numbering β the conversion is its own inverse."""
return (2 ** zoom - 1) - y
Writing these four functions once, in one module, removes most of this problem. The asinh(tan(lat)) term is the Mercator projection; implementing it directly makes the non-linearity explicit.
Example 2 β a diagnostic against a known landmark
import mercantile
def check_placement(lon, lat, zoom, label="landmark"):
"""Which tile should contain this point, in both conventions?"""
tile = mercantile.tile(lon, lat, zoom)
bounds = mercantile.bounds(tile)
merc = mercantile.xy_bounds(tile)
fx = (lon - bounds.west) / (bounds.east - bounds.west)
fy = (bounds.north - lat) / (bounds.north - bounds.south)
print(f" {label} at {lon:.4f}, {lat:.4f}")
print(f" XYZ {tile.z}/{tile.x}/{tile.y}")
print(f" TMS {tile.z}/{tile.x}/{flip_y(tile.z, tile.y)}")
print(f" position within the tile: "
f"{fx * 256:.0f}, {fy * 256:.0f} px of 256")
print(f" geographic bounds {bounds.west:.5f} {bounds.south:.5f} "
f"{bounds.east:.5f} {bounds.north:.5f}")
print(f" mercator bounds {merc.left:.1f} {merc.bottom:.1f} "
f"{merc.right:.1f} {merc.top:.1f}")
return tile
Printing the pixel position within the tile is what turns "it looks about right" into a check. If the landmark should be at pixel (120, 80) and appears at (120, 176), the tile is vertically mirrored within itself rather than misnumbered.
Example 3 β validating a whole pyramid
import os
import mercantile
def validate_pyramid(tile_dir, bounds, zooms, extension=".pbf"):
"""Do the files on disk match the tiles the extent implies?"""
problems = []
for zoom in zooms:
expected = {(t.x, t.y) for t in mercantile.tiles(*bounds, zooms=[zoom])}
directory = os.path.join(tile_dir, str(zoom))
if not os.path.isdir(directory):
problems.append(f"z{zoom}: no directory")
continue
found = set()
for x_dir in os.listdir(directory):
for name in os.listdir(os.path.join(directory, x_dir)):
if name.endswith(extension):
found.add((int(x_dir), int(name[:-len(extension)])))
missing = expected - found
extra = found - expected
flipped = {(x, 2 ** zoom - 1 - y) for x, y in found}
overlap_direct = len(expected & found)
overlap_flipped = len(expected & flipped)
print(f" z{zoom}: {len(found):,} files, {len(expected):,} expected, "
f"{len(missing):,} missing, {len(extra):,} unexpected")
if overlap_flipped > overlap_direct:
problems.append(f"z{zoom}: rows look TMS-numbered "
f"({overlap_flipped} match flipped against "
f"{overlap_direct} direct)")
for p in problems:
print(f" ! {p}")
return problems
Comparing the direct and flipped overlap is a decisive test for the y-axis convention, and it needs no rendering at all β just the filenames.
Explanation
Why two y conventions exist
TMS was specified first and follows the mathematical convention: the origin at the bottom-left, y increasing upward.
XYZ, popularised by web map services, follows the screen convention: the origin at the top-left, y increasing downward. That matches how images are addressed and how a browser lays out a grid.
Both are internally consistent. The problem is only that they differ in one integer and neither records which it uses, so a mismatch is silent.
MBTiles uses TMS. PMTiles, MapLibre, Leaflet and almost every tile server use XYZ.
Why the Mercator y is logarithmic
Mercator preserves angles, which requires the north-south scale to match the east-west scale at every point. As latitude increases, meridians converge, so the east-west scale grows by 1/cos(Ο) β and the north-south scale must grow to match.
Integrating that gives y = ln(tan(Ο/4 + Ο/2)), which is asinh(tan(Ο)).
A linear mapping of latitude to y is correct at the equator and diverges from there. Testing near the equator hides the bug completely, which is why it survives into production.
Why the half-pixel offset is subtle
A raster transform is an affine mapping from pixel corner coordinates. Pixel (0, 0) occupies the region from the origin to one cell east and south.
Sampling code frequently wants the centre, which is +0.5 cells in each direction. Mixing the two shifts everything by half a cell.
At 10 m resolution that is 5 m β below one screen pixel until about zoom 15, and clearly visible at zoom 18. So it passes every low-zoom check and appears only when someone looks closely.
Why a landmark test beats reasoning
All four of these produce plausible-looking tiles. The data is there, the tiles are valid, and the projection maths is subtle enough that reasoning about it is unreliable.
Rendering one tile containing a recognisable feature β a coastline, a road junction, a lake β and checking where the feature lands within the tile answers all four questions at once. It takes a minute and is the only test that catches a convention error you have reproduced consistently in both your generator and your checker.
Edge cases or notes
- XYZ counts rows from the north; TMS from the south.
y_tms = 2^z - 1 - y_xyz. - MBTiles stores TMS rows. PMTiles and most servers use XYZ.
- Tiles are EPSG:3857, not 4326.
- Mercator y is
asinh(tan(lat)), not linear in latitude. - A raster transform gives pixel corners, not centres.
- Test at a mid latitude; equatorial tests hide the projection bugs.
- Check the pixel position within the tile, not just which tile.
- 512-pixel tiles change the resolution per zoom β a z14 512-tile matches a z15 256-tile.
Internal links
- Web map tiles explained: XYZ, zoom levels and the tile pyramid β the addressing scheme
- PMTiles and MBTiles explained β where the TMS flip is needed
- How to render raster tiles from a COG in Python β tile bounds in practice
- How to build vector tiles from a GeoDataFrame in Python β the generator side
- My web map is blank or the layer never appears β when nothing renders at all
- Choosing a map projection for display: what Web Mercator distorts β the projection itself
- Raster and vector do not line up in Python β the same class of problem
- How to export a static tile pyramid for offline use β validating the output
FAQ
Why is my tile layer upside down?
TMS row numbering used where XYZ is expected. Convert with y_xyz = 2^z - 1 - y_tms.
Why are my tiles shifted, and worse at high latitude?
The data is in EPSG:4326 and being treated as Web Mercator. The error is zero at the equator and grows with latitude.
Why is my layer stretched north-south?
A linear latitude-to-y mapping. Web Mercator's y is asinh(tan(latitude)), which is logarithmic.
Why is everything off by half a pixel?
A raster transform gives pixel corners. Adding 0.5 cells gives centres; mixing the two shifts everything by half a cell.
How do I check my tiles are in the right place?
Render the tile containing a recognisable landmark and check where the feature lands within the 256-pixel grid, not just which tile it is in.
Do MBTiles and PMTiles use the same numbering?
No. MBTiles stores rows TMS-style from the bottom; PMTiles uses XYZ from the top.
Why did my tests pass and production fail?
Probably testing near the equator, where the linear-latitude bug has no effect. Always test at a mid latitude.