Automating Geofabrik Extract Downloads with Checksums Jump to heading
Download a regional .osm.pbf on a schedule so that the file your parser opens is provably complete, provably current, and never a half-finished transfer.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Three independent properties have to hold before a downloaded extract is safe to parse, and each needs its own check.
Completeness is proved by comparing a computed digest against the published one. This catches truncated transfers and corrupted mirrors, and nothing else does — a PBF file that lost its last blob still decompresses and still parses right up to the point where the data stops.
Currency is proved by reading the data’s timestamp, not the file’s. The PBF header carries an osmosis_replication_timestamp describing the state of the map the extract was cut from. Filesystem modification time changes when you copy a file and tells you nothing.
Atomicity is a property of how you write, not of what you downloaded. If the pipeline’s input path is also the download target, a process killed mid-transfer leaves a truncated file exactly where a parser expects a good one, and the next run parses it happily until it stops.
Runnable solution Jump to heading
from __future__ import annotations
import hashlib
import logging
import re
import sys
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
import osmium
import requests
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.extract.download")
UA = "osm-pipeline-example/1.0 (contact@example.org)"
CHUNK = 1 << 20
class IntegrityError(RuntimeError):
"""The downloaded bytes do not match the published digest."""
class StaleExtractError(RuntimeError):
"""The extract is intact but describes a map older than we tolerate."""
@dataclass(frozen=True)
class Source:
url: str
md5_url: str
max_age: timedelta
def published_digest(md5_url: str) -> str:
"""Providers publish '<hex> <filename>' beside each extract."""
response = requests.get(md5_url, headers={"User-Agent": UA}, timeout=60)
response.raise_for_status()
match = re.match(r"([0-9a-f]{32})\s", response.text.strip())
if not match:
raise IntegrityError(f"unparseable digest file at {md5_url}")
return match.group(1)
def download_to(tmp: Path, url: str) -> str:
"""Stream to disk, hashing as we go so the file is read exactly once."""
digest = hashlib.md5()
total = 0
with requests.get(url, headers={"User-Agent": UA},
stream=True, timeout=600) as response:
response.raise_for_status()
with tmp.open("wb") as handle:
for chunk in response.iter_content(chunk_size=CHUNK):
handle.write(chunk)
digest.update(chunk)
total += len(chunk)
logger.info("downloaded %.1f MiB", total / (1 << 20))
if total < (1 << 20):
# An error page served with HTTP 200 is small; a real extract is not.
raise IntegrityError(f"suspiciously small download: {total} bytes")
return digest.hexdigest()
def header_timestamp(path: Path) -> datetime:
"""Read the replication timestamp the provider baked into the PBF header."""
reader = osmium.io.Reader(str(path))
try:
stamp = reader.header().get("osmosis_replication_timestamp")
finally:
reader.close()
if not stamp:
raise StaleExtractError(f"{path} has no replication timestamp in its header")
return datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
def refresh(source: Source, target: Path) -> Path:
tmp = target.with_suffix(target.suffix + ".part")
expected = published_digest(source.md5_url)
actual = download_to(tmp, source.url)
if actual != expected:
tmp.unlink(missing_ok=True)
raise IntegrityError(f"digest mismatch: got {actual}, expected {expected}")
logger.info("digest verified: %s", actual)
age = datetime.now(timezone.utc) - header_timestamp(tmp)
if age > source.max_age:
tmp.unlink(missing_ok=True)
raise StaleExtractError(
f"extract describes a map {age} old, tolerance is {source.max_age}")
logger.info("freshness ok: extract is %s old", age)
tmp.replace(target) # atomic within a filesystem
logger.info("installed %s", target)
return target
if __name__ == "__main__":
base = "https://download.geofabrik.de/europe/poland-latest.osm.pbf"
try:
refresh(Source(base, base + ".md5", timedelta(days=2)),
Path("/data/poland-latest.osm.pbf"))
except (IntegrityError, StaleExtractError) as exc:
logger.error("refresh refused: %s", exc)
sys.exit(1)
Step-by-step walkthrough Jump to heading
- Fetch the digest first. Knowing what the file should hash to before downloading it means the verification is a comparison rather than a decision about whether to bother.
- Hash while streaming. The digest is updated chunk by chunk as bytes land, so a multi-gigabyte file is read from the network once and never re-read from disk.
- Reject implausibly small downloads. A provider error page returned with HTTP 200 is a few kilobytes. A minimum-size assertion catches that class before the digest comparison even runs, with a clearer message.
- Delete on failure. Both failure paths remove the temporary file. Leaving a failed
.partbehind invites a later process to find it and guess. - Read the header, not the filesystem.
osmosis_replication_timestampis the state of the map the extract was cut from. It travels with the file through copies, mirrors and archives, which is exactly what makes it trustworthy. - Fail on staleness, do not warn. A stale extract produces perfectly valid output describing an old map, so the only effective response is to stop the pipeline.
- Swap by rename.
Path.replaceis atomic within a filesystem, so the target path holds either the previous verified file or the new verified file and never anything in between. Keep the temporary file on the same filesystem or the rename degrades into a copy.
Verification Jump to heading
- A deliberately corrupted file is rejected. Truncate a downloaded extract and re-run; the digest comparison must fail and the temporary file must be gone.
- A stale file is rejected. Set the tolerance to an hour and re-run against a daily extract; the freshness gate must fail.
- The target is never partial. Kill the process mid-download repeatedly; the target path must always hold a complete, previously verified file.
- The temporary file shares a filesystem with the target. Check that the rename is instant rather than taking as long as a copy.
- A rerun with an unchanged remote file is cheap. Compare the published digest against the installed file’s digest first and skip the download when they match.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Parser stops mid-file | Truncated transfer accepted | Compare the computed digest before installing |
| Cryptic parse error on a tiny file | Error page saved as the extract | Assert a plausible minimum size first |
| Output describes an old map | No freshness gate | Read the header timestamp and fail on excess age |
| Rename takes minutes | Temporary file on a different filesystem | Put the .part file beside the target |
Stale .part files accumulate |
Failure paths do not clean up | Unlink the temporary file on every failure |
| Digest file unparseable | Provider format differs from the assumption | Match a hex digest with a regular expression, not by splitting |
| Freshness gate always passes | Filesystem mtime used instead of header | Read osmosis_replication_timestamp from the PBF header |
Specification reference Jump to heading
A PBF file’s
OSMHeaderblock may carry optional metadata includingosmosis_replication_timestamp,osmosis_replication_sequence_numberandosmosis_replication_base_url, which describe the replication state the file was produced from. Extract providers populate these for their published regional files. See the PBF file format documentation for the header fields and their semantics.
Frequently Asked Questions Jump to heading
Why not just check the file size?
Because size only catches the gross failures. A transfer that dropped the last few blobs is within a fraction of a percent of the expected size and passes any tolerance loose enough not to produce false alarms as the region grows. The published digest is exact and costs one small extra request. Keep a minimum-size assertion as well, but as a fast check for the error-page case rather than as the integrity test.
Should a stale extract fail the run or just warn?
Fail it. The entire problem with a stale extract is that everything downstream succeeds: the parser is happy, the validation rules pass, the output looks normal. A warning on a successful run is not read by anybody. Failing is also easy to override deliberately when you genuinely want to reprocess an archived file, which is the case a warning is usually trying to accommodate.
What tolerance should I set?
A little over the provider’s publication cadence, tight enough that a mirror which stopped updating is caught within a day or two. For a daily extract, two days is a sensible default: it survives a single missed publication without alarming, and it catches a mirror that has genuinely stalled. Declare it next to the job’s schedule so an inconsistency between the two is visible when somebody reads the configuration.
Can I skip the download when nothing changed?
Yes, and you should. Fetch the published digest first and compare it against the digest of the file you already have; if they match, the remote file is byte-identical and there is nothing to do. That turns a daily job into one small request on days when the provider has not published, which matters when many jobs share a volunteer-funded mirror.
Related Jump to heading
- OSM Extract Providers & Automated Downloads — the parent topic and the provider differences behind this client.
- Mirroring OSM Downloads Behind a Local Cache — running this once for a whole fleet.
- Extracting Metadata from OSM Planet Files — the header fields the freshness gate reads.
- Replication Sequence Numbers & State — the sequence number that accompanies the header timestamp.
- Catching Up a Stale OSM Extract with pyosmium — the alternative to re-downloading when the gate fails.
Up one level: OSM Extract Providers & Automated Downloads.