My Tiles Are Offset or in the Wrong Place
On this page
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
- collapsed to one spot, or shifted β 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
print(tms_to_xyz(14, 8089, 11083), tms_to_xyz(*tms_to_xyz(14, 8089, 11083))) # its own inverse
(14, 8089, 5300) (14, 8089, 11083)
Diagnose by rendering one tile whose contents you can recognise:
import mercantile
tile = mercantile.tile(-2.2444, 53.4792, 14) # Manchester Town Hall, from OpenStreetMap
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)}")
XYZ 14/8089/5300
TMS 14/8089/11083
bounds LngLatBbox(west=-2.26318359375, south=53.47496999954854, east=-2.2412109375, north=53.48804553605621)
If your data appears in the TMS tile rather than the XYZ one, the row numbering is flipped.
Mirrored rows, wrong CRS, stretched tiles
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. In the wrong place entirely: the wrong CRS
Web tiles are in EPSG:3857, whose units are metres. Longitude and latitude handed to something that expects EPSG:3857 are read as metres from the origin:
from pyproj import Transformer
to_lonlat = Transformer.from_crs(3857, 4326, always_xy=True)
lon, lat = -2.2444, 53.4792 # Manchester Town Hall
print("degrees read as metres land at", [round(v, 5) for v in to_lonlat.transform(lon, lat)])
x, y = Transformer.from_crs(4326, 3857, always_xy=True).transform(lon, lat)
print("metres read as degrees:", round(x), round(y), "β not a place on Earth")
degrees read as metres land at [-2e-05, 0.00048]
metres read as degrees: -249845 7072134 β not a place on Earth
Degrees read as metres put the whole of Manchester a few metres from 0Β°, 0Β° in the Gulf of Guinea; metres read as degrees are out of range altogether. Neither is a small shift, which is what makes the cause easy to name once you look at where the data went.
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.
import math
def lat_to_mercator_y(lat_deg):
return math.log(math.tan(math.pi / 4 + math.radians(lat_deg) / 2))
top = lat_to_mercator_y(85.0511287798) # the edge of the Web Mercator world
for lat in (0, 10, 30, 53.4792, 70):
mercator = lat_to_mercator_y(lat) / top # where the latitude really falls, -1..1
linear = lat / 85.0511287798 # where a linear mapping puts it
print(f"lat {lat:7.3f}: mercator {mercator:.4f}, linear {linear:.4f}, "
f"off by {(linear - mercator) * 20037.508:8,.0f} km")
lat 0.000: mercator -0.0000, linear 0.0000, off by 0 km
lat 10.000: mercator 0.0558, linear 0.1176, off by 1,237 km
lat 30.000: mercator 0.1748, linear 0.3527, off by 3,564 km
lat 53.479: mercator 0.3529, linear 0.6288, off by 5,527 km
lat 70.000: mercator 0.5524, linear 0.8230, off by 5,423 km
The symptom is data that lines up at low latitude and drifts north or south as you pan away from the equator β the error the table shows growing from nothing at the equator to thousands of kilometres at Manchester's latitude.
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:
for zoom in (13, 14, 15, 18):
print(f"zoom {zoom}: one pixel is {156543.03392 * math.cos(math.radians(53.4792)) / 2 ** zoom:6.2f} m at Manchester")
zoom 13: one pixel is 11.37 m at Manchester
zoom 14: one pixel is 5.69 m at Manchester
zoom 15: one pixel is 2.84 m at Manchester
zoom 18: one pixel is 0.36 m at Manchester
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.
Conversions written once, checked against Manchester Town Hall
The data used on this page
The landmark is Manchester Town Hall, at 53.4792Β° N, 2.2444Β° W in OpenStreetMap. For the pyramid check, the setup writes the tile files a generator would write for central Manchester at zooms 12 to 15 β empty .pbf files, because the check reads only their names β once in XYZ numbering and once with the rows flipped to TMS.
import os
import mercantile
MANCHESTER = (-2.30, 53.44, -2.19, 53.51)
for scheme in ("xyz", "tms"):
for tile in mercantile.tiles(*MANCHESTER, zooms=range(12, 16)):
y = tile.y if scheme == "xyz" else 2 ** tile.z - 1 - tile.y
os.makedirs(f"{scheme}/{tile.z}/{tile.x}", exist_ok=True)
open(f"{scheme}/{tile.z}/{tile.x}/{y}.pbf", "wb").close()
print(sum(len(files) for _, _, files in os.walk("xyz")), "tile files in each of xyz/ and tms/")
186 tile files in each of xyz/ and tms/
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
tile = mercantile.tile(-2.2444, 53.4792, 14)
print(lonlat_to_tile(-2.2444, 53.4792, 14), "matches mercantile:", lonlat_to_tile(-2.2444, 53.4792, 14) == (tile.z, tile.x, tile.y))
print([round(v, 6) for v in tile_to_bounds(14, tile.x, tile.y)] == [round(v, 6) for v in mercantile.bounds(tile)],
[round(v, 3) for v in tile_to_mercator_bounds(14, tile.x, tile.y)] == [round(v, 3) for v in mercantile.xy_bounds(tile)])
(14, 8089, 5300) matches mercantile: True
True True
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
check_placement(-2.2444, 53.4792, 18, "Manchester Town Hall")
Manchester Town Hall at -2.2444, 53.4792
XYZ 18/129437/84810
TMS 18/129437/177333
position within the tile: 174, 211 px of 256
geographic bounds -2.24533 53.47906 -2.24396 53.47987
mercator bounds -249949.1 7072106.7 -249796.2 7072259.6
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
for scheme in ("xyz", "tms"):
print(scheme)
validate_pyramid(scheme, MANCHESTER, range(12, 16))
xyz
z12: 6 files, 6 expected, 0 missing, 0 unexpected
z13: 12 files, 12 expected, 0 missing, 0 unexpected
z14: 36 files, 36 expected, 0 missing, 0 unexpected
z15: 132 files, 132 expected, 0 missing, 0 unexpected
tms
z12: 6 files, 6 expected, 6 missing, 6 unexpected
z13: 12 files, 12 expected, 12 missing, 12 unexpected
z14: 36 files, 36 expected, 36 missing, 36 unexpected
z15: 132 files, 132 expected, 132 missing, 132 unexpected
! z12: rows look TMS-numbered (6 match flipped against 0 direct)
! z13: rows look TMS-numbered (12 match flipped against 0 direct)
! z14: rows look TMS-numbered (36 match flipped against 0 direct)
! z15: rows look TMS-numbered (132 match flipped against 0 direct)
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.
Two y conventions and logarithmic Mercator
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 up to zoom 14 at Manchester, nearly two pixels at zoom 15, and fourteen 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.
MBTiles rows and EPSG:3857
- 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?
A linear latitude mapping somewhere in the chain β the error is zero at the equator and grows with latitude. Data in the wrong CRS altogether does not shift; it lands near 0Β°, 0Β° or nowhere at all.
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.