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.

Three kinds of mass deletion and how each appears in the diff stream Three panels. A redaction or licence removal shows thousands of deletes from one account, often a documented campaign, geographically clustered by source, legitimate and expected. An accidental bulk delete makes a whole layer disappear in one editor session, often followed by a self-revert, within a tight time window and one area. A deliberate mass removal spreads deletes across regions over unrelated objects, frequently from a young account, with no comment or a misleading one. Three shapes of mass deletion, and how each reads in a diff Redaction / licence removal Thousands of deletes, one account Often a documented campaign Geographically clustered by source Legitimate and expected Recognise it, do not flag it Accidental bulk delete A whole layer disappears Usually one editor session Often followed by a self-revert Tight time window, one area Worth a quick word, not an alarm Deliberate mass removal Deletes spread across regions Objects unrelated to each other Frequently a young account No comment, or a misleading one Surface immediately The deletion count alone cannot separate these. Spread, account age and the comment are what turn a count into a judgement.
A deletion count is the trigger. Spread, account age and the comment are what decide which of the three you are looking at.

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.

Why bulk deletion detection needs a sliding window A four-stage chain. Each diff is scanned once to count deletions per changeset, taking microseconds. A window store holds the last sixty minutes keyed by changeset and is bounded by flushing. A threshold applies both an absolute count and a share, never one alone. Detection is emitted once per changeset rather than once per diff. A sliding window, because a mass deletion is not one diff each diff count deletes per changeset one pass, microseconds window store last 60 minutes, per changeset bounded by flush threshold absolute AND share both, never either emit once dedupe on changeset id not once per diff A changeset deleting forty thousand objects arrives spread over dozens of minutely diffs. Evaluating each diff alone sees forty innocuous ones.
The unit of a mass deletion is the changeset, and a changeset is spread across many diffs. Per-diff evaluation sees only fragments.

Runnable solution Jump to heading

python
#!/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.

Distribution of changesets by deletion count over one month A bar chart of one month of a country stream. 46 200 changesets delete between one and nine objects, ordinary corrective mapping. 3 100 delete between ten and ninety-nine, retagging and small cleanups. 214 delete between one hundred and 999, imports and area cleanups. 18 delete between one thousand and 9 999 and all are worth a look. Three delete ten thousand or more: two redactions and one accident. Where the threshold has to sit one month of a country stream: changesets by deletion count 1–9 deletes 46 200 · ordinary corrective mapping 10–99 3 100 · retagging, small cleanups 100–999 214 · imports, area cleanups 1 000–9 999 18 · all worth a look 10 000+ 3 · two redactions, one accident A threshold at a thousand deletions produces twenty-one items a month on a country stream — a queue a person can actually read.
Deletions are Zipfian like everything else in OSM. That is what makes an absolute threshold workable — the interesting band is tiny.

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:

bash
# 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:

python
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 carrying id, version, changeset, timestamp and visible="false", with no tags, geometry or members. pyosmium exposes these through the normal handler callbacks with obj.visible false; the originating block is not surfaced.

Up one level: Changeset Analysis & Vandalism Detection.