Catching Up a Stale OSM Extract with pyosmium Jump to heading
You have an .osm.pbf that was current three weeks ago and needs to reach today, and the only trace of where it stands is a timestamp — so you must find the replication sequence that timestamp corresponds to, then fetch and apply every diff from there to the stream head, in order.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A replication stream is an append-only sequence of numbered diffs, and “catching up” is nothing more than replaying the contiguous run of diffs between where your file stands and the stream’s current head. The one non-obvious part is finding the starting point. Your extract knows a timestamp, but diffs are addressed by integer sequence, so you need a timestamp-to-sequence lookup. pyosmium’s ReplicationServer.timestamp_to_sequence does exactly that: it binary-searches the stream’s state.txt files to find the sequence whose completeness time brackets your timestamp. Once you hold that starting sequence, apply_diffs streams every subsequent change through libosmium’s version-aware merge — the same create/modify/delete semantics detailed in the parent guide, Applying .osc Change Files with osmium — and reports the sequence it reached.
The critical choice is cadence for the catch-up itself. A three-week gap on the minutely stream is roughly 30,000 tiny files; on the hourly stream it is a few hundred; on the daily stream a few dozen. Use the coarsest stream that still lands you close enough to live, catch up on that, then switch to your steady-state stream for ongoing tracking. This is why the diagram below frames catch-up as a bounded loop over a sequence gap, not an open-ended poll.
Runnable solution Jump to heading
The script below finds the starting sequence from the extract’s timestamp, then drives apply_diffs to write a current file. apply_diffs handles the fetch-apply loop internally and returns the sequence it stopped at, which you persist for steady-state tracking.
from __future__ import annotations
import datetime as dt
import logging
import osmium
from osmium.replication.server import ReplicationServer
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.catchup")
# Coarse stream for a long gap; switch to minute/ for steady-state after.
REPL_URL = "https://planet.openstreetmap.org/replication/hour/"
def find_start_sequence(server: ReplicationServer, when: dt.datetime) -> int:
"""Resolve the replication sequence current as of a timestamp.
timestamp_to_sequence binary-searches the stream's state files. Subtract one
so the first applied diff re-covers the boundary interval rather than skipping
edits that landed within the extract's last partial window.
"""
seq = server.timestamp_to_sequence(when)
if seq is None:
raise RuntimeError(f"no replication sequence found for {when.isoformat()}")
start = max(seq - 1, 0)
logger.info("timestamp %s maps to start sequence %d", when.isoformat(), start)
return start
def catch_up(base: str, out: str, since: dt.datetime, max_diffs: int = 1000) -> int:
"""Bring a stale extract current and return the sequence reached."""
server = ReplicationServer(REPL_URL)
try:
start = find_start_sequence(server, since)
writer = osmium.SimpleWriter(out)
try:
# apply_diffs fetches and merges every diff from start+1 forward,
# up to max_diffs, streaming create/modify/delete into the writer.
reached = server.apply_diffs(
writer, start, max_size=max_diffs, idx="flex_mem", simplify=True
)
finally:
writer.close()
finally:
server.close()
if reached is None:
logger.warning("already current; no diffs to apply from sequence %d", start)
return start
logger.info("caught up: applied through sequence %d", reached)
return reached
if __name__ == "__main__":
# The extract was complete as of this instant (read from its PBF header).
complete_as_of = dt.datetime(2026, 6, 23, 0, 0, tzinfo=dt.timezone.utc)
final_seq = catch_up("stale.osm.pbf", "current.osm.pbf", complete_as_of)
logger.info("record sequence %d as the new steady-state anchor", final_seq)
CLI alternative with pyosmium-get-changes Jump to heading
When you would rather fetch the merged diff separately and apply it with osmium, the pyosmium-get-changes command downloads the gap into a single .osc.gz:
# Fetch every change since the extract's sequence into one merged diff,
# then apply it to the base with osmium.
pyosmium-get-changes \
--server https://planet.openstreetmap.org/replication/hour/ \
--start-date 2026-06-23T00:00:00Z \
--size 4096 \
-o catchup.osc.gz
osmium apply-changes stale.osm.pbf catchup.osc.gz \
--output current.osm.pbf --overwrite
Step-by-step walkthrough Jump to heading
- Pick the catch-up stream.
REPL_URLtargets the hourly stream so a multi-week gap is hundreds of diffs, not tens of thousands; you will re-point atminute/only once the file is near-live. - Map timestamp to sequence.
find_start_sequencecallstimestamp_to_sequence, which binary-searchesstate.txtfiles, then subtracts one so the boundary window is re-covered rather than skipped — replaying a diff is a safe no-op under version semantics. - Drive apply_diffs.
server.apply_diffs(writer, start, ...)fetches each diff afterstartand streams its objects through libosmium’s merge into theSimpleWriter;simplify=Truecollapses multiple versions of the same object within the batch to the latest, which is what a current-state output wants. - Bound the batch.
max_sizecaps how far one call advances, so a very large gap is processed in bounded chunks instead of one unbounded download; callcatch_upin a loop untilreachedstops advancing to finish a huge gap. - Close in order. The
SimpleWriteris closed before the server so the output file is flushed completely, and the server connection is released in the outerfinally. - Record the anchor. The returned sequence becomes your steady-state starting point — persist it exactly as Replication Sequence Numbers and State describes.
Verification Jump to heading
- Sequence advanced. The final log line reports a sequence far above the start; if
reachedequalsstart, the extract was already current or the timestamp resolved past the head. - Header is fresh. Run
osmium fileinfo -e current.osm.pbfand confirm the reportedosmosis_replication_timestampis within one diff interval of now. - Object counts moved. Compare
osmium fileinfonode/way/relation counts before and after; a three-week catch-up on an active region changes counts by a visible margin. - Re-run is a no-op. Running
catch_upagain immediately should return the same sequence and add nothing, proving idempotency.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
timestamp_to_sequence returns None |
Timestamp older than the stream’s retention | Re-anchor from a fresh base extract instead of catching up |
| Catch-up never finishes | Minutely stream over a multi-week gap | Use the hourly or daily stream for the bulk of the gap |
| Output missing recent edits | Timestamp resolved one window too late | Subtract one from the resolved start sequence |
HTTP 404 on a diff |
Wrong replication base URL for the region | Point --server / REPL_URL at the matching stream |
| Memory climbs on a large batch | In-memory location index on a big region | Keep idx="flex_mem" bounded via smaller max_size chunks |
| Duplicate versions in output | simplify=False on a current-state target |
Set simplify=True to collapse to latest per object |
Specification reference Jump to heading
The pyosmium replication client resolves timestamps to sequence numbers and applies change files through libosmium. See the official pyosmium documentation for
osmium.replication.server.ReplicationServercoveringtimestamp_to_sequenceandapply_diffs, and the OSM Wiki “Planet.osm/diffs” page for the replication directory layout andstate.txtformat the client reads.
Frequently Asked Questions Jump to heading
How do I find the sequence my stale extract corresponds to?
Read the extract’s completeness timestamp from its PBF header, then call ReplicationServer.timestamp_to_sequence with that timestamp. It binary-searches the stream’s state.txt files and returns the sequence whose completeness time brackets your timestamp. Subtract one from the result so the boundary window is re-applied rather than skipped.
Should I catch up over the minutely stream?
Not for a large gap. A multi-week gap on the minutely stream is tens of thousands of tiny files. Use the hourly or daily stream to cover the bulk cheaply, then switch to minutely for steady-state tracking once the file is within a day or two of live.
What if the timestamp is older than the stream retains?
timestamp_to_sequence returns None because the diffs no longer exist to fetch. You cannot catch up incrementally past the retention window; download a fresh base extract with a recent header sequence and resume steady-state tracking from there.
Is it safe to re-run the catch-up script?
Yes. Applying diffs is version-aware whole-object replacement, so replaying diffs already merged is a no-op. Re-running from the same start sequence produces identical output and simply reports the same reached sequence, which is why the boundary-window overlap is harmless.
Related Jump to heading
- Applying .osc Change Files with osmium — the create/modify/delete merge semantics this catch-up relies on.
- Replication Sequence Numbers and State — recording the anchor sequence the catch-up returns.
- Applying Minutely Diffs to a PostGIS Database — the database counterpart to this file-based catch-up.
- How to Decode OSM PBF Headers in Python — reading the extract’s completeness timestamp.
- OSM Replication & Diff Sync — the wider update-loop context.
Up one level: Applying .osc Change Files with osmium.