How long access stays open after people stop using it: five years of Wikipedia's rights log
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 1python3 -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.
| Logged reason | Removals | With a prior edit | Median days | First quartile | Third quartile | % over 365 days |
|---|---|---|---|---|---|---|
| inactivity | 259 | 259 | 365.9 | 96.0 | 384.3 | 51.4 |
| resignation | 73 | 71 | 0.0 | 0.0 | 0.0 | 0.0 |
| committee or security | 15 | 15 | 4.1 | 0.7 | 8.2 | 0.0 |
| other or no reason given | 58 | 58 | 0.0 | 0.0 | 14.0 | 3.4 |
| All reasons | 405 | 403 | 94.4 | 0.2 | 374.2 | 33.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
| Year | Removals | With a prior edit | Median days | First quartile | Third quartile | % over 365 days |
|---|---|---|---|---|---|---|
| 2021 | 42 | 42 | 384.5 | 373.7 | 390.0 | 100.0 |
| 2022 | 37 | 37 | 380.0 | 374.2 | 388.4 | 100.0 |
| 2023 | 124 | 124 | 158.0 | 44.5 | 332.6 | 21.8 |
| 2024 | 31 | 31 | 372.1 | 104.8 | 386.2 | 58.1 |
| 2025 | 25 | 25 | 183.2 | 63.8 | 375.3 | 36.0 |
| All years | 259 | 259 | 365.9 | 96.0 | 384.3 | 51.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
| Logged reason | Removals | With a prior edit | Median days | First quartile | Third quartile | % over 365 days |
|---|---|---|---|---|---|---|
| inactivity | 273 | 273 | 347.4 | 73.9 | 382.8 | 49.1 |
| resignation | 87 | 85 | 0.0 | 0.0 | 0.1 | 0.0 |
| committee or security | 43 | 42 | 1.5 | 0.1 | 11.4 | 0.0 |
| other or no reason given | 106 | 106 | 0.2 | 0.0 | 6.0 | 2.8 |
| All reasons | 509 | 506 | 35.6 | 0.1 | 367.5 | 27.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.
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)
| Check | Planted fault that trips it |
|---|---|
| Rows equal distinct log ids | duplicate-events (300 events twice) |
| Rows loaded equal events the API returned | duplicate-events (+300) and missing-month (−628) |
| Every group in the log is in the mapping | unmapped-group |
| Every mapping key matches a group in the log | dead-mapping-key |
| No month under a quarter of the median month | missing-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
duckdbmodule - 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.