# Air quality in Europe, daily

*PM2.5, PM10, NO2 and ozone at Europe's monitoring stations, day by day.*

Daily mean and maximum concentrations of fine particles (PM2.5), particles (PM10), nitrogen dioxide (NO2) and ozone (O3) at every station the European countries report to the European Environment Agency, from 1 January of the current year to the latest reported day, with the number of valid hours behind each value. A second table places every sampling point: station, coordinates, altitude, municipality and whether it measures traffic, industry or background.

## At a glance

| | |
|---|---|
| Table | `pubdata.environment.air_quality_daily` |
| Category | Environment · Environment, Air quality, Europe, Health |
| Rows | 2,436,953 |
| Size | 30.8 MB in 1 data file(s) |
| Iceberg format | v2 |
| Last refreshed | 2026-10-10T10:48:41Z |
| Source version | eea e2a to 2026-10-10 |
| Refresh cadence | weekly (replace) |
| Coverage | the 39 countries reporting to the EEA (EU-27, EFTA, the UK, the Western Balkans, Andorra, Gibraltar, Kosovo); 1 January of the current year to the latest reported day (the table restarts with each new year) |

## Origin

- **Source:** [European Environment Agency, Air Quality download service (E2a, up-to-date data)](https://eeadmz1-downloads-webapp.azurewebsites.net/)
- **Licence:** EEA standard re-use policy: CC BY 4.0
- **Attribution:** © European Environment Agency (EEA), Air Quality e-Reporting (E2a, up-to-date, unverified).
- **Note:** E2a data are reported by the countries hourly and are not yet validated (the verified E1a set follows the year after). ~10,000 sampling-point files (~2.3 GB) are read each refresh and reduced to daily values (a few tens of MB).

- **Recipe:** `pubdata/recipes/air_quality.py` in the LakehouseBox repository; registry entry `pubdata/registry/air_quality.yaml`.

## Schema

| column | type | description |
|---|---|---|
| `date` | date | the day (the measurement hours' start dates) |
| `sampling_point_id` | string | the EEA sampling point, <country>/SPO-… (joins air_quality_stations) |
| `pollutant` | string | PM2.5, PM10, NO2 or O3 |
| `pollutant_code` | long | the EEA pollutant vocabulary code (6001, 5, 8, 7) |
| `mean` | double | mean of the day's valid hourly values |
| `max` | double | highest valid hourly value of the day |
| `valid_hours` | long | how many valid hourly values the day had (24 = complete) |
| `unit` | string | unit as reported, usually ug.m-3 (micrograms per cubic metre) |

**Join keys:** `sampling_point_id`.

## Companion tables

- `pubdata.environment.air_quality_stations`: one row per sampling point: station code and name, country, pollutant, coordinates, altitude, area (urban, suburban, rural) and type (traffic, industrial, background), municipality (34,184 rows)

## For agents

Values are unverified (E2a): an isolated spike can be an instrument fault the verified release later removes. Require
`valid_hours >= 18` (75 % of the day, the EU's own completeness rule) before comparing days or stations. Join
`air_quality_stations` for coordinates and for `station_type` (traffic, industrial, background) and `area`; comparing
a traffic station with a rural background one is comparing different things. Ozone peaks in summer afternoons, so its
daily mean understates it: use `max` or a running 8-hour mean on the hourly source for health thresholds.

## Example SQL (DuckDB)

```sql
-- the worst PM2.5 days this year, with the station behind them
SELECT d.date, s.station_name, s.municipality, s.country_code, round(d.mean, 1) AS pm25
FROM pubdata.environment.air_quality_daily AS d
JOIN pubdata.environment.air_quality_stations AS s USING (sampling_point_id)
WHERE d.pollutant = 'PM2.5' AND d.valid_hours >= 18
ORDER BY d.mean DESC LIMIT 20;
-- days above the WHO 2021 daily guideline for NO2 (25 µg/m³) per country, traffic stations only
SELECT s.country_code, count(*) AS station_days
FROM pubdata.environment.air_quality_daily AS d
JOIN pubdata.environment.air_quality_stations AS s USING (sampling_point_id)
WHERE d.pollutant = 'NO2' AND s.station_type = 'traffic' AND d.valid_hours >= 18 AND d.mean > 25
GROUP BY 1 ORDER BY 2 DESC;
```

## How to read it

Without an account, over HTTPS (the bucket is public):

```sql
INSTALL iceberg; LOAD iceberg; INSTALL httpfs; LOAD httpfs;
-- anonymous: the bucket is public, requests go unsigned, through the public-data cache
CREATE OR REPLACE SECRET pubdata (TYPE S3, ENDPOINT 'lakehousebox-public-data.b-cdn.net', URL_STYLE 'path', REGION 'us-east-1');
CREATE OR REPLACE VIEW air_quality_daily AS SELECT * FROM iceberg_scan('s3://commons--pubdata/environment/air_quality_daily');
SELECT * FROM air_quality_daily LIMIT 10;
```

With a LakehouseBox account:

```bash
# with a LakehouseBox account (any organisation): the public catalog by name, read-only
lhbox duckdb --catalog commons/pubdata     # opens DuckDB attached as `commons_pubdata`
SELECT * FROM commons_pubdata.environment.air_quality_daily LIMIT 10;
```

Public URL of the table's storage: `https://s3.lakehousebox.com/commons--pubdata/environment/air_quality_daily/`. This card: `https://lakehousebox.com/pubdata/cards/air_quality.md`; the page: https://lakehousebox.com/public-data/air_quality. Every dataset as a table: `commons_pubdata.meta.datasets`; as Markdown: https://lakehousebox.com/public-data.md.
