Skip to content
worldgovdata
Documentation / Core concepts

Core concepts

Pagination

Only one endpoint paginates. The one that returns observations does not — it has a hard row cap instead, and a different technique for getting past it.

/v1/indicators takes limit and offset. /v1/data takes neither and refuses anything over 100,000 rows. Both are covered below.

Which endpoints paginate

  • /v1/indicators — the only paged endpoint. limit, offset, and meta.total.
  • /v1/data — no paging. Returns everything that matches, or refuses if that is over 100,000 rows. You narrow the query instead.
  • Everything else — bounded by nature. The country spine is a few hundred rows; a ranking is one row per country; topics is thirteen rows. They return the lot.

limit and offset

On /v1/indicators

On /v1/indicators
ParameterDescription
limitinteger, 1–500Page size. Omit it and you get every match in one response — which is the right choice for a narrow filter and the wrong one for the whole catalogue.default: none (all matches) · example: 500
offsetinteger, ≥ 0Rows to skip. Applied after the sort, alongside limit. 0 and an omitted value mean the same thing.default: 0 · example: 500

Both are validated: a limit above 500 or a negative offset returns 400 bad_request with a details array naming the parameter. Paging is free — /v1/indicators costs no credits at any page count.

Walking the catalogue

GET /v1/indicators?topic=governance&sort=coverage&limit=100&offset=0
GET /v1/indicators?topic=governance&sort=coverage&limit=100&offset=100
GET /v1/indicators?topic=governance&sort=coverage&limit=100&offset=200

meta.total, and when it lies

meta.count is how many rows are in this response. meta.total is how many match the filter overall. Loop until you have collected total rows.

The paging fields

"meta": {
  "generated_at": "2026-07-28T09:14:02.481932+00:00",
  "count": 100,      // rows in this response
  "total": 214,      // rows matching the filter
  "topic": "governance",
  "sort": "coverage"
}
  • On an unpaged response, total equals count. The full count is only computed when the response is actually paged. That is not a bug to work around — if you did not page, you have everything, so the two numbers are the same fact.
  • limit is not echoed in meta. offset is; the page size is not. Track your own page size rather than reading it back.
  • offset is omitted when it was 0. meta drops every field you did not send, so an absent key means “not applied”.

Walking every page

Two exit conditions, not one: the authoritative total, and an empty page. The second matters because the catalogue can change size between your first request and your last, and a loop that only trusts total can spin.

Fetch all indicators

# Every governance indicator, one JSON object per line. Requires jq.
# /v1/indicators is free, so this costs nothing however many pages it takes.
limit=500; offset=0; total=1

while [ "$offset" -lt "$total" ]; do
  page="$(curl -sS "https://api.worldgovdata.com/v1/indicators?topic=governance&limit=$limit&offset=$offset")"
  total="$(printf '%s' "$page" | jq -r '.meta.total // 0')"
  printf '%s' "$page" | jq -c '.data[]'
  offset=$(( offset + limit ))
done

Complete clients with this built in, in all five languages, are on Client libraries.

The row cap on /v1/data

A single /v1/data request will not return more than 100,000 rows. Over that, it refuses — and tells you the real count so you know how far over you are.

too_many_rows · 400

HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "too_many_rows",
    "message": "Query matches 412903 rows, over the 100000 limit. Narrow by countries or year range."
  }
}
  • It costs nothing. The count runs before any data is read, so an over-cap request is refused for free. Probing the size of a query this way is legitimate.
  • It is not a paging hint. There is no page 2 to ask for. You narrow the query — fewer countries, or a shorter year range — and issue it again.
  • Most queries never see it. One indicator across every country and every year it holds is usually well under the cap. It bites on the deep-history series, some of which carry centuries.

Fetching a full panel

The technique is to split the query on the axes it has: the country list, and the year range. Countries first — rows are roughly uniform per country, so a country slice is predictable — with years as the fallback for a single country whose history still will not fit.

Plan the chunk size

The upper bound on a query is one row per country-year. So the largest safe chunk is:

The arithmetic

years  = (year_to - year_from) + 1
chunk  = min(n_countries, floor(100000 / years))

# 218 countries, 1960-2024 → years = 65, 100000 / 65 = 1538 → one chunk of 218
# 218 countries, 1750-2024 → years = 275, 100000 / 275 = 363 → still one chunk

That is an upper bound, so the estimate is conservative and the first attempt almost always succeeds. The recipe below still handles the case where it does not, which is what makes it correct rather than merely usually right.

Do the free work first

You need the country list and the year span before you can plan, and both are free: /v1/countries for the codes and /v1/indicators/{id} for coverage.yr_min and coverage.latest_year. Using latest_year rather than yr_max is deliberate: yr_max can include projection years a few series carry, and paying for forecasts you did not want is an avoidable surprise.

The recipe

Split on countries; if a single-country chunk is still over the cap, split its years; only give up when one country in one year cannot fit, which cannot happen. Every other error code — 402, 403, 404 — is not splittable and is re-raised immediately rather than triggering a pointless bisection.

Fetch a full panel

from typing import Iterator, Sequence

MAX_ROWS = 100000   # the API's hard cap on one /v1/data request


def plan_chunk(n_countries: int, year_from: int, year_to: int) -> int:
    """Largest country-chunk that cannot exceed the cap.

    The upper bound is one row per country-year, so a chunk of N countries holds
    at most N * years rows. Solve N * years <= MAX_ROWS.
    """
    years = max(1, year_to - year_from + 1)
    return max(1, min(n_countries, MAX_ROWS // years))


def fetch_panel(
    client,                     # the Wgd client from /docs/libraries
    indicator: str,
    countries: Sequence[str],
    year_from: int,
    year_to: int,
) -> Iterator[dict]:
    """Every observation for one indicator across many countries.

    Splits on countries first and on years only when a single country still will
    not fit. Each chunk is billed on the rows it actually returned, and a chunk
    that raises costs nothing.
    """
    size = plan_chunk(len(countries), year_from, year_to)
    queue: list[tuple[Sequence[str], int, int]] = [
        (countries[i : i + size], year_from, year_to)
        for i in range(0, len(countries), size)
    ]

    while queue:
        group, lo, hi = queue.pop(0)
        try:
            page = client.get(
                "/data",
                indicator=indicator,
                countries=",".join(group),
                **{"from": lo, "to": hi},
            )
        except WgdError as err:
            if err.code != "too_many_rows":
                raise                        # 402, 403 and 404 are not splittable

            if len(group) > 1:               # split on countries first
                mid = len(group) // 2
                queue[0:0] = [(group[:mid], lo, hi), (group[mid:], lo, hi)]
            elif hi > lo:                    # one country: split on years
                mid = lo + (hi - lo) // 2
                queue[0:0] = [(group, lo, mid), (group, mid + 1, hi)]
            else:
                raise                        # one country, one year, still over
            continue

        yield from page["data"]


# ---------------------------------------------------------------------- usage
client = Wgd()

# Both of these are free: resolve the shape of the job before paying for any of it.
codes = [c["iso3"] for c in client.get("/countries")["data"]]
coverage = client.get("/indicators/wdi.gdp_per_capita_ppp")["data"]["coverage"]

rows = list(
    fetch_panel(
        client,
        "wdi.gdp_per_capita_ppp",
        codes,
        coverage["yr_min"],
        coverage["latest_year"],
    )
)
print(len(rows), "observations")

What chunking costs

Each request rounds up to a whole credit independently, so chunking is very slightly more expensive than one large request: nine chunks of about 1,450 rows cost 135 credits where a single 13,000-row request would have cost 130. That is the price of a job that can resume, and it scales with the number of chunks — which is a reason to use the largest chunk that clears the cap, not the smallest that feels tidy.

The other reason to keep chunk counts low is the rate limit: every chunk is a request against your per-minute budget, whereas the credit cost barely moves. One credit is 100 rows however you slice them.

Two things to try before chunking

  • Ask for CSV. format=csv streams rather than buffering a JSON array, which is usually the real constraint on a large pull. The row cap still applies, but your process stops being the bottleneck.
  • Ask whether you want a ranking instead. If what you need is every country for one year, /v1/rankings gives you exactly that for a flat 1 credit, already ordered, with the rank attached.