Skip to content
99ersstudio
← Studio Log
how-we-built3 min read

How we built the Mietspiegel PLZ lookup

How we built the Mietspiegel PLZ lookup

The Mieter App has to answer one specific question in under 100 ms: given a Berlin PLZ, what is the Mietspiegel range for this apartment? Here is the full lookup path we ended up with, component by component.

The shape of the solution

A PLZ string comes in. It flows through four layers: a PlzResolver that maps the string to a (stadt, bezirk) tuple, a MietspiegelQuery that hits SQLite for the range row, a LazyLoader that pulls the relevant city table into memory on first access, and a RangeChecker that compares the typed rent against the row and returns the verdict. All four live in lib/mietspiegel/ and each is under 200 lines.

PlzResolver

A German PLZ is five digits. The first two narrow it down to a macro region: 10xxx and 12xxx are Berlin, 20xxx is Hamburg, 80xxx and 81xxx are München. The third digit usually resolves to a Bezirk. PlzResolver is a lookup against a static JSON file we generated from the official Bundespost PLZ data. The entry point is:

(String stadt, String bezirk) resolvePlz(String plz);

The file lives at assets/plz/plz_directory.json and holds 8,212 entries across the eight cities we support. It is 340 KB, ships inside the app bundle, and never hits the network.

MietspiegelQuery

SQLite holds one ranges table with columns stadt, bezirk, baujahr_bucket, ausstattung, min_eur_per_sqm, max_eur_per_sqm. Primary key is (stadt, bezirk, baujahr_bucket, ausstattung). The query is:

SELECT min_eur_per_sqm, max_eur_per_sqm FROM ranges
WHERE stadt = ? AND bezirk = ? AND baujahr_bucket = ? AND ausstattung = ?
LIMIT 1;

A real Mietspiegel has a lot more dimensions in principle, but the official tables collapse most of them into four buckets per (stadt, bezirk). We kept the schema intentionally narrow because every extra column is another place for the official city tables to change format the next time they publish.

LazyLoader

The first mistake was loading all eight city SQLite databases into memory at app startup. Cold-start hit 3.8 seconds on a Pixel 4a. Nobody cares about Stuttgart if they live in Berlin.

LazyLoader sits between MietspiegelQuery and the file system. It holds an open SQLite connection per city, opens connections on demand, and keeps the two most-recently-used cities in memory. For the 80 percent of users who never leave their own city, only one connection is ever opened. For the traveling tenants who bounce between Berlin and Hamburg, we keep both warm.

The entry point:

Future<Database> databaseFor(String stadt);

Cold-start dropped from 3.8 seconds to 640 ms after this change. Phase 3 of the app was largely this one component.

RangeChecker

RangeChecker takes the range row and the typed rent per square meter and returns one of three verdicts: below range, inside range, above range. If the rent is above range by more than 10 percent, it also flags the case as likely_mietpreisbremse_violation so the UI can surface the relevant §557 BGB template.

The signature:

RangeVerdict check({
  required double typedRentPerSqm,
  required MietspiegelRange range,
});

The math is boring on purpose. We keep the interesting code in the UI layer, not in the financial calculation layer, because any bug in the rent math would be a user-trust problem we cannot fix after the fact.

What surprised us

The 80-percent-stay-in-their-own-city number was not in any of our planning. We got it by logging which PLZ prefixes our first 200 beta testers actually typed. Berlin PLZs (10xxx, 12xxx) accounted for 81 percent of lookups. München (80xxx, 81xxx) was 11 percent. Hamburg (20xxx) was 4 percent. Everything else combined was 4 percent.

This told us the LRU cache of two cities was enough. It also told us that pre-fetching Berlin's database during the app's idle moments on a Berlin device cut perceived latency on the first Mietpreischeck by another 180 ms. We shipped that pre-fetch in version 1.3 and it has been the single most impactful perf change since lazy loading itself.

Numbers

  • Cold-start before lazy loading: 3.8 seconds on a Pixel 4a
  • Cold-start after lazy loading: 640 ms
  • Pre-fetch improvement on first Mietpreischeck: 180 ms saved
  • 98 integration tests cover the full PLZ-to-verdict path
  • PLZ directory size: 340 KB bundled
  • Cities covered: 8 as of version 1.4
  • Lines of code across all four components: 740 in Dart

Related