Getting tram stops in Hannover from OpenStreetMap
An article from last year’s Perl Advent Calendar gave me an idea. As is often the case, that idea spawned other ideas. One of those new ideas raised the question: “How do I get all tram lines and tram stops in Hannover, Germany from OpenStreetMap?”. Here’s my answer to that question, implemented–because reasons–in Python.

Image credits: Wikimedia Commons
Side project spawn recursion
Ever find that your side projects spawn their own side projects? That definitely happens to me. Last year I was reading the Perl Advent calendar and read an interesting article about Map::Tube, a Perl module implementing a lightweight routing framework for railway systems, created by the awesome Mohammad Sajid Anwar.
After I’d finished reading, and after having had a bit of a look at the project, I thought to myself “Hrm, since there are maps available for many cities around the world, I wonder if I could write a version for Hannover?”.1 That sent me down a rabbit hole. Of course, once one realises that there are many tram stops in the Hannover tram network, it’s thus lots of work to extract and format the data by hand. You’d think it’d be possible to automate this process, right? I mean, the data must be in OpenStreetMap and one can simply extract it from there. Yes, that’s correct, it’s possible to do, but it’s not as simple as one might expect. Anyway, this article describes the rabbit hole that the first rabbit hole spawned (after all it’s rabbit holes all the way down).2
Accessing OpenStreetMap data: a wealth of possibilities
So where to start? It turns out that there are multiple ways to access OpenStreetMap (OSM) data: one can use the API directly, perform name-based lookups with Nominatim, or have read-only access via Overpass. The last option seemed to be the best because, as the OSM API page recommends:
… consider the Overpass API which provides read-only API access.
This way if I make a mistake (and happen to be logged in) I won’t accidentally edit anything I don’t want to.
The Overpass API page mentions interfaces for different programming languages, such as Python, Java and JavaScript. There is also an excellent web-based UI called Overpass Turbo which has a wizard for creating Overpass queries and showing results on a map. This turned out to be very useful for debugging any queries that I was making.
One could, in theory, access the Overpass API directly by hand-crafting appropriate queries and POST-ing them to the API endpoint. Yet, for my situation, it seemed much simpler for me to use something that I was more used to (and for which programming interfaces exist): Python.
There are a few Python modules which wrap the Overpass API interface and two stood out for me:
- the obviously-named
overpass
module, and - the broader
OSMPythonTools
library.
In the end, I decided to use OSMPythonTools
. But, before I did that, I
spent a lot of time playing with overpass
and it seemed to do most of what
I wanted to achieve. Could I have gotten things to work using both modules?
Maybe. But really I only needed one, and OSMPythonTools
it was.
Both modules are documented with examples to help beginner users create queries and fetch information from OSM, so either is a good choice for accessing Overpass from Python. Which module one chooses will depend upon the problem one is trying to solve and its requirements.
Overpass has its own query
language
(Overpass
QL), which
can take some getting used to. The overpass
module is a thin wrapper
around this query language, so it’s a good idea to understand at least the
basics of Overpass QL when using it. The overpass
module returns results
from the Overpass API as Python objects. This is handy because it saves
having to make the extra step of parsing the JSON or XML that the Overpass
API returns. The OSMPythonTools
library also returns results as Python
objects and interfaces not only to Overpass but also to Nominatim and the
full OSM API.
Because Overpass defines its own query language, it can take a while to work out the right way to ask for the information that you’re interested in. This is where Overpass Turbo comes in really handy. Overpass Turbo has a wizard feature in which you can create the outline of a query. Then, once the basics are in place, you refine the query further (while also spending a lot of time reading the docs) to fetch the data of interest.
Finding Hannover in Overpass Turbo
Let’s dip our toes into Overpass by creating queries with the help of Overpass Turbo.
To set us on our path to finding Hannover’s tram lines and stations, let’s first try to find only the city of Hannover, Germany.3
Baby steps: A basic Overpass QL query
An initial query fulfilling this task can be represented by the following Overpass query language code:
[timeout:25][out:json];
area[name="Hannover"];
out body;
Let’s pull this apart element by element to understand what’s happening. The individual elements in this query are:
-
[timeout:25]
tells Overpass that it should timeout the request after 25 seconds. This avoids waiting too long for data and helps reduce the load on the Overpass server.4 -
[out:json]
tells Overpass to return the output as JSON. This is a standard output format and is useful when wanting to munge data further using other tools. The other formats, are XML (the default), CSV, custom, and popup. - The semicolon
;
completes the global setting section. -
area[name="Hannover"];
tells Overpass that we’re looking for an area (a specific kind of data structure in OpenStreetMap) that has thename
tag equal to the string"Hannover"
.5 -
out body;
gets Overpass to output the body of the returned set’s contents. This is also the default value for theout
statement and prints “[…] all information necessary to use the data. These are also tags for all elements and the roles for relation members”.
We now want to enter this query into the Overpass Turbo web service. Navigate to https://overpass-turbo.eu/ in a web browser and you’ll be presented with this landing page:
Remove the initial code from the panel on the left-hand side and replace it with our query code from above. Run the query by clicking on the “Run” button. Overpass Turbo will now present you with a dialog box warning you about incomplete data:
Don’t worry about this; all the warning is trying to tell us is that Overpass Turbo can’t display the data on a map. Still, it can show us the raw data, which is what we’re interested in right now. Click on the “show data” button to display the raw JSON data. The data looks like this:
{
"version": 0.6,
"generator": "Overpass API 0.7.62.5 1bd436f1",
"osm3s": {
"timestamp_osm_base": "2025-02-20T12:01:21Z",
"timestamp_areas_base": "2025-02-06T02:17:44Z",
"copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."
},
"elements": [
{
"type": "way",
"id": 223750395,
"nodes": [
2326281637,
2326281669,
2326281629,
4495193407,
2326281603,
2326281611,
2326281616,
2326281637
],
"tags": {
"building": "hotel",
"name": "Hannover",
"rer_edi_id:ref": "cc68901d-9f54-4a14-80e9-7609a3665fbc",
"source": "Regione Emilia Romagna",
"stars": "2",
"tourism": "hotel"
}
},
{
"type": "way",
"id": 370552711,
"nodes": [
3742480781,
3742480783,
3742480782,
3742480780,
3742480781
],
"tags": {
"alt_name": "kouzina Hannover",
"amenity": "restaurant",
"building": "yes",
"cuisine": "regional",
"name": "Hannover",
"phone": "+30 27330 93000"
}
},
...
<snip>
As we can see from the output, we’ve gotten several matches for our search. In other words, there are many things within the OSM data that match the idea of an area with the name “Hannover”.
Before we have a look at the kinds of things this query has returned, it’s a good idea to mention some OSM terminology.
Some OSM terminology
To understand the output properly, here’s a crash course in OSM nomenclature.
Everything in OSM is either a node, a way, or a relation. These are the fundamental elements of a map within the OpenStreetMap world. They can be linked to one another and they can have metadata attached to them via tags.
To give you more of an idea of what these things represent, I can’t do better than quote their descriptions from the OpenStreetMap wiki:
A node represents a specific point on the earth’s surface defined by its latitude and longitude.
A way is an ordered list of between 1 (!) and 2,000 nodes that define a polyline. Ways are used to represent linear features such as rivers and roads.
A relation is a multi-purpose data structure that documents a relationship between two or more data elements […]
An area is a closed way. In other words, it’s a list of connected nodes that enclose an area on a map. Because we know that a city encloses an area on a map, this is why we searched for areas with the name “Hannover” in the query above.
Armed with this background information, we’re more prepared to dig into the data and understand its content.
Discovering nuggets in OSM data
Returning to the data we received from our first query, we can see that it’s of type “way”.
{
"type": "way",
"id": 223750395,
"nodes": [
2326281637,
2326281669,
2326281629,
4495193407,
2326281603,
2326281611,
2326281616,
2326281637
],
"tags": {
"building": "hotel",
"name": "Hannover",
"rer_edi_id:ref": "cc68901d-9f54-4a14-80e9-7609a3665fbc",
"source": "Regione Emilia Romagna",
"stars": "2",
"tourism": "hotel"
}
},
Its tags
attribute tells us that it represents a hotel. One could guess
that it’s in Italy from the source
metadata in the tags, however, let’s
have a closer look. We can access a map-based view of this way by appending
way/
followed by the way’s id
to the main OSM URL and entering this into
a web browser. For the current example, the id
is 223750395, thus the URL
to open is:
https://www.openstreetmap.org/way/223750395
Unfortunately, this won’t take you directly to the way. You’ll probably only see a map centred on London like this:
If you now click on one of the nodes representing the way (in the bottom left-hand side of the page), you’ll see an orange dot appear showing the node’s location.
Zooming in on the node, you’ll see that it is located on the Adriatic coast of Italy.
Clicking on the way link in the panel on the left-hand side of the screen you’ll see where the way is. Zooming in further to get more detail you’ll see this:
where you can see the outline of a building highlighted in orange. So yes, we’ve been able to confirm that this way points to a hotel on the Adriatic coast in Italy. Nice!
Perhaps I can book my next holiday there and when people ask me where I’m going, I can say “Hannover!”. I guess this is only amusing to me because I live in Hannover, Germany… Oh well.
As you can see, my career as a comedian wouldn’t last long, so let’s return to working with OSM data.
The next element in the list of returned data shows a way representing a restaurant.
{
"type": "way",
"id": 370552711,
"nodes": [
3742480781,
3742480783,
3742480782,
3742480780,
3742480781
],
"tags": {
"alt_name": "kouzina Hannover",
"amenity": "restaurant",
"building": "yes",
"cuisine": "regional",
"name": "Hannover",
"phone": "+30 27330 93000"
}
},
It’s not clear exactly where it is, but we can use the OSM API to find it:
https://www.openstreetmap.org/way/370552711#map=19/36.714866/22.506045
You can see the way highlighted by the orange box in this image:
It’s in Greece! That’s really interesting! It seems that there are lots of “Hannover”s all around the world.
Narrowing the search
One could spend all day digging around and finding useful nuggets of information all over the OSM dataset. But that’s not what we’re here for: we want to find the city of Hannover in Germany. One way to narrow down this search is to filter our query to only look for places that have the name “Hannover”.
In Overpass query language, this looks like:
[timeout:25][out:json];
area[name="Hannover"]["place"];
out body;
where we’ve added the ["place"]
restriction to the area
lookup.
Running this query in Overpass Turbo (and focusing only on the data), we get
{
"version": 0.6,
"generator": "Overpass API 0.7.62.5 1bd436f1",
"osm3s": {
"timestamp_osm_base": "2025-02-20T12:56:45Z",
"timestamp_areas_base": "2025-02-06T02:17:44Z",
"copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."
},
"elements": [
{
"type": "area",
"id": 3600059418,
"tags": {
"TMC:cid_58:tabcd_1:Class": "Area",
"TMC:cid_58:tabcd_1:LCLversion": "8.00",
"TMC:cid_58:tabcd_1:LocationCode": "452",
"admin_level": "8",
"boundary": "administrative",
"de:amtlicher_gemeindeschluessel": "03241001",
"de:regionalschluessel": "032410001001",
"name": "Hannover",
"name:am": "Հաննովեր",
"name:ar": "هانوفر",
"name:az-Arab": "هانوفر",
"name:azb": "هانوفر",
"name:be": "Гановер",
"name:be-tarask": "Гановэр",
"name:bg": "Хановер",
"name:bn": "হানোফার",
"name:ce": "Хановер",
"name:de": "Hannover",
"name:el": "Αννόβερο",
"name:en": "Hanover",
"name:eo": "Hanovro",
"name:es": "Hanóver",
"name:fa": "هانوفر",
"name:fr": "Hanovre",
"name:gd": "Hànobhar",
"name:gl": "Hannóver",
"name:he": "הנובר",
"name:hy": "Հաննովեր",
"name:hyw": "Հաննովեր",
"name:ja": "ハノーファー",
"name:ka": "ჰანოვერი",
"name:kk": "Ганновер",
"name:kk-Arab": "گاننوۆەر",
"name:ko": "하노버",
"name:la": "Hannovera",
"name:lt": "Hanoveris",
"name:lv": "Hannovere",
"name:mk": "Хановер",
"name:mn": "Ханновер",
"name:mr": "हानोफर",
"name:ms": "Hanover",
"name:nds": "Hannober",
"name:nl": "Hannover",
"name:os": "Ганновер",
"name:pa": "ਹੈਨੋਫ਼ਾ",
"name:pl": "Hanower",
"name:prefix": "Landeshauptstadt",
"name:ps": "هانوفر",
"name:pt": "Hanôver",
"name:ro": "Hanovra",
"name:ru": "Ганновер",
"name:sq": "Hanoveri",
"name:sr": "Хановер",
"name:szl": "Hanŏwry",
"name:th": "ฮันโนเฟอร์",
"name:ug": "Hanofér",
"name:uk": "Ганновер",
"name:ur": "ہانوور",
"name:xh": "IHanoveri",
"name:yi": "האנאווער",
"name:yo": "Hanover",
"name:zh": "汉诺威",
"place": "city",
"type": "boundary",
"wikidata": "Q1715",
"wikipedia": "de:Hannover"
}
}
]
}
That’s what we were looking for! We only get one element returned, which is also what we’re after.
Strangely enough, the id
of this area (3600059418) doesn’t seem to refer
to anything in OSM. I.e., if you look up either
https://www.openstreetmap.org/relation/3600059418
or
https://www.openstreetmap.org/way/3600059418
you’ll find that this information can’t be found. It turns out that you
have to remove the leading 36000 and only use 59418, although I’m not sure
why this is.
Anyway, opening the link
https://www.openstreetmap.org/relation/59418
will return the area (and the relevant database relation) for Hannover in
Germany. Note that some zooming and panning in the map view is likely
necessary to get an image similar to that below.
With Hannover found, we can now start looking for its tram lines.
Finding Hannover’s tram lines with Overpass Turbo
Now that we’ve got the area in which to search, let’s look for tram lines within this area. We do this by searching for relations within the given area that are tagged as tram routes. Translating this English description into Overpass query language becomes:
[timeout:25][out:json];
area[name="Hannover"]["place"];
rel[route=tram](area);
out body;
Running the query in Overpass Turbo you’ll find, again, that incomplete data is returned. You can see it anyway by clicking on the “show data” button.
The output will show many ways, nodes and relations listed in the Overpass
Turbo “Data” window. It’s rather hard from this flood of data to get a
feeling for what we’ve been given. One way to get a good overview of the
data is to visualise it. In our present situation, we can make the query
return data visualisable on a map by replacing out body;
with out geom;
.
This will return geometric data, which Overpass Turbo can display on a map.
Let’s do that now.
Change the query to this:
[timeout:25][out:json];
area[name="Hannover"]["place"];
rel[route=tram](area);
out geom;
and run it.
Here Overpass Turbo will warn us that we’ll be downloading a lot of data:
Downloading 2MB of data isn’t a lot for a one-off query, and we’re not going to be making this query often. So, in this case, we can click on the “continue anyway” button to get our nice, juicy data.
Displaying this data in the “Map” view in Overpass Turbo, we see all available tram lines in Hannover:6
Nice! We’re getting somewhere! We can see the tram lines as well as circles for each of the stations along those lines.
In a broad sense, this is the information we need for Map::Tube
. In
particular, we need the lines and their names as well as the stations and
their names. We also need to know how each station links to other stations,
and which line (or lines) the stations are on. So, how do we extract this
information?
You might have noticed that some of the nodes, ways, and relations had
metadata attached to them, stored in a tags
element. The line and station
name information we’re looking for is in the tags
metadata attached to the
ways and nodes representing the lines and stations, respectively. The next
task, therefore, is to extract these tags.
We can get all tags from a query by using out tags;
:
[timeout:25][out:json];
area[name="Hannover"]["place"];
rel[route=tram](area);
out tags;
Running this in Overpass Turbo, you’ll get output like the following appearing in the “Data” view:
{
"version": 0.6,
"generator": "Overpass API 0.7.62.5 1bd436f1",
"osm3s": {
"timestamp_osm_base": "2025-02-20T13:56:00Z",
"timestamp_areas_base": "2025-02-06T02:17:44Z",
"copyright": "The data included in this document is from
www.openstreetmap.org. The data is made available under ODbL."
},
"elements": [
{
"type": "relation",
"id": 10999,
"tags": {
"colour": "#F9B000",
"from": "Messe/Ost (EXPO-Plaza)",
"interval": "10",
"interval:evening": "15",
"interval:sunday": "15",
"name": "Linie 6: Messe/Ost (EXPO-Plaza) → Nordhafen",
"network": "Großraum-Verkehr Hannover",
"network:short": "GVH",
"network:short_name": "GVH",
"network:wikidata": "Q1549516",
"network:wikipedia": "de:Großraum-Verkehr Hannover",
"operator": "Überlandwerke und Straßenbahnen Hannover",
"operator:short": "ÜSTRA",
"operator:wikidata": "Q265625",
"operator:wikipedia": "de:Üstra Hannoversche Verkehrsbetriebe",
"public_transport:version": "2",
"ref": "6",
"route": "tram",
"to": "Nordhafen",
"type": "route",
"wikidata": "Q63350805"
}
},
...
<snip>
This is the information we’re after. In particular, we’re interested in the
name
field. This will allow us to construct the line and station
information for input into Map::Tube
.
Getting only the name
field out of this data isn’t really what Overpass is
built for. What we need is a programming language environment so that we
can start munging and filtering the data that Overpass has returned to us.
Enter Python.
OSM data extraction and munging in Python
I like doing things properly, so let’s create a Python project with all its trappings by using poetry.
Composing a new project
We want to create a new project directory which will contain all our Python
files as well as the project setup configuration. To do this we use the
poetry new
command to create a new project:
$ poetry new osm-hannover-tram-stops
Created package osm_hannover_tram_stops in osm-hannover-tram-stops
Change into the newly-created osm-hannover-tram-stops
directory and
add the OSMPythonTools
module to our project:
$ cd osm-hannover-tram-stops
$ poetry add OSMPythonTools
Creating virtualenv osm-hannover-tram-stops in .../osm-hannover-tram-stops/.venv
Using version ^0.3.5 for osmpythontools
Updating dependencies
Resolving dependencies... (2.9s)
Package operations: 24 installs, 0 updates, 0 removals
- Installing six (1.17.0)
- Installing numpy (2.0.2)
- Installing python-dateutil (2.9.0.post0)
- Installing pytz (2025.1)
- Installing tzdata (2025.1)
- Installing zipp (3.21.0)
- Installing contourpy (1.3.0)
- Installing importlib-resources (6.5.2)
- Installing fonttools (4.56.0)
- Installing cycler (0.12.1)
- Installing kiwisolver (1.4.7)
- Installing packaging (24.2)
- Installing pandas (2.2.3)
- Installing pillow (11.1.0)
- Installing pyparsing (3.2.1)
- Installing soupsieve (2.6)
- Installing typing-extensions (4.12.2)
- Installing beautifulsoup4 (4.13.3)
- Installing geojson (3.2.0)
- Installing lxml (5.3.1)
- Installing matplotlib (3.9.4)
- Installing ujson (5.10.0)
- Installing xarray (2024.7.0)
- Installing osmpythontools (0.3.5)
Writing lock file
poetry
also created a virtual environment for us in the .venv
directory.
By activating the virtual environment …
$ source .venv/bin/activate
… we’re ready to start writing and running some Python code.
Planning a programming project path
As with many things in programming, there’s more than one way to do
it. So is the case with
the OSMPythonTools
package.
Remember how we built a nice query in Overpass Turbo which selected just the
city of Hannover and then only the tags of its tram lines? It turns out
that there are a few ways we could construct such a query using
OSMPythonTools
. One option is to pass a query as a string directly into
an Overpass
class instance. Or we could use the library’s query builder
to make a Query
object and let that construct an Overpass QL query for
us.7 Also, we can use Nominatim (via the
Nominatim
class) to return the id
specific to the area of the city of
Hannover, thus avoiding the area[name="Hannover"]
lookup.8
Let’s have a look at these different paths in action.
Using a plain Overpass QL query
Let’s construct an Overpass query language query as a string and use the
Overpass
class to fetch the data. We’ll then select the names of each
tram line in Hannover.
First, we import the Overpass
class from OSMPythonTools
:
from OSMPythonTools.overpass import Overpass
Then we define our now familiar query string:
query_string = (
'area[name="Hannover"]["place"];'
'rel[route=tram](area);'
'out tags;'
)
which I’ve spread across several lines to make it more readable.
Note that we don’t have to specify the timeout
or out:json
global
options because the Overpass
class does this for us.
Next, we instantiate an Overpass
object and pass it our query string via the
query()
method. Calling this method communicates with the Overpass API
and eventually returns information about the tram lines.
overpass = Overpass()
result = overpass.query(query_string)
The object returned is an OverpassResult
object. As with the JSON data
returned from our queries within Overpass Turbo, this object contains an
array called elements
containing the information we want. In the example
we’re following here, the elements are each of the tram lines available in
Hannover. We extract these elements by calling the .elements()
method on
the OverpassResult
object:
lines = result.elements()
Each of these elements has metadata in its tags
component listed as
key/value pairs. We get this information by calling the .tag()
method on
each element and passing the name of the key we want, which in our case is
name
, i.e.:
line_names = [line.tag('name') for line in lines]
To see the list of tram line names, we can use the pprint
module from the
Python standard library:
import pprint
and print a sorted list of line names:
pprint.pp(sorted(line_names))
Putting all this together, we have this script, which I’ve called
hannover-tram-stops-query-string.py
:
# -*- coding: utf-8 -*-
import pprint
from OSMPythonTools.overpass import Overpass
query_string = (
'area[name="Hannover"]["place"];'
'rel[route=tram](area);'
'out tags;'
)
overpass = Overpass()
result = overpass.query(query_string)
lines = result.elements()
line_names = [line.tag('name') for line in lines]
pprint.pp(sorted(line_names))
# vim: expandtab shiftwidth=4 softtabstop=4
Running this gives the following output:
$ python hannover-tram-stops-query-string.py
[overpass] downloading data: [timeout:25][out:json];area[name="Hannover"]["place"];rel[route=tram](area);out tags;
['Linie 10: Ahlem → Hauptbahnhof/ZOB',
'Linie 10: Hauptbahnhof/ZOB → Ahlem',
'Linie 11: Haltenhoffstraße → Schlägerstraße',
'Linie 11: Haltenhoffstraße → Zoo',
'Linie 11: Zoo → Haltenhoffstraße',
'Linie 13: Fasanenkrug → Hemmingen',
'Linie 13: Hemmingen → Fasanenkrug',
'Linie 16: Königsworther Platz → Messe / Ost (Expo-Plaza)',
'Linie 17: Hauptbahnhof/ZOB → Wallensteinstraße',
'Linie 17: Wallensteinstraße → Hauptbahnhof/ZOB',
'Linie 18: Hauptbahnhof → Messe / Nord',
'Linie 1: Laatzen → Langenhagen',
'Linie 1: Langenhagen → Laatzen',
'Linie 1: Langenhagen → Sarstedt',
'Linie 1: Sarstedt → Langenhagen',
'Linie 2: Alte Heide → Gleidingen',
'Linie 2: Alte Heide → Laatzen / Ginsterweg',
'Linie 2: Alte Heide → Peiner Straße',
'Linie 2: Gleidingen → Alte Heide',
'Linie 2: Peiner Straße → Alte Heide',
'Linie 3: Altwarmbüchen → Wettbergen',
'Linie 3: Wettbergen → Altwarmbüchen',
'Linie 4: Garbsen → Roderbruch',
'Linie 4: Roderbruch → Fuhsestraße/Bhf',
'Linie 4: Roderbruch → Garbsen',
'Linie 5: Anderten → Stöcken',
'Linie 5: Stöcken → Anderten',
'Linie 5: Stöcken → Fuhsestraße/Bhf',
'Linie 6: Messe/Ost (EXPO-Plaza) → Nordhafen',
'Linie 6: Nordhafen → Messe/Ost (EXPO-Plaza)',
'Linie 7: Misburg → Wettbergen',
'Linie 7: Wettbergen → Misburg',
'Linie 8: Dragonerstraße → Messe / Nord',
'Linie 8: Hauptbahnhof → Messe / Nord',
'Linie 8: Messe / Nord → Dragonerstraße',
'Linie 8: Messe / Nord → Hauptbahnhof',
'Linie 9: Empelde → Hauptbahnhof',
'Linie 9: Hauptbahnhof → Empelde',
'Nacht-Linie 10: Ahlem → Hauptbahnhof',
'Nacht-Linie 10: Hauptbahnhof → Ahlem']
Great! We’ve extracted the names of all tram lines in Hannover!
It seems, however, that the size of the task before us is getting larger all the time. Do not despair! We’ll tame this monster before long.
Looking at the output here we see that most of the lines are effectively
listed twice: one line for each direction. This makes sense in the context
of OSM, but for Map::Tube
, we’ll only need one direction.
Also, if you look carefully, you’ll notice that sometimes there are more than two lines for a given line number. We expect only two lines for a given line number; one for each direction. There even exist lines without a corresponding return path. Weird.
I found this rather odd and it did cause some consternation with me for a while until I talked to the nice people at the Hannover OSM user group9 who gave me advice on what to do about it.
It turns out that some of the information is outdated and needs updating. In one case, some information needs to be deleted. This led me down the next rabbit hole…10
In the end–to get data usable within the context of Map::Tube
–we’re
going to have to filter some of the extraneous lines out of the raw data
before munging it into its final form. But we’re getting ahead of
ourselves. Let’s see how else we could have constructed the query to fetch
all tram lines.
Let the code construct the query
Another way to write Overpass queries is to let the OSMPythonTools
Overpass query builder do it for you. Well, that’s not entirely true, it
doesn’t do it all on its own: you need to give the query builder some
information so that it can build the query. Nevertheless, it can still be
handy to get OSMPythonTools
to build Overpass queries because we can avoid
having to learn the finer points of Overpass query language. Let’s have a
look at how to do this.
We begin by importing the Overpass
class and overpassQueryBuilder
from OSMPythonTools
:
from OSMPythonTools.overpass import Overpass, overpassQueryBuilder
We also import the Nominatim
class from the nominatim
package:11
from OSMPythonTools.nominatim import Nominatim
The first job in this code is to use the Nominatim
class to find out what
object in OSM best matches the name “Hannover”:
nominatim = Nominatim()
hannover = nominatim.query('Hannover')
We could also be more specific here, if we want to, and say that we mean the “Hannover” that’s in Germany:
hannover = nominatim.query('Hannover, Germany')
Nominatim is intelligent enough to know what we’re talking about and to give us back the correct information.
Now we use this information to specify the area in which to search for tram lines when constructing the query with the query builder:
query = overpassQueryBuilder(
area=hannover,
elementType='relation',
selector='route=tram',
out='tags'
)
Note that we obtain the same result if we pass only the area’s id
to the
query builder. In other words, using this code:
hannover = nominatim.query('Hannover, Germany')
or this code
hannover = nominatim.query('Hannover, Germany').areaId()
as the argument to the area
option in overpassQueryBuilder()
generates
the same Overpass query string.
Printing the returned query object,
print(query)
we’ll see a very familiar-looking Overpass query string:
area(3600059418)->.searchArea;(relation[route=tram](area.searchArea);); out tags;
It’s nice to know that the automated tools generate similar output to what we worked out ourselves!
Now that we have a query (this time as an object as opposed to a plain
string) we can pass it to an Overpass
instance as before:
overpass = Overpass()
result = overpass.query(query)
and can extract the line names and pretty-print them:
lines = result.elements()
line_names = [line.tag('name') for line in lines]
pprint.pp(sorted(line_names))
Putting this all together, we have this script, which I’ve called
hannover-tram-stops-query-builder.py
:
# -*- coding: utf-8 -*-
import pprint
from OSMPythonTools.overpass import Overpass, overpassQueryBuilder
from OSMPythonTools.nominatim import Nominatim
nominatim = Nominatim()
hannover = nominatim.query('Hannover')
# equivalently...
# hannover = nominatim.query('Hannover, Germany').areaId()
query = overpassQueryBuilder(
area=hannover,
elementType='relation',
selector='route=tram',
out='tags'
)
overpass = Overpass()
result = overpass.query(query)
lines = result.elements()
line_names = [line.tag('name') for line in lines]
pprint.pp(sorted(line_names))
# vim: expandtab shiftwidth=4 softtabstop=4
Running this gives the following output:
$ python hannover-tram-stops-query-builder.py
[nominatim] downloading data: search
[overpass] downloading data: [timeout:25][out:json];area(3600059418)->.searchArea;(relation[route=tram](area.searchArea);); out tags;
['Linie 10: Ahlem → Hauptbahnhof/ZOB',
'Linie 10: Hauptbahnhof/ZOB → Ahlem',
'Linie 11: Haltenhoffstraße → Schlägerstraße',
'Linie 11: Haltenhoffstraße → Zoo',
'Linie 11: Zoo → Haltenhoffstraße',
'Linie 13: Fasanenkrug → Hemmingen',
'Linie 13: Hemmingen → Fasanenkrug',
'Linie 16: Königsworther Platz → Messe / Ost (Expo-Plaza)',
'Linie 17: Hauptbahnhof/ZOB → Wallensteinstraße',
'Linie 17: Wallensteinstraße → Hauptbahnhof/ZOB',
'Linie 18: Hauptbahnhof → Messe / Nord',
'Linie 1: Laatzen → Langenhagen',
'Linie 1: Langenhagen → Laatzen',
'Linie 1: Langenhagen → Sarstedt',
'Linie 1: Sarstedt → Langenhagen',
'Linie 2: Alte Heide → Gleidingen',
'Linie 2: Alte Heide → Laatzen / Ginsterweg',
'Linie 2: Alte Heide → Peiner Straße',
'Linie 2: Gleidingen → Alte Heide',
'Linie 2: Peiner Straße → Alte Heide',
'Linie 3: Altwarmbüchen → Wettbergen',
'Linie 3: Wettbergen → Altwarmbüchen',
'Linie 4: Garbsen → Roderbruch',
'Linie 4: Roderbruch → Fuhsestraße/Bhf',
'Linie 4: Roderbruch → Garbsen',
'Linie 5: Anderten → Stöcken',
'Linie 5: Stöcken → Anderten',
'Linie 5: Stöcken → Fuhsestraße/Bhf',
'Linie 6: Messe/Ost (EXPO-Plaza) → Nordhafen',
'Linie 6: Nordhafen → Messe/Ost (EXPO-Plaza)',
'Linie 7: Misburg → Wettbergen',
'Linie 7: Wettbergen → Misburg',
'Linie 8: Dragonerstraße → Messe / Nord',
'Linie 8: Hauptbahnhof → Messe / Nord',
'Linie 8: Messe / Nord → Dragonerstraße',
'Linie 8: Messe / Nord → Hauptbahnhof',
'Linie 9: Empelde → Hauptbahnhof',
'Linie 9: Hauptbahnhof → Empelde',
'Nacht-Linie 10: Ahlem → Hauptbahnhof',
'Nacht-Linie 10: Hauptbahnhof → Ahlem']
which should look familiar.
What’s nice here is that we received the same list of tram line names as before! I love it when code behaves consistently.
In the end, which exact way you do this (via the query builder or with a hand-built query string) is up to you and depends heavily on your use case.
Tram line number 10
Now that we have the names of the available tram lines, we can ask the next
question: what are the names of the stations along each line? We have to be
careful when asking this question because the order of the stations along
each line is also important to us. This is because we want to link them
together as part of creating the input data for Map::Tube
.
To get a feel for how we want to extract this data, let’s focus on only one
tram line right now, namely Linie 10: Hauptbahnhof/ZOB → Ahlem
. We’ll use
Overpass Turbo to craft an initial Overpass query string. Then, once we’ve
got an appropriate query string, we can use it in a Python script where we
can manipulate the data further.
We start by adapting the query string we developed earlier. To search only for information along a given tram line, we add an extra filter to the relation lookup by requesting only lines with the given name. Thus, our query string goes from
[timeout:25][out:json];
area[name="Hannover"]["place"];
rel[route=tram](area);
out tags;
to
[timeout:25][out:json];
area[name="Hannover"]["place"];
rel[route=tram][name="Linie 10: Hauptbahnhof/ZOB → Ahlem"](area);
out tags;
In other words, we’re searching for a rel
ation within the given area
that is a route
of type tram
with the given name
. Note that because
the name includes spaces and special characters we have to enclose it in
double quotes.
Running the query in Overpass Turbo returns this data:
{
"version": 0.6,
"generator": "Overpass API 0.7.62.5 1bd436f1",
"osm3s": {
"timestamp_osm_base": "2025-02-20T15:45:06Z",
"timestamp_areas_base": "2025-02-06T02:17:44Z",
"copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."
},
"elements": [
{
"type": "relation",
"id": 3004805,
"tags": {
"colour": "#76B82A",
"from": "Hauptbahnhof/ZOB",
"interval": "8",
"interval:evening": "15",
"interval:sunday": "10",
"name": "Linie 10: Hauptbahnhof/ZOB → Ahlem",
"network": "Großraum-Verkehr Hannover",
"network:short": "GVH",
"network:short_name": "GVH",
"network:wikidata": "Q1549516",
"network:wikipedia": "de:Großraum-Verkehr Hannover",
"operator": "Überlandwerke und Straßenbahnen Hannover",
"operator:short": "ÜSTRA",
"operator:wikidata": "Q265625",
"operator:wikipedia": "de:Üstra Hannoversche Verkehrsbetriebe",
"public_transport:version": "2",
"ref": "10",
"route": "tram",
"to": "Ahlem",
"type": "route",
"wikidata": "Q63348270"
}
}
]
}
Which, unfortunately, isn’t very helpful. We’ve only returned the tags for
this particular line, which we already know. We need to zoom out a level
and get the full geometry. To do this we use the geom
option in the out
statement:
[timeout:25][out:json];
area[name="Hannover"]["place"];
rel[route=tram][name="Linie 10: Hauptbahnhof/ZOB → Ahlem"](area);
out geom;
Getting Overpass Turbo to run this updated query gives:
{
"version": 0.6,
"generator": "Overpass API 0.7.62.5 1bd436f1",
"osm3s": {
"timestamp_osm_base": "2025-02-20T15:50:05Z",
"timestamp_areas_base": "2025-02-06T02:17:44Z",
"copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."
},
"elements": [
{
"type": "relation",
"id": 3004805,
"bounds": {
"minlat": 52.3713750,
"minlon": 9.6643241,
"maxlat": 52.3791646,
"maxlon": 9.7428395
},
"members": [
{
"type": "node",
"ref": 5617379122,
"role": "stop_entry_only",
"lat": 52.3790296,
"lon": 9.7425180
},
{
"type": "way",
"ref": 532294655,
"role": "platform",
"geometry": [
{ "lat": 52.3791262, "lon": 9.7428395 },
{ "lat": 52.3791368, "lon": 9.7428259 },
{ "lat": 52.3791489, "lon": 9.7428105 },
{ "lat": 52.3788640, "lon": 9.7422119 },
{ "lat": 52.3788526, "lon": 9.7422265 },
{ "lat": 52.3788413, "lon": 9.7422409 },
{ "lat": 52.3791262, "lon": 9.7428395 }
]
},
...
<snip>
That’s better! We get a relation back containing lots of nodes and ways.
This is now too much data! Really, we’re only interested in the nodes
because only nodes can be stations and we want to extract the station names
from them. We wish to disregard the ways because those represent the paths
between stations and hence don’t contain station name information. To
restrict the output to return only nodes from the given relation, we can use
the node(r);
filter:
[timeout:25][out:json];
area[name="Hannover"]["place"];
rel[route=tram][name="Linie 10: Hauptbahnhof/ZOB → Ahlem"](area);
node(r);
out geom;
Running this query in our familiar friend Overpass Turbo, we get this output:
{
"version": 0.6,
"generator": "Overpass API 0.7.62.5 1bd436f1",
"osm3s": {
"timestamp_osm_base": "2025-02-20T15:52:07Z",
"timestamp_areas_base": "2025-02-06T02:17:44Z",
"copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."
},
"elements": [
{
"type": "node",
"id": 29364467,
"lat": 52.3756537,
"lon": 9.7316469,
"tags": {
"name": "Steintor",
"network": "Großraum-Verkehr Hannover",
"public_transport": "stop_position",
"railway": "tram_stop",
"ref:IFOPT": "de:03241:121:2:122",
"ref_name": "Steintor, Hannover",
"tram": "yes",
"wheelchair": "no"
}
},
{
"type": "node",
"id": 34193131,
"lat": 52.3752519,
"lon": 9.7015927,
"tags": {
"bench": "no",
"bin": "yes",
"name": "Freizeitheim Linden",
"network": "Großraum-Verkehr Hannover",
"operator": "infra Infrastrukturgesellschaft Region Hannover",
"operator:wikidata": "Q1122564",
"public_transport": "stop_position",
"railway": "tram_stop",
"ref:IFOPT": "de:03241:501",
"shelter": "no",
"tactile_paving": "no",
"tram": "yes",
"wheelchair": "no"
}
},
...
<snip>
This is a big improvement: we’ve got nodes with all their metadata and within that is the name information that we’re interested in.
There’s one problem with this output though: it’s in ascending OSM id
order. In other words, it’s not in the order that the stations have from
one end of the line to the other. Oops. We need that property when
providing input to Map::Tube
. So how do we keep our stations all
lined up?
Getting all our stations in a row
Inspecting the data we can see that it isn’t in the right order. For instance, we expect the first node to be called “Hauptbahnhof/ZOB”, because the line goes from there to “Ahlem”. But the first node in our list above is called “Steintor”. Bummer! How do we fix this problem? We need to dive into Python and filter for the nodes there rather than within Overpass.
We’ll start a new script to develop this code. As we’ve done before, the
first thing to do is import the Overpass
class:
from OSMPythonTools.overpass import Overpass
We now have a new query string:
query_string = (
'area[name="Hannover"]["place"];'
'rel[route=tram][name="Linie 10: Hauptbahnhof/ZOB → Ahlem"](area);'
'out geom;'
)
where we’re careful not to filter only for the nodes at this stage.
Instantiating the Overpass
class and passing the query string to its
.query()
method, we get back an OverpassResult
:
overpass = Overpass()
result = overpass.query(query_string)
This will return a single element containing many nodes and ways, denoted as “members”.
line = result.elements()[0]
print(len(line.members())) # => 66 members
Each of these members has a type, which we can get from the .type()
method
on an individual member, for instance:
print(line.members()[0].type()) # => 'node'
print(line.members()[-1].type()) # => 'way'
We only want the nodes, so we filter for them with a list comprehension:
line_nodes = [
member for member in line.members()
if member.type() == 'node'
]
The unfortunate part here is that these member objects representing nodes
don’t contain any metadata, i.e. the tags
element is empty:
print(line_nodes[0].tags()) # => {}
We need the metadata so we can access the station names! What’s going on?
It’s possible to see the lack of metadata by running the following query in Overpass Turbo:
[timeout:25][out:json];
area[name="Hannover"]["place"];
rel[route=tram][name="Linie 10: Hauptbahnhof/ZOB → Ahlem"](area);
out geom;
and looking at the members
array in its output:
{
"version": 0.6,
"generator": "Overpass API 0.7.62.5 1bd436f1",
"osm3s": {
"timestamp_osm_base": "2025-03-14T15:46:06Z",
"timestamp_areas_base": "2025-02-06T02:17:44Z",
"copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."
},
"elements": [
{
"type": "relation",
"id": 3004805,
"bounds": {
"minlat": 52.3713750,
"minlon": 9.6643241,
"maxlat": 52.3791646,
"maxlon": 9.7428395
},
"members": [
{
"type": "node",
"ref": 5617379122,
"role": "stop_entry_only",
"lat": 52.3790296,
"lon": 9.7425180
},
{
"type": "way",
"ref": 532294655,
"role": "platform",
"geometry": [
{ "lat": 52.3791262, "lon": 9.7428395 },
{ "lat": 52.3791368, "lon": 9.7428259 },
{ "lat": 52.3791489, "lon": 9.7428105 },
{ "lat": 52.3788640, "lon": 9.7422119 },
{ "lat": 52.3788526, "lon": 9.7422265 },
{ "lat": 52.3788413, "lon": 9.7422409 },
{ "lat": 52.3791262, "lon": 9.7428395 }
]
},
{
"type": "node",
"ref": 5617379121,
"role": "stop",
"lat": 52.3770040,
"lon": 9.7380860
},
...
<snip>
Notice that the tags
element is conspicuous by its absence. We need nodes
to be returned with a tags
element containing at least a name
key so
that we can extract the station name information. Admittedly, this is what
the result of using the node(r);
filter gave us, but that screwed up the
node order we need. How do we solve this problem?
Fortunately, we have node ID information:
line_nodes_ids = [
node.id() for node in line_nodes
]
pprint.pp(line_nodes_ids)
# => [5617379122,
# 5617379121,
# 29364467,
# 1635648466,
# 2348338693,
# 2435658752,
# 5851385401,
# 34193131,
# 3117148144,
# 252850676,
# 1635712745,
# 1635712806,
# 2348338685]
Thus, to get the full metadata on each of these nodes, we need to request each node individually.
Naively, we could try querying Overpass for specific nodes, referenced by their IDs, i.e. something like this:
nodes = [
overpass.query(f'node(id:{node_id});out geom;')
for node_id in line_nodes_ids
]
Now, you’d think that that code would work, wouldn’t you? Especially if you’d tried querying for only for a single node in Overpass Turbo:
[timeout:25][out:json];
node(id:5617379122);
out geom;
That query produces output including the needed tags
metadata:
{
"version": 0.6,
"generator": "Overpass API 0.7.62.5 1bd436f1",
"osm3s": {
"timestamp_osm_base": "2025-02-20T16:23:12Z",
"copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."
},
"elements": [
{
"type": "node",
"id": 5617379122,
"lat": 52.3790296,
"lon": 9.7425180,
"tags": {
"name": "Hauptbahnhof/ZOB",
"public_transport": "stop_position",
"railway": "tram_stop",
"ref:IFOPT": "de:03241:42:2:40",
"route_ref": "10;17",
"tram": "yes",
"wheelchair": "yes"
}
}
]
}
But that’s not the case when fetching this data in a loop in Python. When trying to get individual nodes in a loop (or as part of a list comprehension) I was getting lots of timeouts with requests that I’d already made. After some frustration and confusion, I stumbled upon a solution: use the OSM API directly.12 I know I wanted to avoid accessing the OSM API directly in the beginning, but it turned out to be the only way to solve this particular problem. Oh well. Ya get that I suppose.
To access the OSM API from OSMPythonTools
, we import the Api
class:
from OSMPythonTools.api import Api
and then instantiate an object:
api = Api()
To query a single node, we pass a query string in the form
'node/<node-id>'
to the .query()
method on the Api
instance, e.g.:
result = api.query('node/5617379122')
To get all nodes representing the stations along the given tram line, we query the OSM API for each node ID found from our earlier Overpass query, i.e.:
nodes = [
api.query(f'node/{node_id}')
for node_id in line_nodes_ids
]
This runs quickly and is fairly talkative, logging each query to the screen:
[api] downloading data: node/5617379122
[api] downloading data: node/5617379121
[api] downloading data: node/29364467
[api] downloading data: node/1635648466
[api] downloading data: node/2348338693
[api] downloading data: node/2435658752
[api] downloading data: node/5851385401
[api] downloading data: node/34193131
[api] downloading data: node/3117148144
[api] downloading data: node/252850676
[api] downloading data: node/1635712745
[api] downloading data: node/1635712806
[api] downloading data: node/2348338685
This time we get a list of ApiResult
objects, each of which has metadata
attached. Phew!
For instance:
pprint.pp(nodes[0].tags())
# => {'name': 'Hauptbahnhof/ZOB',
# 'public_transport': 'stop_position',
# 'railway': 'tram_stop',
# 'ref:IFOPT': 'de:03241:42:2:40',
# 'route_ref': '10;17',
# 'tram': 'yes',
# 'wheelchair': 'yes'}
Great! We now only need to extract the value from the name
key to get
what we want. In other words:
station_names = [
node.tag("name") for node in nodes
]
pprint.pp(station_names)
# => ['Hauptbahnhof/ZOB',
# 'Hauptbahnhof/Rosenstraße',
# 'Steintor',
# 'Goetheplatz',
# 'Glocksee',
# 'Am Küchengarten',
# 'Leinaustraße',
# 'Freizeitheim Linden',
# 'Wunstorfer Straße',
# 'Harenberger Straße',
# 'Brunnenstraße',
# 'Ehrhartstraße',
# 'Ahlem']
We’ve got all station names and in the right order! Yay! It was worth the effort of fetching the node information individually.
Now most of the pieces are in place: we have the tram lines and their stations, and we’ve retained the station order.
A neverending story?
Theoretically, this could be the end of the story. We’ve worked out how to
find all tram lines in Hannover, and we’ve worked out how to extract the
station names (in order) for each line. It’s now just a “simple matter of
programming” to
convert all this information into the output format we need for Map::Tube
,
right? Well, no. There’s still a fair bit of work to do here yet.
It’s time to take a step back and think about the processing steps we need
to perform. We need to get a bit more organised and start collecting data
into objects that will help us create the format that Map::Tube
needs.
This post is long enough as it is, so I’m going to end it here with a view
to the next side project. This next side project recursion step will take
the OSM data collected here and munge it into the Map::Tube
-compatible
format we need.
To be continued…
-
Why Hannover? Well, I live there. So it seemed a logical thing to do. ↩
-
Interestingly enough, that paragraph alone required its own rabbit hole. I wanted to find a link to the Perl Advent Calendar article, but it wasn’t easy to find because the 2024 articles hadn’t been archived yet. A quick pull request later, and I can escape from at least one level of recursion! ↩
-
Admittedly, Nominatim would be a better choice for a name-based lookup. I wanted to describe the Overpass query language here, so this example is a good start. ↩
-
Sometimes the server gets overloaded and sometimes the query is too big to return in a reasonable amount of time. Remember that this is an Open Source project and its resources are limited, so be thoughtful when making queries to the Overpass server. ↩
-
Note that this is the German spelling; in English, there’s only one ‘n’. We need to use the German spelling to find the correct entry. ↩
-
If you only see the raw JSON data, click on the “Map” button in the top right-hand side of the browser window to see the map view. ↩
-
Sometimes it’s easier to describe the query in Python than to write in Overpass query language. ↩
-
The Nominatim service returns OSM objects from names, which makes it useful as a way to focus a search before concentrating only on Overpass. ↩
-
Many thanks to Langläufer for patiently answering my questions! ↩
-
It turns out that the tram operator has changed the name of “Nacht-Linie 10” to “Linie 12”. I updated the name for each direction of this line in changesets 163811900 and 163811942. ↩
-
This way we get to use Nominatim without needing a separate example. ↩
-
A word of warning: don’t use Nominatim to look up the node information. I tried this and it only partially worked. What does “partially work” mean? Well, it’s possible to use Nominatim to query on only node ID. E.g. like this:
nominatim.query('N<node_id>', lookup=True)
. There’s even a.queryString()
method onOverpassResult
objects that gives a string that one can use directly in the Nominatim query. I.e. one can write code like this:stations = [ nominatim.query(node.queryString(), lookup=True) for node in line_nodes ]
and that returns data! Unfortunately, some of the nodes are “linked nodes” and Nominatim returns an empty object for them. Thus we end up with missing station name data and that’s not what we want. Hence it’s necessary to use the OSM API lookup solution instead. ↩
Support
If you liked this post and want to see more, please buy me a coffee!
