Estimating the Cost of an Overpass Query Jump to heading
Find out how big a query’s answer is before you ask for it, using one request that costs almost nothing — and refuse to send anything the pipeline cannot handle.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Overpass offers a counting output mode. Replacing the final output statement with out count; returns a single element whose tags carry the number of nodes, ways, relations and the total — and it does so without serialising any of them. The query still has to be evaluated, so a badly shaped query can still time out while counting, but the response is a few bytes and the server does no serialisation work.
That count is the input to a simple size model. Each element costs a roughly predictable number of bytes in the response, and the multiplier depends almost entirely on the output mode:
out idsis a handful of bytes per element.out tagsadds the tag payload, which for typical OSM features averages a couple of hundred bytes.out centeradds one coordinate pair per way and relation.out geominlines every coordinate of every way, which for a road network is the dominant term by a wide margin — a way with forty nodes carries forty coordinate pairs, and a shared junction node appears once per way that uses it.
The model does not need to be accurate. It needs to distinguish “a few megabytes” from “several gigabytes”, and a rough per-element figure does that reliably.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
import requests
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.overpass.estimate")
ENDPOINT = "https://overpass-api.de/api/interpreter"
HEADERS = {"User-Agent": "osm-pipeline-example/1.0 (contact@example.org)"}
# Rough bytes per element, by output mode. Deliberately generous.
PER_ELEMENT = {
"ids": 30,
"tags": 260,
"center": 300,
"meta": 400,
"geom": 1400, # dominated by inlined way coordinates
}
_OUT_RE = re.compile(r"^\s*\.?\w*\s*out\b[^;]*;", re.M)
class QueryTooLarge(RuntimeError):
"""The estimated response exceeds what this pipeline will accept."""
@dataclass(frozen=True)
class Estimate:
nodes: int
ways: int
relations: int
total: int
mode: str
@property
def bytes(self) -> int:
# Geometry cost falls on ways and relations; nodes are cheap everywhere.
if self.mode == "geom":
return (self.nodes * PER_ELEMENT["tags"]
+ (self.ways + self.relations) * PER_ELEMENT["geom"])
return self.total * PER_ELEMENT[self.mode]
def to_count_query(query: str) -> str:
"""Replace the final output statement with a counting one."""
if not _OUT_RE.search(query):
raise ValueError("query has no out statement to replace")
# Replace only the LAST out statement; earlier ones may be intermediate probes.
matches = list(_OUT_RE.finditer(query))
last = matches[-1]
return query[:last.start()] + "\nout count;" + query[last.end():]
def count(query: str) -> dict[str, int]:
response = requests.post(ENDPOINT, data={"data": to_count_query(query)},
headers=HEADERS, timeout=180)
response.raise_for_status()
elements = response.json().get("elements", [])
if not elements:
return {"nodes": 0, "ways": 0, "relations": 0, "total": 0}
tags = elements[0].get("tags", {})
return {k: int(tags.get(k, 0))
for k in ("nodes", "ways", "relations", "total")}
def estimate(query: str, mode: str) -> Estimate:
counts = count(query)
result = Estimate(counts["nodes"], counts["ways"], counts["relations"],
counts["total"], mode)
logger.info("%d element(s) (%dn/%dw/%dr), est. %.1f MiB in %s mode",
result.total, result.nodes, result.ways, result.relations,
result.bytes / (1 << 20), mode)
return result
def run_if_affordable(query: str, mode: str, ceiling_bytes: int) -> dict:
"""Refuse to send a query whose answer we already know we cannot handle."""
projected = estimate(query, mode)
if projected.bytes > ceiling_bytes:
raise QueryTooLarge(
f"estimated {projected.bytes / (1 << 20):.0f} MiB exceeds the "
f"{ceiling_bytes / (1 << 20):.0f} MiB ceiling — narrow the query")
response = requests.post(ENDPOINT, data={"data": query},
headers=HEADERS, timeout=600)
response.raise_for_status()
return response.json()
if __name__ == "__main__":
q = ('[out:json][timeout:120];'
'way["highway"](50.0,19.8,50.2,20.2);'
'out geom;')
try:
run_if_affordable(q, mode="geom", ceiling_bytes=64 << 20)
except QueryTooLarge as exc:
logger.error("refused: %s", exc)
Step-by-step walkthrough Jump to heading
- Rewrite the output statement, not the query.
to_count_queryreplaces only the lastout, leaving filters, set bindings and intermediate probes intact. Estimating a different query than the one you will run is worse than not estimating. - Replace the last statement, not the first. A query with intermediate
outcalls for debugging would otherwise be truncated at the wrong point. - Read the counts per type. Nodes, ways and relations are separated because geometry cost falls almost entirely on ways and relations, and a query dominated by untagged nodes has a very different profile from one dominated by roads.
- Model geometry separately. In
geommode the estimate applies the large per-element figure only to ways and relations, which is what makes the number useful rather than uniformly pessimistic. - Be generous with the constants. The figures err high deliberately. An estimate that occasionally refuses a query you could have run is a minor annoyance; one that lets through a response that exhausts memory is an incident.
- Refuse, do not warn.
run_if_affordableraises rather than logging, because the whole value of the estimate is that it prevents the request. - Name the remedy in the message. The exception says “narrow the query”, because the person reading it at three in the morning needs the next action, not just the number.
Verification Jump to heading
- The counting query returns the same filters. Diff the rewritten query against the original; only the final output statement should differ.
- The estimate brackets reality. Run a query for real and compare the actual response size against the projection; the estimate should be within a factor of two and should err high.
- Geometry mode is visibly more expensive. Estimate the same query in
centerandgeommodes; the ratio should be large on a way-heavy query and small on a node-heavy one. - The ceiling actually refuses. Set a deliberately low ceiling and confirm no request for the full query is made.
- A counting probe that times out is handled. Point the estimator at a continent-wide query; it should surface the timeout as a refusal rather than an unhandled exception.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Estimate for the wrong query | Whole query rebuilt instead of the output swapped | Replace only the final output statement |
| Counting probe also times out | Query is expensive to evaluate, not just to serialise | Narrow spatially; the probe has already answered you |
| Estimate wildly low | geom modelled with a flat per-element figure |
Apply the geometry figure to ways and relations only |
| Estimate ignored | Projection logged rather than enforced | Raise on exceeding the ceiling |
| Probe returns zero | A set was overwritten before the output statement | Bind sets explicitly and count the intended one |
| Ceiling never triggers | Ceiling set larger than available memory | Derive the ceiling from the smallest worker’s memory |
Specification reference Jump to heading
The
out count;statement returns a single result element whose tags carry the number of nodes, ways, relations and areas in the set being printed, without serialising the elements themselves. The query is still executed, so its declared timeout and memory ceiling still apply. See the Overpass QL output statement documentation for the counting mode and the other output modifiers.
Frequently Asked Questions Jump to heading
Does a counting query cost the server nothing?
It costs the evaluation but not the serialisation, which is usually the smaller half for a large result and the larger half for an expensive filter. That means a counting probe is cheap for the common case of a well-shaped query returning a lot of data, and no cheaper than the real thing for a badly shaped query that scans too much. Usefully, that second case is itself the answer: a probe that times out tells you the query needs narrowing.
How accurate do the per-element byte figures need to be?
Not very. The purpose is to distinguish a few megabytes from a few gigabytes, and any figure within a factor of two does that. Choose values that err high, so the estimator occasionally refuses something you could have run rather than occasionally admitting something that exhausts memory. Measure a handful of real responses against the projection once, adjust the constants, and leave them alone.
Why separate nodes from ways in the estimate?
Because geometry cost is not uniform across element types. In full-geometry mode a way carries one coordinate pair per node it references, so a road with forty nodes is roughly forty times the size of a tagged point. A query returning a million untagged nodes and a query returning a million ways have wildly different payloads, and a single per-element figure cannot express that.
Should the estimate refuse, or just warn?
Refuse. A warning is written to a log that nobody reads on a run that succeeded, and the failure it was warning about — a response that exhausts the worker’s memory — arrives minutes later as something much harder to diagnose. Raising an exception that names the projected size and tells the reader to narrow the query converts a mysterious out-of-memory kill into a clear, immediate message.
Related Jump to heading
- Choosing Between Overpass and a Local Extract — the parent topic; a query that keeps failing this check belongs in a file.
- Overpass API Query Language — the output modes the byte model is built on.
- Handling Overpass Timeouts and Rate Limits — the client whose size guard this estimate complements.
- Replacing an Overpass Query with an osmium Filter — where a query that fails the estimate should go.
- Sizing PBF Chunk Batches to a Memory Budget — the same budgeting discipline applied to local parsing.
Up one level: Choosing Between Overpass and a Local Extract.