Worked example · Data Control Tower

How long access stays open after people stop using it: five years of Wikipedia's rights log

DuckDB2026-10-18Script and CSV downloadable

The short answer

To measure it, join each removal in your permission log to the person's last activity and count the days between. On English Wikipedia, administrator rights removed for inactivity came a median 365.9 days after the last edit, against a written rule of 12 months; 51.4% came more than a year after. Removals logged as a resignation or request came at a median of 0.0 days.

What you will have at the end

A Python script, rights_removal_lag.py, that reads five years of English Wikipedia’s public user-rights log, finds every removal of an elevated right, looks up each person’s last edit before it, and reports the days between the two, split by the reason written in the log. It checks its own input and writes the result as rights_removal_lag.csv. The measure is the same one you would run on your own platforms: for each access that was removed, the days from the holder’s last use to the removal. Access that is removed by the person who holds it and access that is removed only by a rule behave very differently, so the script keeps them apart.

This is a public project’s log, not a client result. Wikipedia’s permission log stands in for a company’s own. The script prints counts, shares and durations only: no user name is printed or published, and the CSV has no per-person row.

Step 1. Read the whole log for a closed period, politely

The log is the rights log, which the MediaWiki API serves through list=logevents with letype=rights, up to 500 events a request (MediaWiki: API:Logevents (read 2026-10-18)). We fix the period at 2021-01-01 to 2025-12-31, so a rerun asks for the same events. MediaWiki asks clients to send a descriptive User-Agent with contact information, to send requests in series, and to use maxlag for non-interactive jobs (MediaWiki: API:Etiquette (read 2026-10-18)). The script does all three and pauses 0.3 seconds between requests. Before you run it, change USER_AGENT to your own contact address. It does not ask for the account that made each change, only for the person it was made to, the time, the groups before and after, and the comment. Entries that Wikipedia has hidden are marked in the API (actionhidden, commenthidden), and the script keeps them out of the analysis.

def api(params):
    """One API request, politely: User-Agent, maxlag, a pause, and a wait if the server says so."""
    q = dict(params, format="json", formatversion="2", maxlag=str(MAXLAG))
    url = API + "?" + urllib.parse.urlencode(q)
    for attempt in range(6):
        time.sleep(PAUSE)
        try:
            with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": USER_AGENT}),
                                        timeout=60) as r:
                retry_after = r.headers.get("Retry-After")
                data = json.load(r)
        except urllib.error.HTTPError as e:
            if e.code in (429, 503):
                time.sleep(float(e.headers.get("Retry-After") or 5))
                continue
            raise
        if data.get("error", {}).get("code") == "maxlag":
            time.sleep(float(retry_after or 5))
            continue
        if "error" in data:
            sys.exit(f"API error: {data['error'].get('code')}")
        return data
    sys.exit("API kept asking us to wait: stopping")


def fetch_events():
    """Every rights-log event in the period, oldest first. Returns a list of tuples (no actor is fetched)."""
    events, requests, cont = [], 0, {}
    while True:
        data = api(dict(action="query", list="logevents", letype="rights", lestart=START, leend=END,
                        ledir="newer", lelimit="500", leprop="ids|title|type|timestamp|comment|details", **cont))
        requests += 1
        for e in data["query"]["logevents"]:
            p = e.get("params") or {}
            hidden = bool(e.get("actionhidden") or e.get("commenthidden") or "oldgroups" not in p)
            events.append((e["logid"], e["timestamp"].replace("T", " ").rstrip("Z"), e.get("action", ""),
                           e.get("title", ""), p.get("oldgroups", []), p.get("newgroups", []),
                           (e.get("comment") or "") if not e.get("commenthidden") else "", hidden))
        if "continue" not in data:
            return events, requests
        cont = {"lecontinue": data["continue"]["lecontinue"]}

It then prints the number of events read and a SHA-256 fingerprint of their sorted ids. If the fingerprint on your run differs from ours, the source has changed and the numbers may too.

Step 2. Say which groups are elevated, and write the reason rules down

Two judgments sit in this measurement, and both are tables in the script so you can disagree with them. The first is which groups count as elevated: administrator, plus the groups with other sensitive powers: bureaucrat, interface administrator, checkuser, oversight, suppress and steward. We classed the group that views IP addresses of temporary accounts as ordinary, which is a judgment you may reverse. Every group name that appears in the log must be in the table, or the run fails. The second is the bucket for the reason: the first rule with a keyword at the start of a word in the log comment wins (so “motion” does not match “demotion”, and “voluntar” does not match “involuntary”), and a comment with none of them is “other or no reason given”.

GROUPS = {
    "sysop": "elevated",            # administrator
    "bureaucrat": "elevated",
    "interface-admin": "elevated",
    "checkuser": "elevated",
    "oversight": "elevated",
    "suppress": "elevated",
    "steward": "elevated",
    # ...and 25 ordinary groups, each listed in the script as "ordinary"
}
REASON_RULES = [
    ("inactivity", ["inactiv"]),
    ("resignation", ["resign", "voluntar", "per request", "requested", "retire", "no longer need"]),
    ("committee or security", ["arbitration", "arbcom", "committee", "motion", "security"]),
]

Step 3. Look up each person’s last edit and measure the gap

A removal is a log event in which a group was held before and not after, made by hand on English Wikipedia (changes to accounts on other wikis, which the log also shows, are left out). For each removal of an elevated group, the script asks list=usercontribs for the one edit at or before the removal, newest first (MediaWiki: API:Usercontribs (read 2026-10-18)). The days between the two timestamps are the measure. DuckDB’s quantile_cont gives the median and quartiles (DuckDB: aggregate functions (read 2026-10-18)).

    removals = con.sql("""
        SELECT logid, ts, title, comment, list_filter(old_groups, g -> NOT list_contains(new_groups, g)) AS lost
        FROM ev
        WHERE NOT hidden AND action = 'rights' AND title NOT LIKE '%@%'
          AND len(list_filter(old_groups, g -> NOT list_contains(new_groups, g))) > 0""").fetchall()


def last_edit(title, removed_at):
    """Timestamp of the person's last edit at or before removed_at, or None if there is none."""
    data = api(dict(action="query", list="usercontribs", ucuser=title.split(":", 1)[1], ucstart=removed_at,
                    ucdir="older", uclimit="1", ucprop="timestamp"))
    rows = data["query"]["usercontribs"]
    return rows[0]["timestamp"].replace("T", " ").rstrip("Z") if rows else None


                n, with_edit, med, q1, q3, over = con.sql(f"""
                    SELECT COUNT(*), COUNT(days), quantile_cont(days, 0.5), quantile_cont(days, 0.25),
                           quantile_cont(days, 0.75), COUNT(*) FILTER (WHERE days > 365)
                    FROM rem WHERE {where}""").fetchone()
                out.append({"scope": scope, "year": year or "all", "reason": reason or "all", "removals": n,
                            "with_prior_edit": with_edit, "no_edit_found": n - with_edit,
                            "median_days": med, "q1_days": q1, "q3_days": q3,

Step 4. Run 1: the measurement

We ran the script on 2026-10-18 with DuckDB 1.5.6. It made 81 requests for the log and one for each of the 509 removals. Its checks, which run before the per-person lookups, passed. The full output of the run follows; the tables after it are built from it.

Python and SQL: run 1
python3 -I rights_removal_lag.py --out rights_removal_lag.csv
Output from our run 1
duckdb 1.5.6, python 3.14.8
source  https://en.wikipedia.org/w/api.php  list=logevents letype=rights  2021-01-01T00:00:00Z to 2025-12-31T23:59:59Z
read 40106 log events in 81 requests; sha256 of the sorted ids = 607fcc94d268c1f6b6f8f702776d73900ba9aa47d7954f63a425bec3e9dd2d2f
8 of them are hidden or carry no group details; they are left out of the analysis
3995 removals of any group by hand; 509 remove an elevated group; looking up each person's last edit (one request each)

scope                   year  reason                     removals with edit median d    q1 d    q3 d  >365d %
any elevated group      all   all                             509       506     35.6     0.1   367.5     27.1
any elevated group      all   inactivity                      273       273    347.4    73.9   382.8     49.1
any elevated group      all   resignation                      87        85      0.0     0.0     0.1      0.0
any elevated group      all   committee or security            43        42      1.5     0.1    11.4      0.0
any elevated group      all   other or no reason given        106       106      0.2     0.0     6.0      2.8
any elevated group      2021  all                              85        85    357.8     0.2   382.8     49.4
any elevated group      2021  inactivity                       47        47    381.0   371.3   389.1     89.4
any elevated group      2021  resignation                      16        16      0.0     0.0     2.2      0.0
any elevated group      2021  committee or security             4         4      4.9     0.2    14.5      0.0
any elevated group      2021  other or no reason given         18        18      0.1     0.0    13.2      0.0
any elevated group      2022  all                              91        90     16.3     0.0   376.7     41.1
any elevated group      2022  inactivity                       42        42    378.0   371.6   387.7     88.1
any elevated group      2022  resignation                      21        20      0.0     0.0     0.0      0.0
any elevated group      2022  committee or security             6         6      0.3     0.1     3.7      0.0
any elevated group      2022  other or no reason given         22        22      0.2     0.0     2.6      0.0
any elevated group      2023  all                             168       167     72.6     3.6   268.8     16.8
any elevated group      2023  inactivity                      125       125    157.5    43.8   329.1     21.6
any elevated group      2023  resignation                      14        14      0.0     0.0     0.1      0.0
any elevated group      2023  committee or security             9         8      2.8     0.4     4.4      0.0
any elevated group      2023  other or no reason given         20        20      0.3     0.0     1.2      5.0
any elevated group      2024  all                              93        92      4.9     0.0   159.1     21.7
any elevated group      2024  inactivity                       33        33    372.1    92.9   382.8     57.6
any elevated group      2024  resignation                      25        24      0.0     0.0     0.0      0.0
any elevated group      2024  committee or security            11        11      1.9     0.5    18.5      0.0
any elevated group      2024  other or no reason given         24        24      0.6     0.0     7.4      4.2
any elevated group      2025  all                              72        72     16.5     0.1   139.0     13.9
any elevated group      2025  inactivity                       26        26    175.7    63.9   374.6     34.6
any elevated group      2025  resignation                      11        11      0.1     0.0     6.2      0.0
any elevated group      2025  committee or security            13        13      1.6     0.2    26.8      0.0
any elevated group      2025  other or no reason given         22        22      0.4     0.0    15.1      4.5
administrator (sysop)   all   all                             405       403     94.4     0.2   374.2     33.5
administrator (sysop)   all   inactivity                      259       259    365.9    96.0   384.3     51.4
administrator (sysop)   all   resignation                      73        71      0.0     0.0     0.0      0.0
administrator (sysop)   all   committee or security            15        15      4.1     0.7     8.2      0.0
administrator (sysop)   all   other or no reason given         58        58      0.0     0.0    14.0      3.4
administrator (sysop)   2021  all                              67        67    372.4     0.5   387.5     62.7
administrator (sysop)   2021  inactivity                       42        42    384.5   373.7   390.0    100.0
administrator (sysop)   2021  resignation                      12        12      0.0     0.0     0.0      0.0
administrator (sysop)   2021  committee or security             3         3      9.6     4.8    19.5      0.0
administrator (sysop)   2021  other or no reason given         10        10      0.4     0.0    19.6      0.0
administrator (sysop)   2022  all                              73        72    365.8     0.0   380.3     51.4
administrator (sysop)   2022  inactivity                       37        37    380.0   374.2   388.4    100.0
administrator (sysop)   2022  resignation                      21        20      0.0     0.0     0.0      0.0
administrator (sysop)   2022  committee or security             3         3      0.4     0.2     2.6      0.0
administrator (sysop)   2022  other or no reason given         12        12      0.2     0.0    27.7      0.0
administrator (sysop)   2023  all                             152       152     90.9    11.7   304.8     17.8
administrator (sysop)   2023  inactivity                      124       124    158.0    44.5   332.6     21.8
administrator (sysop)   2023  resignation                      13        13      0.0     0.0     0.1      0.0
administrator (sysop)   2023  committee or security             4         4      2.8     1.1     4.2      0.0
administrator (sysop)   2023  other or no reason given         11        11      0.0     0.0     2.4      0.0
administrator (sysop)   2024  all                              65        64     17.2     0.0   368.6     29.7
administrator (sysop)   2024  inactivity                       31        31    372.1   104.8   386.2     58.1
administrator (sysop)   2024  resignation                      19        18      0.0     0.0     0.0      0.0
administrator (sysop)   2024  committee or security             3         3      6.7     3.9    17.7      0.0
administrator (sysop)   2024  other or no reason given         12        12      0.0     0.0     3.8      8.3
administrator (sysop)   2025  all                              48        48     27.8     0.1   209.9     20.8
administrator (sysop)   2025  inactivity                       25        25    183.2    63.8   375.3     36.0
administrator (sysop)   2025  resignation                       8         8      0.0     0.0     0.1      0.0
administrator (sysop)   2025  committee or security             2         2      6.0     4.1     7.9      0.0
administrator (sysop)   2025  other or no reason given         13        13      0.2     0.0    19.5      7.7

SELF-CHECK PASSED (made before the lookups): 40106 events read, none twice, every group mapped, every mapping key and reason keyword matched, every month present
wrote rights_removal_lag.csv

Administrators, by the reason written in the log

Administrator rights are the group sysop. Of 405 removals, 403 had an edit on record before the removal; two did not, and are counted in “Removals” but not in the other columns.

Administrator removals by the reason written in the log, 2021 to 2025
Logged reasonRemovalsWith a prior editMedian daysFirst quartileThird quartile% over 365 days
inactivity259259365.996.0384.351.4
resignation73710.00.00.00.0
committee or security15154.10.78.20.0
other or no reason given58580.00.014.03.4
All reasons40540394.40.2374.233.5

Two things stand out. Where the logged reason is a resignation or request (our keywords: resign, voluntar, per request, requested, retire, no longer need; a comment such as “requested by” someone else would land here too), the median is 0.0 days and the third quartile 0.1: in this bucket the right goes away at once. Where the log says inactivity, the median is 365.9 days, and 51.4% of those removals came more than 365 days after the last edit.

Inactivity removals by year

Administrator removals for inactivity, by year
YearRemovalsWith a prior editMedian daysFirst quartileThird quartile% over 365 days
20214242384.5373.7390.0100.0
20223737380.0374.2388.4100.0
2023124124158.044.5332.621.8
20243131372.1104.8386.258.1
20252525183.263.8375.336.0
All years259259365.996.0384.351.4

In 2021, 2022 and 2024 the median was 372 to 385 days. In 2023 it fell to 158.0 days and in 2025 to 183.2. Wikipedia’s own rule has two tests: no edits and no administrative actions for 12 months, or fewer than 100 edits over 60 months, which a March 2022 request for comment added (Wikipedia: Administrators, section on procedural removal (read 2026-10-18)). The second test can fire on people who edited recently, which is consistent with shorter gaps in 2023. We did not test that explanation, and our measure is the last edit only, not the last administrative action.

All elevated groups together

Every elevated group together, by the reason written in the log, 2021 to 2025
Logged reasonRemovalsWith a prior editMedian daysFirst quartileThird quartile% over 365 days
inactivity273273347.473.9382.849.1
resignation87850.00.00.10.0
committee or security43421.50.111.40.0
other or no reason given1061060.20.06.02.8
All reasons50950635.60.1367.527.1

The pattern holds when the other elevated groups (bureaucrat, interface administrator, checkuser, oversight, suppress, steward) are added: the 509 removals are the 405 administrator removals that remove sysop (alone or with other groups) plus 104 that remove another elevated group without sysop. A removal is a log event, not a person.

Step 5. Run 2: plant four faults and see whether the script notices

The same script, with --plant adding four faults to its own input after it has been read: the first 300 events loaded twice, one removal given a group name that is not in the mapping, a mapping key that no event uses, and every event of March 2023 left out. The self-check must stop the run, report each fault, and write no CSV. The planted group name shows in two events because the removal it was planted in is among the 300 duplicated.

Python and SQL: run 2
python3 -I rights_removal_lag.py --out planted.csv --plant duplicate-events,unmapped-group,dead-mapping-key,missing-month
Output from our run 2
duckdb 1.5.6, python 3.14.8
source  https://en.wikipedia.org/w/api.php  list=logevents letype=rights  2021-01-01T00:00:00Z to 2025-12-31T23:59:59Z
read 40106 log events in 81 requests; sha256 of the sorted ids = 607fcc94d268c1f6b6f8f702776d73900ba9aa47d7954f63a425bec3e9dd2d2f
8 of them are hidden or carry no group details; they are left out of the analysis
PLANTED: the first 300 log events loaded twice
PLANTED: one administrator removal given a group name that is not in GROUPS
PLANTED: a group name in GROUPS that no log event uses
PLANTED: the 628 events of March 2023 left out

SELF-CHECK FAILED: no lookups made, no CSV written
  - 39778 rows but 39478 distinct log ids: 300 events loaded twice
  - the API returned 40106 events but 39778 are loaded (-328)
  - groups not in GROUPS: 'new-test-group' in 2 events
  - keys of GROUPS that match no group in the log: 'ghost-group'
  - month 2023-03 has 0 events, under a quarter of the median month (646)
Which planted fault trips which check
CheckPlanted fault that trips it
Rows equal distinct log idsduplicate-events (300 events twice)
Rows loaded equal events the API returnedduplicate-events (+300) and missing-month (−628)
Every group in the log is in the mappingunmapped-group
Every mapping key matches a group in the logdead-mapping-key
No month under a quarter of the median monthmissing-month

Two checks in the script were not tripped by a planted fault: every reason keyword must match at least one removal comment, and every event must fall inside the period. They passed in run 1, and no fault in run 2 was built to fail them. The numbers after the lookups (the table above) have no check of their own: they are counts and quantiles of the stored rows.

Using the same measure on your own platforms

The shape is a join, not a Wikipedia trick. Take the platform’s log of grants and removals (who held what, from when to when) and its log of use (the last query, login or edit per person and object). For each access that was removed, subtract the last use from the removal. Split by how it ended: given up by the holder, removed by a rule, removed for a leaver. The split is where the finding is, because the median of everything together (94.4 days for administrators here) describes nobody. For a quarterly review, the table of counts and quartiles by ending is what you bring to the review; the underlying rows stay with the platform owner.

Two cautions from this run. A written inactivity rule produces gaps that cluster at the rule’s threshold, so the number to watch is how long past the threshold the removals come (here, the third quartile was 384.3 days against a 12-month rule). And a person with no recorded use is a different finding from a long gap: two of 405 administrator removals had no edit on record, and we counted them separately.

Method

  • Dataset: English Wikipedia’s user-rights log, read through https://en.wikipedia.org/w/api.php (list=logevents, letype=rights) for 2021-01-01 00:00:00 to 2025-12-31 23:59:59 UTC, 40,106 events. Wikipedia’s text is licensed CC BY-SA 4.0 (Wikipedia: Copyrights (read 2026-10-18); license text). The figures here are derived counts and durations, and the log itself is not republished.
  • Fingerprint: SHA-256 of the sorted log ids, printed in run 1. The period is closed, so a rerun should return the same events.
  • Unit: one log event that takes an elevated group away. The days are the time from that person’s last edit at or before the event to the event, in days with one decimal.
  • Quartiles: interpolated (quantile_cont). No sampling, no random seed.
  • Requests: one at a time, 0.3 seconds apart, maxlag=5, a User-Agent that names Data Meaning and a contact address.
  • Mapping and reason rules are written out in step 2. They are our judgment.
  • Self-checks: each one is listed against the fault that trips it in step 5. The CSV is written only if they pass.

Limits

  • This is not a client result. Wikipedia is a volunteer project with a written community rule for administrator inactivity. A company’s permissions, owners and rules differ.
  • The last edit is not the last use. Wikipedia’s inactivity rule counts administrative actions too, and we looked up edits only. An administrator who made only administrative actions looks longer inactive here than under the rule. Deleted edits are not in the list we read.
  • The reason is the remover’s text. We bucket the log comment by keywords. A comment that does not name its reason falls in “other or no reason given”, and a wrong or vague comment is bucketed as written.
  • Inactivity is not leaving. A person who stopped using access has not necessarily left. This measure finds access held after use stopped, not people who left a company; for that you need a leaver list from your own HR system.
  • Silent removals are invisible. A right that ended without a log entry is not in the log, so it is not in these numbers.
  • Small cells. Several year-and-reason cells hold fewer than 30 removals, so read their medians loosely.
  • Hidden entries are dropped. Log entries Wikipedia has hidden, or that carry no group details, are left out. Run 1 prints their count: 8 of the 40,106.
  • The source can change. The fingerprint covers the log ids only. A comment hidden later, or an edit deleted later, can change the numbers without changing it, and the last-edit lookups are not fingerprinted. Our numbers are for the log as it stood on 2026-10-18.

Tested on

Tested on

Platform
DuckDB 1.5.6, Python 3.14.8, run from Python with the duckdb module
Edition
Open-source DuckDB, in-memory
Date
2026-10-18
Dataset
English Wikipedia user-rights log, 2021-01-01 to 2025-12-31, 40,106 events, through the MediaWiki API (CC BY-SA 4.0; source)
Code
rights_removal_lag.py, result rights_removal_lag.csv

When this is not enough

  • Your platforms are not Wikipedia. You need your own grants, your own record of use and your own rules for what counts as use.
  • A gap is a prompt for a question. It says where access stayed open. It cannot say whether anything was done with it, or whether the person had left.
  • Leavers need an HR list. Matching people who left to access they still hold takes a leaver list from your own systems, which a platform log does not contain.

Measure the gap on your own platforms

Wikipedia stands in for your own permission log. You can ask the same question of your data platforms’ audit records: how long does access stay open after people stop using it? The gap between access people give up and access a rule removes is yours to measure.

Data Control Tower reads the access and query records Redshift, Databricks and Snowflake already keep, matches them to your HR file, read-only and inside your own Azure, and shows who left but still has access. We’ll walk you through it first on a fictional company’s data.

See Data Control Tower on sample data