Was wir nutzen
Bevor Sie einen Brief freigeben, warnt unsere Adressprüfung, wenn eine deutsche Postleitzahl nicht zum Ort passt oder die Straße nicht in der Straßenliste der Postleitzahl steht. Für Deutschland nutzen wir Daten von OpenStreetMap, © OpenStreetMap-Mitwirkende. Wir beziehen sie von OpenPLZ, das einen Auszug aus OpenStreetMap unter der Open Database License (ODbL) 1.0 veröffentlicht: streets.updated.csv (Datenstand 22. November 2025).
Wir führen unsere Tabellen auf unseren eigenen Servern. Wir senden keine Anschrift an OpenStreetMap oder OpenPLZ.
Was wir aus der Datei übernehmen
- die Postleitzahl (fünf Ziffern);
- die Ortsnamen: die Gemeinde, den Bezirk und den Ortsteil;
- den Straßennamen.
Den Regionalschlüssel nutzen wir nicht. Koordinaten nutzen wir nicht.
Wie wir die Daten verändern
- Wir prüfen jede Zeile. Eine Zeile ohne sechs Felder oder ohne deutsche Postleitzahl bricht die Erstellung ab.
- Ortsnamen: Wir behalten den Namen nach „ : “ („Ortsbeirat 14 : Stadtmitte“ ergibt „Stadtmitte“) und trennen Listen an Kommas. Namen mit einer Ziffer oder einem unerwarteten Zeichen lassen wir weg.
- Straßennamen: OpenStreetMap kann mehrere Namen mit „;“ oder „|“ verbinden. Jeder Teil zählt als Straße. Unsichtbare Zeichen entfernen wir. Namen mit einem unerwarteten Zeichen lassen wir weg.
- Wir fassen die Ortsnamen je Postleitzahl zusammen, ohne doppelte Namen. Die Gemeinde mit den meisten Straßen steht vorn.
- Wir geben jeder Straße einen Straßenschlüssel. Damit passen „Hauptstraße“, „Hauptstrasse“ und „Hauptstr.“ zusammen. Wir behalten eine Zeile je Postleitzahl und Straßenschlüssel.
Lizenz unserer veränderten Daten
Unsere Tabellen sind eine abgeleitete Datenbank von OpenStreetMap. Sie stehen unter der ODbL 1.0. Nach Abschnitt 4.6 der ODbL bietet diese Seite die Methode unserer Änderungen an: den Code unten. Mit der Datei von OpenPLZ und diesem Code erhalten Sie dieselben Tabellen. Wenn Sie die Tabellen selbst brauchen, schreiben Sie an support@agentletterdesk.com.
Der Code
Der Code unten ist der Code, der unsere Tabellen baut. Die Seite zeigt ihn so, wie er in unserem Build steht. Die Kommentare im Code sind Englisch. Alle unsere Datenquellen.
Datei: scripts/address-de.ts
Liest die Datei von OpenPLZ Zeile für Zeile. Es prüft jede Zeile, nimmt die Postleitzahl, die Ortsnamen und die Straßennamen und lässt weg, was eine Prüfung nicht besteht.
// 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 };
}Datei: scripts/address-streets.ts
Bereinigt einen Straßennamen und behält eine Zeile je Postleitzahl und Straßenschlüssel.
/** 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;
}Datei: src/letters/street-check.ts
Bildet den Straßenschlüssel. Er übergeht Groß- und Kleinschreibung, Akzente, Leerzeichen, Bindestriche und Punkte und liest „Straße“, „Strasse“, „Str.“ und „Str“ als dieselbe Endung.
// ---- 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("");
}Datei: scripts/gen-address-data.ts
Bereinigt Ortsnamen und fasst sie je Postleitzahl zusammen, ohne doppelte Namen.
/** 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(";");
}Datei: scripts/gen-address-data.ts
Findet die letzte Fassung der Datei, lädt sie herunter und baut die Tabelle der Postleitzahlen und Orte.
/** 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,
};
}Datei: scripts/gen-address-data.ts
Baut die Tabelle der Postleitzahlen und Straßen.
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" },
};
}Datei: scripts/address-streets.ts
Schreibt die Straßentabelle als Text: eine Zeile je Postleitzahl und Straße.
/** 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("");
}