---
title: "Sentinel-5P NO₂ in Python: mapping air pollution from TROPOMI over the UK and north-west Europe"
description: "Map NO2 air pollution from Sentinel-5P TROPOMI in Python: qa_value filter, gridding, seasonal composites, city rankings and the weekend drop."
url: https://www.technicalgis.com/tutorials/sentinel-5p-no2-air-pollution-python/
author: TechnicalGIS Team
publisher: TechnicalGIS (https://www.technicalgis.com/about/)
published: 2026-10-19
updated: 2026-10-19
language: en-GB
---

# Sentinel-5P NO₂ in Python: mapping air pollution from TROPOMI over the UK and north-west Europe

> Map NO2 air pollution from Sentinel-5P TROPOMI in Python: qa_value filter, gridding, seasonal composites, city rankings and the weekend drop.

- Level: Intermediate
- Tested with: Python 3.12
- Source code: [Download scripts](https://www.technicalgis.com/code/sentinel-5p-no2-air-pollution-python/)

Series · Part 1 of 2

## Mapping NO₂ air pollution from space and from the ground

1.  Sentinel-5P NO₂ in Python: mapping air pollution from TROPOMI over the UK and north-west Europe
2.  [Kriging in Python with PyKrige: IDW vs ordinary kriging of UK NO₂ monitoring data](https://www.technicalgis.com/tutorials/kriging-spatial-interpolation-python/)

## Key takeaways

1.  Sentinel-5P TROPOMI measures the tropospheric NO₂ column: the number of NO₂ molecules above each square metre, from the ground to about 10 km. It is not the concentration you breathe at street level.
2.  Keep only pixels with qa\_value > 0.75 (stored as integers above 75). On one April 2025 overpass this kept 80% of the pixels over north-west Europe and removed cloud, snow and poor retrievals.
3.  1,140 daytime orbits from 2025 gave 12.8 million good pixels and a median of 124 per 0.05° cell. Antwerp (110 µmol/m²), London (108) and Rotterdam (99) had the highest city means; the Scottish Highlands averaged 17.5.
4.  Traffic and industry show up as a weekly cycle: London's Sunday column was 22% lower than its weekday mean, and the median UK city was 15% lower.
5.  Summer columns are lower than winter and spring ones (London 75 vs 116–129 µmol/m²) because sunlight destroys NO₂ faster, not only because emissions fall.
6.  Clouds decide how much you see: on an average day only 18% (October) to 42% (April) of the map had a usable pixel, so build composites, not single-day maps.

## Before you start

-   Comfortable with NumPy and pandas; the Sentinel-2 Lake Mead tutorial introduces STAC searches
-   Python 3.10+ with pystac-client, planetary-computer, fsspec, aiohttp, h5py, numpy, pandas, geopandas, rasterio, requests and matplotlib
-   An internet connection and about 30 minutes for the first run: the script streams about 20 GB of NetCDF chunks, with no account or API key

**Short answer:** to map NO₂ air pollution from Sentinel-5P in Python, search the free **Level-2 NO₂** product (`L2__NO2___`, offline stream) with STAC on the Microsoft Planetary Computer, and open each NetCDF file with `h5py`. Read `nitrogendioxide_tropospheric_column`, `qa_value`, `latitude` and `longitude` from the `PRODUCT` group, and keep pixels with **qa\_value > 0.75**. Then bin the pixel centres into a regular latitude–longitude grid by summing values and counts, and divide the sums by the counts over a month, a season or a year. The result is a map of the **tropospheric NO₂ column** in mol/m². It shows where NO₂ is emitted and how it changes, but it is **not** the ground-level concentration that air-quality limits refer to.

Nitrogen dioxide (NO₂) comes mainly from burning fuel in road vehicles, ships, power stations and industry. It harms the lungs, and in the UK the legal annual limit at street level is 40 µg/m³. NO₂ lasts only a few hours in daylight before it reacts away, so it stays close to its sources. That makes it the best air pollutant to see from space: satellite NO₂ maps look like maps of cities, motorways, ports and power stations.

In this tutorial we use **every daytime Sentinel-5P overpass of 2025** over the British Isles and the Low Countries to build an annual map, seasonal and monthly composites, a ranking of 21 cities, and a weekday-versus-Sunday comparison.

You will learn how to:

1.  search the **Sentinel-5P Level-2 NO₂** archive with STAC, and fill gaps from the AWS Open Data copy;
2.  read only the rows you need from a remote **NetCDF4/HDF5** file with `h5py` and `fsspec`;
3.  apply the **qa\_value > 0.75** filter and see what it removes;
4.  **grid** irregular swath pixels onto a regular 0.05° raster and save it as a GeoTIFF;
5.  build **annual, seasonal and monthly composites**;
6.  rank cities and measure the **weekday versus weekend** difference;
7.  understand **units**, **clouds** and why a **tropospheric column is not a surface concentration**.

This is Part 1 of a two-part series. In [Part 2, kriging in Python](https://www.technicalgis.com/tutorials/kriging-spatial-interpolation-python/), we interpolate NO₂ measured at UK ground stations with IDW and ordinary kriging, and use the annual map from this tutorial as a covariate to show how well column and surface NO₂ agree.

All outputs below are real results from [`s5p_no2_air_pollution.py`](https://www.technicalgis.com/code/sentinel-5p-no2-air-pollution-python/), run on 19 October 2026 with Python 3.12, pystac-client 0.9.0, planetary-computer 1.0.0, h5py 3.16.0 and fsspec 2026.9.0.

## What Sentinel-5P measures, and what it does not

TROPOMI looks down at sunlight that has passed through the atmosphere, bounced off the surface or clouds, and come back up. NO₂ absorbs blue light between 405 and 465 nm with a distinctive pattern of narrow bands. The retrieval fits that pattern to work out how much NO₂ the light passed through along its slanted path (the **slant column**). It then subtracts the stratospheric part and divides by an **air mass factor** to get a vertical column for the troposphere.

So each pixel tells you **how many NO₂ molecules there are in a vertical column of air** from the ground up to the tropopause (about 10–12 km over the UK), per unit of area. It is a column _amount_, not a _concentration_:

| Quantity | Example unit | Measured by |
| --- | --- | --- |
| Tropospheric vertical column | mol/m², µmol/m², molecules/cm² | Sentinel-5P TROPOMI, OMI, GOME-2 |
| Surface concentration | µg/m³, ppb | Ground monitors such as the UK AURN, diffusion tubes |

Most NO₂ in a polluted area sits in the lowest kilometre, so the column and the surface concentration are related, but how they relate changes with the **mixing height** (how deep the air near the ground is mixed), the wind, the time of day and the season. A deep summer afternoon boundary layer spreads the same emissions through more air, which lowers the surface concentration without changing the column. Part 2 measures how close the relationship is for UK stations.

Three more facts to keep in mind:

-   **One look per day, around 13:30 local time.** Sentinel-5P is in a sun-synchronous orbit and crosses the equator at 13:30 local solar time. It never sees the morning or evening rush hour, or the night.
-   **Pixels of 5.5 × 3.5 km** at the centre of the swath (since August 2019), growing towards the swath edges. A city shows up; a single road does not.
-   **No data under cloud.** The instrument cannot see NO₂ below a thick cloud, so those pixels get a low qa\_value and must be removed.

## Step 1: search the Sentinel-5P archive with STAC

The Microsoft Planetary Computer holds the Sentinel-5P Level-2 archive as NetCDF files in the `sentinel-5p-l2-netcdf` collection. Each file is one orbit: a swath about 2,600 km wide and half the globe long. We search one month at a time and filter on two STAC properties: the product (`no2`) and the processing mode (`OFFL`, the final offline stream, rather than the near-real-time `NRTI` files that also appear in recent months).

`s5p_no2_air_pollution.py`

```python
STAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"
YEAR = 2025
W, S, E, N = -11.0, 48.0, 10.0, 60.5        # north-west Europe: Ireland to the Ruhr, Paris to Shetland
RES = 0.05                                   # output grid in degrees (about 3.3 km E-W x 5.6 km N-S here)

catalog = pystac_client.Client.open(STAC_URL, modifier=planetary_computer.sign_inplace)
items = []
for month in range(1, 13):                   # one search per month keeps each response small
    start = pd.Timestamp(YEAR, month, 1)
    end = start + pd.offsets.MonthEnd(1)
    search = catalog.search(collections=["sentinel-5p-l2-netcdf"], bbox=[W, S, E, N],
                            datetime=f"{start:%Y-%m-%d}/{end:%Y-%m-%d}",
                            query={"s5p:product_name": {"eq": "no2"},
                                   "s5p:processing_mode": {"eq": "OFFL"}})
    items += list(search.items())
```

The box also catches night-time orbits that clip the Arctic, so we keep only files whose middle time is between 09:00 and 15:00 UTC. The search revealed something you should always check for: **whole days missing** from the Planetary Computer copy, almost all of July 2025. The same offline archive is mirrored in the AWS Open Data bucket `meeo-s5p`, which needs no login, so the script lists the missing days there and adds those files:

`s5p_no2_air_pollution.py`

```python
AWS = "https://meeo-s5p.s3.amazonaws.com"
filled = []
for d in missing:
    xml = requests.get(AWS, params={"list-type": "2", "prefix": f"OFFL/L2__NO2___/{d:%Y/%m/%d}/"}, timeout=60).text
    for key in re.findall(r"<Key>([^<]+\.nc)</Key>", xml):
        row = describe(f"{AWS}/{key}", "AWS Open Data")
        if 9 <= row["time"].hour <= 15:
            filled.append(row)
```

`Output — STAC search`

```text
1242 OFFL NO2 files intersect the box in 2025; 954 are daytime orbits
days with no file on the Planetary Computer, by month: {7: 29, 8: 11, 12: 5}
added 186 daytime files for 45 missing days from the AWS mirror
1140 files on 365 days; daytime orbits per day 2 to 5
files by source: {'Planetary Computer': 954, 'AWS Open Data': 186}
processor versions: {'020800': 1015, '020901': 125}
```

Every day of 2025 now has two to five overpasses of the box. All files come from processor versions 2.8 and 2.9, which use the same NO₂ algorithm, so the year is consistent. Mixing very old (v1.x) and new data is a common mistake: early processor versions underestimated polluted columns, so long time series should use the reprocessed (RPRO) data.

## Step 2: look inside one file without downloading it

A Level-2 NO₂ file is 400–600 MB, but we need only the rows of the swath over our box. NetCDF4 files are HDF5 files underneath, and `h5py` can read them from any file-like object. `fsspec` provides one over HTTPS that fetches only the byte ranges `h5py` asks for, in 1 MB blocks:

`s5p_no2_air_pollution.py`

```python
def open_remote(href):
    """Open the NetCDF4/HDF5 file over HTTPS; h5py fetches only the chunks we read."""
    if "blob.core.windows.net" in href:
        href = planetary_computer.sign(href)          # short-lived SAS token, no account needed
    f = fsspec.filesystem("https").open(href, block_size=2**20, cache_type="blockcache")
    return h5py.File(f, "r")

with open_remote(sample["href"]) as h:
    p = h["PRODUCT"]
    for name in ["nitrogendioxide_tropospheric_column", "qa_value", "latitude"]:
        d, a = p[name], p[name].attrs
        attrs = {k: (a[k].decode() if isinstance(a[k], bytes) else float(np.ravel(a[k])[0]))
                 for k in ["units", "scale_factor", "_FillValue", "multiplication_factor_to_convert_to_molecules_percm2"]
                 if k in a}
        print(f"{name:36s} {d.dtype} shape {d.shape} chunks {d.chunks} {attrs}")
```

`Output — one file`

```text
S5P_L2_NO2____20250408T113622_20250408T131752_38790
nitrogendioxide_tropospheric_column  float32 shape (1, 4173, 450) chunks (1, 2087, 225) {'units': 'mol m-2', '_FillValue': 9.969209968386869e+36, 'multiplication_factor_to_convert_to_molecules_percm2': 6.022139974343092e+19}
qa_value                             uint8 shape (1, 4173, 450) chunks (1, 4173, 450) {'units': '1', 'scale_factor': 0.009999999776482582, '_FillValue': 255.0}
latitude                             float32 shape (1, 4173, 450) chunks (1, 2087, 225) {'units': 'degrees_north', '_FillValue': 9.969209968386869e+36}
```

The arrays have the shape (time, scanline, ground pixel): 4,173 scanlines along the orbit and 450 pixels across the swath. Note three details:

-   **Units are mol/m².** Values over Europe are around 0.00002–0.0002 mol/m², so we multiply by 10⁶ and work in **µmol/m²**. The file also gives a factor of 6.022 × 10¹⁹ to convert to molecules/cm², the unit in most papers: 1 µmol/m² = 6.022 × 10¹³ molecules/cm², so 100 µmol/m² ≈ 6 × 10¹⁵ molecules/cm².
-   **qa\_value is stored as an integer** from 0 to 100 with a scale factor of 0.01. If you read it with `h5py` the scale factor is not applied, so the filter is `qa > 75`. With `xarray`, which applies it, the filter is `qa > 0.75`.
-   **The fill value is 9.97 × 10³⁶**, which `h5py` also does not mask for you.

## Step 3: read the swath, apply the qa\_value filter and grid it

To read only the rows we need, we take the latitude down the centre of the swath, find the scanlines inside the box (plus 1° margin), and slice all variables to those rows:

`s5p_no2_air_pollution.py`

```python
QA_MIN = 75                 # qa_value is stored as 0-100 (scale factor 0.01): keep qa_value > 0.75
TO_UMOL = 1e6               # mol m-2 -> umol m-2

def read_swath(href):
    """Return lon, lat, NO2 (umol m-2) and raw qa (0-100) for the swath rows that cross the box."""
    with open_remote(href) as h:
        p = h["PRODUCT"]
        lat_mid = p["latitude"][0, :, 225]           # centre of the swath, to find the rows we need
        rows = np.where((lat_mid > S - 1) & (lat_mid < N + 1))[0]
        if rows.size == 0:
            return None
        r0, r1 = rows.min(), rows.max() + 1
        lat = p["latitude"][0, r0:r1, :]
        lon = p["longitude"][0, r0:r1, :]
        no2 = p["nitrogendioxide_tropospheric_column"][0, r0:r1, :].astype("float64")
        qa = p["qa_value"][0, r0:r1, :]
    no2[no2 > 1e30] = np.nan                          # _FillValue 9.97e36
    return lon, lat, no2 * TO_UMOL, qa
```

The Sentinel-5P product user manual recommends **qa\_value > 0.75** for the tropospheric NO₂ column. That threshold removes pixels with a cloud radiance fraction above 0.5, snow or ice cover, solar zenith angles that are too large, and retrievals that failed. A lower threshold of 0.5 keeps partly cloudy pixels and is sometimes used for the total column, but not for tropospheric NO₂ maps.

Then we **grid** the swath. TROPOMI pixels are irregular quadrilaterals that change size across the swath, so we cannot simply stack orbits. The simplest robust method is to bin each good pixel’s centre into a regular grid and keep two arrays: the **sum** of the values and the **count** of pixels per cell. Sums and counts can be added across any number of orbits and divided at the end, which gives the mean of all good pixels:

`s5p_no2_air_pollution.py`

```python
def grid_swath(lon, lat, no2, qa):
    """Bin the centres of pixels that pass the QA filter into the 0.05 degree grid: sum and count."""
    ok = (qa > QA_MIN) & np.isfinite(no2) & (lon >= W) & (lon < E) & (lat >= S) & (lat < N)
    ix = np.minimum(((lon[ok] - W) / RES).astype(int), NX - 1)   # guard against float rounding at the edges
    iy = np.minimum(((N - lat[ok]) / RES).astype(int), NY - 1)
    cell = iy * NX + ix
    total = np.bincount(cell, weights=no2[ok], minlength=NX * NY).reshape(NY, NX)
    count = np.bincount(cell, minlength=NX * NY).reshape(NY, NX)
    return total.astype("float32"), count.astype("uint16"), int(ok.sum())
```

The 0.05° grid cell (about 3.3 × 5.6 km at 54° N) is about the size of one TROPOMI pixel. A single orbit leaves many cells empty, but a year of orbits fills every cell many times over. For finer maps from fewer orbits, use **area-weighted regridding** with the pixel corners (`latitude_bounds` and `longitude_bounds` in the `GEOLOCATIONS` group), as tools such as HARP do; this oversamples each pixel into every cell it overlaps.

Here is the filter on one clear spring day:

`Output — QA filter on the sample orbit`

```text
75,551 pixels in the box; 60,352 (80%) pass qa_value > 0.75
good pixels: median 38.3, 99th percentile 191.9 umol/m2; 1.0% are negative
rejected pixels: median 16.4 umol/m2
```

![Two maps of one Sentinel-5P overpass over the British Isles and north-west Europe on 8 April 2025. Left: all retrievals, with a continuous swath and red plumes over London, the Midlands, Manchester, the Low Countries, the Ruhr and Paris. Right: only pixels with qa_value above 0.75, with white gaps over the North Sea, south-east England and the Channel where cloud removed the data.](https://www.technicalgis.com/_astro/01-single-orbit-qa-filter.DrYcJ0Wi_ZlB63e.webp)

_One overpass on 8 April 2025 at 12:27 UTC. The unfiltered swath (left) looks complete, but under cloud the retrieval has to assume an NO₂ profile it cannot see; the filtered swath (right) keeps the 80% of pixels that are reliable. A band of NO₂ stretches east from London across the southern North Sea towards the Low Countries._

About 1% of the good pixels are **negative**. That is not an error to remove: in clean air the true column is close to zero, and random retrieval noise pushes some values below it. Deleting negatives would bias every average upwards, so keep them.

## Step 4: process the whole year

The script runs the same read-and-grid function for all 1,140 orbits with eight threads, and saves each orbit’s sum and count grids as a small `.npz` file, so a rerun or a crash does not repeat the downloads. The first run took about 30 minutes on a home broadband connection.

`s5p_no2_air_pollution.py`

```python
with ThreadPoolExecutor(max_workers=8) as pool:
    futures = [pool.submit(process, row) for row in files.itertuples()]
    for i, fut in enumerate(as_completed(futures), 1):
        fut.result()

dates = pd.DatetimeIndex(sorted(files["time"].dt.normalize().unique()))
day_total = np.zeros((len(dates), NY, NX), "float32")
day_count = np.zeros((len(dates), NY, NX), "uint16")
for row in files.itertuples():
    g = np.load(GRIDS / f"{row.id}.npz")
    k = dates.get_loc(row.time.normalize())
    day_total[k] += g["total"]
    day_count[k] += g["count"]

def composite(day_mask, min_obs=5):
    """Mean of all good pixels on the selected days; NaN where there are fewer than min_obs."""
    tot, cnt = day_total[day_mask].sum(0), day_count[day_mask].sum(0).astype("float64")
    with np.errstate(invalid="ignore", divide="ignore"):
        mean = tot / cnt
    mean[cnt < min_obs] = np.nan
    return mean, cnt
```

Keeping a **daily** stack of sums and counts (365 × 250 × 420) means any composite, whether a month, a season or all Sundays, is just a boolean mask over days.

`Output — year gridded`

```text
1140 orbits on 365 days: 12,829,397 good-quality pixels on a 420 x 250 grid of 0.05 deg
```

## Step 5: the annual mean map, saved as a GeoTIFF

`s5p_no2_air_pollution.py`

```python
annual, annual_n = composite(np.ones(len(dates), bool))
with rasterio.open(OUT / f"s5p_no2_{YEAR}_mean.tif", "w", driver="GTiff", width=NX, height=NY, count=2,
                   dtype="float32", crs="EPSG:4326", transform=from_origin(W, N, RES, RES),
                   nodata=np.nan, compress="deflate") as dst:
    dst.write(annual.astype("float32"), 1)
    dst.write(annual_n.astype("float32"), 2)
```

Band 2 stores the number of pixels in each cell, so anyone using the map can see how well supported each value is. This file is what [Part 2](https://www.technicalgis.com/tutorials/kriging-spatial-interpolation-python/) reads.

`Output — annual mean`

```text
saved s5p_no2_2025_mean.tif: band 1 = mean column (umol/m2), band 2 = pixel count
good pixels per 0.05 deg cell in 2025: median 124, 5th percentile 79, minimum 22
annual mean over the box: median 23.4, max 147.9 umol/m2 (8.9 x 10^15 molecules/cm2)
highest cell: 51.275 N, 4.275 E
```

![Map of the 2025 mean Sentinel-5P tropospheric NO2 column over Ireland, Great Britain, northern France, the Low Countries and western Germany. Dark red hotspots cover London, the Netherlands and Belgium, the Ruhr and Paris; Manchester and the West Yorkshire towns form a smaller orange hotspot; Scotland, Ireland and the open sea are blue.](https://www.technicalgis.com/_astro/02-annual-mean-no2.Bkh0x7LF_IkaGG.webp)

_Mean tropospheric NO₂ column for 2025, from 12.8 million pixels with qa_value > 0.75. The highest cell is the Port of Antwerp (147.9 µmol/m²). Note the shipping lane through the English Channel and the haze of NO₂ over the southern North Sea between England and the Netherlands._

The map shows the main features of north-west European NO₂: London and its outflow towards the Thames Estuary, the Midlands and the M62 corridor from Liverpool to Leeds, Glasgow and Belfast as small hotspots, and the much larger and more continuous area of the Netherlands, Belgium and the Ruhr. Ship exhaust in the busy English Channel raises the column over the sea south of England. Over the clean Scottish Highlands, the column is about 17.5 µmol/m²: a background from long-range transport, soil emissions and NO₂ made by lightning in the upper troposphere.

## Step 6: rank cities and compare weekdays with weekends

To rank cities fairly, we average every good pixel within **10 km** of each city centre, using the same daily sums and counts. Then we repeat it for weekdays, Saturdays and Sundays:

`s5p_no2_air_pollution.py`

```python
def city_means(day_mask):
    tot, cnt = day_total[day_mask].sum(0), day_count[day_mask].sum(0)
    return {c: tot[m].sum() / cnt[m].sum() for c, m in masks.items()}

weekday = dates.dayofweek < 5
saturday = dates.dayofweek == 5
sunday = dates.dayofweek == 6
rank = pd.DataFrame({"annual": city_means(np.ones(len(dates), bool)), "weekday": city_means(weekday),
                     "saturday": city_means(saturday), "sunday": city_means(sunday)})
rank["sun_vs_wkday_%"] = 100 * (rank["sunday"] / rank["weekday"] - 1)
```

`Output — city ranking, mean column within 10 km of the centre, 2025 (µmol/m²)`

```text
              annual  weekday  saturday  sunday  sat_vs_wkday_%  sun_vs_wkday_%
Antwerp        110.3    115.4      98.7    95.0           -14.5           -17.7
London         108.1    113.5      99.2    88.9           -12.6           -21.7
Rotterdam       99.4    104.1      86.7    87.0           -16.7           -16.4
Essen (Ruhr)    96.7    103.8      79.6    76.5           -23.3           -26.3
Paris           96.4    102.3      81.7    80.1           -20.1           -21.7
Cologne         90.6     94.9      82.6    76.4           -13.0           -19.5
Amsterdam       87.2     89.6      81.6    80.4            -9.0           -10.3
Manchester      80.8     84.2      75.2    63.3           -10.7           -24.8
Brussels        79.5     83.0      70.2    70.8           -15.4           -14.7
Leeds           68.4     70.9      64.5    57.5            -9.1           -19.0
Birmingham      67.0     70.3      61.4    51.8           -12.7           -26.3
Lille           66.9     68.5      67.9    57.9            -0.8           -15.4
Sheffield       66.6     67.9      64.4    61.4            -5.1            -9.6
Liverpool       59.0     60.6      51.8    56.8           -14.4            -6.2
Bristol         49.6     51.9      44.1    42.7           -14.9           -17.7
Cardiff         44.9     46.7      41.7    38.3           -10.6           -17.9
Newcastle       43.6     43.7      48.4    38.1            10.6           -13.0
Dublin          42.4     44.9      37.4    32.0           -16.6           -28.7
Glasgow         40.0     41.0      33.3    39.8           -18.7            -2.8
Edinburgh       35.8     36.9      32.3    32.9           -12.5           -10.8
Belfast         32.6     33.5      28.5    31.0           -14.7            -7.5
UK cities (median): Saturday -13%, Sunday -15% vs weekdays
rural background (Scottish Highlands): 17.5 umol/m2
```

![Horizontal bar chart of 21 cities ranked by their 2025 mean NO2 column, with a red bar for Monday to Friday and a blue bar for Sunday. Antwerp and London are at the top with weekday means of about 115 µmol/m²; Edinburgh and Belfast are at the bottom at about 35. Labels show Sunday changes from -3% for Glasgow to -29% for Dublin.](https://www.technicalgis.com/_astro/06-city-ranking-weekday-sunday.CFd8eExL_CxGaF.webp)

_City ranking for 2025. Every city except Glasgow has a clearly lower column on Sundays. The large industrial and port areas (Antwerp, Rotterdam, the Ruhr) and the largest cities lead the ranking._

The **weekly cycle** is one of the clearest signs that satellite NO₂ tracks human activity: there is no natural process that knows what day it is. London’s Sunday column is **22%** below its weekday mean, Manchester’s 25% and Birmingham’s 26%. Saturday sits in between, as you would expect from lighter commuter and freight traffic. The map shows where the drop happens:

![Three stacked maps of south-east England, the Midlands and the Low Countries. The top map shows the Monday-to-Friday mean with strong red hotspots over London, Manchester, the Netherlands, Belgium and the Ruhr. The middle map shows the Sunday mean with the same hotspots much weaker. The bottom map shows Sunday minus weekday in blue (lower on Sunday) over the cities and in noisy pale red over the North Sea.](https://www.technicalgis.com/_astro/05-weekday-vs-sunday.CyjMjftW_eWcm1.webp)

_Weekday and Sunday means and their difference. The drop is concentrated on London, Manchester–Leeds, Birmingham, Rotterdam, Antwerp and the Ruhr. The difference map is noisy because there are only 52 Sundays, and on any single Sunday clouds remove much of the map._

Treat the small-city numbers with care. There were only 52 Sundays in 2025, and after cloud filtering a city has good pixels on far fewer of them. Glasgow’s −3% and Liverpool’s −6% are probably within the noise of which Sundays happened to be clear and windy, not evidence that Glasgow’s traffic is the same every day. Weather matters as much as emissions on any one day: a calm Sunday can easily have a higher column than a windy Monday.

## Step 7: seasonal and monthly composites

`s5p_no2_air_pollution.py`

```python
SEASONS = {"Winter (DJF)": [12, 1, 2], "Spring (MAM)": [3, 4, 5], "Summer (JJA)": [6, 7, 8],
           "Autumn (SON)": [9, 10, 11]}
seasonal = {s: composite(dates.month.isin(m)) for s, m in SEASONS.items()}

cover = (day_count > 0).mean(axis=(1, 2))          # share of the box with a good pixel each day
monthly = pd.DataFrame({"cover_%": 100 * cover}, index=dates).resample("MS").mean()
```

`Output — seasonal composites (µmol/m²)`

```text
Winter (DJF)  London  116.1 | Manchester   86.6 | box median  25.6 | cells with >= 5 good pixels 99%
Spring (MAM)  London  129.0 | Manchester   94.2 | box median  27.4 | cells with >= 5 good pixels 100%
Summer (JJA)  London   74.9 | Manchester   58.3 | box median  20.2 | cells with >= 5 good pixels 100%
Autumn (SON)  London   95.6 | Manchester   61.3 | box median  19.9 | cells with >= 5 good pixels 100%
```

![Four maps of the British Isles, one per season of 2025. Winter and spring show strong red hotspots over London and the Manchester–Leeds area and yellow haze over much of England. Summer is mostly blue and green with only a small orange spot over London. Autumn is in between.](https://www.technicalgis.com/_astro/03-seasonal-composites.C617VSEh_23F5B4.webp)

_Seasonal composites for 2025 over the British Isles. London's summer column was 42% lower than in spring: in strong sunlight NO₂ is destroyed in a few hours, so it does not build up. In winter, low sun and long nights let it last longer, and heating adds emissions._

The seasonal cycle is mostly **chemistry**, not a change in traffic. In summer, sunlight drives the reactions that turn NO₂ into nitric acid quickly (a lifetime of a few hours), so the same emissions give a smaller column. In winter the lifetime is closer to a day. Comparing a summer month with a winter month and calling the difference an “improvement” is a common error; compare the same months of different years instead.

The monthly view adds the most important practical number: how much of the map you actually get.

`Output — monthly composites (city columns in µmol/m²)`

```text
     cover_%  London  Manchester  Glasgow  London_days
Jan     23.7   122.8       105.7     32.5           12
Feb     26.3   163.4        96.4     51.5           10
Mar     35.4   138.8       107.4     55.6           21
Apr     42.4   136.3        96.7     48.7           23
May     41.4   101.1        77.5     41.0           19
Jun     31.2    70.9        54.1     25.8           20
Jul     26.5    85.1        64.8     30.6           10
Aug     31.6    72.3        55.0     32.2           18
Sep     30.4   103.4        61.5     25.1           17
Oct     18.1    98.3        63.4     28.8            7
Nov     25.4    88.4        58.1     32.6           15
Dec     20.5    87.3        61.9     44.8           15
```

![Two-panel chart for 2025. Top: bars showing the share of grid cells with at least one good pixel on an average day, from 18% in October to 42% in April. Bottom: monthly mean NO2 columns for London, Manchester and Glasgow, with London peaking at 163 µmol/m² in February and falling to about 71 in June.](https://www.technicalgis.com/_astro/04-monthly-coverage-and-cities.Cj6aVQOJ_Z2t0Opr.webp)

_Top: cloud and the quality filter leave a usable pixel in only 18–42% of the map on an average day. Bottom: monthly composites for three cities. February's high London value rests on only 10 days with data, and October's on 7._

On an average October day, **82% of the map had no usable pixel**. London had good data on only 7 days that month. A monthly composite therefore represents a handful of clear days, and clear days are often calm, high-pressure days when NO₂ builds up. This **clear-sky bias** means satellite composites can be higher than the true monthly mean. Always report the number of days or pixels behind each value, as the `London_days` column does.

## Units, and why the column is not what you breathe

The same number written four ways:

| Unit | Annual mean, Port of Antwerp cell | Typical use |
| --- | --- | --- |
| mol/m² | 0.000148 | Sentinel-5P files |
| µmol/m² | 147.9 | This tutorial |
| molecules/cm² | 8.9 × 10¹⁵ | Most research papers (multiply mol/m² by 6.022 × 10¹⁹) |
| Dobson units | 0.33 | Ozone; rarely NO₂ (1 DU = 446.2 µmol/m²) |

None of these can be converted to µg/m³ without knowing the vertical profile. As a rough illustration only: if a 100 µmol/m² column were mixed evenly through a 1 km deep layer of air, the concentration would be 100 × 10⁻⁶ mol/m² × 46 g/mol ÷ 1,000 m ≈ 4.6 µg/m³. London’s background monitors record four to five times that. Real profiles are strongly peaked in the lowest few hundred metres, especially near roads, mixing depths in the early afternoon vary from a few hundred metres in winter to well over a kilometre in summer, and the satellite columns themselves are biased low over cities (see the pitfalls below). That is why studies that estimate surface NO₂ from satellites use a chemical transport model or a statistical model trained on ground stations. [Part 2](https://www.technicalgis.com/tutorials/kriging-spatial-interpolation-python/) does the statistical version for the UK.

## Pitfalls and limitations

-   **Cloud and clear-sky bias.** Only 18–42% of the map survives on an average day. Composites over a month or less rest on a few clear days that are not a random sample of the weather.
-   **Air mass factor errors.** The conversion from slant to vertical column uses an assumed NO₂ profile from a global model at about 1° resolution, and a surface reflectance climatology. Over cities, the coarse model profile spreads NO₂ too high and too thinly. Validation against ground-based MAX-DOAS instruments found early TROPOMI columns 23–37% **lower** over polluted sites (Verhoelst et al., 2021); version 2 reduced this bias but did not remove it (van Geffen et al., 2022).
-   **Pixel-centre gridding.** Binning centres is simple and fine for a year of data, but it ignores pixel shape. Use area-weighted oversampling with the pixel corners if you need sharper maps from a few orbits.
-   **Processor versions.** Do not mix v1.x with v2.x data, or offline with near-real-time data, in a trend analysis. Use the reprocessed RPRO archive for multi-year studies.
-   **Missing days in a mirror.** The Planetary Computer copy lacked 45 days of 2025. Always check the calendar of your search result; here the AWS Open Data mirror filled every gap.
-   **Transport.** NO₂ blows downwind for tens of kilometres, especially in winter. The London plume over the North Sea, and the haze between England and the Netherlands, are not local sources.
-   **13:30 local time only.** Rush-hour peaks are invisible. Geostationary instruments, TEMPO over North America and Sentinel-4 over Europe, are designed to measure NO₂ every hour of daylight.

## Frequently asked questions

### What does Sentinel-5P measure?

Sentinel-5P carries one instrument, TROPOMI, a spectrometer that measures sunlight reflected by the Earth and its atmosphere at ultraviolet, visible, near-infrared and shortwave-infrared wavelengths. From the absorption patterns of different gases it retrieves daily global maps of nitrogen dioxide, ozone, sulphur dioxide, formaldehyde, carbon monoxide, methane and aerosols, with pixels of 5.5 × 3.5 km at the centre of the swath.

### Is TROPOMI NO₂ the same as ground-level NO₂?

No. TROPOMI measures the tropospheric column, the total number of NO₂ molecules between the ground and about 10 km per square metre, in mol/m². Ground monitors measure the concentration in the air at one spot, in µg/m³. The two are related because most NO₂ is near the ground, but the relationship depends on boundary layer depth, wind and the vertical profile. In Part 2 we find a correlation of 0.75 between the 2025 column and UK background station means, and only 0.40 at roadside stations.

### What qa\_value should I use for Sentinel-5P NO₂?

Use qa\_value > 0.75 for the tropospheric NO₂ column, as the product user manual recommends. It removes most cloudy pixels (cloud radiance fraction above 0.5), snow and ice, and failed retrievals. In the raw file qa\_value is an integer from 0 to 100, so with h5py the test is `qa > 75`; xarray and most other readers apply the 0.01 scale factor for you.

### How do I convert mol/m² to molecules/cm²?

Multiply by 6.022 × 10¹⁹, which is Avogadro’s number (6.022 × 10²³ per mole) divided by 10⁴ cm² per m². The Sentinel-5P files store this factor in the attribute `multiplication_factor_to_convert_to_molecules_percm2`. For example, 100 µmol/m² is 6.0 × 10¹⁵ molecules/cm².

### Why are some Sentinel-5P NO₂ values negative?

In clean air the true tropospheric column is close to zero, and random noise in the retrieval, and in subtracting the stratospheric column, pushes some values below zero. About 1% of good pixels over north-west Europe were negative in our sample orbit. Keep them when you average, because removing only the low noise biases the mean upwards.

### Where can I download Sentinel-5P data without an account?

The Microsoft Planetary Computer (`sentinel-5p-l2-netcdf`, with free signed URLs) and the AWS Open Data bucket `meeo-s5p` both serve the Level-2 archive without a login, and Google Earth Engine has gridded Level-3 versions if you have an Earth Engine account. The official source is the Copernicus Data Space Ecosystem, which needs a free account.

## Next steps

-   Continue with [Part 2: kriging in Python with PyKrige](https://www.technicalgis.com/tutorials/kriging-spatial-interpolation-python/), which interpolates UK ground-station NO₂ and tests this tutorial’s annual map as a covariate.
-   If you are new to STAC, start with [Measuring Lake Mead’s shrinking shoreline with Sentinel-2 and STAC](https://www.technicalgis.com/tutorials/sentinel-2-stac-lake-mead/).
-   Compare NO₂ hotspots with surface heat in [Land surface temperature from Landsat in Python](https://www.technicalgis.com/tutorials/land-surface-temperature-landsat-python/); both trace the footprint of London.
-   Put the GeoTIFF on an interactive map with [Interactive web maps with Folium](https://www.technicalgis.com/tutorials/interactive-web-maps-folium/), or sample and reproject it with the rasterio patterns in [Raster terrain analysis with rasterio](https://www.technicalgis.com/tutorials/raster-terrain-analysis-rasterio/).
-   Build a multi-year trend: run the script for 2019–2025 with the reprocessed RPRO data, and compare the same months each year to avoid the seasonal effect.

## Data and licences

-   **Sentinel-5P TROPOMI Level-2 NO₂ (OFFL, processors 2.8 and 2.9)**: contains modified Copernicus Sentinel data 2025, processed by ESA and KNMI. Accessed from the [Microsoft Planetary Computer](https://planetarycomputer.microsoft.com/dataset/sentinel-5p-l2-netcdf) and the [Sentinel-5P AWS Open Data bucket](https://registry.opendata.aws/sentinel5p/) (`meeo-s5p`) on 19 October 2026.
-   **Country outlines**: Natural Earth, public domain.
-   References: van Geffen, J. et al. (2022), “Sentinel-5P TROPOMI NO₂ retrieval: impact of version v2.2 improvements and comparisons with OMI and ground-based data”, _Atmospheric Measurement Techniques_ 15; Eskes, H. et al., _Sentinel-5 Precursor Level 2 Nitrogen Dioxide Product User Manual_ (KNMI); Verhoelst, T. et al. (2021), “Ground-based validation of the Copernicus Sentinel-5P TROPOMI NO₂ measurements”, _Atmospheric Measurement Techniques_ 14.
-   Code: [`s5p_no2_air_pollution.py`](https://www.technicalgis.com/code/sentinel-5p-no2-air-pollution-python/), tested with Python 3.12.0, pystac-client 0.9.0, planetary-computer 1.0.0, h5py 3.16.0, fsspec 2026.9.0, numpy 2.5.4 and pandas 3.0.6.

## Key terms

- **TROPOMI**: The TROPOspheric Monitoring Instrument on the European Sentinel-5 Precursor satellite, launched in 2017. It is a push-broom spectrometer that measures sunlight reflected by the Earth in the ultraviolet to shortwave infrared, once a day at about 13:30 local time.
- **Tropospheric vertical column density**: The amount of a gas in a vertical column of the lower atmosphere above one unit of surface area, in mol/m² or molecules/cm². It integrates the whole troposphere, so it is not a surface concentration.
- **qa_value**: A per-pixel quality score from 0 to 1 in Sentinel-5P Level-2 files. For tropospheric NO₂, the product user manual recommends qa\_value > 0.75, which removes cloudy scenes (cloud radiance fraction above 0.5), snow and ice, and failed retrievals.
- **Air mass factor (AMF)**: The factor that converts the slant column the instrument measures along the light path into a vertical column. It depends on clouds, surface brightness, the viewing geometry and an assumed NO₂ profile, and it is the largest source of error in the product.
- **OFFL**: The offline processing stream of Sentinel-5P, produced a few days after measurement with final inputs. Use OFFL (or reprocessed RPRO) data for analysis rather than near-real-time NRTI files.

## Data for this tutorial

-   [Microsoft Planetary Computer](https://www.technicalgis.com/data-sources/#src-microsoft-planetary-computer) · Sentinel-5P Level-2 NetCDF collection, searched with STAC
-   [Copernicus Data Space Ecosystem](https://www.technicalgis.com/data-sources/#src-copernicus-data-space-ecosystem) · The official Sentinel-5P archive (free account needed)
-   [Natural Earth](https://www.technicalgis.com/data-sources/#src-natural-earth) · Country outlines for the maps

Licences and formats for each source are listed in the [GIS data sources catalogue](https://www.technicalgis.com/data-sources/).

[Part 2 → Kriging in Python with PyKrige: IDW vs ordinary kriging of UK NO₂ monitoring data](https://www.technicalgis.com/tutorials/kriging-spatial-interpolation-python/)

---

Source: https://www.technicalgis.com/tutorials/sentinel-5p-no2-air-pollution-python/
