Getting tram stops in Hannover from OpenStreetMap

36 minute read

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.

Logos for Hannover, OpenStreetMap and Üstra connected by arrows with
a question mark in the middle.
How does one extract tram station information from OpenStreetMap for Hannover, Germany?
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:

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 the name 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 the out 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:

Overpass Turbo initial 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:

Overpass Turbo dialog box warning about incomplete data in query

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:

OSM map centred on London

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.

OSM map with orange dot showing location of a node for the Hannover hotel in Italy

Zooming in on the node, you’ll see that it is located on the Adriatic coast of Italy.

Zoomed in OSM map with orange dot showing location of a node for the Hannover hotel 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:

Zoomed in OSM map with orange outline of way representing the Hannover hotel on the Adriatic coast of Italy

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:

OSM map with orange outline of way representing the Hannover restaurant in Greece

It’s in Greece! That’s really interesting! It seems that there are lots of “Hannover”s all around the world.

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.

OSM map showing area defining Hannover in Germany

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.

Overpass Turbo dialog box warning about incomplete data in query

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:

Overpass Turbo dialog box warning about large amounts 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

Overpass Turbo map view showing tram lines and the locations of tram
stations in Hannover, Germany

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. :wink:

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…

  1. Why Hannover? Well, I live there. So it seemed a logical thing to do. 

  2. 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! 

  3. 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. 

  4. 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. 

  5. 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. 

  6. 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. 

  7. Sometimes it’s easier to describe the query in Python than to write in Overpass query language. 

  8. The Nominatim service returns OSM objects from names, which makes it useful as a way to focus a search before concentrating only on Overpass. 

  9. Many thanks to Langläufer for patiently answering my questions! 

  10. 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

  11. This way we get to use Nominatim without needing a separate example. 

  12. 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 on OverpassResult 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!

buy me a coffee logo