DEV Community

Chris
Chris

Posted on Fully Autonomous

89 German bylaws, three ways to bill rain: building a fee dataset people can cite

People who call a drain-cleaning company ask a surprising number of questions that have nothing to do with drains. One of the most common: why is my neighbour's sewage bill so much lower than mine?

I build and maintain the website of Fachservice Rohrreinigung, a German drain and sewer cleaning business. We wanted a proper answer to that question: one comparable yearly number per city, where every number can be traced back to the city's own legal document. It ended up covering 89 cities. Most of the work was not collecting numbers. It was building the checks that stop a wrong number from going live.

This post covers the data model, the three billing systems that refused to fit it, and the failures that turned into validation rules.

One household, one formula

German municipalities set their wastewater fees in a bylaw (Satzung) or an official price sheet. To compare them you need a fixed reference household. Ours is four people in a detached house with 200 m³ of fresh water and 130 m² of sealed roof and paving per year:

export const MODELL = {
  schmutzwasserM3: 200,       // m³ fresh water = billed wastewater
  versiegelteFlaecheM2: 130,  // m² sealed area draining into the sewer
  formel: '200 × wastewater rate + 130 × rainwater rate + base fee, if any',
};
Enter fullscreen mode Exit fullscreen mode

Each city is one record with its rates, the year they took effect and a source:

{
  slug: 'ludwigsburg',
  stadt: 'Ludwigsburg',
  gebuehrenmodell: 'gesplittet',      // split fee: wastewater + rainwater
  schmutzwasser_eur_m3: 1.57,
  niederschlagswasser_eur_m2: 0.28,
  gueltig_ab: '2025',
  quelle_url: 'https://www.ludwigsburg.de/…/Abwassersatzung_ab_2025.pdf',
  jahreskostenMusterhaushalt: 350.4,
}
Enter fullscreen mode Exit fullscreen mode

jahreskostenMusterhaushalt (yearly cost for the model household) is stored, but never trusted. The validator recomputes it from the rates on every run. More on that below.

Three ways to bill the rain

Most cities fit the model: a price per m³ of wastewater and a price per m² of sealed area. Two well-known cities show why "most" matters.

Leipzig weights the area by surface type. According to the Leipziger Wasserwerke rainwater price page, densely sealed surfaces count 100%, partially sealed ones 50% and unsealed ones 0%. Our model treats all 130 m² as fully sealed, so Leipzig fits, as long as you remember the assumption.

Magdeburg bills rain by volume. The AGM terms convert the area into an estimated runoff volume and charge per cubic metre:

V = Ψ × r × A
Ψ  runoff coefficient: 0.95 pitched roof, 0.60 paving without sealed joints, 0.30 green roof
r  0.494 m³ per m² per year
A  connected area in m²
Enter fullscreen mode Exit fullscreen mode

At 1.59 €/m³, 100 m² of pitched roof comes to 0.95 × 0.494 × 100 × 1.59 = 74.62 € a year.

We could have converted Magdeburg into a per-m² figure by picking a surface type. We didn't. A comparison table that quietly assumes a roof type for one row and not the others is wrong in a way no reader can spot. Instead, the record carries a flag, and the page reports two averages: one over all cities and one over the fully comparable ones.

{ slug: 'magdeburg', schmutzwasser_eur_m3: 4.07, nichtVergleichbar: true, /* … */ }
Enter fullscreen mode Exit fullscreen mode

The general rule: when a data point does not fit the model, mark it. Don't bend it until it does.

Sources: official or nothing

Every rate must come from the city's bylaw, its official gazette or the price page of its utility. Comparison portals are not accepted as evidence, however convenient. The validator enforces this with a crude but effective hostname blocklist:

const VERBOTENE_QUELLEN = /statista|hausjournal|nebenkosten|vergleich\.|check24|verivox|wikipedia|immobilienscout/i;
Enter fullscreen mode Exit fullscreen mode

Every rate was then checked a second time, independently, at the same source. Anything that could not be found again was dropped. That is also why there are 89 rows and not more.

What broke, and the rule each failure left behind

1. Prose in a year field

Part of the research was done with AI research agents that fill in records from source documents. One of them put a whole explanatory sentence into gueltig_ab instead of a bare year. Twenty-nine source titles were over 200 characters long, the longest 371. Both would have broken narrow table columns. Neither was visible locally, because at the time the static build hung on my machine without any output.

Now it is a check:

if (!/^\d{4}$/.test(String(g.gueltig_ab || ''))) {
  fehler.push(`${g.stadt}: gueltig_ab is "${g.gueltig_ab}", expected a bare year`);
}
Enter fullscreen mode Exit fullscreen mode

If you let a model fill structured fields, validate the shape of every field and not just its presence. Models are very good at writing helpful explanations into fields that should hold a number.

2. The prompt that was too big to finish

The agent that wrote the explanatory page text first received all 89 records, including each record's source quotes and notes: 149 KB of JSON. It stalled six times in a row. Projecting the data down to the fields the text actually uses brought it to 18 KB, and it ran on the first try. Prompt size is a failure mode, and the fix was on the data side, not the prompt side.

3. Numbers are rendered, never written

Several of the site's city pages show the local fee, for example the pages on drain cleaning in Schorndorf and Rohrreinigung for Gladbeck in the Ruhr area. Those numbers are not typed into the page text. The city page renders them from the same record the comparison table uses.

The reason is simple: bylaws change. If a rate appears in hand-written prose on a city page and in the data on the comparison page, the next update changes one of them, and the site then contradicts itself. A separate check fails the build if a fee figure shows up in a page's prose instead of coming from the data.

The validator, briefly

The fee check runs before every push and fails on any of these:

  • The formula. jahreskostenMusterhaushalt must equal 200 × wastewater rate + 130 × rainwater rate + base fee, within one cent.
  • Plausibility bands. 1–7 €/m³ for wastewater and 0.2–3 €/m² for rainwater. This is not a hard truth, just a brake on comma and unit mistakes (2,85 vs 28.5).
  • Split fee without a rain rate. A record marked as split-fee without a rainwater rate must carry the nichtVergleichbar flag. Otherwise its yearly total is incomplete and the page would not say so.
  • Headline figures match the table. The cheapest city, the most expensive city, the spread and the averages shown at the top must equal what the table computes.
  • Sitemap date. The page's "last checked" date and its sitemap lastmod must agree.
  • The text names the current extremes. If the cheapest city changes and the page text still names the old one, the check warns.

Running a page's frontmatter without rendering it

Because the full build was unreliable on my machine, the check also executes the page's real Astro frontmatter, without the renderer. It strips the .astro component imports, rewrites the data imports to absolute paths, transpiles the result with esbuild and imports it from a data: URL:

const { transform } = await import('esbuild');
const js = (await transform(frontmatter, {
  loader: 'ts',
  format: 'esm',
  target: 'es2022',
  tsconfigRaw: { compilerOptions: { verbatimModuleSyntax: true } },
})).code;

const mod = await import('data:text/javascript;base64,' +
  Buffer.from(js + probe, 'utf8').toString('base64'));
Enter fullscreen mode Exit fullscreen mode

One flag is essential: verbatimModuleSyntax. Without it, esbuild's TypeScript loader drops every import that the frontmatter itself does not use. Several imports are only used in the markup below the frontmatter, so the probe reported "x is not defined" for variables that exist perfectly well in the real page. The TypeScript caveats in the esbuild documentation explain why the loader behaves this way.

The result

For the same household, the yearly bill across the 89 cities ranges from 350.40 € in Ludwigsburg to 1,412.90 € in Potsdam. Potsdam's rates are high, but it lands at the top partly because of a 120 € annual base fee. On the price per cubic metre alone, Herford (5.84 €/m³) is more expensive than Potsdam (5.60 €/m³), so a comparison of headline rates would have ranked them the other way round.

That last point is the lesson I'd keep from the whole project. A comparison is only as honest as its least visible component. Base fees, provision charges and surface weightings are easy to miss because they don't sit next to the headline rate. So it's worth reading each tariff sheet once more, specifically for the charges that don't scale with volume, before trusting any total.


Disclosure: I build and run the site linked above. The rates quoted are from the official sources linked in this post as of October 2026. If you spot a city whose numbers have changed, I'd like to hear about it.

Top comments (0)