Why Do My Crime Counts Change Every Time I Download the Data?

On this page

Problem statement

You count last year's crimes, publish the numbers, and a colleague who downloads the same dataset a few months later gets different ones. Neither of you made a mistake. Open crime data is a live extract from a police records system: offences reported long after they happened keep arriving with old dates, records are reclassified as investigations proceed, and locations are corrected. A count is a count as of a date, and without that date it cannot be reproduced.

This page measures how many of Chicago's 2024 records arrived after the year was over, using the fact that record IDs are issued in sequence, finds which offence types arrive late, looks at what the updated_on field does and does not tell you, and sets up a download that records its date and fetches only what changed.

Quick answer

Estimate when each record entered the system from its ID, and count the ones dated 2024 that arrived after a download made in early 2025 would have been taken:

x = C[C.id > 1_000_000].copy()                                        # homicides use a separate, low ID sequence
x["entered"] = pd.Timestamp("2024-01-01") + pd.to_timedelta(np.interp(x.id, CLOCK.med, CLOCK.day), unit="D")
for cut in ("2025-01-08", "2025-07-01", "2026-01-01"):
    late = x.entered > pd.Timestamp(cut)
    print(f"2024 records entered after {cut}: {late.sum():,} ({late.mean():.2%})")
2024 records entered after 2025-01-08: 3,421 (1.32%)
2024 records entered after 2025-07-01: 1,023 (0.39%)
2024 records entered after 2026-01-01: 475 (0.18%)

A count of 2024 made on 8 January 2025, the first day the dataset could include all of December, would have missed 3,421 records, 1.32% of the eventual total. 1,023 arrived after July 2025 and 475 after the start of 2026, more than a year late.

Line chart of the estimated share of Chicago records now dated 2024 that had already been entered, by download date from January 2025 to October 2026: all records start near 98.4 percent and pass 99 percent within weeks; robbery is above 99.8 percent from the start; deceptive practice and offences involving children stand near 91 percent at the 8 January 2025 line and climb slowly to 100 percent by October 2026. Line chart of the estimated share of Chicago records now dated 2024 that had already been entered, by download date from January 2025 to October 2026: all records start near 98.4 percent and pass 99 percent within weeks; robbery is above 99.8 percent from the start; deceptive practice and offences involving children stand near 91 percent at the 8 January 2025 line and climb slowly to 100 percent by October 2026.
How complete a 2024 count would have been, by download date.

An entry clock from ids, late offences, update times

1. Build an entry clock from the IDs

Each new record gets the next ID. For each day since January 2024, the median ID of records dated that day is close to the IDs being issued then, because most records are entered within days. Check that the clock runs forwards and how fast:

per_day = np.diff(CLOCK.med.values).mean()
print(f"{len(CLOCK)} days; IDs issued per day on average: {per_day:,.0f}")
print(f"records dated 2024 by ID sequence: {(C.id > 1_000_000).sum():,} main, {(C.id <= 1_000_000).sum()} in the low sequence, "
      f"{C[C.id <= 1_000_000].primary_type.value_counts().to_dict()}")
lag = (x.entered - x.time).dt.days
print(f"estimated entry delay: median {lag.median():.0f} days, 95th percentile {lag.quantile(0.95):.0f}, 99th {lag.quantile(0.99):.0f}")
998 days; IDs issued per day on average: 1,017
records dated 2024 by ID sequence: 259,045 main, 593 in the low sequence, {'HOMICIDE': 593}
estimated entry delay: median -1 days, 95th percentile 18, 99th 161

The clock issues about 1,017 IDs a day. Its error shows in the median delay of minus one day: records dated on a day get IDs over the following days, so the median ID for that day runs slightly ahead. The tail is what matters, and it is long: one record in twenty arrives more than 18 days after its date, one in a hundred more than 161 days.

2. See which offences arrive late

late = x.entered > pd.Timestamp("2025-01-08")
share = (x[late].primary_type.value_counts() / x.primary_type.value_counts()).dropna().sort_values(ascending=False)
print("share of each type entered after 8 Jan 2025:")
print(share.head(5).map("{:.1%}".format).to_string())
print("largest numbers:", x[late].primary_type.value_counts().head(3).to_dict())
print(f"robbery: {share.get('ROBBERY', 0):.1%}; battery: {share.get('BATTERY', 0):.1%}; all types: {late.mean():.1%}")
share of each type entered after 8 Jan 2025:
primary_type
HUMAN TRAFFICKING             21.7%
OBSCENITY                     11.3%
OFFENSE INVOLVING CHILDREN     8.9%
DECEPTIVE PRACTICE             8.5%
SEX OFFENSE                    7.9%
largest numbers: {'DECEPTIVE PRACTICE': 1393, 'THEFT': 637, 'OTHER OFFENSE': 326}
robbery: 0.1%; battery: 0.4%; all types: 1.3%

Over a fifth of human trafficking records and about one in eleven for offences involving children and for deceptive practice arrive after the year-end download date, against 0.1% for robbery. By number, deceptive practice dominates, with 1,393 late records.

Share of Chicago records dated 2024 entered after 8 January 2025, by offence type. entered after 8 January 2025, share of the typehuman trafficking21.7%offence involving children8.9%deceptive practice8.5% (1,393)sex offense7.9%all types1.3% (3,421)battery0.4%robbery0.1%010%20%Offences reported after the fact keep arriving with old dates spatialworkflow.io
Late arrivals by offence type.

3. Read updated_on carefully

Every record has an updated_on timestamp. It shows when the row last changed in the published extract, which includes bulk reprocessing, not only corrections:

u = pd.to_datetime(C.updated_on)
print(f"2024 records last updated in 2025 or later: {(u >= '2025-01-01').mean():.1%}")
top = u.dt.to_period("M").value_counts().head(1)
print(f"busiest update month: {top.index[0]} with {top.iloc[0]:,} rows")
print(f"updated in the last 30 days before download: {(u >= u.max() - pd.Timedelta(days=30)).sum():,}")
2024 records last updated in 2025 or later: 65.4%
busiest update month: 2025-05 with 135,327 rows
updated in the last 30 days before download: 409

Two thirds of 2024 records were last updated in 2025 or later, 135,327 of them in May 2025 alone. That is a bulk rewrite of the extract, not 135,327 corrections, so updated_on says that a row may have changed, not that it did or how.

A repeatable, updatable download

The data used on this page

The City of Chicago's Crimes - 2001 to Present (ijzp-q8t2): every record dated 2024, and the median record ID per day for records dated from January 2024, aggregated on the server, as downloaded on the date the page was run.

from pathlib import Path

import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
URL = "https://data.cityofchicago.org/resource/ijzp-q8t2.json"
F = Path("data/chicago_crimes_2024.parquet")
if not F.exists():
    rows, offset = [], 0
    while True:
        part = requests.get(URL, timeout=300, params={
            "$where": "year=2024", "$limit": 50_000, "$offset": offset, "$order": "id",
            "$select": "id,date,primary_type,arrest,domestic,community_area,latitude,longitude,location_description,block,updated_on"}).json()
        rows += part; offset += len(part)
        if len(part) < 50_000:
            break
    pd.DataFrame(rows).to_parquet(F)
C = pd.read_parquet(F).astype({"id": int})
C["time"] = pd.to_datetime(C.date)
f = Path("data/id_clock.json")
if not f.exists():
    f.write_bytes(requests.get(URL, timeout=300, params={
        "$select": "date_trunc_ymd(date) AS day, median(id) AS med", "$where": "year >= 2024 AND id > 1000000",
        "$group": "day", "$order": "day", "$limit": 5000}).content)
CLOCK = pd.read_json(f).assign(day=lambda d: (pd.to_datetime(d.day) - pd.Timestamp("2024-01-01")).dt.days)
CLOCK["med"] = np.maximum.accumulate(CLOCK.med.astype(float))       # force the clock to run forwards
print(f"{len(C):,} records dated 2024; ID clock over {len(CLOCK)} days")
259,638 records dated 2024; ID clock over 998 days

Example 1: a download that can be repeated and updated

Save every download with its date, and fetch later changes with updated_on instead of downloading everything again:

stamp = pd.Timestamp.now(tz="America/Chicago").strftime("%Y-%m-%d")
last = pd.to_datetime(C.updated_on).max()
since = requests.get(URL, timeout=120, params={
    "$select": "count(*) AS n", "$where": f"year = 2024 AND updated_on > '{last:%Y-%m-%dT%H:%M:%S}'"}).json()[0]["n"]
print(f"snapshot would be saved as chicago_crimes_2024_{stamp}.parquet")
print(f"newest updated_on in the snapshot: {last:%Y-%m-%d %H:%M}; 2024 rows changed since then: {since}")
snapshot would be saved as chicago_crimes_2024_2026-10-01.parquet
newest updated_on in the snapshot: 2026-09-30 15:43; 2024 rows changed since then: 0

No 2024 row had changed between the snapshot and this query, which is what a fresh snapshot should show; run weeks later, the same query returns the rows to fetch.

What to keep with each crime data download so counts can be reproduced and updated. data/chicago_crimes_2024_2026-10-01.parquetthe rows as downloadednever overwrittendownload date in the namecounts are as of this datenewest updated_onthe starting point for incrementsquery and filters usedyear=2024, fields selectedCompare periods at the same delay after they end spatialworkflow.io
What to keep with each download.

A police records system is a working database

How a crime record keeps changing after it is first published. a record is not finished when it appearsoffencehappensthe date on therecordreported,maybe late1 in 100 after 161daysentered, IDissuedabout 1,017 a dayinvestigatedtype, arrest,location changeextractrefresheddaily, minus 7 daysEach refresh can change the past as well as add to it spatialworkflow.io
A record is not finished when it appears.

A police records system is a working database. Offences are reported days, months or years after they happen and are entered with their occurrence date; investigations change classifications, add arrests and fix locations. The open data extract is refreshed from that system, so its past changes as well as its present. Chicago's dataset withholds the latest seven days, but the changes do not stop after a week.

The size of the effect depends on the offence. Fraud, offences involving children and sexual offences are often reported long after the event, and a noticeable share of each year's records for them arrives in the following year or later. Street crimes such as robbery are reported quickly and change little. So a comparison of this year's partial count with last year's complete one is biased towards a fall, and most strongly for the late-arriving types.

The practical response is bookkeeping. Keep dated snapshots, report counts with their download date, compare like with like (both counts as of the same delay after year end), and fetch increments by updated_on rather than replacing the whole file. The ID sequence used here is a reconstruction, not a field the city publishes; it is good enough to show the scale of late arrivals, not to date individual records.

Deleted records and bulk updates

Crime counts that change after download: problems and fixes. changing data trapsTrapInsteada count with no datesave snapshots with the downloaddatepartial year against full yearcompare at the same delayupdated_on read as a correction135,327 rows rewritten in onemonthfull re-download each timefetch rows where updated_on > lastChicago records dated 2024, as of 1 October 2026 spatialworkflow.io
Changing data traps.
  • Deleted records: records removed from the extract simply disappear; only comparing snapshots by ID shows them.
  • Low IDs: homicide records use a separate ID sequence, one row per victim, and are left out of the entry clock.
  • Bulk updates: a month in which over a hundred thousand old rows were touched at once is reprocessing, not a hundred thousand corrections.
  • The seven-day lag: it applies to new records; corrections to old ones appear whenever they are made.
  • Other cities: many portals behave the same way; look for an updated or last_modified field and a statement about lags.

FAQ

Why do crime statistics change after they are published?

Because late reports keep arriving with old dates, and records are reclassified or corrected as investigations proceed; an open dataset is refreshed from the live system.

How many crimes are reported late?

In Chicago's 2024 records, about 1.3% were entered after 8 January 2025, when a year-end download could first include all of December; for fraud the share was about 8%, for human trafficking over a fifth.

How do I make crime counts reproducible?

Save each download with its date, report counts as of that date, and compare periods at the same delay after they end.

How do I download only changed crime records?

Filter on the updated_on field with a SoQL $where, using the newest value in your last snapshot.