pycountry
pycountry vendors Debian's iso-codes database and puts a small Python API in front of it. Import it and you get five lookup tables ready to query offline: pycountry.countries (249 ISO 3166-1 entries), pycountry.subdivisions (5046 ISO 3166-2 entries with parent links), pycountry.historic_countries (31 withdrawn ISO 3166-3 entries), pycountry.currencies (178 ISO 4217 entries), pycountry.languages (7923 ISO 639-3 entries) and pycountry.scripts (226 ISO 15924 entries). Each table supports get(field=value) for exact lookups, lookup(value) for a case-insensitive scan across every field, search_fuzzy(text) for approximate name matching, and plain iteration. It also ships the gettext catalogs from iso-codes, so you can render country and language names in dozens of locales without calling anything over the network. It is a data package with a query helper attached, not a geo or i18n framework.
The default answer for offline ISO code lookups in Python, and the translation catalogs alone justify it for anything multilingual. Just budget for the 23 MB on disk and check that you actually need five standards, because if you only validate two-letter country codes you are paying a lot for a set membership test.
Use it if
- You need to validate or normalize ISO codes offline: turning 'de', 'DEU', '276' or 'Germany' into one canonical record with no network call and no API key
- You are building an address or checkout form and want the ISO 3166-2 subdivision list per country, including the parent and parent_code links that let you render region then department
- You need country or language names in a locale other than English: the gettext catalogs ship inside the package, so gettext.translation('iso3166-1', pycountry.LOCALES_DIR, languages=['de']) gives you Deutschland with no service dependency
- You want one dependency-free package that covers 3166-1, 3166-2, 3166-3, 4217, 639-3 and 15924 rather than gluing four small libraries together
- Install size matters: the wheel is 7.67 MB and unpacks to roughly 23 MB on disk because it bundles the full iso-codes JSON plus gettext .mo catalogs for every standard and every locale. If all you need is a set of two-letter codes, the iso3166 package is a rounding error by comparison and does the same validation
- You expected data beyond the ISO standards. There are no calling codes, no currency minor units or decimal digits, no continent or region grouping, no capital cities, no flag emoji on subdivisions, and no lat/lon. People discover these gaps after they have already written the import
- The data is frozen at release time and the maintainers say so explicitly in the README: no changes to the data will be accepted, it is a straight copy of Debian's iso-codes. 26.2.16 pins iso-codes 4.20.1. If ISO publishes a change next month, you wait for the next pycountry release or you patch it yourself with add_entry
- Releases are roughly annual CalVer drops (22.3.5, 23.12.11, 24.6.1, then 26.2.16 in February 2026) and the last commit on main landed 2026-02-18. It is maintained, but it is not fast, so plan for a gap between an ISO update and a version you can pip install
- LGPL-2.1 is the license. Importing it from your own code is normally fine, but if your legal review flags copyleft, or you bundle everything into one redistributed binary with PyInstaller or Nuitka, that is a conversation you need to have before the code review, not after
- You want display names your users recognize. These are the ISO political names: Taiwan comes back as 'Taiwan, Province of China' and 'Korea, Republic of' is not what most product designers had in mind. Babel's CLDR names are a better fit for anything user-facing
Setup reality
pip install pycountry needs Python 3.10 or newer, pulls in nothing else, and needs no compiler. The cost is disk and image size rather than build time: 7.67 MB of wheel expands to about 23 MB in site-packages, which is real money in a Lambda layer or a slim container, and there is no extras marker to install only the tables you use. Import is cheap because the JSON files load lazily on first access to each database, so the initial pycountry.subdivisions query pays a one-off cost of tens of milliseconds while 5046 records parse. The two access patterns behave differently on a miss and nothing warns you: get() returns None, lookup() raises LookupError. Optional fields are equally sharp, because a record only carries the keys the standard filled in, so country.official_name and language.common_name raise AttributeError on entries that lack them and getattr with a default is the only safe read. Translations need the gettext module and the right domain name (iso3166-1, iso3166-2, iso4217, iso639-3, iso15924) pointed at pycountry.LOCALES_DIR.
Patterns
Resolve a country from whatever code you were handedlookup-by-any-code
import pycountry
country = pycountry.countries.lookup('de')
print(country.alpha_2, country.alpha_3, country.numeric, country.name)
# DE DEU 276 Germany
# lookup() scans every field case-insensitively:
pycountry.countries.lookup('DEU')
pycountry.countries.lookup('276')
pycountry.countries.lookup('germany')lookup() returns the first field match and stops, so a two-letter string that is also a valid alpha_3 prefix can surprise you. When you know which field you have, get(alpha_2=...) is both faster and unambiguous.
Tell the two miss behaviours aparthandle-unknown-code
import pycountry
print(pycountry.countries.get(alpha_2='XX')) # None
try:
pycountry.countries.lookup('Narnia')
except LookupError as exc:
print(exc) # Could not find a record for 'narnia'get() returns None, lookup() raises LookupError. Code that switches between them without adjusting the error handling ships a silent None into the database or an uncaught exception into a request handler.
Read fields that only some records haveoptional-fields
import pycountry
aland = pycountry.countries.get(alpha_2='AX')
print(getattr(aland, 'official_name', aland.name))
bengali = pycountry.languages.get(alpha_2='bn')
print(bengali.name, getattr(bengali, 'common_name', None))
# Bengali BanglaRecords only carry the keys the standard populated, and missing attributes raise AttributeError rather than returning None. official_name, common_name and alpha_2 on languages are the three that bite most often, so read them with getattr and a default.
Validate a country code without paying for a full lookupvalidate-user-input
import pycountry
from functools import lru_cache
@lru_cache(maxsize=1)
def valid_alpha_2() -> frozenset[str]:
return frozenset(c.alpha_2 for c in pycountry.countries)
def is_valid(code: str) -> bool:
return code.upper() in valid_alpha_2()Iterating the database materializes all 249 records once; after that the check is a set hit. Do not call search_fuzzy in a validation path, it is a scan over names plus unicode normalization and costs milliseconds per call.
Match a country from a loose user-typed namefuzzy-search
import pycountry
print(pycountry.countries.search_fuzzy('England'))
# [Country(alpha_2='GB', alpha_3='GBR', ... name='United Kingdom', ...)]
print([c.alpha_2 for c in pycountry.countries.search_fuzzy('Cote')])
# ['CI', 'FR', 'HN']It returns a ranked list, never a single record, and it raises LookupError when nothing matches. Accents are normalized, so Cote finds Cote d'Ivoire, but the tail of the list is noise: take the first result only when you also show the user what you picked.
List the states or regions of one countrysubdivisions-for-country
import pycountry
for sub in sorted(pycountry.subdivisions.get(country_code='US'), key=lambda s: s.name):
print(sub.code, sub.type, sub.name)
print(len(pycountry.subdivisions.get(country_code='US'))) # 57get(country_code=...) returns a list while get(code=...) returns one record, from the same method name. The US count is 57 because ISO 3166-2 includes DC, outlying territories and Puerto Rico, so do not expect 50.
Walk from a subdivision up to its parent and countrysubdivision-hierarchy
import pycountry
fr_01 = pycountry.subdivisions.get(code='FR-01')
print(fr_01.name, fr_01.type) # Ain Metropolitan department
print(fr_01.parent_code) # FR-ARA
print(fr_01.parent.name) # Auvergne-Rhone-Alpes
print(fr_01.country.name) # Franceparent_code is None for top-level subdivisions, and .parent is None with it, so guard before chaining. The hierarchy is only two deep in most countries but France and the UK go deeper, which is why hard-coding one level breaks on real address data.
Look up a currency and its numeric codecurrency-lookup
import pycountry
inr = pycountry.currencies.get(alpha_3='INR')
print(dict(inr))
# {'alpha_3': 'INR', 'name': 'Indian Rupee', 'numeric': '356'}
print(len(pycountry.currencies)) # 178Three fields, that is all ISO 4217 gives here. There is no minor-unit count, so pycountry cannot tell you that JPY has zero decimal places and BHD has three. Money formatting needs babel or your payment provider's table.
Render country names in another languagetranslate-names
import gettext
import pycountry
german = gettext.translation(
'iso3166-1', pycountry.LOCALES_DIR, languages=['de']
)
german.install()
print(_('Germany')) # DeutschlandThe domain names are iso3166-1, iso3166-2, iso4217, iso639-3 and iso15924, one catalog per standard. install() rebinds the global _ for the whole process, so in a web app prefer german.gettext(name) and keep the translation object scoped.
Serialize a record for JSON outputcast-to-dict
import json
import pycountry
country = pycountry.countries.lookup('de')
print(json.dumps(dict(country)))
# {"alpha_2": "DE", "alpha_3": "DEU", "flag": "\ud83c\udde9\ud83c\uddea", ...}dict(record) only includes the keys that record actually has, so two countries in the same response can serialize with different key sets. Normalize to a fixed schema before returning it from an API or your clients will trip over the missing official_name.
Add a code the standard does not havecustom-entries
import pycountry
pycountry.countries.add_entry(
alpha_2='XK', alpha_3='XXK', name='Kosovo', numeric='926'
)
print(pycountry.countries.get(alpha_2='XK'))
print(len(pycountry.countries)) # 250
pycountry.countries.remove_entry(alpha_2='XK')This mutates a process-global singleton, so run it once at startup rather than per request, and remember your test suite shares that state. It is the sanctioned escape hatch because the maintainers refuse data changes upstream, Kosovo being the usual reason people need it.
Resolve a country code that no longer existshistoric-countries
import pycountry
ussr = pycountry.historic_countries.get(alpha_3='SUN')
print(ussr.name, ussr.withdrawal_date)
# USSR, Union of Soviet Socialist Republics 1992-08-30
print(ussr.alpha_4) # SUHHThis is a separate ISO 3166-3 table of 31 records and pycountry.countries will not find any of them, so archival data needs both lookups. Historic entries carry alpha_4 and withdrawal_date, which current countries do not have.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| iso3166 | PyPI | You only need ISO 3166-1 country codes and want a tiny package instead of 23 MB on disk |
| babel | PyPI | You need CLDR display names, currency formatting and locale-aware output rather than raw ISO records |
| country_converter | PyPI | You are reconciling messy country name variants across datasets and want regional groupings like EU, OECD and continent |
| pycountry-convert | PyPI | You specifically want country to continent mapping layered on top of pycountry |