Why a sensor's location is blurred to five kilometres
The number was not chosen for privacy theatre. It comes from what a hexagon of that size can and cannot tell you about someone.
2 August 2026 · 4 min read
Every reading on openaqi is published against a hexagon of about 5.2 km², and never against the coordinate it was measured at. People ask why that number, so here is the reasoning, including the parts that are uncomfortable.
The problem with publishing a point
Air quality data is unusually revealing. A sensor reports continuously, from a fixed location, in a way that correlates with occupancy — CO₂ rises when someone is home and falls when they leave. Publish the coordinate and you have published a presence sensor attached to an address.
That is not a hypothetical concern about aggregation. It is the obvious first use of the raw feed.
So the coordinate cannot be published. The question is what to publish instead.
What the alternatives cost
Round the coordinates. Rounding to three decimal places gives about 100 m, which is a house. Two places is about 1 km, which sounds better until you notice that rounding produces a grid, and a grid with one contributor in it is a rectangle with a single sensor at a known offset from its corner.
Add noise. Jittering by a random amount within a radius is defensible for a single release, but the sensor reports every minute forever. Enough samples and the noise averages out to the true position. Differential privacy handles this properly, and would mean adding noise to the readings themselves, which for a dataset whose whole value is "is the air bad right now" is a poor trade.
Aggregate into cells. Assign each sensor to a fixed area and publish the area. The position published is a property of the area, not of the sensor, so no amount of observation over time narrows it down. This is the one we chose.
Why H3, and why resolution 7
H3 is a hexagonal grid. Hexagons matter here for one practical reason: every neighbour is the same distance away. On a square grid, diagonal neighbours are 41% further than edge neighbours, so "the cell next door" means two different things depending on direction — which shows up immediately when you start answering "what is the air like near here" by widening the search.
Resolution 7 gives cells of about 5.16 km². Resolution 6 is 36 km², which is most of a small city and too coarse to show that the ring road is worse than the park. Resolution 8 is 0.74 km², around 900 m across — which in a suburb is a few streets, and with one contributor in it is close enough to an address to be uncomfortable.
Five kilometres is the point where a cell in a dense city still contains thousands of homes, and a cell in the countryside is honestly labelled as covering an area where the air genuinely does not vary much.
What we gave up
Being honest about the cost: at this resolution you cannot see a street canyon. You cannot show that one side of a main road is worse than the other, which is true and would be useful. A person standing next to a bus stop is in the same cell as a person in a park half an hour's walk away.
That is a real loss and we accepted it, because the alternative is a dataset that publishes where people live. If a contributor wants finer resolution for their own sensor, the right answer is an explicit opt-in, not a default that quietly reveals more than they expected.
Enforced by the schema, not by policy
The table that stores readings has no coordinate column:
CREATE TABLE readings (
ts DateTime64(3,'UTC'),
station_id UUID,
h3_r7 UInt64, -- the cell, and nothing finer
metric LowCardinality(String),
value Float64
) ENGINE = MergeTreeRaw coordinates live in one Postgres table, are used once to derive the cell at registration, and are read by nothing public. A full compromise of the readings database leaks location no more precisely than five kilometres, because there is nothing more precise in it.
That distinction matters. A policy is a promise about behaviour, and behaviour changes when a team changes. A missing column is a fact.
One trap worth writing down
ClickHouse's geoToH3 takes latitude first, contrary to some documentation and contrary to the longitude-first convention that GeoJSON uses. Getting it backwards does not throw. It silently relocates every sensor to a plausible-looking position in the wrong hemisphere, and produces a map that looks fine until someone notices the readings are all in the ocean.
We verify it with a fixture: a station at a known coordinate must resolve to a cell whose centre is within about 3 km. It is the only kind of test that catches an error that looks correct.
Also
The ingest path has no message queue, and does not need one
We measured before adding a broker. The numbers said the agents already are the queue — and a better one than anything we would have run in the cluster.
Units are the hard part
A number without its unit is not a measurement. Here is what that costs when thirty different devices report the same pollutant three different ways.