
How to Normalize Country Names and ISO Codes During Import
Country data looks simple until it comes from more than one system. The same
country can arrive as Germany, Deutschland, DE, DEU or 276. Names
change with language, and codes come in several formats. A spreadsheet drops
the leading zero from a numeric code, and a typo can sit one letter away from
more than one real country. The importer has to know what it can read exactly,
what it can correct safely, and what it should leave for a person to decide.
Twelve trial sites sit on the register, and the country column came back twelve
different ways. One site wrote Deutschland. One wrote USA. One wrote 040,
and the spreadsheet that opened the file read it as a number and dropped the
zero.
The table behind the register holds two letters.
| A | B | C | D | E | |
|---|---|---|---|---|---|
| 1 | Site ID | Institution | Country | PI | Enrolled |
| 2 | STE-1041 | Mitte Clinical Centre | Deutschland | R. Hoffmann | 84 |
| 3 | STE-1042 | Cascade Valley Medical | USA | L. Okonkwo | 131 |
| 4 | STE-1043 | Cocody Research Unit | Côte d’Ivoire | A. Bamba | 47 |
| 5 | STE-1044 | Moravská klinika | Czech Republic | J. Marek | 62 |
| 6 | STE-1045 | Maasstroom Trial Centre | Ntherlands | W. de Bruin | 58 |
| 7 | STE-1046 | Ljubljana Clinical Institute | Slovania | M. Zupan | 66 |
| 8 | STE-1047 | Hanbit Medical Institute | Korea | H. Park | 95 |
| 9 | STE-1048 | Kinshasa Referral Hospital | Congo | M. Lutete | 38 |
| 10 | STE-1049 | St Alwyn's Trial Unit | UK | C. Bewley | 73 |
| 11 | STE-1050 | Donauklinik Krems | 40 | S. Aigner | 29 |
| 12 | STE-1051 | Windhoek Central Research Unit | NA | T. Shipanga | 54 |
| 13 | STE-1052 | Site pending activation | N/A | 0 |
1Site ID,Institution,Country,PI,Enrolled2STE-1041,Mitte Clinical Centre,Deutschland,R. Hoffmann,843STE-1042,Cascade Valley Medical,USA,L. Okonkwo,1314STE-1043,Cocody Research Unit,Côte d’Ivoire,A. Bamba,475STE-1044,Moravská klinika,Czech Republic,J. Marek,626STE-1045,Maasstroom Trial Centre,Ntherlands,W. de Bruin,587STE-1046,Ljubljana Clinical Institute,Slovania,M. Zupan,668STE-1047,Hanbit Medical Institute,Korea,H. Park,959STE-1048,Kinshasa Referral Hospital,Congo,M. Lutete,3810STE-1049,St Alwyn's Trial Unit,UK,C. Bewley,7311STE-1050,Donauklinik Krems,40,S. Aigner,2912STE-1051,Windhoek Central Research Unit,NA,T. Shipanga,5413STE-1052,Site pending activation,N/A,,0Most of those writings were correct when the site wrote them. Two are typos, and
one row has no country yet. The rest makes sense to the people who filled it in,
and the column as a whole still does not arrive in the shape the register needs.
Turning the column into DE, US and AT is the work, and the part worth
getting right is where that work has to stop.
Matching against a list of codes lands on the wrong country
ISO 3166-1 assigns 249 alpha-2 codes. Kosovo has none, so a list that also
covers it borrows XK from the user-assigned range and reaches 250 entries.
Those 250 codes sit inside the 676 two-letter combinations the alphabet
allows, which fills 37 percent of the space.
That density is why a one-edit rule over the codes has so many candidates to
choose from. Measured across the list, one code has 23 other real codes one
substitution away, on average. MM has 38. Take any of the 676 combinations,
assigned or not, and it sits one substitution from 18 real codes on average,
from 5 at the sparsest. Those counts are for two-letter input, and a longer
value reaches the codes by deleting a character instead.
Declare the country column as a plain list of the 250 codes, in alphabetical order, and let the generic value matcher compare each distinct value in the column against them. On Updog Importer 0.1.88, the result looks like this.
USA → SA Saudi ArabiaUK → CK Cook IslandsN/A → NA NamibiaTBD → BD BangladeshCS → AS American Samoausa reaches sa by deleting one character. It reaches us and ua the
same way. All three score 65, so the first one on the list wins, and
alphabetical order is the only reason Saudi Arabia becomes the answer. One
edit is the budget at four characters, and it widens as the string gets
longer. The score clears the acceptance threshold, the mapping is filled in,
and nothing on screen says that several countries were equally close.
Long country names fail in the opposite direction. Deutschland, Czech Republic and Côte d’Ivoire do not reach a two-letter code at all. A
two-character option is too short to share a word with the input, and the
edit budget refuses any pair whose lengths differ by more than one.
A wrong country that a person can see is a bad import. A wrong country that
looks decided is worse. Changing the candidates from codes to country names
fixes only half of the problem. The same generic matcher can read Czech Republic as Dominican Republic and Korea as North Korea.
The candidate pool decides what the matcher is allowed to reach. The matching rules decide when one of those candidates is safe enough to choose.
The column stores the code and prints the name
Storing the name puts a value in your database that a later CLDR update can change under you. CLDR 43, released on 12 April 2023, states that "in English, Türkiye is now the primary country name for the country code TR, and Turkey is available as an alternate". CLDR 42 had it the other way round.
Each browser reads that data from an ICU build of its own. Chrome bundles one,
Safari uses the one that ships with the operating system. A column filled from
two browsers can hold Turkey in one row and Türkiye in the next. The code
stayed TR through all of it.
So a country column in Updog Importer stores the alpha-2 code in upper case for
every value it resolves, and prints the CLDR name in the language you pass as
locale.
stored value DElocale: "en" Germanylocale: "ar" ألمانياCSV export DEonComplete DEThe person reads the name. Your handler receives DE. The displayed name can
change with language or CLDR version without changing the value stored in the
dataset.
The browser already holds the country names
Intl.DisplayNames turns a region code into a name in a given language, and it
reads from the CLDR the engine already ships for the rest of Intl. It has been
available across browsers since April 2021.
That means the importer does not need to ship a table of translated country
names. Give Intl.DisplayNames the 250 country codes in one locale and it
builds a name index for that language at runtime, weighing nothing in the
bundle. Updog Importer builds that index across 26 languages and three name
styles the first time an imported value reaches this step.
On Node 24.19.0 with ICU 78.3, the resulting index contains 4,852 forms that
each name one country. Once built, a thousand lookups of Deutschland take
about a third of a millisecond.
Deutschland, Almanya, Niemcy, Duitsland and 德国 all reach DE
through it. None of the five comes from a translation table shipped with
Updog.
Intl.DisplayNames does not cover every form the importer needs. CLDR also
carries alternative English names such as Czech Republic, Turkey,
Swaziland and Ivory Coast, outside the long, short and narrow forms it
returns. Alpha-3 and numeric ISO codes are separate again. Intl.DisplayNames
throws for DEU, an alpha-3 code, and 040, the numeric code for Austria,
comes back as 040 rather than Austria.
Updog therefore ships two small tables beside the runtime index. One holds 98 name forms the engine returns in none of its 26 languages, 1,146 bytes gzipped. The other maps alpha-3 and numeric ISO codes to alpha-2, 249 records at 1,301 bytes gzipped.
UK sits in the same table for another reason. On this engine UK is the short
name for GB, and the index drops every form of plain Latin letters under four
characters. Çad and Irã normalize into cad and ira, and a file's
department code would read as a country.
The browser supplies most of the names. The importer adds the forms the browser cannot reach, and removes the ones too short to trust.
Exact reading answers before the matcher does
Each imported value goes through the same reading ladder, and the first step
that answers wins. A
date column votes on one format for the whole column,
since 01/05/2026 cannot be read without knowing what its neighbours are. A
country column needs no such vote. Deutschland and 40 name their countries
whatever sits above or below them.
1 normalize the text2 two letters → the alpha-2 list3 three letters → the alpha-3 map4 one to three digits, padded to three → the numeric map5 name shared by two countries → ambiguity guard, hold both6 the runtime name index, 26 languages7 the alternative-forms table8 nothingNormalization runs first for every step below it. It decomposes the text, drops
combining marks, reads apostrophes and hyphens as spaces, collapses runs of
space and lowercases the result. Côte d’Ivoire with a typographic apostrophe,
Cote d'Ivoire with a straight one and Cote d Ivoire with none all arrive at
the same key, and so do ألمانيا and المانيا.
The numeric step repairs a spreadsheet's own damage. Austria's record is AT,
AUT, 040, and a spreadsheet can turn the numeric one into 40. Padding a
one- to three-digit value back to three digits restores the lookup key and
resolves the row to AT.
This is the same loss of a leading zero that affects a stock code or a barcode. A country column can repair it because ISO numeric codes have a fixed width of three digits.
Seven of the twelve country values in the register are settled by this exact-reading ladder, with no fuzzy matching anywhere in it. Two more reach the ambiguity guard, step 5, where the importer deliberately refuses to choose one country.
A correction only happens when one country is close
Ntherlands is one missing letter from Netherlands, and the person who
uploaded the file should not have to spend a decision on it. Updog Importer
corrects that kind of value automatically, under rules narrow enough to state
in full.
The matcher searches country names in eight languages across three name
styles, plus the alternative forms that clear the same cut-offs, 70 of that
table's 98. Two-letter codes never enter that pool, so N/A never comes near
Namibia and TBD never comes near Bangladesh.
The distance is Damerau-Levenshtein. A deletion, an insertion, a substitution
or a swap of two neighbouring characters each cost one edit, so Germnay
reaches Germany. The allowed distance is one edit, and exactly one country has
to be that close.
That last rule is what makes the correction safe.
Ntherlands → NL only Netherlands is one edit awaySlovania → none Slovenia and Slovakia are both one edit awayA person reading the full row may know that Ljubljana is in Slovenia. The
matcher does not use that context, because Slovenia and Slovakia are both
one edit from Slovania, and picking either one would be a coin toss printed
as a decision. The same rule keeps Austria away from Australia, Mali from
Malta, and Rwanda from Uganda.
Two cut-offs sit in front of the matcher, both read off the normalized value. A
value under four characters never reaches it, because UL stands one edit
from 17 real codes. Drop that floor and Chd becomes Chad. A value that still
carries anything besides letters and spaces never reaches it either, which
stops Chad2, Ir4n and Peru?.
The rules are tested against generated misspellings of every English country
name in the 250-code list. At each character position, the fixture drops the
character, doubles it, swaps it with its neighbour, or replaces it with one of
a e i o s r n, skipping a replacement that changes nothing and a swap at the
last position. That produces 22,578 generated values, 22,497 of them distinct,
and it is the fixture the matcher is tested against.
| Outcome | Values |
|---|---|
| Read back to the right country | 18,929 |
| Refused | 3,645 |
| Landed on another exact country name | 4 |
| Fuzzy-corrected to the wrong country | 0 |
With the candidate pool already built, one pass over that corpus takes about 0.3 seconds on Node 24.19.0 with ICU 78.3.
The four values in the third row are not bad fuzzy corrections. Each mutation
becomes an exact name for another country before fuzzy matching runs. Chile
can become Chine, the French name for China. Gambia can become sambia,
the German name for Zambia. Iraq can become Iran, and Iceland can become
Ireland. Exact reading accepts those values because, by the time they
arrive, they are valid country names in their own right.
Most refusals are deliberate too. Of the 3,645 refused values, 3,399 fail the
letters-and-spaces cutoff before fuzzy matching starts. CLDR writes twenty
English names with an ampersand, a full stop or a bracket, Antigua & Barbuda
and São Tomé & Príncipe among them, and every misspelling of those keeps the
punctuation that stops it at the door. The uniqueness rule turns away 209
more.
The matcher gives up often on purpose. The useful number in the table is the zero.
A word that names two countries stays a question
Korea names two countries. So does Congo, and Virgin Islands names two
entries in the list. Nothing in the value itself picks one over the other, so
the importer keeps both open and leaves the decision to the person who has the
rest of the row.
A country value therefore leaves the matching step with one of four verdicts.
| Verdict | Register value | What happens |
|---|---|---|
| Exact | Deutschland, USA, Côte d’Ivoire, Czech Republic, UK, 40, NA |
the code lands, no tag |
| Corrected | Ntherlands |
the code lands, tagged Corrected |
| Ambiguous | Korea, Congo |
both codes marked Possible match, nothing chosen |
| Unresolved | Slovania, N/A |
nothing marked, the text stays |
NA exposes a different boundary. It is the alpha-2 code for Namibia, so the
importer reads it exactly when the string reaches the country column.
Some upstream tools remove it first. pandas.read_csv treats bare NA as
missing by default, along with values such as N/A, NULL and nan. Reading
it with keep_default_na=False keeps the string, and turns off recognition of
the rest of that list too.
If NA has already become a missing value before the file reaches the
importer, country matching cannot recover Namibia. That fix belongs upstream.
The column takes one line of configuration
A country column is declared like any other column.
import type { DataEditorColumn } from "@updog/data-editor";
export const columns: DataEditorColumn[] = [ { id: "siteRef", title: "Site ref", validators: [{ type: "required" }] }, { id: "institution", title: "Institution" }, { id: "country", title: "Country", editor: { type: "country" } }, { id: "investigator", title: "Investigator" }, { id: "enrolled", title: "Enrolled", editor: { type: "number" }, validators: [{ type: "number", min: 0, decimalPlaces: 0 }], },];editor: { type: "country" } enables the 250-code list, exact reading, safe
correction, built-in validation, and the country-specific matcher that runs
during value matching.
If none of those steps resolves a value, the importer keeps the original text in the cell and flags it as invalid. The person still sees what the file wrote and can choose the country by hand.
The list is closed by default. enableCustomValue: true adds a Create option to
the dropdown, and a value the person creates passes validation and reaches
onComplete as typed. multiple: true stores a string[], splits a
multi-country cell on its delimiter, and resolves each token separately.
The matching step shows what it decided
Value matching works on distinct values rather than rows. If the same country spelling appears a hundred times, the importer decides it once and applies that decision everywhere it occurs. The same step handles enum columns. A country column uses its own reading and correction rules instead of the generic value matcher.
The file value appears on the left and the resolved country on the right.
Ntherlands is shown opposite Netherlands with a Corrected tag. The person
can change it or clear it.
Korea sits opposite an empty box. Its list marks North Korea and South Korea
Possible match.
Slovania also sits opposite an empty box. Nothing is marked, because the
matcher refused to prefer Slovenia over Slovakia.
Four rows reach the grid flagged, at file lines 7, 8, 9 and 13.
SlovaniaKoreaCongoN/AEach one keeps its original text, so the person picking the country still has what the site wrote.
The column can hold fewer than 250 codes
A trial runs in the countries its protocol names, and this register covers eleven of them. The dropdown offers the other 239 all the same, and the matcher weighs every one of them against each imported value.
only on the country editor takes the codes the column accepts.
const protocolCountries = [ "AT", "CD", "CI", "CZ", "DE", "GB", "KR", "NA", "NL", "SI", "US",];
const country: DataEditorColumn = { id: "country", title: "Country", editor: { type: "country", only: protocolCountries },};The dropdown, the matching step and the built-in rule all work from that list.
Switzerland reaches no code on this column, stays in the cell as text, and
gets flagged.
The matcher builds its pool out of the codes the column takes, so a narrower column hands it fewer countries to weigh. Three of the four flagged rows in the register settle on their own.
Korea → KR the protocol holds one KoreaCongo → CD the protocol holds one CongoSlovania → SI Slovakia left the pool, Slovenia stands alone one edit awayN/A → none still nothing to readKorea and Congo stop being ambiguous because one of each pair remains.
Slovania gets corrected because the uniqueness rule now measures one edit
against eleven countries. The rules stayed as they were, and the list they run
over got shorter.
The importer drops a code ISO 3166-1 does not carry and names it in a console
warning, and an empty array means the whole list. It reads the array once per
identity, so keep it stable across renders the way you keep options.
only decides which codes the column takes. The names those codes print come
from somewhere you do not control.
The rest of the column stays fixed
The names come from CLDR through the browser, politically contested wording included. As of August 2026, an English screen on these engines carries the CLDR wording for Taiwan, Palestinian Territories and Hong Kong SAR China. No prop replaces one of those names.
A formatter can draw different text in the cell, but matching still runs
against the underlying CLDR names. That makes the pairing one-way, and find
and replace by the printed name does not work on this column. Display and
matching therefore remain separate.
Exact reading covers 26 languages, plus the current interface locale.
Correction covers a narrower set, eight languages. Almanya reads exactly,
and a misspelled Almanyaa does not.
Automatic correction also runs only during the upload wizard, where the
matching step shows the result to the person importing the file. A value such
as Ntherlands that arrives later through paste, loadData, or a formula
stays as typed and gets flagged. A correction the person never sees is a
change to their data made behind their back.
Two adjacent problems remain outside this column. Nationality uses demonyms
rather than country names, and Intl.DisplayNames has no demonym type, so
that stays a select over your own list. Subnational regions belong to ISO
3166-2 and depend on the selected country, so they need a separate data model.
The importer reads and the person settles
The importer resolves what the file makes clear. It writes the country code when the value is exact, marks a safe correction, shows both candidates when a name is ambiguous, and leaves the value unresolved when it cannot choose safely.
The person settles what remains open. On a column over all 250 codes, this
register hands them the two Koreas, the two Congos, and Slovania.
Your handler receives codes such as DE, US and AT for every country the
importer or the person resolved. Anything left unresolved reaches it as the
original text, flagged invalid. Your application still decides what a repeat
import of the same sites means in your table.
The next register may write Alemania instead of Deutschland. It is another
spelling of the same country, handled by the same column.