Lat/Lon to Tile Coordinates: The Complete Math
How to convert any lat/lon to a slippy-map tile z/x/y — the formulas, working JavaScript, and the four edge cases that bite real apps in production.
Umar Farooq · compiled MapMath from a decade of map-app work
· 10 min read
Almost every web map you've ever used — Google Maps, OpenStreetMap, Mapbox, Leaflet, the live tracker in your delivery app — works by stitching together small square images called tiles. Each tile has a fixed identity: a zoom level z, a column x, and a row y. If you know how to convert lat/lon to tile coordinates, you can fetch any map tile from any provider, position a marker on the right pixel inside it, or build a covering set of tiles for a polygon you want to highlight.
This post is the practical, complete version: the formulas, the working JavaScript that drops into any project, and the four edge cases that quietly break naive implementations in production.
What "tile coordinates" actually means
Open OpenStreetMap and watch the network panel as you drag the map. You'll see requests fly out to URLs that look like this:
https://tile.openstreetmap.org/12/2893/1670.png
The three numbers are the tile coordinates. The first is the zoom level z, the second is the column x, the third is the row y. Together they identify exactly one 256×256-pixel image somewhere in the world.
The reason the whole web-mapping ecosystem agrees on this addressing — called the slippy map convention — is that it makes mixing layers trivial. A road network from one provider and a satellite layer from another will line up perfectly because both publishers cut their tiles using the same math.
Tile grid explorer
z=2 · 4×4 = 16 tiles
Here's how the grid works:
- At zoom 0, the entire world is one tile (
0/0/0). - At zoom 1, the world is a 2×2 grid (4 tiles).
- At zoom z, the world is 2z × 2z tiles — a quadtree where every zoom-in splits each tile into four.
Tile x increases as you move east. Tile y increases as you move south — top is y = 0, bottom is y = 2^z − 1. The downward y is a quirk inherited from screen pixel coordinates, where (0, 0) is the top-left corner.
The lat/lon to tile coordinates formulas
To go from a geographic point (latitude φ in degrees, longitude λ in degrees) to a tile at zoom z:
where φr is the latitude converted to radians.
The x formula is straightforward — longitude −180°…+180° is linearly mapped onto the integer range 0…n−1. Longitude is uniform: one degree east at the equator covers the same number of tiles as one degree east near the pole.
The y formula is the interesting one. The Web Mercator projection stretches the world vertically as you move toward the poles, so a degree of latitude near the equator covers more tiles vertically than a degree of latitude near, say, 60° N. The ln(tan + sec) term is the inverse Gudermannian function — that's the Mercator projection in disguise. It's what makes Greenland look the size of Africa on every web map, and it's why this formula isn't just a linear scale.
The 1 − …/2 normalises the output to [0, 1] top-to-bottom (north → south), and multiplying by n and flooring gives the tile row.
Working JavaScript: lat/lon → tile
Here's a drop-in function. No dependencies, ES module syntax, ~10 lines:
/**
* Convert lat/lon (degrees) to slippy-map tile coordinates at a given zoom.
*
* @param {number} lat latitude in degrees, valid (-85.05113, +85.05113)
* @param {number} lon longitude in degrees, valid [-180, 180)
* @param {number} zoom integer zoom level, typically 0–22
* @returns {{ z: number, x: number, y: number }}
*/
export function latLonToTile(lat, lon, zoom) {
const n = 2 ** zoom;
const latRad = (lat * Math.PI) / 180;
const x = Math.floor(n * (lon + 180) / 360);
const y = Math.floor(
n * (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2
);
return { z: zoom, x, y };
}
A worked example. Lahore is at roughly (31.5204° N, 74.3587° E). At zoom 12:
latLonToTile(31.5204, 74.3587, 12);
// → { z: 12, x: 2894, y: 1669 }
You can verify this directly — paste this in a browser tab:
https://tile.openstreetmap.org/12/2894/1669.png
You get a 256×256 PNG showing the slice of the world that contains Lahore. The math works.
Going back: tile → lat/lon
The inverse is just as useful. Given a tile (z, x, y), what's the top-left corner in lat/lon?
/**
* Top-left corner of a tile, in lat/lon degrees.
*/
export function tileToLatLon(x, y, zoom) {
const n = 2 ** zoom;
const lon = (x / n) * 360 - 180;
const lat = (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI;
return { lat, lon };
}
To get the bounding box of a tile (its full coverage), call the function for both (x, y) (top-left) and (x + 1, y + 1) (bottom-right):
export function tileBounds(x, y, zoom) {
const nw = tileToLatLon(x, y, zoom);
const se = tileToLatLon(x + 1, y + 1, zoom);
return {
north: nw.lat,
south: se.lat,
west: nw.lon,
east: se.lon,
};
}
This is what you'd use to tell whether a marker falls inside a particular tile, or to query a vector tile for the features it contains.
Sub-tile precision: lat/lon → pixel within a tile
The floor() in the tile formula throws away precision. If you actually want to place a marker on a tile — say, render a dot for Lahore — you need the pixel coordinate within the tile, not just which tile it falls in.
The trick: don't floor. Compute the fractional tile position, then split it into integer tile + fractional offset, and multiply the fraction by 256:
/**
* Lat/lon → tile + pixel offset within that tile (0–255).
*/
export function latLonToTilePixel(lat, lon, zoom) {
const n = 2 ** zoom;
const latRad = (lat * Math.PI) / 180;
const xFloat = n * (lon + 180) / 360;
const yFloat =
n * (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
const x = Math.floor(xFloat);
const y = Math.floor(yFloat);
return {
z: zoom,
x, y,
pixelX: Math.floor((xFloat - x) * 256),
pixelY: Math.floor((yFloat - y) * 256),
};
}
For Lahore at zoom 12:
latLonToTilePixel(31.5204, 74.3587, 12);
// → { z: 12, x: 2894, y: 1669, pixelX: 9, pixelY: 198 }
Drop a 16×16 marker icon at (9, 198) inside the tile 12/2894/1669.png and it lands exactly on Lahore. This is the basis of every static-map renderer.
Four edge cases that quietly break naive implementations
The formulas above are correct, but they have boundaries. Here are the four cases that catch most people the first time they ship a map feature to production.
1. Latitude beyond ±85.05113° (the Mercator cutoff)
Web Mercator can't represent the poles. The log(tan(φ)) term blows up to infinity as φ approaches ±90°. To keep the world map square (so the tile pyramid stays a quadtree), the convention is to clamp at:
If you pass lat = 89 to the naive function, you get a tile y value way outside the valid [0, 2^z) range. The fix is a one-line clamp at the entry to your function:
const MAX_LAT = 85.0511287798;
const safeLat = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
If your users have any chance of passing in arctic coordinates (satellite trackers, polar research, aviation), don't skip this. The bug is silent — the tile request returns a 404 or a transparent image — and people will spend hours wondering why their map is blank near the poles.
2. Longitude exactly 180° (the antimeridian)
Longitude is defined on [-180, 180], but the tile range is [0, 2^z) — half-open. If lon = 180 exactly, the formula gives x = n, which is one past the last valid tile.
You almost never see exactly 180.0 from a sensor, but you do see it in test fixtures, hand-written GeoJSON, and shapefiles that have been re-projected. A clamp on longitude wrap-around is the safe move:
function normaliseLon(lon) {
// Wrap into [-180, 180), so 180 becomes -180 (same meridian, valid tile)
return ((lon + 180) % 360 + 360) % 360 - 180;
}
Same fix applies if you're given a longitude outside the standard range — say lon = 540 because someone added 360 to "go around the world twice." Normalising at the entry to your tile function keeps everything downstream sane.
3. Float precision at zoom 22 and above
At zoom 22, the world is divided into 222 = 4,194,304 tiles per side. The pixel resolution is roughly 15 cm per pixel at the equator. JavaScript's Number is a 64-bit float with about 15–17 decimal digits of precision, and the cumulative error in the tan + sec calculation starts to creep into the last bit.
In practice this manifests as a marker being one pixel off, or two adjacent points falling into different tiles when they shouldn't. If you're rendering at sub-meter precision (drone mapping, surveying, indoor positioning) and you see jitter, the issue is float math, not your geometry.
Mitigation: do the maths in higher precision (a library like decimal.js), or — much simpler — cap your zoom at 20 and use a vector-tile renderer that interpolates within the tile rather than rasterising at a deeper zoom.
4. Fractional zoom levels
The formulas assume integer zoom. But every modern web-map library — Mapbox GL, MapLibre, Google Maps — supports fractional zooms (12.3, 15.7, etc.) for smooth pinch-zoom and animation.
The math actually works with non-integer zoom — 2 ** 12.5 is a perfectly valid number — but you can't fetch a tile at zoom 12.5, because the tile server only has integer-zoom images. The standard approach: fetch the integer-zoom tile (floor or round) and scale it in CSS / GL to display at the fractional zoom level.
const fractionalZoom = 12.5;
const tileZoom = Math.floor(fractionalZoom); // fetch this
const scale = 2 ** (fractionalZoom - tileZoom); // CSS transform: scale(scale)
If your renderer is doing the wrong thing at half-zooms, this split is usually the missing piece.
Putting it together: fetching a real tile
With everything above, the end-to-end "show me the tile for this point" workflow is a one-liner:
const { z, x, y } = latLonToTile(31.5204, 74.3587, 12);
const url = `https://tile.openstreetmap.org/${z}/${x}/${y}.png`;
console.log(url);
// → https://tile.openstreetmap.org/12/2894/1669.png
If you swap the base URL for any other tile provider that follows the slippy-map convention, the same (z, x, y) triple works. Some examples:
| Provider | URL pattern |
|---|---|
| OpenStreetMap | https://tile.openstreetmap.org/{z}/{x}/{y}.png |
| OpenStreetMap (DE) | https://tile.openstreetmap.de/{z}/{x}/{y}.png |
| Stamen Toner | https://stamen-tiles.a.ssl.fastly.net/toner/{z}/{x}/{y}.png |
| ESRI World Imagery | https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x} |
Notice ESRI swaps x and y in the URL — always check the docs. The underlying coordinate values are the same; only the URL convention differs.
When you're using OpenStreetMap's public tile server directly, remember to set a meaningful User-Agent header and respect their tile usage policy. For anything beyond hobby use, run your own tile server (with tileserver-gl) or use a paid provider.
Why this matters beyond just rendering
Knowing how lat/lon maps to tile (z, x, y) unlocks a surprising number of things:
- Spatial caching — index your data by tile, not by lat/lon range. Lookups become hash-keyed instead of range-scanned.
- Map-data preloading — given a user's current viewport, compute the visible tiles plus a one-tile buffer, and pre-fetch them.
- Vector-tile feature queries — vector tiles (
.mvt/.pbf) are addressed by(z, x, y)too. To ask "what features are at this lat/lon", you compute the tile, fetch it, decode it, and run a point-in-feature test inside the tile. - Coverage-set construction — given a polygon (a service area, a geofence), the set of
(z, x, y)tiles that cover it lets you query, render, or pre-compute selectively.
The tile math is the bridge between a continuous lat/lon and a discrete, addressable, cacheable grid. Most map-app performance problems are really tile-math problems — too many tiles fetched, the wrong zoom for the data, tiles re-fetched on every pan because nothing's cached. Get the math right once and a lot downstream gets easier.
Going further
This post covers the tile math itself. The full picture — Web Mercator's derivation, pixel coordinates within the world, why projections distort, and the alternatives to slippy-map tiles (vector tiles, S2 cells, H3 hexagons) — lives in the MapMath guide. The tile-math chapter goes deeper on the projection, and there's a working JS file with the functions above plus pixel-coordinate helpers ready to drop in.
If something here is unclear, broken, or you've hit a fifth edge case that should be on the list — email me. I'd rather fix this post than leave it stale.