How to Analyse Crime Trends Over Time in Python

On this page

Problem statement

"Is crime going up?" is the question every crime dataset is asked first, and the one most easily answered wrongly. A monthly series of recorded incidents mixes the trend with the season, ends in a month that is not yet complete, and adds together offence types moving in opposite directions. Across a city, the change in one neighbourhood can be the reverse of the change in the next.

This page pulls monthly counts of every recorded incident in Chicago from January 2019 with Socrata's server-side aggregation, removes the incomplete months at the end, separates season from trend with a twelve-month rolling sum, compares offence types, and maps the change in motor vehicle theft by community area between 2019 and 2025.

Quick answer

Ask the API for counts rather than rows, drop months that are not complete, and compare whole years or rolling twelve-month totals:

M = MONTHLY[MONTHLY.month <= LAST_FULL].groupby("month").n.sum()
roll = M.rolling(12).sum()
print(f"{M.index.min():%b %Y} to {M.index.max():%b %Y}: {len(M)} complete months")
print(f"12 months to {M.index.max():%b %Y}: {roll.iloc[-1]:,.0f} incidents; a year earlier: {roll.iloc[-13]:,.0f} ({roll.iloc[-1] / roll.iloc[-13] - 1:+.1%})")
print(M.groupby(M.index.year).sum().loc[2019:2025].to_string())
Jan 2019 to Aug 2026: 92 complete months
12 months to Aug 2026: 232,609 incidents; a year earlier: 245,532 (-5.3%)
month
2019    261938
2020    213071
2021    210002
2022    240496
2023    263846
2024    259638
2025    238100

Recorded incidents fell by a fifth from 2019 to 2020 and 2021, returned to the 2019 level in 2023 and 2024, and fell again in 2025. The twelve months to August 2026 are 5.3% below the twelve months before.

Line chart of recorded incidents in Chicago per month from January 2019 to August 2026, with a 12-month rolling sum on a second axis: monthly counts swing between about 13,000 and 25,000 with a summer peak each year; the rolling sum falls from about 262,000 in 2019 to about 204,000 in early 2021, climbs back to about 264,000 in 2024 and falls to about 233,000 by August 2026; September 2026, incomplete, is a red dot at 14,429. Line chart of recorded incidents in Chicago per month from January 2019 to August 2026, with a 12-month rolling sum on a second axis: monthly counts swing between about 13,000 and 25,000 with a summer peak each year; the rolling sum falls from about 262,000 in 2019 to about 204,000 in early 2021, climbs back to about 264,000 in 2024 and falls to about 233,000 by August 2026; September 2026, incomplete, is a red dot at 14,429.
Monthly counts swing with the season; the rolling sum shows the trend.

The last complete month, season against trend, split by type

1. Find the last complete month

The dataset leaves out the most recent seven days, and the current month is partial anyway. Compare the last months with the same months a year earlier before trusting them:

tot = MONTHLY.groupby("month").n.sum()
for m in tot.index[-3:]:
    prev = tot.get(m - pd.DateOffset(years=1))
    print(f"{m:%b %Y}: {tot[m]:,} incidents; {m - pd.DateOffset(years=1):%b %Y}: {prev:,} ({tot[m] / prev - 1:+.0%})")
print(f"data updated {UPDATED:%d %b %Y}; last complete month used: {LAST_FULL:%b %Y}")
Jul 2026: 21,245 incidents; Jul 2025: 22,712 (-6%)
Aug 2026: 20,745 incidents; Aug 2025: 21,386 (-3%)
Sep 2026: 14,429 incidents; Sep 2025: 20,406 (-29%)
data updated 01 Oct 2026; last complete month used: Aug 2026

September 2026 looks like a 29% fall, and is an artefact: the data were updated on 1 October and the last seven days are withheld. July and August are complete and show falls of 6% and 3%.

2. Separate the season from the trend

Crime in Chicago has a strong season. Comparing a summer month with a winter month says nothing about the trend:

season = M.groupby(M.index.month).mean()
print(f"busiest calendar month on average: {pd.Timestamp(2000, season.idxmax(), 1):%B} ({season.max():,.0f}); "
      f"quietest: {pd.Timestamp(2000, season.idxmin(), 1):%B} ({season.min():,.0f}), {season.max() / season.min() - 1:.0%} apart")
print(f"largest month-to-month change: {M.pct_change().abs().max():.0%}; largest change in the 12-month sum: {roll.pct_change().abs().max():.1%}")
busiest calendar month on average: July (22,325); quietest: February (17,109), 30% apart
largest month-to-month change: 36%; largest change in the 12-month sum: 3.1%

July averages 30% more incidents than February. Month-to-month changes reach 36%; the twelve-month sum never moves by more than 3.1% in a month, because each step swaps one month for the same month a year earlier.

Line chart of five offence types in Chicago indexed to 2019 equals 100: motor vehicle theft rises to 326 in 2023 and ends 2025 at 192, plus 92 percent; assault ends at plus 5 percent; theft at minus 12; robbery, after a 2023 peak, at minus 27; narcotics halves in 2020 and ends at minus 51 percent. Line chart of five offence types in Chicago indexed to 2019 equals 100: motor vehicle theft rises to 326 in 2023 and ends 2025 at 192, plus 92 percent; assault ends at plus 5 percent; theft at minus 12; robbery, after a 2023 peak, at minus 27; narcotics halves in 2020 and ends at minus 51 percent.
Types diverge.

3. Split the total by offence type

Y = MONTHLY[MONTHLY.month.dt.year.between(2019, 2025)].pivot_table(index="primary_type", columns=MONTHLY.month.dt.year, values="n", aggfunc="sum")
types = ["THEFT", "BATTERY", "ASSAULT", "MOTOR VEHICLE THEFT", "ROBBERY", "BURGLARY", "NARCOTICS", "HOMICIDE"]
change = (Y[2025] / Y[2019] - 1).loc[types]
print(Y.loc[types, [2019, 2021, 2023, 2025]].astype(int).assign(change_2019_2025=change.map("{:+.0%}".format)).to_string())
month                 2019   2021   2023   2025 change_2019_2025
primary_type                                                    
THEFT                62505  40830  57527  55198             -12%
BATTERY              49556  40512  44303  42660             -14%
ASSAULT              20626  20347  22643  21600              +5%
MOTOR VEHICLE THEFT   8979  10605  29256  17258             +92%
ROBBERY               7997   7923  11059   5815             -27%
BURGLARY              9638   6662   7494   9740              +1%
NARCOTICS            15080   5420   5455   7419             -51%
HOMICIDE               509    813    638    440             -14%

The total hides opposite movements. Motor vehicle theft rose 92% from 2019 to 2025, after more than tripling to 29,256 in 2023; narcotics fell by half and robbery by 27%. Narcotics incidents are recorded mostly when police find them, so their fall may reflect enforcement as much as offending.

Where motor vehicle theft changed

The data used on this page

The City of Chicago's Crimes - 2001 to Present dataset (ijzp-q8t2), aggregated on the server with SoQL $group: incidents per month and offence type from January 2019, and motor vehicle thefts per community area in 2019 and 2025; and the community area boundaries. Counts are as published on the download date.

from pathlib import Path

import geopandas as gpd
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
URL = "https://data.cityofchicago.org/resource/ijzp-q8t2.json"


def soql(name, **params):
    f = Path(f"data/{name}.parquet")
    if not f.exists():
        pd.DataFrame(requests.get(URL, params={"$limit": 50_000, **params}, timeout=300).json()).to_parquet(f)
    return pd.read_parquet(f)


MONTHLY = soql("chicago_monthly_by_type", **{"$select": "date_trunc_ym(date) AS month, primary_type, count(*) AS n",
                                             "$where": "year >= 2019", "$group": "month, primary_type"})
MONTHLY = MONTHLY.astype({"n": int}).assign(month=lambda d: pd.to_datetime(d.month))
AREA = soql("chicago_mvt_by_area", **{"$select": "year, community_area, count(*) AS n",
                                      "$where": "primary_type = 'MOTOR VEHICLE THEFT' AND year IN (2019, 2025)",
                                      "$group": "year, community_area"}).astype({"n": int, "year": int})
f = Path("data/updated.txt")
if not f.exists():
    f.write_text(str(requests.get("https://data.cityofchicago.org/api/views/ijzp-q8t2.json", timeout=60).json()["rowsUpdatedAt"]))
UPDATED = pd.Timestamp(int(f.read_text()), unit="s")
LAST_FULL = (UPDATED - pd.Timedelta(days=7)).to_period("M").to_timestamp() - pd.DateOffset(months=1)
G = Path("data/chicago_community_areas.geojson")
if not G.exists():
    G.write_bytes(requests.get("https://data.cityofchicago.org/resource/igwz-8jzy.geojson?$limit=100", timeout=120).content)
CA = gpd.read_file(G).astype({"area_numbe": int})
print(f"{len(MONTHLY):,} month-type rows, {MONTHLY.n.sum():,} incidents; {len(AREA)} area-year rows")
2,659 month-type rows, 1,856,587 incidents; 155 area-year rows

Example 1: where motor vehicle theft changed

The city-wide change hides a range of local ones. Compare 2019 with 2025 by community area, keeping the counts beside the percentages:

A = AREA.dropna(subset=["community_area"]).astype({"community_area": int}).pivot(index="community_area", columns="year", values="n")
print(f"thefts with no valid area code (0 or missing): {AREA[~AREA.community_area.astype(float).isin(CA.area_numbe)].n.sum()}")
A = A.join(CA.set_index("area_numbe").community, how="inner").fillna({2019: 0, 2025: 0}).astype({2019: int, 2025: int})
A["change"] = A[2025] / A[2019] - 1
print(f"city: {A[2019].sum():,} in 2019, {A[2025].sum():,} in 2025 ({A[2025].sum() / A[2019].sum() - 1:+.0%})")
print(f"areas up: {(A.change > 0).sum()} of {len(A)}; more than doubled: {(A.change > 1).sum()}; down: {(A.change < 0).sum()}")
print(A.sort_values("change")[["community", 2019, 2025, "change"]].iloc[[0, 1, -2, -1]].round(2).to_string(index=False))
thefts with no valid area code (0 or missing): 1
city: 8,979 in 2019, 17,257 in 2025 (+92%)
areas up: 73 of 77; more than doubled: 37; down: 3
     community  2019  2025  change
SOUTH LAWNDALE   211   186   -0.12
   EDISON PARK    11    10   -0.09
       OAKLAND    18   118    5.56
     HEGEWISCH    14   105    6.50

Motor vehicle theft rose in 73 of 77 community areas and more than doubled in 37. The largest percentage rises are in areas with small 2019 bases: Hegewisch went from 14 to 105, Oakland from 18 to 118.

Choropleth of the percentage change in recorded motor vehicle thefts by Chicago community area from 2019 to 2025, on a diverging scale from minus 300 to plus 300 percent: almost every area is red, rising most along the South Side lakefront, in Oakland from 18 to 118 and Hegewisch from 14 to 105; South Lawndale, 211 to 186, is one of the few that fell; Austin went from 646 to 828. Choropleth of the percentage change in recorded motor vehicle thefts by Chicago community area from 2019 to 2025, on a diverging scale from minus 300 to plus 300 percent: almost every area is red, rising most along the South Side lakefront, in Oakland from 18 to 118 and Hegewisch from 14 to 105; South Lawndale, 211 to 186, is one of the few that fell; Austin went from 646 to 828.
Where motor vehicle theft changed, with the counts behind the largest changes.

Offending, reporting and recording at once

Steps to a defensible statement about a crime trend. before saying crime went up or down1aggregate on the server$group: 2,659 rows, not 1.9 million2drop incomplete monthsSep 2026 looked like -29%3remove the seasonJuly 30% above February4split by typevehicle theft +92%, robbery -27%5show counts with % changeHegewisch 14 to 105A trend is in recorded incidents, as published on a date spatialworkflow.io
Before saying crime went up or down.

A trend in recorded crime is a trend in three things at once: offending, reporting and recording. A rise in motor vehicle theft can be more cars stolen, more owners reporting thefts for insurance, or a change in how attempted thefts are classified. The data cannot separate these on its own, which is why a careful trend statement names the series exactly: recorded incidents of a type, as published on a date.

The mechanics are simpler. Use complete periods only. Compare like with like: the same months a year apart, whole years, or a rolling twelve-month total, which removes the season by always containing one of each month. Split totals into types before concluding anything, because a total is a sum of series that can move in opposite directions, and a large type dominates it. And when mapping change by area, show the counts as well as the percentages, because a small base turns a handful of incidents into a dramatic percentage.

Revised records, boundaries and month lengths

Crime trends: problems and fixes. crime trend trapsTrapInsteadlatest month in the seriescomplete months only: 7-day lagmonth against previous monthsame month a year earlier, or12-month sumone total for all crimeby type: they move in oppositedirectionspercent change on small countsprint the counts beside itChicago incidents, 2019 to August 2026 spatialworkflow.io
Crime trend traps.
  • Records change after publication: classifications and locations are revised, so last year's counts downloaded today differ slightly from the same counts downloaded last year.
  • Boundary changes: community areas have been stable for decades; police districts and beats have not.
  • Leap years and month lengths: February has fewer days; use rates per day when comparing single months.
  • Events: one-off events, such as the 2020 pandemic lockdowns, are part of the series; a rolling sum spreads them over twelve months.
  • The city's warning: the dataset description cautions against comparisons over time; it does not forbid them, but it is a reason to state the caveats.

FAQ

How do I get monthly crime counts from a Socrata API?

Use SoQL aggregation: $select=date_trunc_ym(date) AS month, count(*) AS n with $group=month, so the server returns counts instead of every row.

Why does the latest month always show a drop?

Because it is incomplete: Chicago withholds the most recent seven days, and the current month has not finished. Use complete months only.

How do I remove seasonality from a crime trend?

Compare the same months in different years, or use a rolling twelve-month sum, which always contains one of each month.

Did motor vehicle theft rise in Chicago?

Yes: recorded motor vehicle thefts rose from 8,979 in 2019 to 17,258 in 2025, after peaking at 29,256 in 2023.