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.
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.
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.
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.
Internal links
- Air pollutants and their units explained — the three questions every value needs
- How to compute an air quality index in Python — where US tables need ppb
- Air quality indices explained — indices and their units
- How to download air quality station data in Python — reading the unit fields
- PV yield off by a factor — the same class of error in solar data
- Satellite NO2 explained — a third unit that converts to neither
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.