Going Stellar

Home/Methods

E-commerce · Addresses

Normalising delivery addresses across countries: postcodes, Cyrillic, street-length limits

Address normalisation turns what a customer typed into the form a courier accepts and a national register confirms. We do it per country: first we find out whether that country's postcode describes a street or a whole locality, then we validate against a register whose flaws we know, normalise the register and the search with the same function, and send the courier its own spelling and identifiers. Most of the failures we hit were in the plumbing around the address, not in the address.

Updated

What address normalisation is, in one paragraph

Address normalisation is the step that turns the free text a customer typed at checkout into fields a courier accepts and an official or open register confirms. For orders shipped to several countries, normalise delivery addresses per country: decide whether the country's postcode is street-level or locality-level, validate against a national register, compare text using one shared normalisation function, send the courier the exact name and identifier from the courier's own list, and respect the courier's field limits. Repair a field automatically only when two independent signals agree; otherwise a person decides.

A nomenclature here means a table of valid localities, streets and postcodes for one country. A sentinel value is a placeholder a data source uses for "not filled in". Both matter below. The two-signal rule, and the sentinel postcode that nearly shipped, are covered in our research article on address repair; this method is the plumbing underneath it.

Step 1: find out what a postcode means in each country

The first question for any country is whether its postcode identifies a street or a whole locality. The answer changes the data model, and a validator copied from one country to another without asking it is wrong from the start.

In Romania the postcode is street-level, with house-number ranges and odd/even parity. The Czech Republic is the reverse: in 74% of municipalities there is one postcode, and the country has only 2,677 postcodes against 37,914 in Romania, so you derive the postcode from the locality, not the street from the postcode. Poland is street-level like Romania: one main street in the capital carries 20+ postcodes, sliced by number and parity. Bulgaria has 4-digit postcodes at locality level, about 4,600 of them. In Moldova the postcode follows the house number: one street in the capital has three postcodes for three number ranges, so any rule that judges by street name is wrong on two thirds of that street.

So the Polish validator is a port of the Romanian one, and the Czech one had to be written differently.

Step 2: pick a register per country and learn its flaws first

Every national address register we used had a flaw that would have broken a naive build. We now look for the flaw before loading the data.

For the Czech Republic the official address register is published as a monthly CSV under CC BY 4.0, with streets, house numbers and postcodes. For Poland we used OpenAddresses, which is derived from the official register, but the postcodes in it are systematically wrong in places, because they are assigned by spatial overlay rather than by postal routing. The authoritative street-to-postcode list is a paid product of the national post. Our decision was free-first: validate street and number against the open data, and keep the customer's postcode when the street and locality exist, because customers usually know their own postcode.

Bulgaria has no official street register we could use; the official locality list has no streets. OpenStreetMap was the only source of streets, and the ready-made shapefile extracts did not include the address tags at all, so we parsed the full extract. OpenStreetMap had only 1,209 of roughly 4,600 Bulgarian postcodes; the free GeoNames postcode file took that to 4,679. For Hungary and Slovakia we combined GeoNames postcodes with OpenStreetMap streets. For Moldova the national post publishes a public API with 706,242 house numbers, each with one postcode.

Step 3: normalise the register and the search with the same function

A search that normalises text differently from the way the register was normalised always answers "not found", and "not found" looks exactly like "does not exist". This was our most expensive class of silent failure.

Our folding function decomposed every character and dropped the combining marks. On Cyrillic that is destructive: "й" decomposes into "и" plus a breve, so 303 of 6,195 Bulgarian localities could never be found. On Polish it did too little: "ł" has no decomposed form, so it stayed "ł" while the register had "l", and 6,382 of 35,714 Polish names were unreachable, among them Łódź and Wrocław. Letters such as ł, đ, ø, ß, æ and œ have no decomposed form, and Cyrillic should not be decomposed at all.

Romanian has its own version: the register held one county under the old cedilla "ţ" in 2,055 rows and under the correct comma-below "ț" in 2 rows, so the table showed 43 counties instead of 42. In Moldova "â" and "î" are the same sound written two ways, and the register had 149 streets with one and 439 with the other.

The check is one query: count the rows where norm(name) <> name_norm. If it is not zero, you are searching in a different language from the one the register was written in.

Step 4: send the courier its own spelling and identifier

A courier API resolves an address against the courier's own nomenclature, not against yours. Resolve the locality in the courier's list and send its identifier and canonical name, not the text the customer typed.

One courier's API keeps its Bulgarian localities only in Latin script. A search in Cyrillic returns zero results. Our own transliteration of the capital produced "SOFIYA"; the courier has "SOFIA", and "SOFIYA" also returns zero. Writing our transliteration would have fixed a seaside resort and broken the largest city. The rule we kept: our candidate is only a hint; we write exactly the name the courier has. We resolve by postcode first, then by transliterated name, and answer only when one candidate remains, because some village names exist in two regions.

The same integration also sent the country as a name rather than an ISO code, so every foreign order was treated as domestic. It kept working by inertia: 116 labels succeeded because their postcodes were correct and the courier fell back on the postcode. Only the orders with a non-existent postcode failed. The same API returns only 10 rows when you list countries or localities without a filter, so a map built from an unfiltered listing silently missed a whole country.

Step 5: respect field limits and what customers paste

Customers paste the whole address into the first line. One courier's API refuses the entire label when the street field is longer than 50 characters; a pasted line of 59 characters blocked the parcel. We now cut the street field at 50 and send the full address in the courier's free-text note, which the driver reads.

Customers also paste the region into the city field, as in "Габрово, Габрово". If the name contains a comma, we retry with the part before it. In Poland the house number often sits in the second address line in glued forms such as 2m39 (building, separator, flat); a pattern that required a word boundary missed them and sent good orders to a person. Fixing it took the share going to a person from 2.5% to 1.0% on a test of 400 real orders. Czech addresses had the same glued-number bug.

Letter case is a family too. A rule that splits a street type glued to a name worked on Title Case and broke full capitals: "ALEEA OLT" became "ale ea olt". Over 120 days, 6,401 of 250,153 orders were in capitals and 732 had a mutilated street. An earlier fix had covered two of the three letter cases.

Where we were wrong

We trusted validation endpoints. One courier's address-validation call returned VALID for three forms of the same street, including a wrong one, and its price-quote call accepted a locality whenever the name was unique nationally, ignoring the region the customer wrote. That accepts exactly the small villages that sit in a different region from the one typed. The real check is to create the shipment and read it back.

We treated our own errors as the customer's. Orders sat on hold for days marked "wrong address" when the stored error was an exception in our code; after resetting the failure counter, 13 of 14 got a label on the first attempt. An office-pickup rule matched only the Cyrillic word for "office", so an order that asked for an office in Latin letters shipped to the home address instead. And the courier accepts a label with no street at all, so "the label was created" does not prove there is somewhere to deliver: 17 Bulgarian orders that named only a courier office stayed with a person.

Limits

The rescue rates we measured are ours: on the first Czech test the nomenclature rescued 86% of orders the geocoder had rejected that had a valid address. Re-running the validator over 544,336 orders, it handled 97.20% and passed the rest to a geocoder. Those numbers describe our customers, couriers and registers, not yours. Bulgaria's ceiling is lower than the other countries because there is no official street register.

Checklist

1. Street-level or locality-level postcodes? Decide before porting. 2. Find the register's flaw (wrong postcodes, sentinels, missing tags) before loading it. 3. Prove norm(name) = name_norm on every row; never decompose Cyrillic. 4. Send the courier its own identifier and spelling; answer only on one candidate. 5. Cut fields at the courier's limits and put the full text in the note. 6. Test pasted, glued and all-capitals input. 7. Classify your own exceptions as retryable, not as a bad address. 8. Read the shipment back instead of trusting a validation endpoint.

Questions and answers

How do I validate international shipping addresses for an online store?

Validate international addresses per country. First decide whether the country's postcode is street-level (Romania, Poland) or locality-level (the Czech Republic, Bulgaria). Load a national or open register, check it for known flaws, and normalise both the register and the customer's text with the same function. Then resolve the locality in the courier's own list and send the courier's identifier. Repair a field automatically only when two independent signals agree.

Why does my address lookup fail on Cyrillic or Polish place names?

Address lookups fail on Cyrillic and Polish names when the search normalises text differently from the stored register. Decomposing Cyrillic turns й into и plus a mark, so names stop matching; Polish ł has no decomposed form, so stripping accents leaves it unchanged while the register stores l. Count the rows where your normalisation of the name differs from the stored normalised column; anything above zero means lookups will miss.

Should I transliterate Bulgarian addresses before sending them to a courier?

Transliterate Bulgarian addresses only to look up the courier's own Latin-script name, never to write your own version onto the label. In our case our transliteration of the capital produced SOFIYA while the courier's nomenclature had SOFIA, and SOFIYA returned no match. Resolve by postcode first, then by transliterated name, and accept the result only when exactly one candidate remains.

What do I do when a customer's street line is too long for the courier API?

When a street line exceeds a courier's field limit, cut the structured street field to the limit and put the full address in the courier's free-text note field, which the driver reads. One courier API we use refuses the whole label when the street field is longer than 50 characters, so an uncut pasted address blocks the parcel even though the address itself is fine.

Is the courier's address validation endpoint enough to confirm an address?

A courier's validation endpoint is not enough on its own. One validation call we tested returned VALID for three forms of the same street, including a wrong one, and a price-quote call accepted a locality whenever its name was unique nationally, ignoring the region. Create the shipment and read it back from the courier to confirm what the courier actually understood.

How to cite this

Going Stellar (2026). Normalising delivery addresses across countries: postcodes, Cyrillic, street-length limits. GOING STELLAR S.R.L..
https://goingstellar.ro/methods/normalising-international-delivery-addresses/

Read as Markdown

All methods

Contact

Tell us what is not working

Write in English or Romanian. A person reads it — usually the same person who would do the work — and answers within one business day.