This guide has 2 routes. Pick one, or read both. Each route is complete on its own.
A satellite that looks at the Earth at night sees lights: streets, factories, houses, fields burning, ships, gas flares. VIIRS is an instrument on the Suomi NPP and NOAA satellites with a day-night band that is sensitive enough to see a village. Averaged over a year, with clouds, moonlit nights, fires and flares taken out, it gives a map of the steady lights of the places people live and work. People use it as a rough sign of settlement, of economic activity, and of how a city grows from year to year.
The original data comes from the Earth Observation Group at the Colorado School of Mines, and its downloads now need a free account. WorldPop, a research group at the University of Southampton, republishes the yearly average for each country as a GeoTIFF with no account, with flares and volcanoes filtered out. This guide uses that file, for India in 2023.
The values are radiance, in nanowatts per square centimetre per steradian, written nW/cm2/sr. It is a measure of how bright the cell is as the satellite sees it, and not of how many lamps there are. Empty land is close to 0, a village is a fraction of a unit to a few units, and the centre of a large city is tens of units. In the result below, the brightest cell of Ludhiana district reads 45.5.
What you are downloading
The file is for the whole country, and it is large: about 1.1 GB for India. The pixels are 3 arc seconds, about 100 m, in latitude and longitude (EPSG:4326). WorldPop publishes one file per year from 2015 to 2023. The address follows a pattern, with the three-letter country code twice and the year:
https://data.worldpop.org/GIS/Covariates/Global_2015_2030/IND/VIIRS/v1/fvf/ind_viirs_fvf_2023_100m_v1.tif
IND and ind are the country, 2023 is the year, and 100m is the cell size. For another country, change the code. The server does not allow reading part of the file, so you download the whole thing, and then cut it on your computer.
Route A: download with a link
- Open the address above in your browser, or open the WorldPop page in the sources, pick Nighttime Lights 2015-2023, and choose the country and the year. The browser downloads the
.tif. Allow for 1.1 GB and some time. - In QGIS, add the file with Layer > Add Layer > Add Raster Layer.
- To cut it to your district, open Raster > Extraction > Clip Raster by Mask Layer, as in How to clip a raster to a boundary. Use the boundary of the district as the mask.
- Style the result with Layer Properties > Symbology. Night lights have a few very bright cells and many dark ones, so the default stretch shows almost all of the map as black. Set the Min to 0 and the Max to 20 or less, or use a logarithmic or a pseudocolour ramp.
Route B: Python
You need Python with requests, numpy, rasterio, geopandas and pandas.
from pathlib import Path
import requests
# Step 1: the file address, and a name to save it under
URL = "https://data.worldpop.org/GIS/Covariates/Global_2015_2030/IND/VIIRS/v1/fvf/ind_viirs_fvf_2023_100m_v1.tif"
path = Path("ind_viirs_fvf_2023_100m_v1.tif")
# Step 2: download in pieces, and skip it if the file is already there. It is about 1.1 GB.
if not path.exists():
with requests.get(URL, stream=True, headers={"User-Agent": "timeandspatial-guide/1.0 (write your email address here)"}, timeout=120) as reply:
reply.raise_for_status()
with open(path, "wb") as out:
for chunk in reply.iter_content(chunk_size=1 << 22):
out.write(chunk)
print(path.name, "|", round(path.stat().st_size / 1e6), "MB")
ind_viirs_fvf_2023_100m_v1.tif | 1090 MB
import numpy as np
import rasterio
# Step 1: the facts of the file
with rasterio.open(path) as src:
print("size:", src.width, "x", src.height, "cells | cell size:", round(src.res[0] * 3600, 1), "arc seconds | CRS: EPSG", src.crs.to_epsg())
print("data type:", src.dtypes[0], "| NoData:", src.nodata)
print("extent:", [round(v, 1) for v in src.bounds])
size: 35040 x 34507 cells | cell size: 3.0 arc seconds | CRS: EPSG 4326
data type: float32 | NoData: -99999.0
extent: [68.2, 6.8, 97.4, 35.5]
import geopandas as gpd
from rasterio.features import geometry_mask
from rasterio.mask import mask
# Step 1: the district boundary from the shared data of this section, and the country file
district = gpd.read_file("ludhiana_workflow_data/ludhiana_district.geojson")
with rasterio.open(path) as src:
# Step 2: cut the file to the district. Cells outside the boundary get -9999.
clipped, transform = mask(src, district.geometry, crop=True, nodata=-9999)
profile = src.profile
lights = clipped[0]
inside = lights != -9999
print("clip:", lights.shape[1], "x", lights.shape[0], "cells,", int(inside.sum()), "inside the district")
# Step 3: save the cut as a small file, so the big one is not needed again
profile.update(height=lights.shape[0], width=lights.shape[1], transform=transform, nodata=-9999, compress="deflate")
with rasterio.open("ludhiana_lights_2023.tif", "w", **profile) as dst:
dst.write(clipped)
clip: 1166 x 556 cells, 502032 inside the district
# Step 1: how bright is the district? Only the cells inside the boundary count.
v = lights[inside].astype("float64")
print("lowest:", round(float(v.min()), 2), "| median:", round(float(np.median(v)), 2), "| mean:", round(float(v.mean()), 2), "| highest:", round(float(v.max()), 1))
print("share of cells with radiance above 0.5:", round(float((v > 0.5).mean()), 2))
# Step 2: where is the brightest cell?
masked = np.where(inside, lights, -np.inf)
r, c = np.unravel_index(np.argmax(masked), masked.shape)
x, y = rasterio.transform.xy(transform, r, c)
print("brightest cell at", round(x, 3), "E,", round(y, 3), "N | radiance", round(float(lights[r, c]), 1))
lowest: 0.0 | median: 1.79 | mean: 3.78 | highest: 45.5
share of cells with radiance above 0.5: 0.94
brightest cell at 75.85 E, 30.912 N | radiance 45.5
# Step 1: how much of the light of the district is in the city? Take 10 km around the centre of Ludhiana city.
centre = gpd.GeoSeries(gpd.points_from_xy([75.85], [30.90]), crs=4326).to_crs(32643).buffer(10000).to_crs(4326).iloc[0]
city_mask = geometry_mask([centre], out_shape=lights.shape, transform=transform, invert=True)
city = city_mask & inside
total = float(lights[inside].sum())
print("cells within 10 km of the city centre:", int(city.sum()), "of", int(inside.sum()), "(", round(float(city.sum() / inside.sum()) * 100), "% of the district area )")
print("share of the district's total light in that circle:", round(float(lights[city].sum()) / total * 100), "%")
print("mean radiance: city circle", round(float(lights[city].mean()), 1), "| rest of the district", round(float(lights[inside & ~city].mean()), 1))
cells within 10 km of the city centre: 42624 of 502032 ( 8 % of the district area )
share of the district's total light in that circle: 39 %
mean radiance: city circle 17.3 | rest of the district 2.5
The file is 35,040 by 34,507 cells of 3 arc seconds and declares its own NoData value, -99999. The cut is 1,166 by 556 cells, with 502,032 cells inside the district. In Ludhiana district the median radiance is 1.79, the mean 3.78, and 94 percent of the cells are above 0.5, so the countryside is not dark: Punjab has dense villages and many farm lights. The brightest cell, at 75.85 E, 30.912 N, reads 45.5, and it is in the city. Within 10 km of the city centre lies 8 percent of the district area and 39 percent of its light, with a mean radiance of 17.3 against 2.5 in the rest of the district.
The central steps are the same as in the clip guide: mask cuts the raster to the district and gives -9999 outside it. The sum of radiance over a region is a common stand-in for how much light a place gives off, and comparing the share inside a circle with the share of the area is a quick way to see how much the lights are concentrated.
What night lights can and cannot tell you
- The value is brightness seen from space, at the time of the satellite pass, averaged over a year. It does not count lamps, people or money.
- A bright cell can be a city, a factory, an airport, a highway interchange or a brick kiln. Look at the place on a map before you decide what it is.
- Many poor or off-grid places are dark, and bright places are not always rich. Night lights are a clue, and they are not a measure of income.
- Cells with no data hold -99999 in this file, a value that the file declares. Mask them before you take a mean.
- WorldPop filtered flares and volcanoes. A file straight from another source may not have this filter.
- The cell is about 100 m here. The VIIRS annual data of the Earth Observation Group is on a 15 arc second grid, about 500 m, so detail finer than that in a 100 m file is not real detail.
Common mistakes
- Downloading the country file again and again. Cut it once and keep the small file.
- Using the default stretch and concluding that the district is dark. Set a maximum of 20 or less, or use a log scale.
- Treating 0 as no light for sure. It means that the average was too low to measure. NoData is -99999 in this file, which is a different thing.
- Comparing two years from two different products. Use the same source and version for every year.
- Averaging the numbers of cells without masking the -9999 outside the boundary.

