Overpass query example

Elevation profile of a mountain railway

Draw the climb of a railway line from its route geometry and the elevation profile API. Example: the Pilatus railway, the steepest rack railway.

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

Running the query…

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

  1. rel["route"="train"]["ref"="CC83"]["to"="Pilatus Kulm"] picks the uphill route of line CC83. The way down is a separate relation.
  2. out geom; returns the relation with the coordinates of every member.
  3. The members with an empty role are the track; stop and platform members are left out. Join the ways in order, and turn a way round when its end, not its start, touches the previous one.
  4. Send the path to /elevation/profile as {"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) or samples, 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"])

Save the query as query.overpassql. It works the same in Overpass Turbo, QGIS and JOSM.

More examples

All examples →