Formatting Japanese and Korean Addresses in Python

TL;DR: Japanese and Korean addresses run largest unit first, use block-and-lot numbering rather than street numbering, and exist in two scripts and two competing systems respectively. Store components in a country-neutral record and render per country and per audience; never bake the Anglophone order into the schema. The parent workflow is international address format standardization.

Block-and-Lot, Not Street-and-Number

The structural difference that defeats a Western parser is that most Japanese addresses do not name a street at all. A location is identified by a district, a block within it, and a building within the block — written as three numbers separated by hyphens.

Japanese components, largest unit first A Japanese address is broken into its components in written order: postcode, prefecture, city, ward, district name, then the chōme, banchi and gō numbers, then the building name and unit. Beneath, a note contrasts this with the Western order and explains that the three numbers identify a block and a building rather than a street position. Written order — largest administrative unit first postcode prefecture city · ward district chōme-banchi-gō building 150-0001 東京都 渋谷区 神宮前 1-2-3 ○○ビル 4F The three numbers are not a street position chōme = a district subdivision · banchi = a land block within it · gō = a building on that block Numbering follows registration order, not geography, so neighbouring buildings need not be consecutive

The consequence for a pipeline is that a “street name” field is the wrong shape for this data. Forcing the district into it and the block triple into a house-number field produces something that renders acceptably and matches nothing, because the components no longer mean what their column names claim.

Korea Has Two Systems in Parallel

South Korea introduced a road-name address system that formally replaced the older lot-number system, and both remain in circulation: official documents use road names, while many residents and older records still use lot numbers. They are not interchangeable, and the same location has a different address in each.

System Shape Status Handling
road name (도로명) road name + building number official since 2014 preferred for new records and delivery
lot number (지번) district + lot number legacy, still widely used accept, store, and map where possible

Storing which system a record uses is not optional. A pipeline that mixes them produces duplicate customers — the same person under two addresses that no string comparison will ever reconcile — and a deduplication key built from either alone cannot catch it. Where a mapping service is available, resolving both forms and keying on the road-name form is the approach that converges.

Korea’s Two Systems, Same Building

One location, two Korean address systems The same building is written twice. The road-name form gives a road name and building number. The legacy lot-number form gives a district name and lot number. The two strings share no tokens, so no string comparison can recognise them as the same place. road-name (도로명) — official 세종대로 110 road name + building number lot-number (지번) — legacy 태평로1가 31 district name + lot number Both identify the same building. They share no tokens, so fuzzy matching will never connect them and a customer who uses each once becomes two records. Store which system a record uses, and resolve to the road-name form where a mapping service is available.

A Renderer, Not a Formatter

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class EastAsianAddress:
    country: str                 # "JP" or "KR"
    postcode: str
    region: str                  # prefecture / do
    city: str                    # shi / si
    district: str                # chō / dong or road name
    block: str                   # "1-2-3" for JP; building number for KR
    building: str | None = None
    unit: str | None = None
    romanised: bool = False      # True when the fields hold Latin text


def render_domestic(a: EastAsianAddress) -> str:
    """Local-script line, largest unit first — for domestic delivery."""
    parts = [a.postcode, a.region, a.city, a.district, a.block]
    parts += [p for p in (a.building, a.unit) if p]
    return " ".join(p for p in parts if p)


def render_international(a: EastAsianAddress) -> str:
    """Romanised line, smallest unit first — for an international carrier."""
    parts = [a.unit, a.building, a.block, a.district, a.city, a.region, a.postcode]
    body = ", ".join(p for p in parts if p)
    return f"{body}, {'JAPAN' if a.country == 'JP' else 'SOUTH KOREA'}"

Two renderers over one record is the entire pattern. The record is country-neutral, the order lives in the renderer, and adding a third audience — a label printer with a line-length limit, say — is a new function rather than a schema migration.

Normalisation That These Markets Specifically Need

Full-width digits are the most common issue: - are distinct code points from 1-9 and appear routinely in text entered with a Japanese input method. NFKC folding converts them, which is one more reason it is the right form for a match key, as covered in Unicode and character normalization in Python.

The hyphen in a block triple is the second. At least four characters appear in practice — the ASCII hyphen, the full-width hyphen-minus, the Japanese katakana-hiragana prolonged sound mark used incorrectly, and the en dash — and unifying them to ASCII before comparison is what makes 1-2-3 match 1-2-3.

The third is the optional trailing marker. Japanese addresses sometimes spell out 1丁目2番3号 rather than using the numeric triple, and the two forms are equivalent. A small substitution pass that converts the spelled form to the numeric one is far simpler than teaching every downstream comparison about both.

One Record, Two Audiences

Two renderings from one stored record A single component record feeds two renderers. The domestic renderer emits the local script in largest-first order for a national postal operator. The international renderer emits a romanised line in smallest-first order for a foreign carrier. The stored record itself carries no ordering. component record no order baked in domestic — local script, largest first 150-0001 東京都 渋谷区 神宮前 1-2-3 international — romanised, smallest first 1-2-3 Jingumae, Shibuya, Tokyo 150-0001, JAPAN

Matching Across Scripts

The hardest practical problem in these markets is not rendering but matching: the same address arrives in local script from one channel and romanised from another, and both are correct.

Build the match key from the local-script form wherever it is present, and treat the romanisation as an alias rather than as a second address. Romanisation is not deterministic — Tokyo appears as Tokyo, Tôkyô and Toukyou depending on the scheme — so a key built from it inherits that variation and splits records that a local-script key would have merged.

For records that arrive romanised only, the pragmatic approach is a secondary key built from the romanised form with macrons stripped and long vowels collapsed, plus the postcode. The postcode does most of the work: it is script-independent, it is present far more often than any other component, and in both markets it narrows the candidate set to a handful of blocks.

Where a mapping service is available, resolving a romanised record back to its local-script form at ingestion is better than either key. It costs one lookup per new address, produces a single canonical representation, and removes the alias handling entirely — which is worth more than the lookup costs on any corpus with a meaningful share of these markets.

Edge Cases and Failure Modes

Romanised and local forms treated as different records. They describe the same place, and a customer who orders once in each will appear twice. Key on the local form where present, and store the romanisation as an alias rather than as a separate address.

Building names as street names. A Japanese building name is a proper noun that behaves nothing like a street, and putting it in a street field breaks both matching and rendering. It belongs in its own field, exactly as a BLDG designator does elsewhere.

Korean addresses without a system marker. When it is unclear which system a record uses, the presence of a road-name suffix such as -ro or -gil is a strong signal; a bare district plus a number is usually a lot number. Record the inference rather than silently assuming.

Postcode length changes. Korea moved from six-digit to five-digit postcodes in 2015, and historical records still carry the old form. Store the length alongside the value or map old codes forward — comparing a five and a six-digit code as strings will never match.

One more note on storage: keep the country code on the record even when the corpus is single-market today. Every rule on this page is conditional on it, and retrofitting a country column onto a table that assumed one is far more disruptive than carrying an apparently redundant field from the start.

Integration Note

This renderer sits at the output boundary, after parsing and after the country has been determined. The country determination itself is the harder problem when a record arrives without one, and the postcode-shape signals in international address format standardization are what resolve it: both the Japanese NNN-NNNN form and the Korean five-digit form are distinctive enough to infer from, which is fortunate given how differently the two are handled from here on.

The country code is also what selects the normalisation rules, the postcode grammar and the renderer, so it earns its column three times over.