# OpenStreetMap data in our address check

## What we use

Before you approve a letter, our address check warns when a German postal code does not match the city, or when the street is not in the street list of the postal code. For Germany, we use data from [OpenStreetMap](https://www.openstreetmap.org/copyright), © OpenStreetMap contributors. We take it from OpenPLZ, which publishes an extract of OpenStreetMap under the [Open Database License (ODbL) 1.0](https://opendatacommons.org/licenses/odbl/1-0/): [streets.updated.csv](https://github.com/openpotato/openplzapi.data) (data as of 22 November 2025).

We keep our tables on our own servers. We send no address to OpenStreetMap or OpenPLZ.

## What we take from the file

- the postal code (five digits);
- the place names: the municipality, the borough and the suburb;
- the street name.

We do not use the regional key. We use no coordinates.

## How we change it

1. We check every row. A row without six fields or without a German postal code stops the build.
2. Place names: we keep the name after " : " ("Ortsbeirat 14 : Stadtmitte" gives "Stadtmitte"), and we split lists at commas. We drop names with a digit or an unexpected character.
3. Street names: OpenStreetMap can join several names with ";" or "|". Each part counts as a street. We remove invisible characters. We drop names with an unexpected character.
4. We group the place names per postal code, without repeated names. The municipality with the most streets comes first.
5. We add a street key to each street. It lets "Hauptstraße", "Hauptstrasse" and "Hauptstr." match. We keep one row per postal code and street key.

## Licence of our changed data

Our tables are a derivative database of OpenStreetMap. They are licensed under the [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/). Under section 4.6 of the ODbL, this page offers the method of our changes: the code below. With OpenPLZ's file and this code, you get the same tables. If you need the tables themselves, write to [support@agentletterdesk.com](mailto:support@agentletterdesk.com).

## The code

The code below is the code that builds our tables. The page shows it as it is in our build. [All our data sources](https://agentletterdesk.com/imprint#data-sources).

### File: `scripts/address-de.ts`

Reads OpenPLZ's file row by row. It checks every row, takes the postal code, the place names and the street names, and drops what fails a check.

```ts
// Germany for the address check (data/research/address-check.md §2, §3.1, §6.3;
// Tom, 2026-10-16: option (b)). The input is OpenPLZ's street file
// streets.updated.csv (github.com/openpotato/openplzapi.data, src/de/osm), an
// extract of OpenStreetMap under the Open Database License (ODbL) 1.0. These
// pure functions decide what we take from that file and how we change it. They
// are published as text on our page /data-sources/openstreetmap (ODbL 4.6: the
// method of making the alterations), together with the shared cleaning and
// grouping code they call. Tested on small fixtures in test/unit/address-de.test.ts.
//
// What we take, per row of the file (Name, PostalCode, Locality, RegionalKey,
// Borough, Suburb):
//   - the postal code (5 digits, not "00…");
//   - the place names: Locality (the municipality), then Borough and Suburb;
//   - the street name (Name).
// We drop RegionalKey and every row or name that fails a check below.
//
// How we change it:
//   1. Place names: a borough written "Ortsbeirat 14 : Stadtmitte" keeps the part
//      after " : "; a list "Gehlsdorf, Hinrichsdorf" gives each name; a part with
//      a digit ("Stadtbezirk 8") or an unexpected character is dropped.
//   2. Street names: OpenStreetMap joins several names with ";" or "|"
//      ("Bismarckstr.;Roggemannstr."): each part counts as a street. Invisible
//      characters (zero-width space, soft hyphen) are removed. A part with an
//      unexpected character ("[Feldweg]", "#", emoji) is dropped and counted.
//   3. Grouping (scripts/gen-address-data.ts, scripts/address-streets.ts): one
//      record per postal code with its place names, repeated names removed; one
//      row per (postal code, street key). The street key (streetKey in
//      src/letters/street-check.ts) ignores case, accents, spaces, hyphens and
//      dots, and reads "Straße", "Strasse", "Str." and "Str" as one ending, the
//      way OpenPLZ already shortens "straße" to "str.".
import { cleanStreet, csvFields, StreetTable } from "./address-streets";

/** The header of OpenPLZ's German street file. Another header stops the script: the format changed. */
export const OPENPLZ_DE_HEADER = "Name,PostalCode,Locality,RegionalKey,Borough,Suburb";

/** A German postal code: five digits, never "00…" (the lowest is 01067). */
export const DE_POSTAL_CODE = /^(?!00)\d{5}$/;

/** At most this share of rows may lose a street name to the checks (or MAX_SKIPPED_ROWS); more means the format changed. */
export const MAX_SKIPPED_SHARE = 0.001;
/** …and never more than this many on a small file (2025-11-22: 14 of 1,201,689 rows). */
export const MAX_SKIPPED_ROWS = 100;

/** Letters, digits and the punctuation of real place names (the same set as the generator's other countries). */
const PLACE_CHARS = /^[\p{L}\p{M}\p{N} .,'’\-/()&]+$/u;
/** Characters that OpenStreetMap names carry but that show nothing: zero-width space, joiners, soft hyphen, BOM. */
const INVISIBLE = /[\u200b-\u200d\u2060\u00ad\ufeff]/g;

/** A place name as we keep it, or null: a name with a digit or an unexpected character is dropped (OpenStreetMap is edited by many people). */
function placeName(raw: string): string | null {
  const s = raw.replace(INVISIBLE, "").replace(/\s+/g, " ").trim();
  return s === "" || s.length > 80 || /\d/.test(s) || !PLACE_CHARS.test(s) ? null : s;
}

/**
 * The place names of one row: the municipality (Locality), and the names of
 * the borough and the suburb ("Ortsbeirat 14 : Stadtmitte" → "Stadtmitte";
 * "Gehlsdorf, Hinrichsdorf" → both).
 */
export function germanPlaceNames(locality: string, borough: string, suburb: string): { locality: string | null; areas: string[] } {
  const areas: string[] = [];
  for (const area of [borough, suburb]) {
    const named = area.includes(" : ") ? area.slice(area.lastIndexOf(" : ") + 3) : area;
    for (const part of named.split(",")) {
      const s = placeName(part);
      if (s && !areas.includes(s)) areas.push(s);
    }
  }
  return { locality: placeName(locality), areas };
}

/**
 * The street names of one row's Name field: each part of a list joined by ";"
 * or "|", without invisible characters. `skipped` counts the parts that fail
 * cleanStreet (an unexpected character); they are dropped, not fixed.
 */
export function germanStreetNames(raw: string): { names: string[]; skipped: number } {
  const names: string[] = [];
  let skipped = 0;
  for (const part of raw.replace(INVISIBLE, "").split(/[;|]/)) {
    if (part.trim() === "") continue;
    let name: string | null;
    try {
      name = cleanStreet(part);
    } catch {
      skipped++;
      continue;
    }
    if (name && !names.includes(name)) names.push(name);
  }
  return { names, skipped };
}

export interface GermanData {
  /**
   * (postal code, place name) pairs in the order the generator adds them: per
   * code, the municipalities with the most streets first, then the boroughs
   * and suburbs. The first name is the one a warning shows.
   */
  places: [string, string][];
  /** The (postal code, street) rows. */
  streets: StreetTable;
  /** Data rows read. */
  rows: number;
  /** Street parts dropped by a check. */
  skipped: number;
}

/**
 * OpenPLZ's German street file. Every row must have six fields and a German
 * postal code; a bad row stops the script (the file changed its format), as
 * does a share of dropped street names above MAX_SKIPPED_SHARE.
 */
export function parseOpenPlzGermany(csv: string): GermanData {
  const lines = csv.replace(/^\ufeff/, "").split(/\r?\n/);
  if (lines[0] !== OPENPLZ_DE_HEADER) throw new Error(`OpenPLZ DE: header changed: ${JSON.stringify((lines[0] ?? "").slice(0, 120))}`);
  const localities = new Map<string, Map<string, number>>();
  const areas = new Map<string, Set<string>>();
  const streets = new StreetTable(DE_POSTAL_CODE);
  let rows = 0;
  let skipped = 0;
  for (const line of lines.slice(1)) {
    if (line === "") continue;
    const f = csvFields(line, ",");
    if (f.length !== 6 || !DE_POSTAL_CODE.test(f[1]!)) throw new Error(`OpenPLZ DE: bad row ${JSON.stringify(line.slice(0, 80))}`);
    rows++;
    const [name, code, locality, , borough, suburb] = f as [string, string, string, string, string, string];
    const p = germanPlaceNames(locality, borough, suburb);
    if (p.locality) {
      const m = localities.get(code) ?? new Map<string, number>();
      m.set(p.locality, (m.get(p.locality) ?? 0) + 1);
      localities.set(code, m);
    }
    const a = areas.get(code) ?? new Set<string>();
    for (const name of p.areas) a.add(name);
    areas.set(code, a);
    const s = germanStreetNames(name);
    skipped += s.skipped;
    for (const street of s.names) streets.add(code, street);
  }
  const places: [string, string][] = [];
  for (const code of [...areas.keys()].sort()) {
    const byStreets = [...(localities.get(code) ?? [])].sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : 1));
    for (const [name] of byStreets) places.push([code, name]);
    for (const name of areas.get(code)!) places.push([code, name]);
  }
  if (skipped > Math.max(MAX_SKIPPED_ROWS, rows * MAX_SKIPPED_SHARE)) throw new Error(`OpenPLZ DE: ${skipped} street names dropped of ${rows} rows. Did the file change its format?`);
  return { places, streets, rows, skipped };
}
```

### File: `scripts/address-streets.ts`

Cleans a street name and keeps one row per postal code and street key.

```ts
/** Letters, digits and the punctuation of real street names ("Landis + Gyr-Strasse", "Rue de l'Église", "Kellergasse 'Moorberg'", "warum zu Fuss?"). */
const STREET_CHARS = /^[\p{L}\p{M}\p{N} .,'’`´\-–/()&+!?:°]+$/u;

/**
 * A street name as a snapshot keeps it, or null to skip it: spaces collapsed
 * (also no-break spaces), double quotes become single ones (an agent text
 * quotes the name), a name whose letters the source lost ("�") is skipped.
 * Anything unexpected (a tab, a separator, a control character, over 100
 * characters) stops the script: the source changed its format.
 */
export function cleanStreet(raw: string): string | null {
  const s = raw.replace(/[\s ]+/g, " ").replace(/["“”„]/g, "'").trim();
  if (s === "" || s.includes("�")) return null;
  if (/[;|\t\u0000-\u001f\u007f]/.test(s)) throw new Error(`street with a separator or control character: ${JSON.stringify(s.slice(0, 80))}`);
  if (s.length > 100) throw new Error(`street longer than 100 characters: ${s.slice(0, 80)}…`);
  if (!STREET_CHARS.test(s)) throw new Error(`street with an unexpected character: ${JSON.stringify(s)}`);
  return s;
}

/** The rows of a list, one per (code, key), the first name of each key kept; sorted by code, then key. */
export class StreetTable {
  private readonly rows = new Map<string, StreetRow>();
  constructor(private readonly codePattern: RegExp) {}
  add(code: string, rawName: string): void {
    if (!this.codePattern.test(code)) throw new Error(`bad postal code ${JSON.stringify(code.slice(0, 20))}`);
    const name = cleanStreet(rawName);
    if (!name) return;
    const key = streetKey(name);
    if (!key) return;
    const id = `${code}\t${key}`;
    if (!this.rows.has(id)) this.rows.set(id, { code, key, name });
  }
  get size(): number {
    return this.rows.size;
  }
  sorted(): StreetRow[] {
    return [...this.rows.values()].sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
  }
}

/** One CSV line with a separator and "…" quoting ("" inside quotes is one "). */
export function csvFields(line: string, sep: string): string[] {
  const out: string[] = [];
  let cur = "";
  let quoted = false;
  for (let i = 0; i < line.length; i++) {
    const c = line[i]!;
    if (quoted) {
      if (c === '"') {
        if (line[i + 1] === '"') {
          cur += '"';
          i++;
        } else quoted = false;
      } else cur += c;
    } else if (c === '"') quoted = true;
    else if (c === sep) {
      out.push(cur);
      cur = "";
    } else cur += c;
  }
  out.push(cur);
  return out;
}
```

### File: `src/letters/street-check.ts`

Makes the street key. It ignores case, accents, spaces, hyphens and dots, and reads "Straße", "Strasse", "Str." and "Str" as one ending.

```ts
// ---- Normalisation -------------------------------------------------------------------

/** Whole words that people abbreviate (after case and diacritics folding): the key keeps one form. */
const WORDS: Readonly<Record<string, string>> = {
  sankt: "st",
  skt: "st",
  saint: "st",
  sainte: "ste",
  doktor: "dr",
  professor: "prof",
  av: "avenue",
  ave: "avenue",
  ch: "chemin",
  che: "chemin",
  rte: "route",
  bd: "boulevard",
  bld: "boulevard",
  boul: "boulevard",
  imp: "impasse",
};

/**
 * A street name as a lookup key: case, diacritics, ß/ss, ä/ae, spaces, hyphens
 * and dots do not count; "Straße", "Strasse", "Str." and "Str" are one ending;
 * Sankt/St., Doktor/Dr., Professor/Prof. and the French abbreviations (av.,
 * ch., rte, bd) are one word each. "Elgendorfer Str." = "Elgendorferstraße";
 * "Dr.-Karl-Renner-Ring" = "Dr. Karl Renner Ring".
 */
export function streetKey(name: string): string {
  return name
    .normalize("NFKD")
    .replace(/\p{M}/gu, "")
    .toLowerCase()
    .replace(/ß/g, "ss")
    .replace(/æ/g, "ae")
    .replace(/œ/g, "oe")
    .replace(/[øǿ]/g, "o")
    .replace(/[łŀ]/g, "l")
    .split(/[^\p{L}\p{N}]+/u)
    .filter(Boolean)
    .map((t) => {
      const w = WORDS[t] ?? t;
      // Straße/Strasse/Str. → "str"; then the German "e" spelling of umlauts (Mueller = Müller).
      return (w.endsWith("strasse") ? `${w.slice(0, -7)}str` : w).replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
    })
    .join("");
}
```

### File: `scripts/gen-address-data.ts`

Cleans place names and groups them per postal code, without repeated names.

```ts
/** P: P.O. boxes only. G: street addresses only (no P.O. boxes). */
type Flag = "" | "P" | "G";
interface Row {
  names: string[];
  keys: Set<string>;
  flag: Flag;
}
type Table = Map<string, Row>;

/** A loose key for removing repeated names (the check itself matches more loosely). */
const dedupeKey = (name: string) =>
  name
    .normalize("NFKD")
    .replace(/\p{M}/gu, "")
    .toLowerCase()
    .replace(/[^\p{L}\p{N}]+/gu, " ")
    .trim();
const shouting = (s: string) => s === s.toUpperCase() && s !== s.toLowerCase();

/** Letters, digits and the punctuation of real place names ("Biel/Bienne", "'s-Gravenhage", "St. Pölten", "Cuesmes (Mons)"). */
const NAME_CHARS = /^[\p{L}\p{M}\p{N} .,'’\-/()&]+$/u;

function clean(name: string): string | null {
  const s = name.replace(/\s+/g, " ").trim();
  if (s === "" || s.includes("�")) return null; // the source lost a letter (Statistik Austria: Hungarian ő, ű)
  if (/[;:|\u0000-\u001f\u007f]/.test(s)) throw new Error(`name with a separator or control character: ${JSON.stringify(s)}`);
  if (s.length > 80) throw new Error(`name longer than 80 characters: ${s}`);
  if (!NAME_CHARS.test(s)) throw new Error(`name with an unexpected character: ${JSON.stringify(s)}`);
  return s;
}

function add(table: Table, code: string, name: string, flag?: Flag) {
  const row = table.get(code) ?? { names: [], keys: new Set<string>(), flag: "" as Flag };
  if (flag) row.flag = flag;
  table.set(code, row);
  const s = clean(name);
  if (!s) return;
  const key = dedupeKey(s);
  if (key === "") return;
  if (row.keys.has(key)) {
    // Keep the first place, but prefer "Antwerpen" to "ANTWERPEN".
    const i = row.names.findIndex((n) => dedupeKey(n) === key);
    if (i >= 0 && shouting(row.names[i]!) && !shouting(s)) row.names[i] = s;
    return;
  }
  row.keys.add(key);
  row.names.push(s);
}

function encode(table: Table, pattern: RegExp): string {
  return [...table]
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([code, row]) => {
      if (!pattern.test(code)) throw new Error(`bad postal code ${JSON.stringify(code)}`);
      if (row.names.length === 0) throw new Error(`postal code ${code} has no name`);
      return `${code}${row.flag}:${row.names.join("|")}`;
    })
    .join(";");
}
```

### File: `scripts/gen-address-data.ts`

Finds the last version of the file, downloads it and builds the table of postal codes and places.

```ts
/** OpenPLZ's German street file, pinned to its last commit (the URL names it). */
const OPENPLZ_REPO = "openpotato/openplzapi.data";
const OPENPLZ_DE_PATH = "src/de/osm/streets.updated.csv";
const OPENPLZ_SOURCE_NAME = "OpenPLZ API Data, streets.updated.csv (extract of OpenStreetMap)";
const OPENPLZ_LICENCE = "ODbL 1.0 (© OpenStreetMap contributors; OpenPLZ, Stüber Systems GmbH)";

interface OpenPlzFile {
  data: GermanData;
  /** The raw URL of the file at its commit. */
  url: string;
  /** The day of the file's last commit: the data date. */
  committed: string;
}

async function openPlzGermany(dir: string): Promise<OpenPlzFile> {
  const commits = await download(dir, "commits.json", `https://api.github.com/repos/${OPENPLZ_REPO}/commits?path=${OPENPLZ_DE_PATH}&per_page=1`);
  const last = (json(commits) as { sha?: unknown; commit?: { committer?: { date?: unknown } } }[])[0];
  const sha = String(last?.sha ?? "");
  if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`OpenPLZ: no commit for ${OPENPLZ_DE_PATH}`);
  const committed = checkedDay(String(last?.commit?.committer?.date ?? ""), "OpenPLZ commit date");
  const f = await download(dir, "streets.updated.csv", `https://raw.githubusercontent.com/${OPENPLZ_REPO}/${sha}/${OPENPLZ_DE_PATH}`);
  const data = parseOpenPlzGermany(text(f));
  expect("DE rows", data.rows, 1000000);
  console.log(`  DE: ${data.rows} rows, ${data.skipped} street names dropped by the checks`);
  return { data, url: f.url, committed };
}

async function germany(dir: string): Promise<Output> {
  const { data, url, committed } = await openPlzGermany(dir);
  const table: Table = new Map();
  for (const [code, name] of data.places) add(table, code, name);
  expect("DE codes", table.size, 8000);
  return {
    country: "DE",
    label: "Germany",
    sources: [{ id: "openplz-osm", name: OPENPLZ_SOURCE_NAME, url, licence: OPENPLZ_LICENCE, dataDate: committed, dateNote: "last commit of the file" }],
    format:
      "postal code → place names (municipalities with the most streets first, then boroughs and suburbs); street codes only: P.O.-box and large-recipient codes are not in OpenStreetMap. Changed by scripts/address-de.ts (published on /data-sources/openstreetmap)",
    data: encode(table, DE_POSTAL_CODE),
    count: table.size,
  };
}
```

### File: `scripts/gen-address-data.ts`

Builds the table of postal codes and streets.

```ts
async function germanStreets(dir: string): Promise<StreetOutput> {
  const { data, url, committed } = await openPlzGermany(dir);
  expect("DE (postal code, street) pairs", data.streets.size, 1000000);
  const t = snapshotText(data.streets.sorted());
  return {
    meta: { country: "DE", asOf: committed, rows: data.streets.size, sha256: snapshotHash(t) },
    text: t,
    source: { id: "openplz-osm", name: OPENPLZ_SOURCE_NAME, url, licence: OPENPLZ_LICENCE, dataDate: committed, dateNote: "last commit of the file" },
  };
}
```

### File: `scripts/address-streets.ts`

Writes the street table as text: one line per postal code and street.

```ts
/** The snapshot text of a country: "<code>\t<key>\t<name>\n" per row. */
export function snapshotText(rows: readonly StreetRow[]): string {
  return rows.map((r) => `${r.code}\t${r.key}\t${r.name}\n`).join("");
}
```
