Get World Bank data in Python

Narwhal API indexes 1,489 annual World Bank WDI indicators under CC BY 4.0. To get World Bank data in Python, search for an indicator, add a lowercase country code to its ID template, and request observations with your API key. The response includes periods, decimal-string values, attribution and a licence URL. Your plan determines the history window and daily allowance for uncached fetches.

Do it with the API

  1. Get a free key and prepare Python Create an account at signup, get your API key, and set NARWHAL_API_KEY in the shell where you will run Python. Replace the placeholder with your key. Install requests for HTTP calls and pandas for the DataFrame. Each request uses the key in the Authorization header; the complete script in the final step reads it from the environment.
    Install dependencies and set your key
    python -m pip install requests pandas
    export NARWHAL_API_KEY="replace-with-your-key"
  2. Search for an indicator Search the catalogue for gdp per capita with source=wdi. Print each result's id_template and title, then choose the indicator you need. Catalogue search is open on every plan and calls no source. The params argument handles spaces in the search phrase. Search results describe indicators; a country belongs in the series ID you build next.
    Find World Bank indicator templates
    import os
    import requests
    
    BASE_URL = "https://api.narwhalapi.com"
    headers = {
        "Authorization": f"Bearer {os.environ['NARWHAL_API_KEY']}"
    }
    response = requests.get(
        f"{BASE_URL}/v1/economics/catalogue",
        headers=headers,
        params={"q": "gdp per capita", "source": "wdi"},
        timeout=10,
    )
    response.raise_for_status()
    catalogue = response.json()
    for hit in catalogue["results"]:
        print(hit["id_template"], hit["title"])
  3. Add a country to the template Replace {country} in your chosen template with a lowercase ISO 3166-1 alpha-3 code. Indonesia uses idn. A World Bank ID has the form wdi:<indicator>/<iso3>, including the slash between the indicator and country. Pass the complete ID through the id query parameter. The snippet selects the first search result; review its printed title before you use it.
    Build a country-specific series ID
    if not catalogue["results"]:
        raise SystemExit("No matching indicators found.")
    selected = catalogue["results"][0]
    series_id = selected["id_template"].replace("{country}", "idn")
    print(series_id)
  4. Read annual observations Request observations for that ID. Annual periods use YYYY; optional start and end bounds are inclusive. The response limit accepts up to 1,000 observations, with a default of 100. If next_cursor has a value, pass it as cursor to continue. A larger limit does not extend your plan's history window, and missing years remain absent from the response.
    Read the selected series
    response = requests.get(
        f"{BASE_URL}/v1/economics/catalogue/series/observations",
        headers=headers,
        params={"id": series_id, "limit": 1000},
        timeout=10,
    )
    response.raise_for_status()
    observations = response.json()["observations"]
  5. Create a DataFrame Build a DataFrame from the observations, convert period to an integer, and convert value to a float. Values arrive as decimal strings. Use Decimal instead of float() when you want decimal arithmetic. Keep the response's unit label alongside your analysis: an indicator's title describes what its values measure. The conversion does not fill gaps or create observations for missing years.
    Convert periods and values
    import pandas as pd
    
    frame = pd.DataFrame(observations, columns=["period", "value"])
    frame["period"] = frame["period"].astype(int)
    frame["value"] = frame["value"].map(float)
    print(frame)
  6. Batch countries and handle request errors Run this complete script for the worked GDP series and a latest-value batch for Indonesia and Canada. Join IDs with commas; the latest endpoint accepts 1 to 50 distinct IDs. It charges one call per series returned, in one step. The script prints each country's period and value, reports missing IDs, and stops with an error message for 400, 404 or 429 fetch_limit_reached responses.
    Read GDP history and batch the latest values
    import os
    import requests
    import pandas as pd
    
    BASE_URL = "https://api.narwhalapi.com"
    HEADERS = {
        "Authorization": f"Bearer {os.environ['NARWHAL_API_KEY']}"
    }
    
    def get_json(path, params):
        response = requests.get(
            f"{BASE_URL}{path}",
            headers=HEADERS,
            params=params,
            timeout=10,
        )
        try:
            response.raise_for_status()
        except requests.HTTPError:
            problem = response.json()
            code = problem.get("code", "unknown_error")
            if response.status_code == 429 and code == "fetch_limit_reached":
                retry_after = response.headers.get("Retry-After")
                raise SystemExit(
                    f"Uncached fetch allowance used. Retry-After: {retry_after}"
                )
            if response.status_code in (400, 404):
                raise SystemExit(f"{code}: {problem.get('detail', '')}")
            raise
        return response.json()
    
    series_id = "wdi:ny.gdp.mktp.cd/idn"
    rows = []
    params = {"id": series_id, "limit": 1000}
    while True:
        payload = get_json(
            "/v1/economics/catalogue/series/observations", params
        )
        rows.extend(payload["observations"])
        cursor = payload.get("next_cursor")
        if not cursor:
            break
        params["cursor"] = cursor
    
    frame = pd.DataFrame(rows, columns=["period", "value"])
    frame["period"] = frame["period"].astype(int)
    frame["value"] = frame["value"].map(float)
    print(frame)
    
    countries = ("idn", "can")
    ids = [f"wdi:ny.gdp.mktp.cd/{country}" for country in countries]
    batch = get_json(
        "/v1/economics/catalogue/latest",
        {"ids": ",".join(ids)},
    )
    for observation in batch["observations"]:
        country = observation["id"].rsplit("/", 1)[1]
        print(country, observation["period"], observation["value"])
    for missing in batch["missing"]:
        print("Missing:", missing)

How do I read the Indonesia GDP example?

The series wdi:ny.gdp.mktp.cd/idn is Indonesia's GDP in current US dollars. A Pro-key request on 27 October 2026 with limit=3 returned these observations. This example uses total GDP; use the template from your catalogue search when you want GDP per capita.

Captured observations from the GDP response
[
  {"period": "1967", "value": "5667756627.92093"},
  {"period": "1968", "value": "7076465299.81141"},
  {"period": "1969", "value": "8337423312.88344"}
]

For 1967, float("5667756627.92093") converts the value for numerical work in your DataFrame. Preserve the original string if you also need its decimal representation. Responses include World Bank WDI attribution and the CC BY 4.0 licence URL. Keep that attribution and licence information with the data you use.

Why does my Free key return only a few years?

Free has a 12-month history window, with an annual minimum of the latest 5 observations. Narwhal applies whichever gives you more observations: the plan window or that minimum. Annual series therefore retain several observations even when most fall outside the ordinary window. The annual minimum applies on every plan.

Which limit applies on my plan:

PlanUncached fetches per account per UTC dayHistory window
Free10Last 12 months, with at least the latest 5 annual observations
Basic100Last 5 years, with at least the latest 5 annual observations
Pro1,000Full history

A range wholly before your allowed window returns 403 plan_upgrade_required. For annual series, Narwhal checks that range after loading the series so it can account for the minimum. Use the plan details to choose the history window your analysis needs.

What does the 429 fetch_limit_reached error mean?

Your account has used its daily allowance and the request needs an uncached fetch. Read Retry-After before retrying; the allowance resets at 00:00 UTC. A cached series still answers after you reach the cap. World Bank cache durations range from 24 hours to 7 days, so another request does not necessarily require a fetch.

For 400 invalid_series_id, check the indicator, slash and lowercase country code. 400 invalid_query means the query is invalid. A 404 not_found can mean the index does not know the indicator; Narwhal remembers that result for 24 hours. Check the ID against your catalogue result before repeating the request.

Stale answers and 400, 404 and 503 responses are uncharged. If the source is unavailable and no cached copy exists, Narwhal returns 503 upstream_busy with Retry-After. See caching and 429 handling for retry handling.

Published .

Next steps