ppb and µg/m³ Mixed Up

On this page

Problem statement

A London NO2 annual mean of 21.8 sits comfortably under the EU limit of 40. The same station, the same year, is 41.6 — and over it. Nothing about the air changed; one number is in parts per billion and the other in micrograms per cubic metre. Gas concentrations are published in both units, the tables they are compared against use one or the other, and the column header is often the only clue.

The error is a factor of about two for NO2 and ozone, 2.6 for SO2 and 1.15 for CO, always in a direction that makes a site look cleaner or dirtier than it is. It passes every range check because both numbers are plausible.

Quick answer

Identify the unit, then convert at the reference temperature the standard uses:

µg/m³ = ppb × M / Vm        Vm = 24.06 L/mol at 20 °C (EU), 24.45 L/mol at 25 °C (US)
import io, requests, pandas as pd

M = {"NO2": 46.01, "O3": 48.00, "SO2": 64.07, "CO": 28.01}
VM = {"EU (20 °C)": 22.414 * 293.15 / 273.15, "US (25 °C)": 22.414 * 298.15 / 273.15}

print(f"{'gas':<5}" + "".join(f"{k:>14}" for k in VM))
for gas, m in M.items():
    print(f"{gas:<5}" + "".join(f"{m / vm:>14.3f}" for vm in VM.values()) + "   µg/m³ per ppb")

text = requests.get("https://uk-air.defra.gov.uk/datastore/data_files/site_data/MY1_2023.csv",
                    timeout=120).text
d = pd.read_csv(io.StringIO(text), skiprows=4, low_memory=False)
no2 = pd.to_numeric(d["Nitrogen dioxide"], errors="coerce")
unit = d.iloc[:, d.columns.get_loc("Nitrogen dioxide") + 2].dropna().iloc[0]
mean = no2.mean()
print(f"\nMarylebone Road 2023 NO2: {mean:.1f} {unit} = {mean / (M['NO2'] / VM['EU (20 °C)']):.1f} ppb")
gas      EU (20 °C)    US (25 °C)
NO2           1.913         1.881   µg/m³ per ppb
O3            1.995         1.962   µg/m³ per ppb
SO2           2.663         2.619   µg/m³ per ppb
CO            1.164         1.145   µg/m³ per ppb

Marylebone Road 2023 NO2: 41.6 ugm-3 = 21.8 ppb

Read the unit field, convert once at load time, and keep the unit in the column name from then on.

Read the unit, convert gases only, use the right temperature

1. Read the unit from the data, not from memory

European networks publish µg/m³; US EPA data and many sensor platforms publish ppb (and ppm for ozone and CO). Model output can be either. Print the unit field of every source you load.

2. Convert gases only

Particles have no molar mass; PM2.5 and PM10 are always mass concentrations. Only gases convert.

3. Use the standard's reference temperature

EU limit values for gases are defined at 20 °C and 101.3 kPa; US conversions use 25 °C. The difference is 1.7% — small, but systematic, and enough to move values across a band edge.

4. Name columns with their unit

no2_ugm3 and no2_ppb cannot be confused in a join. no2 can.

5. Test against a known value

A converted NO2 value should be roughly half its µg/m³ counterpart. If a "ppb" series has the same mean as the µg/m³ one from a nearby station, one of them is mislabelled.

Conversion factors from ppb to µg/m³ for NO2, ozone, SO2 and CO at 20 °C and 25 °C. µg/m³ per ppbgasEU, 20 °CUS, 25 °CNO21.9131.881O31.9951.962SO22.6632.619CO1.1641.145particles have no molar mass: PM is always µg/m³µg/m³ = ppb × molar mass ÷ molar volume spatialworkflow.io
The factor depends on the gas and, slightly, on the reference temperature.

What a mix-up does to compliance and AQI

The data used on this page

Hourly measurements from the UK's Automatic Urban and Rural Network, downloaded by the page's own code from DEFRA's site-data archive: London Marylebone Road (MY1). The files are ratified data; the hour in each row is the end of the measurement hour, in GMT.

import requests

print("MY1_2023.csv:", requests.head("https://uk-air.defra.gov.uk/datastore/data_files/site_data/MY1_2023.csv", timeout=60, allow_redirects=True).status_code)
MY1_2023.csv: 200

Example 1 — what the mix-up does to a compliance verdict

ppb = no2 / (M["NO2"] / VM["EU (20 °C)"])
for label, v, lim in (("µg/m³ against the µg/m³ limit", mean, 40),
                      ("ppb against the µg/m³ limit", ppb.mean(), 40)):
    print(f"{label:<32} {v:5.1f} vs {lim} → {'EXCEEDS' if v > lim else 'complies'}")
µg/m³ against the µg/m³ limit     41.6 vs 40 → EXCEEDS
ppb against the µg/m³ limit       21.8 vs 40 → complies

The second line is the bug: a ppb value compared with a µg/m³ limit. It halves the concentration and turns an exceedance into a comfortable pass.

Example 2 — the other direction, into a US AQI

US_NO2_PPB = [(0, 53, 0, 50), (54, 100, 51, 100), (101, 360, 101, 150), (361, 649, 151, 200)]

def us_no2(c):
    c = int(c)
    for lo, hi, ilo, ihi in US_NO2_PPB:
        if lo <= c <= hi:
            return round((ihi - ilo) / (hi - lo) * (c - lo) + ilo)
    return None

daily_max = no2.groupby(d.Date).max().dropna()
right = daily_max.map(lambda c: us_no2(c / (M["NO2"] / VM["US (25 °C)"])))
wrong = daily_max.map(us_no2)                               # µg/m³ fed to a ppb table
print(f"days with NO2 sub-index > 50: correct {(right > 50).sum()}, "
      f"µg/m³ read as ppb {(wrong > 50).sum()}")
print(f"days with NO2 sub-index > 100: correct {(right > 100).sum()}, "
      f"µg/m³ read as ppb {(wrong > 100).sum()}")
days with NO2 sub-index > 50: correct 35, µg/m³ read as ppb 269
days with NO2 sub-index > 100: correct 0, µg/m³ read as ppb 35

Feeding µg/m³ into a ppb table roughly doubles every concentration: days with an NO2 sub-index above 50 go from 35 to 269, and days above 100 from none to 35. The dominant pollutant of the index changes with them.

Example 3 — a guard for loaders

EXPECTED = {"NO2": ("ugm-3", "µg/m³", "ug.m-3"), "O3": ("ugm-3", "µg/m³", "ug.m-3")}

def check_unit(pollutant, unit):
    unit = str(unit).strip()
    if unit not in EXPECTED[pollutant]:
        raise ValueError(f"{pollutant} arrived in {unit!r}; convert to µg/m³ before use")
    return unit

print(check_unit("NO2", unit))
try:
    check_unit("NO2", "ppb")
except ValueError as e:
    print("caught:", e)
ugm-3
caught: NO2 arrived in 'ppb'; convert to µg/m³ before use

A one-line assertion at load time is cheaper than finding the factor of two in a published map.

The Marylebone Road 2023 NO2 annual mean read correctly in µg/m³ and wrongly as ppb against the EU limit. the same station, the same yearµg/m³ vs 40 µg/m³✕41.6✕exceeds the EU limit✕correct comparisonppb vs 40 µg/m³✓21.8✓"complies"✓a unit errorA factor of about two, in whichever direction flatters the result spatialworkflow.io
The same station, the same year: one unit exceeds the limit, the other passes it.

Why two units exist

Why two units exist

Chemists describe gas mixtures by mixing ratio — molecules of pollutant per billion molecules of air — which does not change with temperature or pressure. Health and legal standards were written in mass per volume, because that is what an inhaled dose is. Both are legitimate; they measure different things about the same air.

Why the factor depends on temperature

A cubic metre of warm air contains fewer molecules than a cubic metre of cold air at the same pressure. The same mixing ratio therefore corresponds to a slightly smaller mass concentration at 25 °C than at 20 °C. Standards fix a reference temperature so that the conversion is a constant, not a weather variable.

Why range checks do not catch it

NO2 in a city is plausibly 20 or 40 in either unit. Unlike a factor of 1,000 or 3.6, a factor of two keeps every value inside the range any sanity check would allow. Only the unit field, or a comparison with a trusted nearby station, reveals it.

Days with an NO2 US AQI sub-index above 50 and above 100 at Marylebone Road, correctly converted and with µg/m³ read as ppb. µg/m³ fed to a ppb tablesub-index > 50, correct35 dayssub-index > 50, µg/m³ as ppb269 dayssub-index > 100, correct0 dayssub-index > 100, µg/m³ as ppb35 days0140280Every concentration roughly doubled, every category shifted spatialworkflow.io
The unit error turns 35 moderate days into 269.

Ozone in ppm, CO and NOx as NO2

  • Ozone in ppm in US tables: 0.070 ppm is 70 ppb, about 137 µg/m³ at 25 °C.
  • CO is usually mg/m³ in Europe and ppm in the US; 1 ppm ≈ 1.15 mg/m³.
  • NOx "as NO2" uses NO2's molar mass for the whole sum; convert NO separately with 30.01.
  • Satellite columns (mol/m²) are neither unit and do not convert to either without a vertical profile.
  • Sensor platforms often report ppb for gases and µg/m³ for particles in the same file.

FAQ

How do I convert NO2 from ppb to µg/m³?

Multiply by 1.913 at 20 °C (EU reference) or 1.881 at 25 °C (US reference).

How do I convert ozone from ppb to µg/m³?

Multiply by 1.995 at 20 °C or 1.962 at 25 °C.

Do PM2.5 values need converting?

No. Particle concentrations are always mass per volume.

Which unit do EU limit values use?

µg/m³ at 20 °C and 101.3 kPa for gases.

Which unit do US AQI tables use for gases?

ppb for NO2 and SO2, ppm for ozone and CO.

How can I tell which unit a dataset uses?

Read its unit field. If there is none, compare its mean with a nearby station of known unit: NO2 in ppb is about half the µg/m³ value.