Introduction

Montgomery County publishes recorded crimes on the county’s open data portal (data.montgomerycountymd.gov, dataset icn6-v9z3). This report utilized an October 14, 2026, download, holding 511,719 rows and 30 columns. Each row is one offense. Key columns include the offense, crime category, police district, start time and dispatch time. The analysis covers the nine full years from 2017 through 2025, highlighting types of crime occurring in the county, where and when.

Data Quality

The cleaned Montgomery County crime dataset holds 441,223 crimes. The analysis excludes 2016 and 2026 because neither covers a full year. The analysis keeps the seven police districts and leaves out records with no district or a district coded “Other.” It also drops records coded “Crime Against Not a Crime.” These are police information reports, not offenses. Additionally, start times fail at the hour level with officers entering a midnight or noon datapoint when they may not know when a crime began, and both hours spike as a result. The time-of-day chart uses dispatch time instead and includes 400,661 records. The offense rankings also leave out “All Other Offenses,” a catch-all that names no crime. August through December 2023 ranks last among all nine years in every month with no official reason provided in the data set.

# Libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')

# Read data
df = pd.read_csv('C:/Users/kelly/MC_Crime.csv')

# Clean data
keep = ['Incident ID', 'Start_Date_Time', 'Dispatch Date / Time', 'Crime Name1', 'Crime Name2', 'Police District Name']
crime = df[keep].copy()
crime.rename(columns={'Start_Date_Time': 'Start Date',
                      'Dispatch Date / Time': 'Dispatch Date',
                      'Crime Name1': 'Crime Category',
                      'Crime Name2': 'Offense',
                      'Police District Name': 'District'}, inplace=True)
crime['Start Date'] = pd.to_datetime(crime['Start Date'], format='%m/%d/%Y %I:%M:%S %p')
crime['Dispatch Date'] = pd.to_datetime(crime['Dispatch Date'], format='%m/%d/%Y %I:%M:%S %p')
crime['Year'] = crime['Start Date'].dt.year

district_names = {'SILVER SPRING': 'Silver Spring',
                  'WHEATON': 'Wheaton',
                  'MONTGOMERY VILLAGE': 'Montgomery Village',
                  'BETHESDA': 'Bethesda',
                  'ROCKVILLE': 'Rockville',
                  'GERMANTOWN': 'Germantown',
                  'TAKOMA PARK': 'Takoma Park'}

crime = crime[crime['Year'].isin(range(2017, 2026))]
crime = crime[crime['District'].isin(list(district_names.keys()))]
crime = crime[crime['Crime Category'] != 'Crime Against Not a Crime']
crime['District'] = [district_names[each] for each in crime['District']]

Descriptive Statistics

Property crime makes up half the dataset: 218,214 of 441,223 crimes (49.5%). Crimes against society account for 175,907 (39.9%) and crimes against persons 47,102 (10.7%). Silver Spring leads every year, averaging 10,336 crimes and ranging from 8,992 to 11,896. Wheaton averages 9,083 and Montgomery Village 8,202. Takoma Park, the smallest district, averages 1,471.

# Descriptive statistics
yearly = crime.groupby(['District', 'Year'])['Incident ID'].count().reset_index(name='Crimes')
round(yearly.groupby('District')['Crimes'].describe()[['mean', 'min', 'max']], 1)
##                        mean     min      max
## District                                    
## Bethesda             7055.0  6443.0   7431.0
## Germantown           6181.6  5678.0   7135.0
## Montgomery Village   8201.9  7190.0   9423.0
## Rockville            6696.1  5719.0   7663.0
## Silver Spring       10336.1  8992.0  11896.0
## Takoma Park          1471.4  1237.0   2114.0
## Wheaton              9082.7  7636.0  10916.0
crime['Crime Category'].value_counts()
## Crime Category
## Crime Against Property    218214
## Crime Against Society     175907
## Crime Against Person       47102
## Name: count, dtype: int64

Findings

Share of Crimes Against Persons by District (Scatter Plot)

Most Montgomery County, Maryland districts report fewer crimes than before 2020, and more of those crimes are assaults and other crimes against persons. The share rose in every large district. Silver Spring went from about 8% in 2017 to 12% in 2025, and Wheaton from about 9% to 11%. Montgomery Village and Germantown now have the highest share, at 13 to 15%. Bethesda had the lowest share until 2023.

# Prepare data: crimes and share against persons by district and year
crime['Person'] = np.where(crime['Crime Category'] == 'Crime Against Person', 1, 0)
person = crime.groupby(['District', 'Year'])['Person'].sum().reset_index(name='Person')
grid = yearly.copy()
grid['Person'] = person['Person']
grid['Person Share'] = round(grid['Person'] / grid['Crimes'] * 100, 1)
grid['Crimes_20'] = round(grid['Crimes'] / 20, 0)
district_rank = {'Takoma Park': 1, 'Germantown': 2, 'Rockville': 3, 'Bethesda': 4,
                 'Montgomery Village': 5, 'Wheaton': 6, 'Silver Spring': 7}
grid['Rank'] = [district_rank[each] for each in grid['District']]
grid = grid.sort_values('Rank')
my_colorbar_ticks = [*range(int(grid['Person Share'].min()) + 1, int(grid['Person Share'].max()) + 1, 1)]
my_colorbar_tick_labels = [str(each) + '%' for each in my_colorbar_ticks]
my_x_ticks = [*range(2017, 2026, 1)]

# Chart 1: Scatterplot
plt.figure(figsize=(18, 10))
plt.scatter(grid['Year'], grid['District'], marker='8', cmap='viridis',
            c=grid['Person Share'], s=grid['Crimes_20'], edgecolors='black')
plt.title('Montgomery County, MD Crimes by District, 2017-2025', fontsize=18, fontweight='bold')
plt.xlabel('Year', fontsize=18)
plt.text(2017, -1.2, 'N = ' + '{:,}'.format(crime.shape[0]) + ' Crimes.  Size = Total Crimes.  2016 and 2026 Excluded (Incomplete Years).', fontsize=12)
plt.ylabel('Police District', fontsize=18)
cbar = plt.colorbar()
cbar.set_label('Crimes Against Persons (% of District Total)', rotation=270, fontsize=14, color='black', labelpad=30)
cbar.set_ticks(my_colorbar_ticks)
cbar.set_ticklabels(my_colorbar_tick_labels)
plt.xticks(my_x_ticks, fontsize=14, color='black')
plt.yticks(fontsize=14, color='black')
plt.subplots_adjust(left=0.15, bottom=0.15)
plt.show()

Crime Mix by District (Stacked Bar Chart)

Property crime makes up most of Bethesda’s crime, the largest property share of any large district. Rockville is the only district where crimes against society outnumber property crimes. These include drugs, DUI and disorderly conduct. Crimes against persons form the smallest share in every district.

# Prepare data: crimes by district and crime category, 2017-2025
stacked_df = crime.groupby(['District', 'Crime Category'])['Incident ID'].count().reset_index(name='Crimes')
stacked_df = stacked_df.pivot(index='District', columns='Crime Category', values='Crimes')
stacked_df.rename(columns={'Crime Against Person': 'Crimes Against Persons',
                           'Crime Against Property': 'Crimes Against Property',
                           'Crime Against Society': 'Crimes Against Society'}, inplace=True)
stacked_df['Total'] = stacked_df['Crimes Against Persons'] + stacked_df['Crimes Against Property'] + stacked_df['Crimes Against Society']
stacked_df = stacked_df.sort_values('Total', ascending=False)
max_total = stacked_df['Total'].max()
del stacked_df['Total']

category_order = ['Crimes Against Persons', 'Crimes Against Property', 'Crimes Against Society']
stacked_df = stacked_df.reindex(columns=category_order)

# Chart 5: Stacked bar chart
fig = plt.figure(figsize=(18, 10))
ax = fig.add_subplot(1, 1, 1)

stacked_df.plot(kind='bar', stacked=True, ax=ax)

plt.ylabel('Reported Crimes', fontsize=18, labelpad=10)
plt.title('Montgomery County, MD Crime Mix by District, 2017-2025', fontsize=18, fontweight='bold')
ax.set_xticks(np.arange(7))
ax.set_xticklabels([str(each).replace(' ', '\n') for each in stacked_df.index])
plt.xticks(rotation=0, horizontalalignment='center', fontsize=14)
plt.yticks(fontsize=14)
ax.set_xlabel('Police District', fontsize=18)

handles, labels = ax.get_legend_handles_labels()
handles = [ handles[2], handles[1], handles[0] ]
labels  = [ labels[2],  labels[1],  labels[0]  ]
plt.legend(handles, labels, loc='best', fontsize=14)

ax.yaxis.set_major_formatter( FuncFormatter( lambda x, pos: '{:,}'.format(int(x))))

ax.text(-0.5, max_total * -0.2, 'N = ' + '{:,}'.format(crime.shape[0]) + ' Crimes.  2016 and 2026 Excluded (Incomplete Years).', fontsize=12)
plt.subplots_adjust(bottom=0.18)
plt.show()

Top Offenses by District (Heatmap)

Thefts from motor vehicles in Silver Spring, Maryland, top every offense in every district, at 8,904 over nine years. Germantown records the most shoplifting at 6,100, and Montgomery Village leads in DUI at 4,789. Bethesda leads the county in identity theft (2,893) and theft from buildings (3,439) despite its lower total. That fits its property-heavy mix. Simple assault runs high everywhere except Takoma Park. Silver Spring (6,713), Montgomery Village (6,504) and Wheaton (5,995) record the most.

# Prepare data: top 10 offenses by police district, 2017-2025
top_ten = crime[crime['Offense'] != 'All Other Offenses'].groupby('Offense')['Incident ID'].count().reset_index(name='Crimes')
top_ten = top_ten.sort_values('Crimes', ascending=False).head(10)
x4 = crime[crime['Offense'].isin(top_ten['Offense'])].groupby(['Offense', 'District'])['Incident ID'].count().reset_index(name='Crimes')
x4['Offense'] = [str(each).replace('All other', 'All Other').replace('from Building', 'From Building').replace('Damage/', 'Damage/\n') for each in x4['Offense']]
hm_df = pd.pivot(x4, index='Offense', columns='District', values='Crimes')
hm_df = hm_df.fillna(0)
offense_order = [str(each).replace('All other', 'All Other').replace('from Building', 'From Building').replace('Damage/', 'Damage/\n') for each in top_ten['Offense']]
hm_df = hm_df.reindex(index=offense_order)
district_order = ['Silver Spring', 'Wheaton', 'Montgomery Village', 'Bethesda', 'Rockville', 'Germantown', 'Takoma Park']
hm_df = hm_df.reindex(columns=district_order)

# Chart 9: Heatmap
fig = plt.figure(figsize=(18, 10))
ax = fig.add_subplot(1, 1, 1)

comma_fmt = FuncFormatter(lambda x, p: format(int(x), ','))

ax = sns.heatmap(hm_df, linewidth = 0.2, annot = True, cmap = 'coolwarm', fmt = ',.0f',
                 square = False, annot_kws = {'size': 14},
                 cbar_kws = {'format': comma_fmt, 'orientation': 'vertical'})

plt.title('Montgomery County, MD Top 10 Offenses by Police District, 2017-2025', fontsize=18, pad=15, fontweight='bold')
plt.xlabel('Police District', fontsize=18, labelpad=10)
plt.ylabel('Offense', fontsize=18, labelpad=10)
ax.set_xticklabels([str(each).replace(' ', '\n') for each in hm_df.columns])
plt.yticks(rotation=0, size=14)
plt.xticks(rotation=0, size=14)
ax.tick_params(axis='both', length=0)

cbar = ax.collections[0].colorbar
max_count = hm_df.to_numpy().max()
my_colorbar_ticks = [*range(2000, int(max_count), 2000)]
cbar.set_ticks(my_colorbar_ticks)
my_colorbar_tick_labels = ['{:,}'.format(each) for each in my_colorbar_ticks]
cbar.set_ticklabels(my_colorbar_tick_labels)
cbar.set_label('Reported Crimes', rotation=270, fontsize=14, color='black', labelpad=20)

ax.text(0, 11.9, 'N = ' + '{:,}'.format(int(hm_df.to_numpy().sum())) + ' Crimes in the Top 10 Offenses.  2016 and 2026 Excluded (Incomplete Years).', fontsize=12)
plt.subplots_adjust(left=0.2, bottom=0.2)
plt.show()

Crimes by Hour and Day of Week (Multiple Line Chart)

Crime in Montgomery County, Maryland, peaks at 3 PM on weekdays. Tuesday at 3 PM is the busiest hour of the week. Weekday volume rises sharply from 6 AM and drops after 4 PM. However, weekends are quieter during the day and busier after midnight. Saturday and Sunday each log about 2,000 dispatches at midnight against about 1,300 Monday through Thursday. After 9 PM, Friday has the highest count of any weekday. Crime is also lowest every day between 4 and 5 AM.

# Prepare data: crimes by dispatch hour and day of week, 2017-2025
crime['Hour'] = crime['Dispatch Date'].dt.hour
crime['WeekDay'] = crime['Dispatch Date'].dt.strftime('%a')
hour_df = crime.groupby(['Hour', 'WeekDay'])['Incident ID'].count().reset_index(name='Crimes')

# Chart 4: Multiple line plot
fig = plt.figure(figsize=(18, 10))
ax = fig.add_subplot(1, 1, 1)

my_colors = {'Mon':'blue',
             'Tue':'red',
             'Wed':'green',
             'Thu':'gray',
             'Fri':'purple',
             'Sat':'gold',
             'Sun':'brown'}

for key, grp in hour_df.groupby('WeekDay'):
    grp.plot(ax=ax, kind='line', x='Hour', y='Crimes', color=my_colors[key], label=key, marker='8')

plt.title('Montgomery County, MD Crimes by Hour and Day of Week, 2017-2025', fontsize=18, fontweight='bold')
ax.set_xlabel('Hour of Day (Time Police Were Dispatched)', fontsize=18)
ax.set_ylabel('Reported Crimes', fontsize=18, labelpad=20)
ax.tick_params(axis='x', labelsize=14, rotation=0)
ax.tick_params(axis='y', labelsize=14, rotation=0)

ax.set_xticks(np.arange(24))
ax.set_ylim(0, hour_df['Crimes'].max() * 1.1)
handles, labels = ax.get_legend_handles_labels()
handles = [ handles[1], handles[5], handles[6], handles[4], handles[0], handles[2], handles[3] ]
labels  = [ labels[1],  labels[5],  labels[6],  labels[4],  labels[0],  labels[2],  labels[3]  ]
plt.legend(handles, labels, loc='best', fontsize=14, ncol=1)

ax.yaxis.set_major_formatter( FuncFormatter( lambda x, pos: '{:,}'.format(int(x))))

ax.text(0, hour_df['Crimes'].max() * -0.15, 'N = ' + '{:,}'.format(hour_df['Crimes'].sum()) + ' Crimes.  2016 and 2026 Excluded (Incomplete Years).', fontsize=12)
plt.subplots_adjust(bottom=0.15)
plt.show()

Monthly Crime Rankings by Year (Bump Chart)

Monthly crime in Montgomery County ran highest before the pandemic, with 2017 or 2018 ranking first in every month. COVID broke that pattern in 2020. Crime fell from second place in January to ninth from April through July. In 2023 crime ranked between third and fifth through June, then fell to last place from August through December.

# Prepare data: rank each month's crime count across years, 2017-2025
crime['MonthName'] = crime['Start Date'].dt.strftime('%b')
bump_df = crime.groupby(['Year', 'MonthName'])['Incident ID'].count().reset_index(name='Crimes')
bump_df = bump_df.pivot(index='Year', columns='MonthName', values='Crimes')

month_order = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
bump_df = bump_df.reindex(columns=month_order)
bump_df = bump_df.dropna()

bump_df_ranked = bump_df.rank(0, ascending=False, method='min')
bump_df_ranked = bump_df_ranked.T

# Chart 8: Bump chart
fig = plt.figure(figsize=(18, 10))
ax = fig.add_subplot(1, 1, 1)

bump_df_ranked.plot(kind='line', ax=ax, marker='o', markeredgewidth=1, linewidth=6,
                    markersize=18,
                    markerfacecolor='white')

ax.invert_yaxis()

num_rows = bump_df_ranked.shape[0]
num_cols = bump_df_ranked.shape[1]

plt.ylabel('Monthly Ranking (1 = Most Crimes)', fontsize=18, labelpad=10)
plt.title('Montgomery County, MD Ranking of Monthly Crimes by Year, 2017-2025', fontsize=18, pad=15, fontweight='bold')
plt.xticks(np.arange(num_rows), month_order, fontsize=14)
plt.yticks(range(1, num_cols+1, 1), fontsize=14)
ax.set_xlabel('Month', fontsize=18)

handles, labels = ax.get_legend_handles_labels()
handles = [ handles[8], handles[7], handles[6], handles[5], handles[4], handles[3], handles[2], handles[1], handles[0] ]
labels  = [ labels[8],  labels[7],  labels[6],  labels[5],  labels[4],  labels[3],  labels[2],  labels[1],  labels[0]  ]
ax.legend(handles, labels, bbox_to_anchor=(1.01, 1.01), fontsize=14)

ax.text(0, num_cols + 1.6, 'N = ' + '{:,}'.format(crime.shape[0]) + ' Crimes.  2016 and 2026 Excluded (Incomplete Years).', fontsize=12)
plt.subplots_adjust(bottom=0.15)
plt.show()

Conclusion

Montgomery County has less post-pandemic crime, with total crime falling after 2019 while the share of crimes against persons rose in every large district. Bethesda deals mostly with property crime while Silver Spring leads the county in car break-ins. Montgomery Village and Germantown have the highest share of crimes against persons. Demand for police peaks on weekday afternoons and weekend nights. The late-2023 drop deserves a closer look before these trends guide resources, budgets or community programs.