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.

World map of 72,603 places with 2,780 that fall outside every 1:10m country polygon in blue, mostly along coasts and on small islands, and 356 whose polygon country differs from OurAirports' code in red, clustered in northern Cyprus, Crimea, Western Sahara, French Guiana and along borders. World map of 72,603 places with 2,780 that fall outside every 1:10m country polygon in blue, mostly along coasts and on small islands, and 356 whose polygon country differs from OurAirports' code in red, clustered in northern Cyprus, Crimea, Western Sahara, French Guiana and along borders.
Misses and disagreements.

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.

Most common disagreements between Natural Earth country polygons and OurAirports country codes. polygon country vs OurAirports code: largest disagreementsCY coded -99 (N. Cyprus)61UA as RU (Crimea)40EH as MA (W. Sahara)21GF as FR (Fr. Guiana)20TR as SY18US as CA1403570Agreement 99.49% of 69,823 matched places; most differences are status, not location spatialworkflow.io
Status, not location.

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.

Map of Hong Kong with Natural Earth's 1:10m land polygons and OurAirports places: Hong Kong International Airport lies in the water outside the generalised coastline, because it is built on reclaimed land around Chek Lap Kok. Map of Hong Kong with Natural Earth's 1:10m land polygons and OurAirports places: Hong Kong International Airport lies in the water outside the generalised coastline, because it is built on reclaimed land around Chek Lap Kok.
Reclaimed land outside the polygons.

The answer is as good as the boundaries

Country lookup by point in polygon: problems and fixes. country lookup trapsTrapInsteadcoastal points in the seanearest country within a tolerancecoarse boundaries1:10m: 2,780 misses; 1:110m: 4,330ISO_A2 = -99use ISO_A2_EHterritories vs sovereignsstate which codes you usedisputed areasrecord the dataset's policyNatural Earth admin-0, OurAirports spatialworkflow.io
Country lookup traps.

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

Checks before trusting a country lookup. before trusting a country lookupboundary resolution1:10m for points near coastsunmatched points2,036 within 5 km of landcode fieldISO_A2_EHcheck a known sample99.49% agree hererecord source and versionboundaries change72,603 places, 258 countries and territories spatialworkflow.io
Before trusting a country lookup.
  • Territorial waters: a point 5 km offshore is legally within a country's territorial sea (12 nautical miles); land polygons do not include it.
  • -99 codes: Natural Earth's ISO_A2 is -99 for France, Norway and a few others in some versions; use ISO_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.

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.