How to Download and Use a WorldPop Population Raster in Python

On this page

Problem statement

WorldPop publishes population rasters for every country at 3 arc-seconds, about 100 m, for every year from 2015 to 2030 in its R2025A release. Each country is one GeoTIFF, which makes it easy to fetch, but the files have traps for anyone who reads them straight into NumPy: a large negative nodata value that wrecks any sum, cells that are not 100 m square, and two different products, constrained and unconstrained, that put the same people in very different places.

This page finds the Netherlands 2020 file through the WorldPop REST API, downloads it, sums it correctly, checks the total against Statistics Netherlands, works out what one cell really measures on the ground, and compares the constrained raster with the older unconstrained one.

Quick answer

Read the raster with masked=True so the nodata cells drop out, then sum:

with rasterio.open(WP) as src:
    pop = src.read(1, masked=True)
    print(f"{src.name.split('/')[-1]}: {src.width} x {src.height} cells, nodata {src.nodata}")
print(f"WorldPop R2025A 2020: {pop.sum():,.0f} people in {pop.count():,} populated cells")
print(f"Statistics Netherlands, 1 January 2020: {OFFICIAL:,}; difference {pop.sum() / OFFICIAL - 1:+.2%}")
nld_pop_2020_CN_100m_R2025A_v1.tif: 4643 x 3364 cells, nodata -99999.0
WorldPop R2025A 2020: 17,601,682 people in 3,547,924 populated cells
Statistics Netherlands, 1 January 2020: 17,407,585; difference +1.12%

The R2025A raster for 2020 holds 17,601,682 people in 3,547,924 cells, 1.12% more than the 17,407,585 Statistics Netherlands registered on 1 January 2020. The file covers the country as WorldPop drew it, so the comparison is country against country.

Map of the Netherlands from the WorldPop R2025A constrained 2020 raster on a log scale from 0.1 to over 60 people per 3 arc-second cell: towns and cities are dark blue blobs, the Randstad darkest; ribbon villages along roads and dikes show as thin lines; the Veluwe and other areas without buildings are grey with no value. Map of the Netherlands from the WorldPop R2025A constrained 2020 raster on a log scale from 0.1 to over 60 people per 3 arc-second cell: towns and cities are dark blue blobs, the Randstad darkest; ribbon villages along roads and dikes show as thin lines; the Veluwe and other areas without buildings are grey with no value.
WorldPop R2025A, the Netherlands 2020.

Find files through the API, mask nodata, know what a cell measures

1. Find the file through the REST API

The hub's REST API lists every file for a product alias and country, so the URL does not have to be guessed. G2_CN_POP_R25A_100m is the constrained 100 m R2025A product:

api = requests.get("https://hub.worldpop.org/rest/data/pop/G2_CN_POP_R25A_100m", params={"iso3": "NLD"}, timeout=120).json()["data"]
years = [int(d["popyear"]) for d in api]
print(f"{len(api)} files for NLD, {min(years)} to {max(years)}")
y2020 = next(d for d in api if d["popyear"] == "2020")
print(y2020["files"][0].split("/R2025A/")[1], "| DOI", y2020["doi"], "| released", y2020["date"])
16 files for NLD, 2015 to 2030
2020/NLD/v1/100m/constrained/nld_pop_2020_CN_100m_R2025A_v1.tif | DOI 10.5258/SOTON/WP00839 | released 2025-09-01

The API lists 16 files for the Netherlands, one per year from 2015 to 2030, all released on 1 September 2025 under one DOI. The years after the census inputs are projections.

2. Mask the nodata value before any arithmetic

Every cell outside the populated area holds -99999. Read without a mask and those cells dominate any sum or mean:

with rasterio.open(WP) as src:
    raw = src.read(1)
empty = raw == -99999
print(f"cells: {raw.size:,}; nodata: {empty.sum():,} ({empty.mean():.0%}); zero: {(raw == 0).sum():,}")
print(f"raw sum: {raw.astype('float64').sum():,.0f}")
print(f"sum without nodata: {raw[~empty].astype('float64').sum():,.0f}")
cells: 15,619,052; nodata: 12,071,128 (77%); zero: 12,777
raw sum: -1,207,083,127,190
sum without nodata: 17,601,682

More than three quarters of the cells, 12,071,128, are nodata, so an unmasked sum comes out at minus 1.2 trillion. Only 12,777 cells are a true zero. Drop the nodata cells and the sum is the total from the quick answer.

What the 15.6 million cells of the WorldPop R2025A Netherlands 2020 raster hold. cells in the Netherlands 2020 file, millionsall cells15,619,052nodata -9999912,071,128 (77%)with people3,535,147exactly zero12,77704m8m12m16mUnmasked, the nodata cells sum to minus 1.2 trillion people spatialworkflow.io
What the cells hold.

3. What one cell measures on the ground

The cells are 3 arc-seconds on both sides. A degree of longitude shrinks with latitude, so in the Netherlands a cell is much narrower than it is tall:

lat = np.radians(52.2)
dy = 3 / 3600 * 111_320
dx = 3 / 3600 * 111_320 * np.cos(lat)
print(f"one cell at 52.2 N: {dx:.0f} m wide x {dy:.0f} m tall = {dx * dy / 1e4:.2f} ha")
q = np.percentile(pop.compressed(), [50, 90, 99])
print(f"people per cell: median {q[0]:.1f}, 90th percentile {q[1]:.1f}, 99th {q[2]:.1f}, max {pop.max():.1f}")
print(f"densest cell as people per hectare: {pop.max() / (dx * dy / 1e4):,.0f}")
one cell at 52.2 N: 57 m wide x 93 m tall = 0.53 ha
people per cell: median 1.3, 90th percentile 15.6, 99th 38.6, max 65.5
densest cell as people per hectare: 124

A cell here is 57 m wide and 93 m tall, 0.53 ha, so cell values are not people per hectare. The median populated cell holds 1.3 people and the fullest 65.5, which works out at 124 people per hectare.

Constrained against unconstrained WorldPop

The data used on this page

WorldPop's R2025A constrained 100 m population for the Netherlands for 2020 (DOI 10.5258/SOTON/WP00839), the older 2020 unconstrained 100 m raster from the 2000–2020 series, and the population on 1 January 2020 from Statistics Netherlands' StatLine table 03759ned.

import json
from pathlib import Path

import numpy as np
import rasterio
import requests

Path("data").mkdir(exist_ok=True)
SRC = {"nld_pop_2020_CN_100m_R2025A_v1.tif": "https://data.worldpop.org/GIS/Population/Global_2015_2030/R2025A/2020/NLD/v1/100m/constrained/nld_pop_2020_CN_100m_R2025A_v1.tif",
       "nld_ppp_2020.tif": "https://data.worldpop.org/GIS/Population/Global_2000_2020/2020/NLD/nld_ppp_2020.tif"}
for name, url in SRC.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=600).content)
WP, OLD = Path("data/nld_pop_2020_CN_100m_R2025A_v1.tif"), Path("data/nld_ppp_2020.tif")
C = Path("data/cbs_pop_2020.json")
if not C.exists():
    C.write_bytes(requests.get("https://opendata.cbs.nl/ODataApi/odata/03759ned/TypedDataSet", timeout=300, params={
        "$filter": "Geslacht eq 'T001038' and Leeftijd eq '10000' and BurgerlijkeStaat eq 'T001019' and Perioden eq '2020JJ00' "
                   "and (substring(RegioS,0,2) eq 'GM' or substring(RegioS,0,2) eq 'NL')",
        "$select": "RegioS,BevolkingOp1Januari_1"}).content)
OFFICIAL = next(r["BevolkingOp1Januari_1"] for r in json.loads(C.read_text())["value"] if r["RegioS"].startswith("NL"))
print(f"R2025A file {WP.stat().st_size / 1e6:.1f} MB; 2000-2020 series file {OLD.stat().st_size / 1e6:.1f} MB")
R2025A file 18.5 MB; 2000-2020 series file 32.2 MB

The R2025A file is 18.5 MB and the older series' file 32.2 MB.

Example 1: constrained against unconstrained

WorldPop's older 2000–2020 series is unconstrained: it gives every land cell some people. The constrained product only fills cells where buildings were mapped. Compare the two for 2020:

with rasterio.open(OLD) as src:
    old = src.read(1, masked=True)
for label, a in (("unconstrained, 2000-2020 series", old), ("constrained, R2025A", pop)):
    v = a.compressed()
    print(f"{label:>32}: {v.sum():>12,.0f} people, {v.size:>9,} cells with a value, {(v >= 1).sum():>9,} with at least one person")
print(f"unconstrained cells under 0.1 person: {(old.compressed() < 0.1).sum():,}")
 unconstrained, 2000-2020 series:   17,349,948 people, 6,569,268 cells with a value, 2,676,207 with at least one person
             constrained, R2025A:   17,601,682 people, 3,547,924 cells with a value, 2,057,395 with at least one person
unconstrained cells under 0.1 person: 63,499

The unconstrained raster spreads 17.35 million people over 6.57 million cells, almost twice as many as the constrained one, and 2.68 million of them get at least one person against 2.06 million. Only 63,499 cells hold less than 0.1 person: unconstrained does not mean thinly spread everywhere, but every person it places on farmland is one fewer in a town, because each unit's total is fixed.

Two maps of central Amsterdam in 2020 side by side: the unconstrained WorldPop raster spreads people smoothly over 31,391 cells, 747,275 people, including parks and the port; the constrained R2025A raster puts 798,047 people in 28,220 cells, with sharp gaps for water, parks and industrial areas and the dense ring of the old city clearly visible. Two maps of central Amsterdam in 2020 side by side: the unconstrained WorldPop raster spreads people smoothly over 31,391 cells, 747,275 people, including parks and the port; the constrained R2025A raster puts 798,047 people in 28,220 cells, with sharp gaps for water, parks and industrial areas and the dense ring of the old city clearly visible.
Constrained and unconstrained, Amsterdam.

A random forest spreads census counts

How WorldPop estimates a 100 m population raster with a random forest. how a WorldPop raster is estimatedunit countscensus or registercovariatesbuildings, lights,land coverrandom forestdensity fromcovariatesconstrainonly cells withbuildingsrescalecells add up toeach unitCountry totals match the inputs by design; the pattern inside units is the model spatialworkflow.io
How WorldPop is estimated.

WorldPop estimates population with a random forest. For each country it takes census or register counts for administrative units and a stack of covariates on the 100 m grid, such as building footprints, night-time lights, land cover, roads and slope, learns how population density relates to them at the unit level, predicts a density surface, and then rescales the prediction so that each unit's cells add up to its count. The constrained products only allow people in cells where a building dataset shows settlement; the unconstrained ones spread a little population over every land cell.

Two things follow from that. A country total is close to the input counts by design, so a WorldPop sum for a whole country says little about accuracy; the test is how it distributes people within units. And the random forest predicts smooth densities. The densest R2025A cell in the Netherlands works out at 124 people per hectare, while Statistics Netherlands' 100 m census grid has a cell with 2,180 residents. The raster places people in the right neighbourhoods more reliably than in the right blocks.

UN-adjusted versions, borders and float sums

Reading WorldPop population rasters: problems and fixes. WorldPop trapsTrapInsteadsumming the raw arraymasked=True: -99999 cells drop outa cell as one hectare57 m x 93 m = 0.53 ha at 52 Nfloat32 accumulationsum in float64constrained vs unconstrained mixedone product: 3.5 m vs 6.6 m cellsWorldPop 2020, the Netherlands spatialworkflow.io
WorldPop traps.
  • UN-adjusted versions: the older series also comes as _UNadj, rescaled to the UN's country total; check which version a file is before comparing totals.
  • Borders: each country raster is cut to that country's boundary as WorldPop drew it; an area across a border needs two files mosaicked.
  • Float32 sums: the files are 32-bit floats; summing millions of cells in float32 can lose precision, so pass dtype='float64' to the sum for large areas.
  • Years are model years: R2025A years after the last census are projections; a 2020 file is not a 2020 count.

FAQ

How do I download WorldPop data in Python?

Query https://hub.worldpop.org/rest/data/pop/<alias>?iso3=<ISO3> for the product, take the URL from the files field of the year you want, and download it with requests.

Why is the sum of my WorldPop raster negative?

The nodata value is -99999 and it was included in the sum. Read with masked=True, or drop cells equal to src.nodata, before any arithmetic.

What is the difference between constrained and unconstrained WorldPop?

Constrained rasters only put people in cells where buildings were detected; unconstrained ones give every land cell a share, including farmland and forest.

Is WorldPop 100 m really 100 m?

The cells are 3 arc-seconds on each side, about 93 m north to south everywhere but narrower east to west away from the equator: about 57 m in the Netherlands.