import os
import requests
import pandas as pd
import geopandas as gpd
import plotly.express as px
csv_path = "ukraine_refugees.csv"
def download_data(path):
if os.path.exists(path):
return
# Fetch refugee population data for Ukraine as country of origin
all_items = []
for year in range(2022, 2026):
url = f"https://api.unhcr.org/population/v1/population/?coo=UKR&coa_all=true&year={year}&limit=200"
resp = requests.get(url, headers={"Accept": "application/json"})
data = resp.json()
for item in data["items"]:
all_items.append({
"year": item["year"],
"country": item["coa_name"],
"iso_code": item["coa_iso"],
"refugees": int(item.get("refugees", 0) or 0),
"asylum_seekers": int(item.get("asylum_seekers", 0) or 0)
})
df = pd.DataFrame(all_items)
# Combine refugees + asylum seekers as "total displaced"
df["total"] = df["refugees"] + df["asylum_seekers"]
# write to CSV for reference
df.to_csv(path, index=False)
download_data(csv_path)Ukrainian Refugee Flows — Animated Map
About
This page fetches data from the UNHCR Refugee Statistics API and creates an animated choropleth map showing refugees from Ukraine recorded in each country of asylum over time.
The data shows year-end stock figures (total refugees recorded in each host country), not annual flows. Year-over-year changes approximate net flows.
Load Data
df = pd.read_csv("ukraine_refugees.csv")
print(f"Data: {len(df)} rows, {df['year'].nunique()} years, {df['country'].nunique()} countries")
df.head()World Map
# Load country boundaries
world = gpd.read_file("https://naturalearth.s3.amazonaws.com/110m_cultural/ne_110m_admin_0_countries.zip")
# Replace missing ISO codes (-99) with None
world["ISO_A3"] = world["ISO_A3"].replace("-99", None)
world = world.dropna(subset=["ISO_A3"])
# Create a cartesian product of all countries and all years to ensure
# every country is represented in every animation frame (e.g. with 0 value)
years_df = pd.DataFrame({"year": sorted(df["year"].unique())})
grid = world[["ISO_A3", "NAME", "geometry"]].merge(years_df, how="cross")
# Merge with refugee data and sort by year to ensure chronological animation
merged = grid.merge(df, left_on=["ISO_A3", "year"], right_on=["iso_code", "year"], how="left")
merged = merged.sort_values("year")
merged["total"] = merged["total"].fillna(0).astype(int)
merged["refugees"] = merged["refugees"].fillna(0).astype(int)
merged["asylum_seekers"] = merged["asylum_seekers"].fillna(0).astype(int)
merged["country"] = merged["country"].fillna(merged["NAME"])
fig = px.choropleth(
merged,
locations="ISO_A3",
color="total",
hover_name="country",
hover_data={"ISO_A3": False, "refugees": True, "asylum_seekers": True},
animation_frame="year",
color_continuous_scale="OrRd",
range_color=(0, merged["total"].max()),
title="Refugees from Ukraine by country of asylum (2022–2025)",
labels={"total": "Total refugees"}
)
fig.update_layout(
geo=dict(showframe=False, showcoastlines=True, coastlinecolor="lightgrey",
showcountries=True, countrycolor="lightgrey"),
height=600
)
fig.show()Top Host Countries
top = df.groupby("country")["refugees"].max().sort_values(ascending=False).head(15).index
top_df = df[df["country"].isin(top)].copy()
fig2 = px.bar(
top_df.sort_values(["year", "refugees"], ascending=[True, False]),
x="country", y="refugees", color="country",
animation_frame="year",
title="Top host countries for Ukrainian refugees by year",
labels={"refugees": "Refugees", "country": ""}
)
fig2.update_layout(showlegend=False, height=500)
fig2.show()Data Notes
- Source: UNHCR Refugee Statistics API — population endpoint
- Population type: Refugees under UNHCR’s mandate (including refugee-like situations) + asylum seekers
- Data is year-end stock (total present on 31 December), not cumulative arrivals
- The unusually high figure for “Russian Federation” includes Ukrainians displaced to Russia and areas under Russian control
- Some countries may show decreases between years due to onward movement, returns, or statistical revisions