A route relation holds the ways of a line in order. Join their geometry into one path and send it to /elevation/profile: you get a height every 90 m with the distance from the start, the total climb, and the lowest and highest points.
The Pilatus railway near Lucerne is the steepest rack railway in the world, with gradients of up to 48%.
The query
[out:json][timeout:25]; rel["route"="train"]["ref"="CC83"]["to"="Pilatus Kulm"]; out geom;
Run it
Live result from overpass.ch, cached for up to 12 hours. Map data © OpenStreetMap contributors (ODbL). Heights produced using Copernicus WorldDEM™-90 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved. Sea depths: GEBCO Compilation Group (2026) GEBCO 2026 Grid.
How it works
rel["route"="train"]["ref"="CC83"]["to"="Pilatus Kulm"]picks the uphill route of line CC83. The way down is a separate relation.out geom;returns the relation with the coordinates of every member.- The members with an empty role are the track;
stopandplatformmembers are left out. Join the ways in order, and turn a way round when its end, not its start, touches the previous one. - Send the path to
/elevation/profileas{"path": [[lat, lon], …]}. In this data the line is about 4.3 km long and climbs about 1,590 m.
Variations
- The Gornergrat railway in Zermatt:
rel["route"="train"]["name"="R48 (CC): Zermatt GGB => Gornergrat"];(9.3 km, about 1,490 m of climb). - Change the spacing with
interval(in metres, at least 10) orsamples, as described in the documentation. - A GPX track instead of an OSM route: post the file itself to
/elevation/profile, see add elevation to a GPX file.
Run it from your code
Two requests: the query returns the route, and its joined geometry goes to /elevation/profile.
import math
import requests
API = "https://api.overpass.ch/v1/YOUR_API_KEY"
query = open("query.overpassql").read()
r = requests.post(f"{API}/interpreter", data={"data": query}, timeout=120)
r.raise_for_status()
route = r.json()["elements"][0]
def gap(a, b): # distance in degrees, enough to compare
return math.hypot(a[0] - b[0], (a[1] - b[1]) * math.cos(math.radians(a[0])))
ways = [[[p["lat"], p["lon"]] for p in m["geometry"]] for m in route["members"]
if m["type"] == "way" and m["role"] in ("", "forward", "backward")]
path = list(ways[0])
for i, way in enumerate(ways[1:]):
near = lambda p: min(gap(p, way[0]), gap(p, way[-1]))
if i == 0 and near(path[0]) < near(path[-1]):
path.reverse() # the first way points backwards
if gap(path[-1], way[-1]) < gap(path[-1], way[0]):
way = way[::-1] # so does this one
path += way
r = requests.post(f"{API}/elevation/profile", json={"path": path}, timeout=60)
r.raise_for_status()
answer = r.json()
print(answer["profile"]) # distance_m, ascent_m, descent_m, min_m, max_m, …
for p in answer["results"][:5]:
print(p["distance"], p["elevation"])
const API = "https://api.overpass.ch/v1/YOUR_API_KEY";
const res = await fetch(`${API}/interpreter`, { method: "POST", body: new URLSearchParams({ data: query }) });
if (!res.ok) throw new Error(await res.text());
const route = (await res.json()).elements[0];
// distance in degrees, enough to compare
const gap = (a, b) => Math.hypot(a[0] - b[0], (a[1] - b[1]) * Math.cos((a[0] * Math.PI) / 180));
const ways = route.members
.filter((m) => m.type === "way" && ["", "forward", "backward"].includes(m.role))
.map((m) => m.geometry.map((p) => [p.lat, p.lon]));
const path = [...ways[0]];
ways.slice(1).forEach((way, i) => {
const near = (p) => Math.min(gap(p, way[0]), gap(p, way.at(-1)));
if (i === 0 && near(path[0]) < near(path.at(-1))) path.reverse(); // the first way points backwards
if (gap(path.at(-1), way.at(-1)) < gap(path.at(-1), way[0])) way.reverse(); // so does this one
path.push(...way);
});
const pr = await fetch(`${API}/elevation/profile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
if (!pr.ok) throw new Error(await pr.text());
const { profile, results } = await pr.json();
console.log(profile, results.slice(0, 5));
Save the query as query.overpassql. It works the same in Overpass Turbo, QGIS and JOSM.