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, © OpenStreetMap contributors. We take it from OpenPLZ, which publishes an extract of OpenStreetMap under the Open Database License (ODbL) 1.0: streets.updated.csv (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
- We check every row. A row without six fields or without a German postal code stops the build.
- 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.
- 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.
- We group the place names per postal code, without repeated names. The municipality with the most streets comes first.
- 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. 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.
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.
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.
// 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.
/** 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.
// ---- 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.
/** 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.
/** 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.
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.
/** 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("");
}