← Back to blog

Pulling World Bank Economic Data with Python (2026)

Pulling World Bank Economic Data with Python (2026)

The World Bank Open Data API is one of the friendlier public data sources you'll encounter. No authentication required, clean JSON responses, well-documented indicator codes, and data spanning 200+ countries going back decades. It covers GDP, population, inflation, trade, poverty, health, and education metrics — the kind of macro dataset that underpins serious economic research.

The catch: at scale, bulk downloads and high-frequency polling will hit undocumented rate limits. The API is public and generous for moderate use, but if you're building a data pipeline that pulls hundreds of indicators across all countries and years, you'll need to handle throttling carefully and design your pipeline for idempotent incremental updates.

This guide covers the full API structure, practical data retrieval patterns, async bulk collection, rate limit handling, SQLite storage, and automated refresh pipelines.

What Data Is Available

The World Bank tracks thousands of indicators across the full development spectrum:

National accounts and output - GDP (current USD), GDP per capita, GDP growth rate (annual %) - GNI, GNI per capita (Atlas method and PPP) - Gross capital formation, gross savings - Industry/services/agriculture as % of GDP

Population and demographics - Total population, urban and rural population - Population growth rate, fertility rate, birth/death rates - Age dependency ratio, median age - Urban population % of total

Inflation and monetary - CPI, inflation rate (annual %) - Lending interest rate, real interest rate - Broad money (M2) as % of GDP

Trade and balance of payments - Exports and imports of goods and services - Trade balance, current account balance as % of GDP - Foreign direct investment (net inflows, % of GDP) - External debt, total reserves

Poverty and inequality - Poverty headcount ratio at $2.15/day (2017 PPP) - Poverty headcount ratio at national poverty lines - Gini coefficient (income inequality measure) - Income share held by lowest/highest quintiles

Education - Literacy rate (adult and youth) - School enrollment (primary, secondary, tertiary) - Government expenditure on education (% of GDP) - Pupil-teacher ratios

Health - Life expectancy at birth - Infant mortality rate (per 1,000 live births) - Maternal mortality ratio - Hospital beds per 1,000 people - Health expenditure (% of GDP, per capita)

Environment and infrastructure - CO2 emissions per capita - Renewable energy % of total - Access to electricity (% of population) - Internet users % of population - Mobile subscriptions per 100 people

The full catalog is browsable at data.worldbank.org/indicator — over 1,600 indicators total.

The World Bank API Structure

Base URL: https://api.worldbank.org/v2/

All responses default to XML. Always pass format=json.

Key URL patterns:

# Single indicator for one country
GET /v2/country/{code}/indicator/{indicator}?format=json

# Specific date range
GET /v2/country/{code}/indicator/{indicator}?format=json&date=2010:2023

# Multiple countries (semicolon-separated, max ~50)
GET /v2/country/US;CN;DE/indicator/NY.GDP.MKTP.CD?format=json

# All countries
GET /v2/country/all/indicator/NY.GDP.MKTP.CD?format=json

# Most recent N values
GET /v2/country/{code}/indicator/{indicator}?format=json&mrv=5

# Indicator metadata
GET /v2/indicator/{indicator}?format=json

Response structure. Every response is a two-element JSON array: - [0] — metadata: total, page, pages, per_page, lastupdated - [1] — data array of observation objects

Each observation has: country.id, country.value, indicator.id, indicator.value, date, value, unit, obs_status, decimal.

Pagination. Default per_page is 50. Max is 1000. Use page parameter to iterate. The metadata object tells you pages (total page count) and total (total records).

Common indicator codes:

Code Description
NY.GDP.MKTP.CD GDP (current USD)
NY.GDP.PCAP.CD GDP per capita (current USD)
NY.GDP.MKTP.KD.ZG GDP growth rate (annual %)
NY.GDP.PCAP.PP.CD GDP per capita, PPP (current international $)
SP.POP.TOTL Population, total
SP.URB.TOTL.IN.ZS Urban population (% of total)
FP.CPI.TOTL.ZG Inflation, consumer prices (annual %)
NE.TRD.GNFS.ZS Trade (% of GDP)
BX.KLT.DINV.WD.GD.ZS Foreign direct investment, net inflows (% of GDP)
SI.POV.NAHC Poverty headcount ratio at national lines
SI.POV.GINI Gini index
SE.ADT.LITR.ZS Literacy rate, adult total
SE.ENR.PRSC.FM.ZS School enrollment, primary (gross %)
SP.DYN.LE00.IN Life expectancy at birth, total (years)
SP.DYN.IMRT.IN Mortality rate, infant (per 1,000 live births)
EN.ATM.CO2E.PC CO2 emissions (metric tons per capita)
EG.ELC.ACCS.ZS Access to electricity (% of population)
IT.NET.USER.ZS Individuals using the Internet (% of population)

Basic Data Retrieval

Fetching GDP data for a set of countries with clean error handling:

import httpx
import time
from typing import Optional

BASE_URL = "https://api.worldbank.org/v2"


def fetch_indicator(
    indicator: str,
    countries: list[str],
    start_year: int,
    end_year: int,
    per_page: int = 1000,
) -> list[dict]:
    """
    Fetch a single indicator for one or more countries over a date range.

    Returns a list of observation dicts with country_code, country_name,
    indicator, year, and value fields.
    """
    country_str = ";".join(countries)
    url = f"{BASE_URL}/country/{country_str}/indicator/{indicator}"
    params = {
        "format": "json",
        "date": f"{start_year}:{end_year}",
        "per_page": per_page,
        "page": 1,
    }

    all_rows = []

    while True:
        try:
            response = httpx.get(url, params=params, timeout=30)
            response.raise_for_status()
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 429:
                print(f"Rate limited, waiting 60s...")
                time.sleep(60)
                continue
            raise

        payload = response.json()

        # Validate response structure
        if not isinstance(payload, list) or len(payload) < 2:
            print(f"Unexpected response format for {indicator}")
            break

        meta, data = payload[0], payload[1]

        if data is None:
            break

        for entry in data:
            if entry.get("value") is None:
                continue
            all_rows.append({
                "country_code": entry["country"]["id"],
                "country_name": entry["country"]["value"],
                "indicator_code": indicator,
                "year": int(entry["date"]),
                "value": float(entry["value"]),
                "unit": entry.get("unit", ""),
            })

        # Check if there are more pages
        total_pages = meta.get("pages", 1)
        if params["page"] >= total_pages:
            break

        params["page"] += 1
        time.sleep(0.3)  # gentle pacing

    return all_rows


# Fetch GDP for G7 countries, 2015–2023
g7 = ["US", "GB", "DE", "FR", "JP", "CA", "IT"]
gdp_data = fetch_indicator("NY.GDP.MKTP.CD", g7, 2015, 2023)

for row in sorted(gdp_data, key=lambda x: (x["country_code"], x["year"])):
    gdp_trillions = row["value"] / 1e12
    print(f"{row['country_code']} {row['year']}: ${gdp_trillions:.2f}T")

Async Bulk Collection

For pulling many indicators in parallel, asyncio with httpx reduces wall-clock time dramatically:

import asyncio
import httpx
import pandas as pd
from typing import Optional

BASE_URL = "https://api.worldbank.org/v2"

INDICATORS = {
    "NY.GDP.MKTP.CD": "gdp_usd",
    "NY.GDP.PCAP.CD": "gdp_per_capita",
    "NY.GDP.MKTP.KD.ZG": "gdp_growth_pct",
    "NY.GDP.PCAP.PP.CD": "gdp_per_capita_ppp",
    "SP.POP.TOTL": "population",
    "SP.URB.TOTL.IN.ZS": "urban_pct",
    "FP.CPI.TOTL.ZG": "inflation_pct",
    "NE.TRD.GNFS.ZS": "trade_pct_gdp",
    "BX.KLT.DINV.WD.GD.ZS": "fdi_pct_gdp",
    "SP.DYN.LE00.IN": "life_expectancy",
    "SP.DYN.IMRT.IN": "infant_mortality",
    "SI.POV.GINI": "gini_index",
    "EN.ATM.CO2E.PC": "co2_per_capita",
    "IT.NET.USER.ZS": "internet_users_pct",
    "EG.ELC.ACCS.ZS": "electricity_access_pct",
}


async def fetch_all_pages_async(
    client: httpx.AsyncClient,
    url: str,
    params: dict,
) -> list[dict]:
    """Fetch all paginated results for a World Bank API endpoint."""
    all_data = []
    page = 1

    while True:
        params_copy = {**params, "page": page}

        for attempt in range(3):
            try:
                resp = await client.get(url, params=params_copy, timeout=30)
                resp.raise_for_status()
                break
            except httpx.HTTPStatusError as e:
                if e.response.status_code == 429 and attempt < 2:
                    await asyncio.sleep(30 * (attempt + 1))
                    continue
                raise
            except httpx.TimeoutException:
                if attempt < 2:
                    await asyncio.sleep(5)
                    continue
                raise

        payload = resp.json()
        if not isinstance(payload, list) or len(payload) < 2 or payload[1] is None:
            break

        meta, data = payload[0], payload[1]
        all_data.extend(data)

        if page >= meta.get("pages", 1):
            break
        page += 1

    return all_data


async def fetch_indicator_df(
    client: httpx.AsyncClient,
    indicator_code: str,
    column_name: str,
    start_year: int,
    end_year: int,
    semaphore: asyncio.Semaphore,
) -> pd.DataFrame:
    """Fetch one indicator for all countries and return as DataFrame."""
    async with semaphore:
        url = f"{BASE_URL}/country/all/indicator/{indicator_code}"
        params = {
            "format": "json",
            "date": f"{start_year}:{end_year}",
            "per_page": 1000,
        }

        data = await fetch_all_pages_async(client, url, params)

        records = []
        for entry in data:
            if entry.get("value") is None:
                continue
            # Filter out regional/aggregate codes (length > 3 or all caps multi-word)
            country_id = entry["country"]["id"]
            if len(country_id) != 2:
                continue
            records.append({
                "country_code": country_id,
                "country_name": entry["country"]["value"],
                "year": int(entry["date"]),
                column_name: float(entry["value"]),
            })

        await asyncio.sleep(0.2)  # light pacing between indicator fetches
        return pd.DataFrame(records)


async def collect_all_indicators(
    start_year: int = 2018,
    end_year: int = 2023,
    max_concurrency: int = 4,
) -> pd.DataFrame:
    """
    Pull all configured indicators for all countries in parallel.

    max_concurrency: limit parallel requests to avoid rate limiting.
    Returns a wide DataFrame with one row per country-year.
    """
    semaphore = asyncio.Semaphore(max_concurrency)

    async with httpx.AsyncClient() as client:
        tasks = [
            fetch_indicator_df(client, code, label, start_year, end_year, semaphore)
            for code, label in INDICATORS.items()
        ]
        dfs = await asyncio.gather(*tasks, return_exceptions=True)

    # Merge all indicator DataFrames on country + year
    valid_dfs = [df for df in dfs if isinstance(df, pd.DataFrame) and not df.empty]
    if not valid_dfs:
        return pd.DataFrame()

    merged = valid_dfs[0]
    for df in valid_dfs[1:]:
        merged = merged.merge(
            df, on=["country_code", "country_name", "year"], how="outer"
        )

    return merged.sort_values(["country_code", "year"]).reset_index(drop=True)


# Run the full collection
df = asyncio.run(collect_all_indicators(2018, 2023))
print(f"Collected: {len(df):,} rows, {df['country_code'].nunique()} countries, "
      f"{df.columns.tolist()}")

Rate Limiting in Practice

The World Bank API is permissive for single-user access but has undocumented rate limits that surface during bulk collection. Symptoms: 429 responses, sudden connection resets, or responses that return empty data arrays without error codes. The limits appear to be IP-based rather than key-based.

Observed thresholds: - Single-page requests: fine up to 60/minute per IP - Paginated bulk pulls across all countries: 3-5 concurrent requests is the safe ceiling - Parallel requests beyond that: intermittent 429s and connection drops that require backing off

For production pipelines that poll large indicator sets across all countries on a regular schedule, IP rotation distributes the load across addresses. ThorData's residential proxies work well here — route bulk requests through the pool while keeping your direct IP for low-volume or interactive queries:

import httpx

THORDATA_PROXY = "http://USER:[email protected]:9000"


def make_proxied_client() -> httpx.AsyncClient:
    """Create an httpx async client routed through ThorData residential proxies."""
    return httpx.AsyncClient(
        transport=httpx.AsyncHTTPTransport(proxy=THORDATA_PROXY),
        timeout=30,
        headers={"User-Agent": "WorldBankDataPipeline/1.0"},
    )


async def fetch_with_proxy(indicator: str, countries: list[str], year: int) -> list[dict]:
    """Fetch a single indicator for specific countries via proxy."""
    async with make_proxied_client() as client:
        url = f"{BASE_URL}/country/{';'.join(countries)}/indicator/{indicator}"
        params = {"format": "json", "date": str(year), "per_page": 500}
        resp = await client.get(url, params=params)
        payload = resp.json()
        if len(payload) < 2 or payload[1] is None:
            return []
        return [
            {
                "country_code": e["country"]["id"],
                "year": year,
                indicator: float(e["value"]) if e["value"] else None,
            }
            for e in payload[1]
        ]

Even without proxies, adding asyncio.sleep(0.5) between page fetches and capping concurrency at 3-4 tasks gets you through a full bulk pull without interruptions.

SQLite Storage Schema

For a persistent, queryable data store:

import sqlite3
import json
from datetime import datetime, timezone


def init_world_bank_db(db_path: str = "world_bank.db") -> sqlite3.Connection:
    """Initialize the World Bank data SQLite database."""
    conn = sqlite3.connect(db_path)

    conn.executescript("""
        CREATE TABLE IF NOT EXISTS indicators (
            code        TEXT PRIMARY KEY,
            name        TEXT,
            description TEXT,
            unit        TEXT,
            source      TEXT,
            last_updated TEXT
        );

        CREATE TABLE IF NOT EXISTS observations (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            country_code    TEXT NOT NULL,
            country_name    TEXT NOT NULL,
            indicator_code  TEXT NOT NULL,
            year            INTEGER NOT NULL,
            value           REAL,
            unit            TEXT,
            scraped_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            UNIQUE (country_code, indicator_code, year)
        );

        CREATE TABLE IF NOT EXISTS countries (
            code        TEXT PRIMARY KEY,
            name        TEXT,
            region      TEXT,
            income_group TEXT,
            capital     TEXT,
            iso2        TEXT,
            iso3        TEXT
        );

        CREATE INDEX IF NOT EXISTS idx_obs_country_year
            ON observations(country_code, year);
        CREATE INDEX IF NOT EXISTS idx_obs_indicator
            ON observations(indicator_code);
        CREATE INDEX IF NOT EXISTS idx_obs_year
            ON observations(year);
    """)

    conn.commit()
    return conn


def bulk_insert_observations(conn: sqlite3.Connection, rows: list[dict]):
    """Bulk insert observations, replacing on conflict."""
    conn.executemany("""
        INSERT OR REPLACE INTO observations
            (country_code, country_name, indicator_code, year, value, unit, scraped_at)
        VALUES (?, ?, ?, ?, ?, ?, ?)
    """, [
        (
            r["country_code"],
            r["country_name"],
            r["indicator_code"],
            r["year"],
            r.get("value"),
            r.get("unit", ""),
            datetime.now(timezone.utc).isoformat(),
        )
        for r in rows
    ])
    conn.commit()


def fetch_country_snapshot(conn: sqlite3.Connection, country_code: str, year: int) -> dict:
    """Get all indicators for a country in a given year."""
    rows = conn.execute("""
        SELECT indicator_code, value
        FROM observations
        WHERE country_code = ? AND year = ?
    """, (country_code, year)).fetchall()
    return {row[0]: row[1] for row in rows}


def get_indicator_series(
    conn: sqlite3.Connection,
    indicator_code: str,
    country_codes: list[str],
    start_year: int,
    end_year: int,
) -> list[dict]:
    """Retrieve time series data for specific countries and an indicator."""
    placeholders = ",".join("?" * len(country_codes))
    return conn.execute(f"""
        SELECT country_code, country_name, year, value
        FROM observations
        WHERE indicator_code = ?
          AND country_code IN ({placeholders})
          AND year BETWEEN ? AND ?
        ORDER BY country_code, year
    """, [indicator_code, *country_codes, start_year, end_year]).fetchall()


def get_cross_country_ranking(
    conn: sqlite3.Connection,
    indicator_code: str,
    year: int,
    top_n: int = 20,
) -> list[dict]:
    """Rank countries by indicator value for a specific year."""
    return conn.execute("""
        SELECT country_name, country_code, value
        FROM observations
        WHERE indicator_code = ? AND year = ? AND value IS NOT NULL
        ORDER BY value DESC
        LIMIT ?
    """, (indicator_code, year, top_n)).fetchall()

Country Metadata Collection

The World Bank also provides clean metadata about countries, including region, income group, and ISO codes:

def fetch_country_metadata() -> list[dict]:
    """Fetch country metadata from the World Bank countries endpoint."""
    url = f"{BASE_URL}/country/all"
    params = {"format": "json", "per_page": 300}

    resp = httpx.get(url, params=params, timeout=30)
    payload = resp.json()

    if len(payload) < 2 or payload[1] is None:
        return []

    countries = []
    for c in payload[1]:
        # Skip aggregates/regions — they have multi-character codes
        if len(c["id"]) != 2:
            continue
        countries.append({
            "code": c["id"],
            "name": c["name"],
            "region": c.get("region", {}).get("value", ""),
            "income_group": c.get("incomeLevel", {}).get("value", ""),
            "capital": c.get("capitalCity", ""),
            "iso2": c.get("iso2Code", ""),
        })

    return countries


def populate_country_table(conn: sqlite3.Connection):
    """Fetch and store all country metadata."""
    countries = fetch_country_metadata()
    conn.executemany("""
        INSERT OR REPLACE INTO countries
            (code, name, region, income_group, capital, iso2)
        VALUES (?, ?, ?, ?, ?, ?)
    """, [
        (c["code"], c["name"], c["region"], c["income_group"], c["capital"], c["iso2"])
        for c in countries
    ])
    conn.commit()
    print(f"Stored metadata for {len(countries)} countries")

Building a Country Dashboard Dataset

Combining multiple indicators into a single export-ready CSV for analysis or visualization:

import asyncio
import pandas as pd


async def build_country_dashboard(
    country_codes: list[str],
    year: int = 2022,
    use_proxy: bool = False,
) -> pd.DataFrame:
    """
    Build a single-year wide-format snapshot for a list of countries.

    Returns DataFrame with one row per country, columns for each indicator.
    """
    indicators = {
        "NY.GDP.MKTP.CD": "gdp_usd",
        "NY.GDP.PCAP.CD": "gdp_per_capita",
        "NY.GDP.MKTP.KD.ZG": "gdp_growth_pct",
        "SP.POP.TOTL": "population",
        "FP.CPI.TOTL.ZG": "inflation_pct",
        "NE.TRD.GNFS.ZS": "trade_pct_gdp",
        "SP.DYN.LE00.IN": "life_expectancy",
        "SP.DYN.IMRT.IN": "infant_mortality",
        "SI.POV.GINI": "gini_index",
        "EN.ATM.CO2E.PC": "co2_per_capita",
        "IT.NET.USER.ZS": "internet_pct",
        "EG.ELC.ACCS.ZS": "electricity_access_pct",
    }

    country_str = ";".join(country_codes)
    frames = []

    client_kwargs = {}
    if use_proxy:
        client_kwargs["transport"] = httpx.AsyncHTTPTransport(proxy=THORDATA_PROXY)

    async with httpx.AsyncClient(timeout=30, **client_kwargs) as client:
        for code, label in indicators.items():
            url = f"{BASE_URL}/country/{country_str}/indicator/{code}"
            params = {"format": "json", "date": str(year), "per_page": 500}

            try:
                resp = await client.get(url, params=params)
                payload = resp.json()
            except Exception as e:
                print(f"Failed to fetch {code}: {e}")
                continue

            if len(payload) < 2 or payload[1] is None:
                continue

            for entry in payload[1]:
                if entry.get("value") is None:
                    continue
                frames.append({
                    "country_code": entry["country"]["id"],
                    "country_name": entry["country"]["value"],
                    "year": year,
                    label: float(entry["value"]),
                })

            await asyncio.sleep(0.4)

    if not frames:
        return pd.DataFrame()

    df = pd.DataFrame(frames)
    # Collapse to one row per country (outer merge all columns)
    df = df.groupby(["country_code", "country_name", "year"]).first().reset_index()
    return df.sort_values("country_name")


# Build dashboard for 30 major economies
major_economies = [
    "US", "CN", "DE", "JP", "GB", "FR", "IN", "IT", "CA", "KR",
    "BR", "AU", "ES", "MX", "ID", "NL", "SA", "TR", "CH", "AR",
    "SE", "PL", "BE", "NG", "ZA", "EG", "TH", "PK", "BD", "VN",
]

dashboard = asyncio.run(build_country_dashboard(major_economies, year=2022))
dashboard.to_csv("world_bank_dashboard_2022.csv", index=False)
print(f"Saved {len(dashboard)} countries")
print(dashboard[["country_name", "gdp_per_capita", "life_expectancy", "gini_index"]].to_string(index=False))

Indicator Metadata and Documentation

Every indicator has a full metadata record with description, methodology notes, and source:

def get_indicator_metadata(indicator_code: str) -> dict:
    """Fetch documentation for a specific World Bank indicator."""
    url = f"{BASE_URL}/indicator/{indicator_code}"
    params = {"format": "json"}

    resp = httpx.get(url, params=params, timeout=20)
    payload = resp.json()

    if len(payload) < 2 or not payload[1]:
        return {}

    ind = payload[1][0]
    return {
        "code": ind.get("id"),
        "name": ind.get("name"),
        "unit": ind.get("unit"),
        "source": ind.get("source", {}).get("value"),
        "description": ind.get("sourceNote"),
        "organization": ind.get("sourceOrganization"),
        "topics": [t.get("value") for t in ind.get("topics", [])],
    }


# Example: document GDP per capita indicator
meta = get_indicator_metadata("NY.GDP.PCAP.CD")
print(meta["name"])
print(meta["description"][:200])

Automated Pipeline with Incremental Updates

For production use, track what has already been fetched and only pull new or updated data:

import sqlite3
from datetime import datetime, timezone


def get_last_scraped_year(conn: sqlite3.Connection, indicator_code: str) -> int | None:
    """Return the most recent year stored for an indicator."""
    result = conn.execute("""
        SELECT MAX(year) FROM observations WHERE indicator_code = ?
    """, (indicator_code,)).fetchone()
    return result[0] if result and result[0] else None


def get_api_last_updated(indicator_code: str) -> str | None:
    """Check when the World Bank last updated a specific indicator."""
    url = f"{BASE_URL}/country/all/indicator/{indicator_code}"
    params = {"format": "json", "per_page": 1}

    try:
        resp = httpx.get(url, params=params, timeout=15)
        payload = resp.json()
        return payload[0].get("lastupdated") if payload else None
    except Exception:
        return None


def needs_refresh(conn: sqlite3.Connection, indicator_code: str) -> bool:
    """
    Determine if an indicator should be re-fetched.
    True if API has been updated more recently than our last scrape.
    """
    last_update = get_api_last_updated(indicator_code)
    if not last_update:
        return True

    result = conn.execute("""
        SELECT MAX(scraped_at) FROM observations WHERE indicator_code = ?
    """, (indicator_code,)).fetchone()

    if not result or not result[0]:
        return True

    last_scrape = result[0]
    return last_update > last_scrape[:10]  # compare YYYY-MM-DD


def incremental_refresh(db_path: str = "world_bank.db"):
    """
    Refresh all configured indicators, only pulling those updated since last scrape.
    """
    conn = init_world_bank_db(db_path)

    for indicator_code, column_name in INDICATORS.items():
        if not needs_refresh(conn, indicator_code):
            print(f"Skipping {indicator_code} (up to date)")
            continue

        print(f"Refreshing {indicator_code}...")
        rows = fetch_indicator(indicator_code, ["all"], 2000, 2023)
        bulk_insert_observations(conn, rows)
        print(f"  Stored {len(rows)} observations")
        time.sleep(1.0)

    conn.close()


# Schedule this via cron:
# 0 6 * * 1 python3 /path/to/refresh_world_bank.py

Practical Tips for Production Use

Filter out aggregates. The API returns both individual countries and regional aggregates (e.g., ZG for Sub-Saharan Africa, OED for OECD members). Filter by checking that country.id is exactly 2 characters — those are ISO-3166 alpha-2 codes for actual countries. Anything longer is an aggregate group.

Missing values are normal. Not every country reports every indicator every year. Poverty and inequality metrics (Gini, poverty headcount) often have 5-7 year gaps for developing countries. Always expect sparse data and design your analysis to handle None values without crashing.

Use the mrv parameter for current data. mrv=5 returns the 5 most recent values for an indicator — useful when you want current data without specifying exact years. Combine with gapfill=Y to fill gaps with the most recent available value.

The fields parameter reduces payload size. You can request specific fields: ?fields=country,date,value — useful when you're collecting millions of observations and want to minimize bandwidth.

Quarterly and monthly data exists. Some indicators (inflation, exchange rates) have sub-annual frequency. Use date=2022Q1:2023Q4 or date=2022M01:2023M12 syntax for quarterly and monthly data.

API updates lag. Annual indicators typically appear 12-18 months after the reference year. GDP data for 2023 typically arrives in mid-2024. Don't expect current-year data — the API reflects the latest published World Bank estimates, not real-time statistics.

The World Bank API is genuinely one of the easier public data sources to work with at scale. The combination of broad coverage, no authentication requirement, and clean JSON structure makes it ideal for building economic research tools, country comparison dashboards, and data journalism pipelines. With proper async pagination and rate limit handling, you can collect the full indicator catalog for all 200+ countries in under an hour.