Replacing an Overpass Query with an osmium Filter Jump to heading
Take a query that has outgrown the shared server and turn it into a local pipeline that answers the same question in seconds — then prove the two agree before you delete the old code.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A production Overpass query is almost always four things: a spatial bound, a tag filter, sometimes a recursion, and an output mode. Each maps onto a local equivalent.
The spatial bound becomes osmium extract, either with --bbox for a rectangle or --polygon for a boundary file. The tag filter becomes osmium tags-filter, whose expression syntax covers key existence (n/amenity), exact values (w/highway=residential) and value sets (w/highway=primary,secondary), with the leading letter selecting node, way, relation or any. The recursion becomes a flag rather than an operator: osmium tags-filter keeps referenced nodes by default so filtered ways remain drawable, which is the downward recursion most queries use. The output mode becomes a choice of output format and whether your reader resolves geometry.
The one real asymmetry is ordering. Overpass evaluates a query as a whole and the server decides how to execute it. A local pipeline is a chain of passes, and you choose the order — which means you can get it badly wrong. Filtering by tag before cutting spatially means reading and rewriting the whole region for features you are about to discard; cutting spatially first shrinks the input for every later pass.
Runnable solution Jump to heading
#!/usr/bin/env bash
# Replace:
# [out:json][timeout:180];
# area["name"="Kraków"]["admin_level"="8"]->.a;
# ( node["amenity"="pharmacy"](area.a);
# way["amenity"="pharmacy"](area.a); );
# out center tags;
set -euo pipefail
EXTRACT="/data/poland-latest.osm.pbf"
POLY="/data/krakow.poly"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# 1. Spatial bound FIRST: every later pass reads a far smaller file.
osmium extract --polygon "$POLY" --strategy=complete_ways \
--output "$WORK/area.osm.pbf" "$EXTRACT"
# 2. Tag filter. Referenced nodes are kept by default, so the matched ways
# remain drawable — this is the downward recursion the query relied on.
osmium tags-filter --output "$WORK/pharmacies.osm.pbf" \
"$WORK/area.osm.pbf" n/amenity=pharmacy w/amenity=pharmacy r/amenity=pharmacy
# 3. Output. GeoJSON with centroids is the local analogue of `out center`.
osmium export --output-format=geojsonseq --add-unique-id=type_id \
--output "$WORK/pharmacies.geojsonseq" "$WORK/pharmacies.osm.pbf"
wc -l < "$WORK/pharmacies.geojsonseq"
cp "$WORK/pharmacies.geojsonseq" ./pharmacies.geojsonseq
Reconciliation is the step that makes the switch safe:
from __future__ import annotations
import json
import logging
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.migrate.reconcile")
def keys_from_overpass(path: Path) -> set[str]:
payload = json.loads(path.read_text(encoding="utf-8"))
return {f"{el['type']}/{el['id']}" for el in payload["elements"]
if el.get("tags")}
def keys_from_geojsonseq(path: Path) -> set[str]:
keys: set[str] = set()
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
feature = json.loads(line)
# --add-unique-id=type_id emits ids like "n123" / "w456".
raw = str(feature.get("id", ""))
prefix = {"n": "node", "w": "way", "r": "relation"}.get(raw[:1])
if prefix:
keys.add(f"{prefix}/{raw[1:]}")
return keys
def reconcile(overpass: Path, local: Path) -> bool:
a, b = keys_from_overpass(overpass), keys_from_geojsonseq(local)
only_remote, only_local = a - b, b - a
logger.info("overpass %d, local %d, shared %d", len(a), len(b), len(a & b))
for label, missing in (("only in Overpass", only_remote),
("only in local", only_local)):
if missing:
logger.warning("%d %s, e.g. %s", len(missing), label,
sorted(missing)[:5])
# A handful of differences is expected: the two sources are different dates.
drift = len(only_remote | only_local) / max(len(a | b), 1)
logger.info("symmetric difference %.2f%%", drift * 100)
return drift < 0.02
if __name__ == "__main__":
ok = reconcile(Path("overpass_archive.json"), Path("pharmacies.geojsonseq"))
logger.info("reconciliation %s", "PASSED" if ok else "FAILED")
Step-by-step walkthrough Jump to heading
- Cut spatially first. The extract pass runs over the country file once; every subsequent pass reads a city-sized file. Reversing this order costs minutes per run forever.
- Choose the cut strategy deliberately.
complete_wayskeeps ways that cross the boundary intact along with their outside nodes, matching what an Overpass area filter effectively gives you for drawable geometry. - Name all three element types. The original query unioned nodes and ways; the filter lists node, way and relation prefixes explicitly, which is both clearer and cheaper than a catch-all.
- Rely on reference completion, not a separate pass.
tags-filterkeeps the nodes a matched way references, so the output is drawable without a second step. That is the>recursion, done by default. - Pick an output analogous to the query’s mode.
osmium exportwith centroids corresponds toout center; exporting full geometry corresponds toout geom. Choosing the wrong one changes the file size by an order of magnitude, exactly as it does server-side. - Emit stable ids.
--add-unique-id=type_idgives each feature a type-prefixed identifier, which is what makes the reconciliation below possible and what downstream joins need anyway. - Reconcile against an archived response. The two sources are different dates, so a small symmetric difference is expected and healthy. A large one means a clause was translated wrongly, and the sample identifiers in the log point straight at which.
- Set the drift threshold explicitly. Two percent is a starting point for a daily extract against a live query; tighten it if your extract is fresh and loosen it if it is a week old.
Verification Jump to heading
- The symmetric difference is small. A few percent between a daily extract and a live query is normal; twenty percent means a clause is wrong.
- Differences are dated, not structural. Sample identifiers that appear only in the live response should be recently created objects, not a whole feature class.
- The spatial cut runs once. Time both orderings on a country extract; cutting first should be several times faster overall.
- Matched ways are drawable. Open the filtered output and confirm ways have coordinates rather than dangling references.
- Identifiers are type-prefixed. A numeric id alone collides across element types and will silently merge unrelated features in a later join.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Pipeline slower than the query | Tag filter runs before the spatial cut | Cut spatially first, always |
| Ways have no geometry | Reference completion disabled | Let tags-filter keep referenced nodes by default |
| Whole feature class missing | One element prefix omitted from the filter | List node, way and relation prefixes explicitly |
| Roads end at the boundary | Simple cut strategy used | Cut with complete_ways when geometry crosses the edge |
| Reconciliation shows huge drift | Area filter translated to a loose bounding box | Use a .poly boundary matching the original area |
| Duplicate features after a join | Numeric ids without a type prefix | Export with type-prefixed unique identifiers |
| Reconciliation never passes | Threshold set to exact equality | Expect drift; the two sources are different dates |
Specification reference Jump to heading
osmium tags-filterselects objects by expressions of the form[nwr]/key=value, where the leading characters restrict the object type and the value part may be omitted to match any value or given as a comma-separated list. By default the command also outputs the nodes referenced by matched ways and the members of matched relations, so that geometry can be reconstructed. See the osmium-tool documentation for the full expression grammar and the reference-completion options.
Frequently Asked Questions Jump to heading
Why does the order of passes matter so much?
Because each pass reads its whole input. Cutting spatially first means the expensive tag pass runs over a city-sized file; doing it the other way round means the tag pass reads and rewrites an entire country before the spatial cut discards most of it. Overpass hides this decision behind a query planner, so people migrating a query often do not realise they have inherited responsibility for it.
What replaces an upward recursion?
There is no single command for it, because finding the relations that reference a set of ways requires a pass over relations that you then intersect with your set. In practice it is a short script: read the relations, keep those with a member in your identifier set, then re-run reference completion so their members are present. It appears in a small minority of production queries, which is why the rest of the translation is as mechanical as it is.
How much difference between the two results is acceptable?
Enough to account for the age gap and no more. A daily extract compared against a live query will differ by whatever was edited in between, which for a city-sized area of interest is typically well under a couple of percent. What matters is the shape of the difference: objects that exist only in the live response should be recent creations scattered across the area, not an entire feature class, which would indicate a mistranslated clause.
Do I still need a boundary polygon, or is a bounding box enough?
If the original query used an area filter, use a polygon. A bounding box around a city includes neighbouring territory, and the surplus features it admits look exactly like a translation error when you reconcile. Extract the boundary once from the same administrative relation the query named, keep it in version control alongside the pipeline, and the two definitions stay aligned.
Related Jump to heading
- Choosing Between Overpass and a Local Extract — the parent topic and the decision this migration follows.
- Estimating the Cost of an Overpass Query — the measurement that usually triggers the move.
- Chaining osmium-tool Commands in a Shell Pipeline — composing the passes efficiently.
- Choosing Complete Ways vs Smart in osmium extract — the cut strategy this pipeline depends on.
- Clipping an OSM Extract with a .poly Boundary — producing the boundary file the area filter becomes.
Up one level: Choosing Between Overpass and a Local Extract.