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.
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
overpassmodule, and - the broader
OSMPythonToolslibrary.
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 thenametag 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 theoutstatement 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 relation 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Ă€uferfor 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 onOverpassResultobjects 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. â©










Top comments (0)