Back to all postsA blue and green felt globe on cream paper

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.

trial-sites.csv
ABCDE
1Site IDInstitutionCountryPIEnrolled
2STE-1041Mitte Clinical CentreDeutschlandR. Hoffmann84
3STE-1042Cascade Valley MedicalUSAL. Okonkwo131
4STE-1043Cocody Research UnitCôte d’IvoireA. Bamba47
5STE-1044Moravská klinikaCzech RepublicJ. Marek62
6STE-1045Maasstroom Trial CentreNtherlandsW. de Bruin58
7STE-1046Ljubljana Clinical InstituteSlovaniaM. Zupan66
8STE-1047Hanbit Medical InstituteKoreaH. Park95
9STE-1048Kinshasa Referral HospitalCongoM. Lutete38
10STE-1049St Alwyn's Trial UnitUKC. Bewley73
11STE-1050Donauklinik Krems40S. Aigner29
12STE-1051Windhoek Central Research UnitNAT. Shipanga54
13STE-1052Site pending activationN/A0
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,,0

Most 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 Arabia
UK → CK Cook Islands
N/A → NA Namibia
TBD → BD Bangladesh
CS → AS American Samoa

usa 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 DE
locale: "en" Germany
locale: "ar" ألمانيا
CSV export DE
onComplete DE

The 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 text
2 two letters → the alpha-2 list
3 three letters → the alpha-3 map
4 one to three digits, padded to three → the numeric map
5 name shared by two countries → ambiguity guard, hold both
6 the runtime name index, 26 languages
7 the alternative-forms table
8 nothing

Normalization 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 away
Slovania → none Slovenia and Slovakia are both one edit away

A 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.

Row 7CountrySlovania

Invalid country

Row 8CountryKorea

Invalid country

Row 9CountryCongo

Invalid country

Row 13CountryN/A

Invalid country

Each 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 Korea
Congo → CD the protocol holds one Congo
Slovania → SI Slovakia left the pool, Slovenia stands alone one edit away
N/A → none still nothing to read

Korea 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.