Stable URLs for 42,000 Huts and Peaks
How every hut, bivouac and peak on biwak.info gets a short, readable URL like /p/matterhorn that never changes: unique names keep the plain name, shared names get the region, then the elevation, then the OpenStreetMap id, and a committed registry makes sure a slug never moves to another place.
biwak.info is my map of Alpine huts, bivouacs and peaks.
For a long time the only way to link to a hut was a #Name hash that did an
exact name match and fell back to the search when the name wasn't unique. I
wanted real URLs I can share and that show a proper preview card in a chat:
biwak.info/p/matterhorn.
The data comes from OpenStreetMap: 9,429 huts and bivouacs and 33,437 named peaks, 42,866 places in total. Turning a name into a URL is easy. The hard part is that many places share a name, and that a URL, once shared, should keep pointing to the same place, even when the data changes underneath.

From a name to a slug
The first step is a plain slugify: German umlauts are written out, everything
else is folded to ASCII, lowercased, and every run of other characters becomes
a single dash.
GERMAN_TRANSLIT = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue",
"ß": "ss", "ẞ": "ss"})
def slugify(text):
"""German transliteration for umlauts and ß, then ASCII-fold, lowercase, everything else to single dashes."""
text = (text or "").translate(GERMAN_TRANSLIT)
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii").lower()
return re.sub(r"[^a-z0-9]+", "-", text).strip("-")
So Oberaletschhütte becomes oberaletschhuette, Štuhecov dom becomes
stuhecov-dom and Rifugio Città di Busto becomes rifugio-citta-di-busto.
None of the 42,866 names ends up empty.
Names are not unique
Alpine names repeat a lot. Every valley has its Jagdhütte and its Rosskopf. With the name alone, the most common slugs are:
| Slug | Places |
|---|---|
jagdhuette |
70 |
schwarzkogel |
37 |
buchberg |
32 |
rosskopf |
32 |
mitterberg |
31 |
Before choosing a scheme, I measured how many places would still share a slug with different suffixes:
| Slug made of | Shared slugs | Places affected | Worst |
|---|---|---|---|
| name | 2,181 | 6,690 (15.6 %) | 70 |
| name + country | 1,993 | 5,936 (13.8 %) | 64 |
| name + region | 1,516 | 3,932 (9.2 %) | 27 |
| name + region + elevation | 118 | 394 (0.9 %) | 21 |
Adding the country barely helps: most duplicates are in the same country. The
region roughly halves them, but there are still 27 places called Jagdhütte in
Carinthia alone. Elevation fixes most of the rest. It can't fix everything,
because 4,311 of the huts have no elevation in OpenStreetMap. The worst case
in the last row is casella in Liguria: 21 small huts with the same name and
no elevation.
So there is no single suffix that always works, and adding all of them to every
URL would make /p/matterhorn look like /p/matterhorn-wallis-4478.
Add a suffix only while the name is shared
The rule I settled on: a place gets the shortest slug that is unique, trying these in order:
- the plain name:
/p/matterhorn - name and region:
/p/oberaletschhuettestays plain, but a shared name becomes something like/p/rosskopf-tirol - name, region and elevation:
/p/rosskopf-tirol-2257 - name, region and OpenStreetMap id:
/p/jagdhuette-kaernten-w1202594655
In code this is a generator that yields the candidates for one place, given all current places with the same name slug:
def candidates(p, group):
"""Slugs for POI p in order of preference; `group` = all current POIs with the same name slug."""
base, region = p["base"], slugify(p["region"])
others = [o for o in group if o["id"] != p["id"]]
if not others:
yield base
if not any(slugify(o["region"]) == region for o in others):
yield f"{base}-{region}"
if p["ele"] is not None and not any(slugify(o["region"]) == region and o["ele"] == p["ele"] for o in others):
yield f"{base}-{region}-{p['ele']}"
yield f"{base}-{region}-{p['id']}"
The OpenStreetMap id is the last resort because it's always unique, but nobody can read it. On today's data the 42,866 places split like this:
| Slug | Places |
|---|---|
| plain name | 36,176 |
| + region | 2,758 |
| + elevation | 3,474 |
| + OpenStreetMap id | 458 |
Regions from Natural Earth
The region has to be something people recognise. I cut the Natural Earth admin-1 boundaries down to the units that make sense in the Alps: the Bundesland in Austria and Germany, the Kanton in Switzerland, the département in France (Savoie and Haute-Savoie are better known than the big régions), the regione in Italy with South Tyrol and Trentino split, and the statistical regions in Slovenia. Names use the German form where that is common (Wallis, Tessin, Aostatal), otherwise the local one. The places on the map fall into 63 of these regions.
Each place gets its region by a point-in-polygon lookup with Shapely. The boundaries are simplified, so a hut right on a border or a lake shore can fall outside every polygon. Then the nearest polygon wins:
def lookup(self, lat, lon):
"""(cc, region name); points just outside every polygon (coast, simplified border) take the nearest."""
pt = Point(lon, lat)
for i in self.tree.query(pt):
if self.geoms[i].contains(pt):
break
else:
i = self.tree.nearest(pt)
return self.props[i]["cc"], self.props[i]["name"]
The region is also shown on the preview card and in the detail view, next to the type: "Biwak · Haute-Savoie, Frankreich".
Stable: a registry that never forgets
The scheme above depends on the data. If someone adds a second Matterhorn to
OpenStreetMap tomorrow, the plain rule would rename the real one to
matterhorn-wallis, and every link shared so far would break.
So the build keeps a slug registry, data/slug-registry.json, which is
committed to git. It maps every slug ever handed out to the OpenStreetMap id
and the name:
"matterhorn": ["n26863664", "Matterhorn"],
"oberaletschhuette": ["n11262553552", "Oberaletschhütte"],
The assignment then follows three rules:
- A slug in the registry never moves to another place.
- A place keeps its registry slug as long as it still fits its current
name, even if the plain rule would now give it a different one. When a
second Matterhorn shows up, the old one stays
matterhornand only the newcomer gets a suffix. - A new or renamed place gets the first candidate that isn't taken yet. The old slug of a renamed place stays in the registry and redirects to the new one.
primary = {}
for p in pois:
fitting = [s for s in by_id.get(p["id"], []) if s == p["base"] or s.startswith(p["base"] + "-")]
if fitting:
primary[p["id"]] = min(fitting, key=lambda s: (len(s), s))
for base in sorted(groups):
for p in groups[base]:
if p["id"] in primary:
continue
slug = next(s for s in candidates(p, groups[base]) if s not in registry)
registry[slug] = [p["id"], p["name"]]
primary[p["id"]] = slug
A place that disappears from OpenStreetMap keeps its slug reserved. Opening it starts a search for the old name instead of showing someone else's hut.
Because the registry is a sorted JSON file with one slug per line, a build
that changes slugs shows up as a readable git diff. That made one mistake easy
to spot: the first version folded ü to u, so the Oberaletschhütte was
/p/oberaletschhutte. Two weeks after launch I switched to ue, which changed
7,182 slugs. I rebuilt the registry from scratch, so the old slugs are gone
instead of redirecting.
Two fallbacks
A plain name that is shared, like /p/rosskopf,
doesn't return an error. It opens the search with exactly the places of that
name, with their type, elevation and region, and each of them links to its own
slug. The same works one level down for /p/rosskopf-tirol.
A slug that ends in an OpenStreetMap id always resolves, even if the name part
is wrong or outdated. /p/old-name-n11262553552 answers with a 301 to
/p/oberaletschhuette.
Limits
- Readable doesn't mean unique. 458 places end with an OpenStreetMap id in their slug. Better elevation data in OpenStreetMap would shrink that number, and the registry would still keep the old slugs working as redirects.
- The registry only grows. It has one line per slug ever handed out, 42,866 today.
- Changing the rules touches thousands of URLs. Any change to
slugifyor the regions renames a large part of the places, as the umlaut switch showed.
The same /p/ URLs also let the map open peaks directly, as described in
Loading 33,000 Peaks on Demand with a z10 Tile Grid.
