How to Find the Country of a Coordinate in Python
On this page
Problem statement
Given a latitude and longitude, which country is it in? It is the first enrichment step for user locations, sensor networks and scraped data, and it can be done offline with a point-in-polygon test against a boundary dataset, no geocoding API needed. The details decide whether the answer can be trusted: boundaries at the resolution of the data, points just offshore that fall in no polygon, territories that are coded differently from their sovereign states, and disputed areas where datasets disagree.
This page assigns a country to every airport in OurAirports with a spatial join against Natural Earth's 1:10 million countries, checks the result against the country code OurAirports records, finds the points that fall outside every polygon and assigns them to the nearest country, and looks at where the two sources disagree.
Quick answer
Make points, spatially join them to country polygons, and read the ISO code:
pts = gpd.GeoDataFrame(A, geometry=gpd.points_from_xy(A.longitude_deg, A.latitude_deg), crs=4326)
j = gpd.sjoin(pts, C[["ISO_A2_EH", "NAME", "geometry"]], how="left", predicate="within")
print(f"places: {len(pts):,}; matched to a country: {j.ISO_A2_EH.notna().sum():,}; outside every polygon: {j.ISO_A2_EH.isna().sum():,}")
print(j.loc[j.iata_code.isin(["JFK", "LHR", "HKG", "SIN"]), ["iata_code", "NAME", "ISO_A2_EH", "iso_country"]].to_string(index=False))
places: 72,603; matched to a country: 69,823; outside every polygon: 2,780
iata_code NAME ISO_A2_EH iso_country
LHR United Kingdom GB GB
JFK United States of America US US
HKG NaN NaN HK
SIN NaN NaN SG
69,823 places fall inside a country polygon and 2,780 outside every polygon. Two of the world's busiest airports are among the misses: Hong Kong and Singapore Changi are built largely on land reclaimed from the sea, outside Natural Earth's generalised coastlines.
A known answer, points outside every polygon, resolution
1. Check against a known answer
OurAirports records each place's country. Compare it with the polygon result:
m = j.dropna(subset=["ISO_A2_EH"])
agree = m.ISO_A2_EH == m.iso_country
print(f"agreement: {agree.mean():.2%} of {len(m):,} matched places")
print("most common disagreements:", m[~agree].groupby(["iso_country", "ISO_A2_EH"]).size().nlargest(6).to_dict())
agreement: 99.49% of 69,823 matched places
most common disagreements: {('CY', '-99'): 61, ('UA', 'RU'): 40, ('EH', 'MA'): 21, ('GF', 'FR'): 20, ('TR', 'SY'): 18, ('US', 'CA'): 14}
Where a polygon matches, it agrees with OurAirports' country code for 99.49% of places. The disagreements are mostly about status, not location: 61 airfields in northern Cyprus, where Natural Earth's de facto polygon has the placeholder code -99; 40 in Crimea, which Natural Earth shows as administered by Russia; 21 in Western Sahara, shown as Morocco; 20 in French Guiana, coded as France. A few are border airstrips a few hundred metres on the wrong side of a generalised line, such as 14 between the United States and Canada.
2. Points outside every polygon
Coastal airports, islands too small for the dataset's resolution and offshore platforms fall outside the polygons. Assign them to the nearest country within a tolerance. Distances here are measured in Equal Earth (EPSG:8857), a global equal-area projection: good for ranking the nearest country, approximate as distances:
miss = pts[j.ISO_A2_EH.isna().values]
near = gpd.sjoin_nearest(miss.to_crs(8857), C[["ISO_A2_EH", "geometry"]].to_crs(8857), how="left", distance_col="m")
print(f"outside: {len(miss):,}; within 5 km of a country: {(near.m <= 5000).sum():,}; farther than 50 km: {(near.m > 50000).sum()}")
print("types of the farthest:", near[near.m > 50000].type.value_counts().to_dict())
outside: 2,780; within 5 km of a country: 2,036; farther than 50 km: 259
types of the farthest: {'Point': 259}
Of the 2,780 places outside the polygons, 2,036 are within 5 km of a country, coastal airports and small islands that a nearest-country fallback assigns safely. 259 are more than 50 km from any polygon: offshore platforms, remote islands missing from the 1:10m data and the like.
3. Resolution matters
Repeat the join with the coarser 1:110 million countries and count how many more places fall outside:
j110 = gpd.sjoin(pts, C110[["ISO_A2_EH", "geometry"]], how="left", predicate="within")
print(f"outside the 1:10m polygons: {j.ISO_A2_EH.isna().sum():,}; outside the 1:110m polygons: {j110.ISO_A2_EH.isna().sum():,}")
both = j.ISO_A2_EH.notna() & j110.ISO_A2_EH.notna()
print(f"matched by both but given different countries: {(j.ISO_A2_EH[both] != j110.ISO_A2_EH[both]).sum()}")
outside the 1:10m polygons: 2,780; outside the 1:110m polygons: 4,330
matched by both but given different countries: 656
With the coarser 1:110m polygons, 4,330 places fall outside, 56% more, and 656 places matched by both datasets get a different country: at that resolution a border can be tens of kilometres from its true position.
An offline lookup function
The data used on this page
Every open place in OurAirports, a public-domain database, and the Natural Earth admin-0 country polygons at 1:10 million and 1:110 million, also public domain. ISO_A2_EH is Natural Earth's ISO code with placeholders filled for countries whose official code field is -99.
from pathlib import Path
import geopandas as gpd
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
def cached(path, url):
p = Path(path)
if not p.exists():
r = requests.get(url, timeout=600)
r.raise_for_status()
p.write_bytes(r.content)
return p
A = pd.read_csv(cached("data/airports.csv", "https://davidmegginson.github.io/ourairports-data/airports.csv"), keep_default_na=False, na_values=[""])
A = A[A.type != "closed"].reset_index(drop=True)
C = gpd.read_file(cached("data/ne_10m_admin_0_countries.zip", "https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_0_countries.zip"))
C110 = gpd.read_file(cached("data/ne_110m_admin_0_countries.zip", "https://naciscdn.org/naturalearth/110m/cultural/ne_110m_admin_0_countries.zip"))
print(f"{len(A):,} places; {len(C)} countries at 1:10m, {len(C110)} at 1:110m")
72,603 places; 258 countries at 1:10m, 177 at 1:110m
There are 72,603 open places to classify, against 258 countries and territories at 1:10m and 177 at 1:110m.
Example 1: an offline lookup function
Wrap the polygons in a spatial index for single lookups, with the nearest-country fallback:
C_m = C.to_crs(8857)
def country_of(lat, lon, max_km=25):
p = gpd.GeoSeries(gpd.points_from_xy([lon], [lat]), crs=4326)
hit = C[C.contains(p.iloc[0])]
if len(hit):
return hit.NAME.iloc[0], 0.0
d = C_m.distance(p.to_crs(8857).iloc[0])
return (C.NAME.iloc[d.idxmin()], d.min() / 1000) if d.min() <= max_km * 1000 else (None, d.min() / 1000)
for lat, lon in ((48.8584, 2.2945), (51.47, -0.4543), (43.7384, 7.4246), (25.0, -40.0), (36.14, -5.35)):
print((lat, lon), country_of(lat, lon))
(48.8584, 2.2945) ('France', 0.0)
(51.47, -0.4543) ('United Kingdom', 0.0)
(43.7384, 7.4246) ('Monaco', 0.0)
(25.0, -40.0) (None, np.float64(1616.0050319569418))
(36.14, -5.35) ('Gibraltar', 0.0)
The function finds the Eiffel Tower in France, Heathrow in the United Kingdom and Monaco and Gibraltar as their own entries. A point in the middle of the Atlantic is 1,616 km from the nearest country, beyond the 25 km tolerance, so it returns no country.
The answer is as good as the boundaries
A point-in-polygon test is exact for the polygons it is given, so the quality of the answer is the quality of the boundaries. Natural Earth's 1:10 million countries are generalised for maps of the whole world: coastlines are smoothed to a few hundred metres to a kilometre, small islands are dropped, and boundaries follow Natural Earth's de facto policy, which shows areas as administered rather than as claimed. A point near a coast can therefore land in the sea, and a point in a disputed area gets one answer that another dataset may not give.
The country code is a choice too. ISO 3166 gives territories such as Puerto Rico, Greenland or Hong Kong their own codes, which some datasets follow and others replace with the sovereign state. The disagreements between OurAirports and Natural Earth are mostly of this kind, not wrong locations. For production use, keep the source's code and version beside the result, use the nearest-country fallback with a stated tolerance, and choose a boundary dataset at a resolution suited to points near coasts.
Territorial waters and placeholder codes
- Territorial waters: a point 5 km offshore is legally within a country's territorial sea (12 nautical miles); land polygons do not include it.
-99codes: Natural Earth'sISO_A2is-99for France, Norway and a few others in some versions; useISO_A2_EH.- Speed: a spatial join of 70,000 points against 258 polygons takes seconds; for millions, simplify the polygons or use a grid index.
- Reverse geocoding: for city or address level answers, use a reverse geocoder; see reverse geocoding.
Internal links
- How to find the time zone of a coordinate in Python — another lookup by polygon
- How to reverse geocode points in Python — addresses instead of countries
- Fixing geocodes that land in the country centroid — the opposite problem
FAQ
How do I get the country from latitude and longitude in Python?
Download country polygons such as Natural Earth's, make a GeoDataFrame of points and use geopandas.sjoin(points, countries, predicate="within").
Can I do it without an API?
Yes: a spatial join against downloaded boundaries works offline and has no rate limits.
Why are some points not in any country?
They are just offshore or on islands too small for the boundary dataset; assign them to the nearest country within a tolerance.
Which boundary dataset should I use?
Natural Earth 1:10m for global work; national datasets or GADM for higher resolution near coasts and borders, with attention to their licences.