Detecting Bulk Deletions in an OSM Diff Stream Jump to heading
Catch the single highest-value signal in changeset analysis on its own: a changeset removing thousands of objects, detected across the many diffs it spans.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Deletion is the one OSM operation that is inherently destructive and inherently rare. Ordinary mapping adds and corrects; a changeset dominated by deletions is doing something unusual by construction, which is why it outperforms every other single signal in Changeset Analysis & Vandalism Detection.
Unusual is not the same as malicious.
Two mechanical points shape the implementation. First, a deletion in an OsmChange document is an element with visible="false" carrying only its stub — no tags, no geometry, no member list. If you want to know what disappeared, you must look it up in your own copy before applying the diff, which the semantics in Applying .osc Change Files with osmium make unavoidable.
Second, a large changeset is spread across many diffs.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""Detect bulk deletions across a sliding window of OSM diffs."""
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import osmium
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
ABSOLUTE_THRESHOLD = 1_000 # deletions in the window
SHARE_THRESHOLD = 0.40 # deletions as a fraction of the changeset's operations
MIN_OPERATIONS = 50 # below this, a share is noise
WINDOW = timedelta(hours=1) # how long a changeset stays open in the accumulator
@dataclass
class Window:
"""Per-changeset accumulator, flushed once the changeset goes quiet."""
changeset: int
user: str = ""
deleted_nodes: int = 0
deleted_ways: int = 0
deleted_relations: int = 0
other_ops: int = 0
deleted_ids: list[tuple[str, int]] = field(default_factory=list)
last_seen: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@property
def deletions(self) -> int:
return self.deleted_nodes + self.deleted_ways + self.deleted_relations
@property
def operations(self) -> int:
return self.deletions + self.other_ops
@property
def share(self) -> float:
return self.deletions / self.operations if self.operations else 0.0
class DeletionWatcher(osmium.SimpleHandler):
"""Accumulate deletions per changeset across successive diffs."""
#: keep this many ids per changeset for the report — enough to investigate,
#: bounded so a 400 000-object redaction does not become a 400 000-entry list
SAMPLE_IDS = 20
def __init__(self) -> None:
super().__init__()
self.windows: dict[int, Window] = {}
self.reported: set[int] = set()
def _record(self, obj, kind: str) -> None:
w = self.windows.get(obj.changeset)
if w is None:
w = self.windows[obj.changeset] = Window(changeset=obj.changeset)
w.user = obj.user or w.user
w.last_seen = datetime.now(timezone.utc)
if obj.visible:
w.other_ops += 1
return
setattr(w, f"deleted_{kind}", getattr(w, f"deleted_{kind}") + 1)
if len(w.deleted_ids) < self.SAMPLE_IDS:
w.deleted_ids.append((kind[0], obj.id))
def node(self, n) -> None:
self._record(n, "nodes")
def way(self, w) -> None:
self._record(w, "ways")
def relation(self, r) -> None:
self._record(r, "relations")
def detections(self) -> list[Window]:
"""Changesets that have crossed the threshold and not yet been reported."""
out: list[Window] = []
for cs_id, w in self.windows.items():
if cs_id in self.reported:
continue
big = w.deletions >= ABSOLUTE_THRESHOLD
skewed = w.operations >= MIN_OPERATIONS and w.share >= SHARE_THRESHOLD
if big and skewed:
out.append(w)
self.reported.add(cs_id)
return out
def evict(self, now: datetime | None = None) -> int:
"""Drop changesets that have gone quiet, so the accumulator stays bounded."""
now = now or datetime.now(timezone.utc)
stale = [cid for cid, w in self.windows.items() if now - w.last_seen > WINDOW]
for cid in stale:
del self.windows[cid]
self.reported -= set(stale)
return len(stale)
def scan(diff_paths: list[str]) -> list[Window]:
watcher = DeletionWatcher()
found: list[Window] = []
for path in diff_paths:
watcher.apply_file(path)
for w in watcher.detections():
logger.warning(
"bulk deletion: changeset %d by %s — %d deletions (%.0f%% of %d ops); sample %s",
w.changeset, w.user, w.deletions, w.share * 100, w.operations,
w.deleted_ids[:5],
)
found.append(w)
evicted = watcher.evict()
if evicted:
logger.debug("evicted %d quiet changeset(s)", evicted)
return found
Step-by-step walkthrough Jump to heading
_record keys everything on obj.changeset rather than on the file being read. That is what makes the window work: a changeset appearing in forty consecutive diffs accumulates into one Window, and the threshold is evaluated against the total.
Deletion is detected with obj.visible rather than by looking at which OsmChange block the object came from. pyosmium presents a deleted object as invisible, and the block structure is not exposed — which is fortunate, because it is also the semantically correct test.
deleted_ids is capped at twenty. A reviewer needs a handful of identifiers to look at, and a redaction touching four hundred thousand objects should not turn into a four-hundred-thousand-entry list held in memory and written to a queue.
detections requires both conditions. An absolute count alone flags every large import; a share alone flags a five-object changeset that deleted three. Requiring both is what produces the twenty-odd items a month the distribution predicts.
evict is what keeps this a streaming job rather than a slowly growing memory leak. Changesets stay open in OSM for up to a day, but one that has not been seen for an hour is almost certainly finished; the trade is that a very slow changeset gets reported twice, which the reported set makes harmless within a window.
Verification Jump to heading
Build a fixture from a real diff rather than a synthetic one, because the interesting property is how deletions distribute across files:
# Grab an hour of minutely diffs and count deletions per changeset by hand.
for seq in $(seq 6123400 6123459); do
p=$(printf "%09d" $seq | sed 's|\(...\)\(...\)\(...\)|\1/\2/\3|')
curl -sO "https://planet.osm.org/replication/minute/${p}.osc.gz"
done
zcat *.osc.gz | grep -oP '(?<=<delete>)' | wc -l
Then assert the two threshold conditions independently:
def test_large_import_is_not_flagged():
w = Window(changeset=1, deleted_nodes=1_200, other_ops=95_000)
assert w.deletions >= ABSOLUTE_THRESHOLD # absolute passes
assert w.share < SHARE_THRESHOLD # share does not
# therefore not a detection
def test_small_deletion_burst_is_not_flagged():
w = Window(changeset=2, deleted_ways=40, other_ops=2)
assert w.share > SHARE_THRESHOLD
assert w.deletions < ABSOLUTE_THRESHOLD
def test_mass_removal_is_flagged():
w = Window(changeset=3, deleted_ways=8_000, other_ops=120)
assert w.deletions >= ABSOLUTE_THRESHOLD and w.share >= SHARE_THRESHOLD
Run the scanner over a week of archived diffs and count detections. On a country stream, more than about ten a week means the thresholds are too low for your area.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| Nothing is ever detected | Threshold evaluated per diff | Accumulate per changeset across diffs |
| Every import is flagged | Absolute threshold only | Require the share condition too |
| Memory grows over days | Window never evicted | Evict changesets quiet for an hour |
| The same changeset reported forty times | No dedupe | Track reported changeset ids |
| Deletion count always zero | Checked the block type, not visible |
Test obj.visible |
| Report has no useful detail | Only counts kept | Keep a bounded sample of deleted ids |
Frequently Asked Questions Jump to heading
Should this alert, or feed a queue?
Alert, but to a channel rather than a pager. Bulk deletion is time-sensitive in a way most quality signals are not — if it turns out to be vandalism, the sooner someone in the local community knows, the less rebuilding there is. Twenty items a month on a country stream is a rate a chat channel absorbs comfortably.
How do I tell a redaction from vandalism?
The changeset comment, the account, and the spread. Redactions and licence removals are announced, run from recognisable accounts, and cluster by data source rather than by geography. Fetch the comment as described in Fetching OSM Changeset Metadata from the API and put it in the report — it usually answers the question outright.
Can I recover what was deleted?
Not from the diff, which carries only stubs. From your own database before the diff is applied, yes — which is the argument for running this detector ahead of the apply step rather than after it. From upstream, a full-history file holds every version including the last one before deletion, at the cost of processing a much larger file.
What about deletions spread over many small changesets?
This detector will not see them, by design — each changeset is below both thresholds. Catching that pattern means grouping by user over a longer window instead of by changeset, which is a different and noisier detector. It is worth building only if you have seen the pattern; most mass removals are one changeset because that is how editors work.
Reporting a detection usefully Jump to heading
The report a reviewer receives decides whether the detection leads anywhere. Four fields make it actionable: the changeset identifier as a link to the OSM website, the deletion count and share, the geographic extent as a bounding box or a place name, and a handful of deleted object identifiers to spot-check. That is enough for someone to open the changeset, look at what went, and decide within a minute.
What to leave out is equally worth deciding. A full list of deleted identifiers is unusable at scale and expensive to carry; the raw diff excerpt is not readable; and a computed suspicion score adds nothing when the deletion count is already the reason the item is in front of them.
Where the pipeline runs ahead of the apply step, add one more field that nothing else can supply: a short summary of what the deleted objects were, taken from your own copy before it is updated. “3 400 buildings in one district” is a different report from “3 400 nodes with no tags”, and only a detector positioned before the apply can tell the difference.
Specification reference Jump to heading
In an OsmChange document a
<delete>block contains element stubs carryingid,version,changeset,timestampandvisible="false", with no tags, geometry or members. pyosmium exposes these through the normal handler callbacks withobj.visiblefalse; the originating block is not surfaced.
Related Jump to heading
- Changeset Analysis & Vandalism Detection — the topic this detector belongs to.
- Scoring OSM Changesets for Suspicious Edits — the full scorer this signal dominates.
- Fetching OSM Changeset Metadata from the API — the comment that usually explains a detection.
- Applying .osc Change Files with osmium — why a delete block carries no payload.
- Full-History .osh.pbf Processing — recovering what a deletion removed.
Up one level: Changeset Analysis & Vandalism Detection.