The NIWIS API and how low the Rhine actually is

NIWIS went online on 2026-07-15, run by the Bundesanstalt für Gewässerkunde. It is the first nationwide low-water information system for Germany. I wanted the current water level and discharge of the Rhine and the Neckar together with the long-term average, from one source. The API provides both.

Endpoints and stations

The API is documented on one page and lives at https://niwis-online.de/api/daten, no authentication, JSON, dates as YYYY-MM-DD, decimal point. Values are daily. For sub-daily data PEGELONLINE is the source, and the 70 WSV stations in NIWIS point back to it in their urlInstitution.

There are 698 stations, 397 of them on a named river with level or discharge data, the rest groundwater and springs. /messstelle lists them without the river name, so the river comes from /stammdaten, one request per station. I cache the result in a JSON file.

Derived quantities

/abgeleiteteGroesse returns a catalogue of 43 statistics. Each entry names the endpoint to call it from and the parameters it needs:

{
  "abgeleiteteGroesse": "MQ",
  "messgroesse": "ABFLUSS",
  "benoetigtReferenzzeitraum": true,
  "benoetigtZeitintervall": false,
  "endpunkt": "berechneEinzelwertNummer"
}

A client needs no hardcoded list of statistics, it can loop over the catalogue and build each request from the two boolean flags. The ones I use are MQ/MW (mean discharge and level), MNQ/MNW (mean annual low), Median Q pro Kalendertag and the deciles per calendar day. Reference period and year definition -- calendar year, hydrologisches Jahr or Wasserhaushaltsjahr -- are parameters.

The reference period cannot start before 1991. 1961--1990 returns 400 with Beginn des Referenzzeitraums darf nicht vor dem Jahr 1991 liegen, so a comparison against the older climate normal is not possible. Any window inside the allowed range works, 1991--2019 and 1996--2025 both compute.

In late summer the per-calendar-day median is the useful reference, not the annual mean.

The classification has four steps: "extrem niedrig", "sehr niedrig", "niedrig" and "Kein Niedrigwasser". The last one is open ended upwards and only means "not low water", so a gauge at its long-term average falls into it and so does a gauge in flood. NIWIS classifies low water and nothing else.

Missing values

Missing data appears in three different forms.

A statistic that cannot be computed returns 200 with an empty payload and hatZuvieleFehlwerte: true. That happens when more than 10% of the underlying values are missing, today for 49 of the 359 discharge stations.

Invalid parameters return 400 with a JSON message.

In the measurement series a missing value is -777 with a flag of Fehlwert or BfGAdded, not null:

{"datum": "2026-08-02", "messwert": -777.0, "einheit": "m³/s", "flag": "Fehlwert"}

Filtering on null does not catch these, and filtering by sign is wrong: -777 m³/s is impossible, a water level of -3 cm is not, and Emmerich is at -3 cm. In a sample of 60 stations every -777 carried a flag and no real value did, so the flag is the reliable signal.

Licences

lizenz is per station, with six different values across the 698 stations:

dl-zero-de/2.0   220
dl-by-de/2.0     210
cc-by/4.0        183
dl-de/by-2-0      46
ccbync/4.0        29
cc by-sa 3.0      10

dl-by-de/2.0 and dl-de/by-2-0 are the same Datenlizenz Deutschland spelled two ways, and 29 stations are non-commercial. Republishing values needs a per-station check.

A water level has no useful percentage

Every gauge counts from its own zero mark, the Pegelnullpunkt. That mark is neither the river bed nor sea level, it was fixed at some point in the past, and it sits at a different height at every gauge: 97.72 m above sea level at Maxau, 8.00 m at Emmerich. So 0 cm does not mean "no water", it means "the water is exactly at that mark".

A water level is therefore like a temperature in Celsius. You can work out what percentage of the yearly average today's 15 °C is, and the answer tells you nothing, because 0 °C is not "no temperature". The same thing happens on the Rhine:

station             W cm   %MW    Q m3/s   %MQ
Worms                  9    4%       480   35%
Duisburg-Ruhrort     149   36%       656   30%

Same river, same day, and NIWIS puts both in the worst class, "extrem niedrig". By water level one is at 4% of its average and the other at 36%, nine times as much. By discharge they are at 35% and 30%, which is what two gauges on one river should look like.

Discharge is different because 0 m³/s really does mean no water. That makes it the number to compare between gauges, and the water level the number a ship's captain reads.

Emmerich shows the problem from the other end: it is at -3 cm today, below its own mark, so its percentage comes out negative.

The Rhine on 2026-08-03

Against the 1991--2020 reference period:

station             Q m3/s   %MQ  %day    W cm
Maxau                  428   35%   37%     320
Speyer                 415   34%   36%     173
Worms                  480   35%   38%       9
Mainz                  532   33%   37%     126
Kaub                   557   34%   38%      28
Andernach              614   30%   39%      30
Bonn                   607   30%   39%      83
Köln                   638   31%   40%      69
Düsseldorf             649   31%   39%      22
Duisburg-Ruhrort       656   30%   38%     149
Wesel                  647   29%   38%      87
Rees                   666   30%   38%      33
Emmerich               695   31%   39%      -3

Q is the discharge, the volume of water passing the gauge every second. It grows downstream as tributaries join, which is why Emmerich carries more than Maxau. %MQ compares it to the long-term annual mean, %day to the median for this same date, and %day is the fairer number because a river runs lower in August than in March anyway.

Every gauge on the river is at 36 to 40% of its normal 3 August flow, so a bit over a third of the usual water, and NIWIS puts all 13 of them in its worst class, "extrem niedrig". Every one of them is also below the lowest discharge measured on a 3 August anywhere in the reference period, which makes today a record low for the date along the whole German Rhine. The decile endpoint supplies those minima, its outermost bounds being the measured extremes of the period. At Kaub, the gauge that Rhine shipping uses to work out how much cargo a vessel can load, the previous minimum for this date is 769 m³/s and the river is running 557.

The values are from 2 August, except the discharge at Emmerich, which is from 30 July because the series has gaps since.

Neckar: level and discharge disagree

Neckar
station                   Q m3/s   %MQ  %day Q class           W cm   %MW  %day W class
Horb                           3   22%   44% sehr niedrig        34   50%   72% niedrig
Wendlingen-Kläranlage         11   29%   56% niedrig             47   57%   75% niedrig
Plochingen                     -     -     - -                  154   93%   99% kein NW
Lauffen                        -     -     - -                  221   89%   95% kein NW
Rockenau SKA                  32   24%   48% sehr niedrig       214   91%   99% kein NW

NIWIS classifies water level and discharge separately, and at Rockenau the two verdicts contradict each other. By water level it is "kein Niedrigwasser", no low water at all. By discharge it is "sehr niedrig", and at 32.5 m³/s it is below the 34.5 m³/s that is the lowest discharge ever measured there on a 3 August. Same gauge, same day.

Below Plochingen the Neckar is canalized. The weirs hold the level while the discharge drops, and Plochingen and Lauffen report no discharge at all. The level measures how full the impoundments are, which is what navigation needs. The discharge measures what the catchment delivers, and at a quarter of the normal volume the water warms up faster and holds less oxygen, and less is available for cooling and abstraction.

Nationwide

Of the 359 stations with discharge data, 310 could be classified for today:

extrem niedrig       108   34.8%
sehr niedrig          64   20.6%
niedrig               81   26.1%
Kein Niedrigwasser    57   18.4%

55% of the classified stations are at "sehr niedrig" or worse. Three days earlier the same query returned 129 at "extrem niedrig" and 35 at "kein Niedrigwasser", and the upper Neckar gauges are on steigend again.

Missing features

A gewaesser filter on /messstelle would remove the 698 extra requests, and sub-daily values would remove the need for a second source. The reference period being a parameter instead of a fixed number baked into a published figure is the reason I would use NIWIS for this kind of question.

I explored the endpoints with Claude Code, one call per endpoint with the raw response dumped to a file. The -777 sentinel, the 1991 limit and the licence spread all came out of that and none of them are in the documentation.

In how many German districts did I cycle?

In the previous post I described how I build the YAML file with all German districts. Now I wanted to use this data for something else. Wandrer.earth already tracks which streets I cycled, but not which district (Landkreis) they are in. So: a new score -- how many of the 401 districts and how many of the 16 states (Bundesländer) did I cycle in?

The input is my Wahoo export: 3288 FIT files, from 2019 to today. The tours from before the Wahoo ELEMNT BOLTs, so before February 2019, I checked in VeloViewer. Almost all of them were in Baden-Württemberg, so they would not change the statistics much. I did longer tours before that too, but without Strava and a bike computer I never recorded them. Reading the Wahoo FIT files is the easy part, fitdecode does that, and with StandardUnitsDataProcessor the positions come out as degrees instead of semicircles.

The district file I used for the kfz.cress.space page has an osm_id per district -- but that is a reference, not a shape. I first started looking for a ready-made GeoJSON of German district boundaries, and then realized I don't need one: the ids I already have can fetch exactly the 401 relations from my selfhosted Overpass in a single query.

[out:json][timeout:900];relation(id:27020,27021,...);out geom;

What Overpass returns for out geom is not a polygon, it is the relation's member ways with their coordinates, in arbitrary order and direction. Shapely turns that into polygons in two steps: linemerge glues the ways into closed rings, polygonize makes areas out of them.

The member roles matter here. Ways with role inner are holes, and for district boundaries these holes are mostly the enclaved kreisfreie Städte, e.g. Ansbach the city sits inside Landkreis Ansbach. In total 88 of the 401 districts have at least one such hole. Without subtracting them a ride through the city would count for the city and the surrounding Landkreis:

outer = close(rings["outer"])
inner = close(rings["inner"])
if inner is not None and not inner.is_empty:
    outer = outer.difference(inner)

For the lookup itself all polygons go into a shapely.STRtree, and the whole track of one ride is queried at once:

_, found = tree.query(shapely.points(lons, lats), predicate="intersects")
hits = {keys[i] for i in found}

That vectorized query is fast enough (~0.1s for a ride with 2000 points against all 401 districts) and it needs no shapely.prepare: I added a prepare call assuming it would speed things up, benchmarked it, and it changed exactly nothing -- STRtree.query with a predicate prepares the tree geometries itself.

Before the lookup I round every position to 4 decimals (~11m) and put them in a set, which keeps 58% of the recorded points. Less than I expected, because rounding is a grid and not a clustering: two points a few meters apart still end up in different cells when they straddle a boundary.

The first full run took 11 minutes. The results are cached, because a tour that is already calculated will never change. One entry per FIT file, keyed by filename with the file size as a change check:

"2019-05-19-061304-ELEMNT BOLT 8284-98-0.fit": {
  "districts": ["08111", "08115", "08118"],
  "date": "2019-05-19",
  "size": 570271
}

The districts are the Kreisschlüssel again, so Stuttgart, Böblingen and Ludwigsburg for this tour.

And the answer:

3288 rides
districts:      191 / 401  (47.6%)
federal states: 14 / 16
covered:        143,208 km² (40.1% of Germany)

per federal state
  Baden-Württemberg       41 /  44  █████████
  Bayern                  32 /  96  ███
  Berlin                   1 /   1  ██████████
  Brandenburg              9 /  18  █████
  Bremen                   0 /   2
  Hamburg                  1 /   1  ██████████
  Hessen                  21 /  26  ████████
  Mecklenburg-Vorpommern   2 /   8  ██
  Niedersachsen           17 /  45  ████
  Nordrhein-Westfalen     28 /  53  █████
  Rheinland-Pfalz         24 /  36  ███████
  Saarland                 2 /   6  ███
  Sachsen                  1 /  13  █
  Sachsen-Anhalt           7 /  14  █████
  Schleswig-Holstein       0 /  15
  Thüringen                5 /  23  ██

new districts per year
  2019  + 29  → 29
  2020  +  3  → 32
  2021  + 13  → 45
  2022  + 43  → 88
  2023  + 27  → 115
  2024  + 10  → 125
  2025  + 30  → 155
  2026  + 36  → 191

Just under half of the districts, and 14 of 16 states. Missing completely are Bremen and Schleswig-Holstein. I was in Bremen, Kiel and Lübeck in the past, but not cycling there.

The per-year column is basically a travel log. 2020 with +3 is the lockdown year of riding the same routes around Stuttgart, and since 2022 I cycle a lot of distance with my Brompton, mainly along rivers where a single tour crosses a handful of districts.

In the next years I will monitor this and see if I get even more coverage of districts, and obviously "catch" the last 2 states.

German license plate prefixes per Landkreis

I wanted a YAML file that maps every German county (Landkreis or kreisfreie Stadt) to its license plate prefixes (Kfz-Unterscheidungszeichen). The authoritative source for the prefixes is the KBA list, the county list I used is the de-counties.json with OSM ids, inhabitants and area per county. The json file is already a few years old. I iterated on the two scripts with Claude Code.

The KBA page has no API and no download other than a PDF. The data is static HTML, one page per initial letter, so the only option is scraping all letter pages. BeautifulSoup on the stable CSS classes of the accordion items worked fine -- no regex parsing of HTML.

The real work was joining the two datasets, because the only common field is the county name. Some of the things that came up:

  • The KBA names are free text: qualifiers like "Stadt", "Landkreis" or "Hansestadt", multi-county entries ("Ortenaukreis, Rastatt"), abbreviations ("a. d." vs "an der") and footnote markers.

  • Same-name pairs like München (city) and Landkreis München share some prefixes but not all. The KBA qualifiers disambiguate some of them, for the rest I used the Wikipedia lists of Landkreise and kreisfreie Städte to decide which side gets an ambiguous prefix.

  • The official source has actual typos ("Bad Dürkeim", "Biturg-Prüm") and is even missing one valid prefix completely: LD for Landau in der Pfalz.

The biggest learning: join by key, not by name, whenever possible. The keys in de-counties.json are the official Regionalschlüssel and their first 5 digits are the Kreisschlüssel. The Wikipedia tables carry the Kreisschlüssel in a column, so that join needed no name matching at all. I used the Kreisschlüssel as key for the final YAML file, too.

Because the population in the json file is outdated, I added Wikidata as another source. The Wikidata property P440 is the German district key, so every county gets its Wikidata item and current population (P1082) -- again without name matching. The area (P2046) was a surprise: for 20 of the 403 counties the Wikidata value differs more than 10% from the OSM polygon area, and almost all of them are errors on the Wikidata side -- wrong units (5007 km² for the city of Amberg, the München item even carries its 1867 area in square miles), values from before a county merger, or only the land area. The date qualifiers don't help to filter these, the wrong values often carry recent dates. So the script keeps the OSM polygon area when the two disagree, with one exception: Suhl really grew in 2018/2019 by incorporating neighbouring municipalities, there Wikidata is the correct one.

A YAML detail I didn't know before: PyYAML dumps '01001' quoted but 09162 unquoted, because 09162 is not a valid octal number. A YAML 1.2 parser reads the unquoted version as the integer 9162, so the script now forces quotes on all digit keys.

An entry of the resulting file looks like this:

'09162':
  area_km2: 310.71
  inhabitants: 1505005
  name: München
  osm_id: 62428
  plates:
  - M
  - MUC
  wikidata: Q1726

Everything that cannot be matched is printed to stderr instead of being silently dropped. That report is what surfaced the typos, the missing LD and the counties that needed manual supplements (i.e. Eisenach, which lost its kreisfrei status in 2021). The 2017 county list also still contained the dissolved Landkreis Osterode and the pre-merger Landkreis Göttingen as separate entries, and the old Göttingen even got Osterode's OHA prefix because the KBA list describes today's assignment -- so counties marked as dissolved are left out now. In the end all 401 counties have plates and every KBA prefix is assigned to at least one county.

The generated file is the data source I use for kfz.cress.space. I build the site to experiment with Preact. The internet doesn't actually need another Landkreise/KFZ-Kennzeichen page, but I still wanted to build my own.

Screenshot:

img1