The World Bank's v2 API is free, keyless, and serves 20,000+ development indicators for every country on earth. It is also the only API I have met this year where a successful response is not a JSON object, and where asking for an indicator that does not exist returns HTTP 200.
Quick answer
World Bank v2 returns a two-element array, not an object: [{pagination meta}, [...rows]]. When your indicator code is wrong it returns HTTP 200 with a one-element array carrying a message block instead of rows. So the only safe way to consume it is to branch on array length before you look at anything else — status code and try: rows = data["data"] will both lie to you.
The wire format
Here is a healthy response, trimmed:
[
{ "page": 1, "pages": 66, "per_page": 1, "total": 66, "lastupdated": "2026-07-13" },
[
{ "indicator": {"id": "NY.GDP.MKTP.CD", "value": "GDP (current US$)"},
"country": {"id": "BR", "value": "Brazil"}, "countryiso3code": "BRA",
"date": "2025", "value": 2279920092492.13 }
]
]
Element 0 is pagination. Element 1 is the rows. There is no envelope key, no data, no results. Anyone who has written a client against a modern REST API will reach for response.json()["data"] and get a TypeError on a working request.
That part is merely annoying. Here is the part that costs you a customer.
Why does an invalid indicator return 200?
Ask for an indicator code that does not exist and the API answers:
[ { "message": [ { "id": "120", "key": "Invalid value",
"value": "The provided parameter value is not valid" } ] } ]
Status: 200 OK. One element, not two. No exception, no error field your HTTP layer will notice.
Chain that with the naive parse and the outcome is a run that completes successfully and emits zero rows. On a pay-per-result Actor that is the worst possible failure: the customer is charged a start fee, sees a green run, gets an empty dataset, and has no idea whether their query was wrong or our scraper was.
So the shape check happens first, in one place, before pagination or retries get a say:
INVALID_INDICATOR_KEY = "message"
def _parse_envelope(payload):
if not isinstance(payload, list) or not payload:
raise RuntimeError("unexpected World Bank response shape")
head = payload[0] or {}
if INVALID_INDICATOR_KEY in head:
raise RuntimeError(f"World Bank rejected the request: {head[INVALID_INDICATOR_KEY]}")
...
Isolating it means the paging loop never has to guess. Two shapes exist; exactly one function knows about both.
Sparse by design: value is legitimately null
Development data has holes — a country did not report, a series starts in 1990, a war interrupted collection. value: null is the normal case, not corruption.
This matters for Apify Actors specifically, because a dataset schema that declares
{ "value": { "type": "number" } }
will pass a QA run on Germany's GDP and then die on the first missing year in a real query. The fix is one character:
{ "value": { "type": ["number", "null"] } }
We now treat "does this field survive a sparse record?" as a schema review question on every Actor, because the failure only ever reproduces on customer data.
One row per country, indicator and year
The other design choice worth stating: the API nests indicator and country as objects, and a request can span many countries and years at once. Consumers almost always want a flat table.
So the output is fully flattened — country_name, country_id, indicator_id, indicator_name, year, value — one row per (country, indicator, year). That drops straight into a spreadsheet, a pandas frame, or a BI tool with no unnesting step, and it makes the billing unit legible: one row is one data point.
Is scraping the World Bank API legal?
The World Bank publishes this data under an open licence and documents the API for public reuse — it is about as unambiguous as open data gets. Standard care still applies: request only what you need, and cite the source when you redistribute.
FAQ
Do I need an API key?
No. It is keyless and unauthenticated, and there is no bot detection to work around.
What does per_page max out at?
We page at 1000, which the API accepts comfortably. Watch pages in element 0 of the envelope rather than guessing when to stop.
How do I find an indicator code?
GET /v2/indicator/{code} is the catalogue endpoint — it also confirms an indicator exists before you page its data, which is the cheapest way to fail fast on a typo.
Why is my dataset smaller than countries × years?
Because the missing combinations were never reported. Absence of a row is real information about the series, not a scraper bug.
Packaged and ready to run: World Bank Indicators Scraper — pick any indicators (GDP, population, CO2, poverty, and 20,000+ more), any countries, any year range, get one flat row per country/indicator/year as JSON, CSV, or Excel.
We do the dirty work so your dataset stays clean. 😈
Top comments (0)