# install.packages(c("tidyverse","readxl","plotly","leaflet","sf","ozmaps","htmltools"))
library(tidyverse)
library(readxl)
library(plotly)
library(leaflet)
library(sf)
library(ozmaps)
library(htmltools)
# ---- 1. Load data directly from the Crime Statistics Agency ----
csa_url <- "https://files.crimestatistics.vic.gov.au/2026-09/Data_Tables_LGA_Criminal_Incidents_Year_Ending_June_2026.xlsx"
csa_file <- "Data_Tables_LGA_Criminal_Incidents_Year_Ending_June_2026.xlsx"
if (!file.exists(csa_file)) download.file(csa_url, csa_file, mode = "wb")
# Simple column-name cleaner (lower case, underscores)
clean_names <- function(df) {
names(df) <- names(df) %>% str_to_lower() %>% str_replace_all("[^a-z0-9]+", "_") %>% str_remove_all("^_|_$")
df
}
# Read a sheet, finding the header row automatically (CSA sometimes adds notes above it)
read_tab <- function(sheet) {
raw <- read_excel(csa_file, sheet = sheet, col_names = FALSE, n_max = 20,
.name_repair = "minimal")
hdr <- which(apply(raw, 1, function(r)
any(str_detect(as.character(r), regex("^year$", ignore_case = TRUE)), na.rm = TRUE)))[1]
if (is.na(hdr)) hdr <- 1
read_excel(csa_file, sheet = sheet, skip = hdr - 1) %>% clean_names()
}
sheets <- excel_sheets(csa_file)
sheets <- sheets[str_detect(sheets, regex("table", ignore_case = TRUE))]
tabs <- map(set_names(sheets), read_tab)
has <- function(df, pat) any(str_detect(names(df), pat))
pick <- function(df, pat) { n <- names(df)[str_detect(names(df), pat)]; if (length(n)) n[1] else NA }
# Identify each table by the columns it contains
t_lga <- detect(tabs, ~ has(.x, "rate") & !has(.x, "offence|location|suburb|postcode|charge"))
t_off <- detect(tabs, ~ has(.x, "offence_division") & !has(.x, "suburb|postcode"))
t_sub <- detect(tabs, ~ has(.x, "suburb"))
t_loc <- detect(tabs, ~ has(.x, "location_(division|subdivision)"))
# ---- 2. Preprocessing ----
strip_code <- function(x) str_squish(str_remove(x, "^[A-Z]?[0-9]*\\s+"))
std <- function(df) {
out <- tibble(
year = as.integer(df[[pick(df, "^year$")]]),
lga = str_squish(as.character(df[[pick(df, "local_government_area|^lga")]])),
incidents = as.numeric(df[[pick(df, "incidents")]])
)
rc <- pick(df, "rate")
if (!is.na(rc)) out$rate <- as.numeric(df[[rc]])
for (p in c("offence_division", "location_subdivision", "location_division", "suburb")) {
cl <- pick(df, p)
if (!is.na(cl)) out[[p]] <- strip_code(as.character(df[[cl]]))
}
out %>%
mutate(lga = recode(lga, "Moreland" = "Merri-bek")) %>%
filter(!is.na(year), !is.na(lga), !str_detect(lga, regex("total", ignore_case = TRUE)))
}
lga <- std(t_lga) %>% mutate(pop = incidents / rate * 1e5) # back out population
off <- std(t_off)
sub <- std(t_sub)
loc <- std(t_loc)
latest <- max(lga$year)
first <- min(lga$year)
metro <- c("Banyule","Bayside","Boroondara","Brimbank","Cardinia","Casey","Darebin",
"Frankston","Glen Eira","Greater Dandenong","Hobsons Bay","Hume","Kingston",
"Knox","Manningham","Maribyrnong","Maroondah","Melbourne","Melton","Merri-bek",
"Monash","Moonee Valley","Mornington Peninsula","Nillumbik","Port Phillip",
"Stonnington","Whitehorse","Whittlesea","Wyndham","Yarra","Yarra Ranges")
peers <- c("Port Phillip", "Melbourne", "Yarra", "Stonnington", "Glen Eira")
pop_tbl <- lga %>% filter(is.finite(pop)) %>% select(year, lga, pop)
agg_rate <- function(d, label) {
d %>% select(-any_of("pop")) %>% inner_join(pop_tbl, by = c("year", "lga")) %>%
group_by(year) %>%
summarise(rate = sum(incidents) / sum(pop) * 1e5, .groups = "drop") %>%
mutate(area = label)
}
# Overall rate trend
trend <- bind_rows(
lga %>% filter(lga %in% peers) %>% transmute(year, rate, area = lga),
agg_rate(lga %>% filter(lga %in% metro), "Metro Melbourne"),
agg_rate(lga, "Victoria")
)
# Offence division rates (summed from subgroups, divided by population)
off_lga <- off %>% group_by(year, lga, offence_division) %>%
summarise(incidents = sum(incidents, na.rm = TRUE), .groups = "drop") %>%
inner_join(pop_tbl, by = c("year", "lga")) %>%
mutate(rate = incidents / pop * 1e5)
off_plot <- bind_rows(
off_lga %>% filter(lga %in% peers) %>% transmute(year, area = lga, offence_division, rate),
off_lga %>% filter(lga %in% metro) %>% group_by(year, offence_division) %>%
summarise(rate = sum(incidents) / sum(pop) * 1e5, .groups = "drop") %>%
mutate(area = "Metro Melbourne")
)
person_div <- unique(off_lga$offence_division)[str_detect(unique(off_lga$offence_division),
regex("against the person", ignore_case = TRUE))][1]
# Location of incidents
loc_var <- if ("location_subdivision" %in% names(loc)) "location_subdivision" else "location_division"
loc_share <- bind_rows(
loc %>% filter(lga == "Port Phillip", year == latest) %>% mutate(area = "Port Phillip"),
loc %>% filter(lga %in% metro, year == latest) %>% mutate(area = "Metro Melbourne")
) %>%
group_by(area, location = .data[[loc_var]]) %>%
summarise(incidents = sum(incidents, na.rm = TRUE), .groups = "drop_last") %>%
mutate(share = incidents / sum(incidents) * 100) %>% ungroup()
# Suburbs inside Port Phillip
pp_sub <- sub %>% filter(lga == "Port Phillip", !is.na(suburb)) %>%
group_by(year, suburb, offence_division) %>%
summarise(incidents = sum(incidents, na.rm = TRUE), .groups = "drop")
pp_sub <- bind_rows(
pp_sub,
pp_sub %>% group_by(year, suburb) %>% summarise(incidents = sum(incidents), .groups = "drop") %>%
mutate(offence_division = "All offences")
) %>% complete(year, suburb, offence_division, fill = list(incidents = 0))
# ---- Numbers used in the narrative ----
fmt <- function(x) format(round(x), big.mark = ",")
get <- function(a, y = latest) trend %>% filter(area == a, year == y) %>% pull(rate)
pp_l <- get("Port Phillip"); met_l <- get("Metro Melbourne"); vic_l <- get("Victoria")
pp_chg <- (pp_l / get("Port Phillip", first) - 1) * 100
pp_rank <- lga %>% filter(year == latest, lga %in% metro) %>%
mutate(r = rank(-rate)) %>% filter(lga == "Port Phillip") %>% pull(r)
pp_person <- off_plot %>% filter(area == "Port Phillip", year == latest, offence_division == person_div) %>% pull(rate)
met_person <- off_plot %>% filter(area == "Metro Melbourne", year == latest, offence_division == person_div) %>% pull(rate)
street_pat <- regex("street|footpath", ignore_case = TRUE)
st_pp <- loc_share %>% filter(area == "Port Phillip", str_detect(location, street_pat)) %>% pull(share) %>% sum()
st_met <- loc_share %>% filter(area == "Metro Melbourne", str_detect(location, street_pat)) %>% pull(share) %>% sum()
sk_l <- pp_sub %>% filter(year == latest, suburb == "St Kilda", offence_division == "All offences") %>% pull(incidents)
bal_l <- pp_sub %>% filter(year == latest, suburb == "Balaclava", offence_division == "All offences") %>% pull(incidents)
bal_f <- pp_sub %>% filter(year == first, suburb == "Balaclava", offence_division == "All offences") %>% pull(incidents)
hl <- function(a, b) ifelse(a > b, "higher", "lower")
x_of <- function(a, b) sprintf("%.1f", a / b)
pp_prop_share <- off_lga %>% filter(year == latest, lga == "Port Phillip") %>%
mutate(s = incidents / sum(incidents) * 100) %>%
filter(str_detect(offence_division, regex("property", ignore_case = TRUE))) %>% pull(s)
pp_person_rank <- off_lga %>% filter(year == latest, lga %in% metro, offence_division == person_div) %>%
mutate(r = rank(-rate)) %>% filter(lga == "Port Phillip") %>% pull(r)
top_loc_met <- loc_share %>% filter(area == "Metro Melbourne") %>% slice_max(share, n = 1) %>% pull(location)
pp_sub_total <- pp_sub %>% filter(year == latest, offence_division == "All offences") %>% pull(incidents) %>% sum()
sk_person <- pp_sub %>% filter(year == latest, suburb == "St Kilda", offence_division == person_div) %>% pull(incidents)
bal_person <- pp_sub %>% filter(year == latest, suburb == "Balaclava", offence_division == person_div) %>% pull(incidents)
next_three <- lga %>% filter(year == latest, lga %in% metro, !lga %in% peers) %>%
slice_max(rate, n = 3) %>% pull(lga)
Most nights my shift ends long after the dinner rush. The last plates are washed, the floor is mopped, and then I walk out onto St Kilda’s streets to get home to Balaclava. Friends, family back in India, and even some customers have told me the same thing: “St Kilda at night? Be careful.”
So I decided to stop guessing and look at the data. This report uses the Crime Statistics Agency’s official records of every criminal incident in Victoria from 2017 to 2026 to answer one question: is my late-night walk home actually riskier than the rest of Melbourne, and if so, where and how?
In the year ending June 2026, the City of Port Phillip (which covers St Kilda and Balaclava) recorded 10,984 criminal incidents per 100,000 people. That is higher than metropolitan Melbourne (6,313) and higher than Victoria overall (6,464). That is about 1.7 times the metro average, and the rate has moved +16% since 2017. So the warnings are not made up.
Hover over the lines to compare areas, click legend items to hide them, and drag the slider below the chart to zoom into a period.
pal_trend <- c("Port Phillip" = "#D55E00", "Melbourne" = "#999999", "Yarra" = "#56B4E9",
"Stonnington" = "#009E73", "Glen Eira" = "#CC79A7",
"Metro Melbourne" = "#0072B2", "Victoria" = "#000000")
p1 <- plot_ly()
for (a in names(pal_trend)) {
d <- trend %>% filter(area == a)
p1 <- p1 %>% add_trace(
data = d, x = ~year, y = ~rate, type = "scatter", mode = "lines+markers", name = a,
line = list(color = pal_trend[[a]], width = ifelse(a == "Port Phillip", 4, 2),
dash = case_when(a == "Metro Melbourne" ~ "dash", a == "Victoria" ~ "dot", TRUE ~ "solid")),
marker = list(color = pal_trend[[a]], size = ifelse(a == "Port Phillip", 8, 5)),
hovertemplate = paste0("<b>", a, "</b><br>Year ending June %{x}<br>",
"%{y:,.0f} incidents per 100,000<extra></extra>"))
}
p1 %>% layout(
title = list(text = "Chart 1. Criminal incident rate: Port Phillip vs inner-city neighbours", x = 0),
xaxis = list(title = "Year ending June", rangeslider = list(visible = TRUE), dtick = 1),
yaxis = list(title = "Incidents per 100,000 population", rangemode = "tozero"),
legend = list(orientation = "h", y = -0.45),
annotations = list(list(x = latest, y = pp_l, text = "My council area",
showarrow = TRUE, arrowhead = 2, ax = -60, ay = -40))
)
But Port Phillip is never the highest line. The City of Melbourne, with the CBD’s huge daytime and night-time crowds, sits well above everyone. Port Phillip moves together with its nightlife neighbours Yarra and Stonnington, and sits far above Glen Eira, the quieter council right across the road from Balaclava.
Port Phillip ranks number 3 out of 31 metropolitan councils for overall crime rate, and number 4 for crimes against the person. The map lets you switch between all incidents and crimes against the person (assaults, robbery, threats and similar), which is the category that actually matters for someone walking alone at night.
Use the layer box (top right) to switch views. Hover for the rate, click a council for its five-year change.
lga_sf <- ozmaps::abs_lga %>% st_transform(4326)
name_col <- if ("NAME" %in% names(lga_sf)) "NAME" else names(lga_sf)[1]
lga_sf <- lga_sf %>%
mutate(lga = str_squish(str_remove(.data[[name_col]], "\\s*\\(.*\\)$")),
lga = recode(lga, "Moreland" = "Merri-bek"))
xy <- suppressWarnings(st_coordinates(st_point_on_surface(lga_sf)))
lga_sf <- lga_sf[xy[, 1] > 144 & xy[, 1] < 146.5 & xy[, 2] > -39 & xy[, 2] < -37.3 &
lga_sf$lga %in% metro, ]
map_dat <- lga_sf %>%
left_join(lga %>% filter(year == latest) %>% select(lga, rate_all = rate), by = "lga") %>%
left_join(lga %>% filter(year == latest - 5) %>% select(lga, rate_old = rate), by = "lga") %>%
left_join(off_lga %>% filter(year == latest, offence_division == person_div) %>%
select(lga, rate_person = rate), by = "lga") %>%
mutate(chg = (rate_all / rate_old - 1) * 100)
make_pal <- function(x) {
b <- unique(quantile(x, probs = seq(0, 1, 0.2), na.rm = TRUE))
colorBin("YlOrRd", domain = x, bins = b)
}
pal_all <- make_pal(map_dat$rate_all); pal_per <- make_pal(map_dat$rate_person)
lab_all <- sprintf("<b>%s</b><br>%s incidents per 100,000", map_dat$lga, fmt(map_dat$rate_all)) %>% lapply(HTML)
lab_per <- sprintf("<b>%s</b><br>%s crimes against the person per 100,000", map_dat$lga, fmt(map_dat$rate_person)) %>% lapply(HTML)
pop_txt <- sprintf("<b>%s</b><br>Change since %d: %+.0f%%", map_dat$lga, latest - 5, map_dat$chg)
hl_opt <- highlightOptions(weight = 3, color = "#222", bringToFront = TRUE)
leaflet(map_dat) %>%
addTiles() %>%
addPolygons(group = "All incidents", fillColor = ~pal_all(rate_all), fillOpacity = 0.75,
color = "white", weight = 1, label = lab_all, popup = pop_txt, highlightOptions = hl_opt) %>%
addPolygons(group = "Crimes against the person", fillColor = ~pal_per(rate_person), fillOpacity = 0.75,
color = "white", weight = 1, label = lab_per, popup = pop_txt, highlightOptions = hl_opt) %>%
addCircleMarkers(lng = c(144.9809, 144.9935), lat = c(-37.8676, -37.8693), radius = 6,
color = "#0072B2", fillOpacity = 1, stroke = TRUE, weight = 2,
label = c("St Kilda: where I work", "Balaclava: where I live")) %>%
addLegend(pal = pal_all, values = ~rate_all, group = "All incidents", position = "bottomright",
title = paste0("All incidents<br>per 100,000 (", latest, ")"),
labFormat = labelFormat(big.mark = ",", digits = 0)) %>%
addLegend(pal = pal_per, values = ~rate_person, group = "Crimes against the person",
position = "bottomleft", title = "Against the person<br>per 100,000",
labFormat = labelFormat(big.mark = ",", digits = 0)) %>%
addLayersControl(overlayGroups = c("All incidents", "Crimes against the person"),
options = layersControlOptions(collapsed = FALSE)) %>%
hideGroup("Crimes against the person") %>%
setView(lng = 145.05, lat = -37.85, zoom = 9)
Inner Melbourne forms a clear hot zone and Port Phillip is part of it. But the darkest band is not only the bayside nightlife strip. Maribyrnong, Greater Dandenong and Frankston sit right up there too, yet they rarely come up in the “be careful in St Kilda” conversation.
A high total rate does not automatically mean danger to people. For crimes against the person, Port Phillip recorded 1,383 per 100,000 compared with 943 across metro Melbourne, roughly 1.5 times higher. The chart below breaks every incident into its offence category.
Press play (or drag the slider) to watch how the mix changed year by year. Click legend items to compare just two areas.
pal_off <- c("Port Phillip" = "#D55E00", "Melbourne" = "#999999", "Yarra" = "#56B4E9",
"Stonnington" = "#009E73", "Glen Eira" = "#CC79A7", "Metro Melbourne" = "#0072B2")
off_plot %>%
mutate(area = factor(area, levels = names(pal_off)),
offence_division = fct_reorder(offence_division, rate, .fun = mean)) %>%
plot_ly(x = ~rate, y = ~offence_division, color = ~area, colors = pal_off,
frame = ~year, type = "bar", orientation = "h",
hovertemplate = "<b>%{fullData.name}</b><br>%{y}<br>%{x:,.0f} per 100,000<extra></extra>") %>%
layout(barmode = "group",
title = list(text = "Chart 3. Offence mix per 100,000 people, by council", x = 0),
xaxis = list(title = "Incidents per 100,000 population"),
yaxis = list(title = ""),
legend = list(orientation = "h", x = 0, y = 1.08),
margin = list(t = 90)) %>%
animation_opts(frame = 900, transition = 400, redraw = FALSE) %>%
animation_slider(currentvalue = list(prefix = "Year ending June "))
Property and deception offences (theft, burglary, car break-ins) are the biggest bar in every area. In Port Phillip they make up about 73% of all incidents. That changes the story: most of what pushes my council’s number up is about things, not about people walking home.
This is the question closest to my walk. In Port Phillip, the street and footpath is the single most common place for an incident: about 32% of them, compared with 21% across metro Melbourne, where the most common location is private homes.
Hover to see exact shares and counts. Click a legend item to isolate one area.
loc_share %>%
mutate(location = ifelse(location %in% (loc_share %>% group_by(location) %>%
summarise(m = max(share)) %>% slice_max(m, n = 8) %>% pull(location)),
location, "All other locations")) %>%
group_by(area, location) %>% summarise(share = sum(share), incidents = sum(incidents), .groups = "drop") %>%
mutate(location = fct_relevel(fct_reorder(location, share, .fun = max), "All other locations"),
area = factor(area, levels = c("Port Phillip", "Metro Melbourne"))) %>%
plot_ly(x = ~share, y = ~location, color = ~area, colors = c("#D55E00", "#0072B2"),
type = "bar", orientation = "h", customdata = ~incidents,
hovertemplate = "<b>%{fullData.name}</b><br>%{y}<br>%{x:.1f}% of incidents (%{customdata:,} recorded)<extra></extra>") %>%
layout(barmode = "group",
title = list(text = paste0("Chart 4. Where incidents happen, year ending June ", latest), x = 0),
xaxis = list(title = "Share of all incidents in the area (%)", ticksuffix = "%"),
yaxis = list(title = ""),
legend = list(orientation = "h", y = -0.2))
This is the chart that hit me most. In most of Melbourne, crime happens in or around homes. In my area it happens out on the street, exactly where I am at midnight. That fits what I see after shift: crowds, pubs, the Esplanade and Acland Street. Where crime happens is shaped by how a place is used, not just who lives there.
Zooming into the suburbs inside Port Phillip, St Kilda recorded 3,458 incidents in the latest year, the most of any suburb in the council. Balaclava, just a short walk away, recorded 779. For crimes against the person the gap is even wider: 555 in St Kilda against 77 in Balaclava. Note these are counts, not rates, because suburb populations are not published in this table.
Use the dropdown to switch offence type. Hover for exact counts, and double-click a suburb in the legend to isolate it.
divs <- c("All offences", sort(setdiff(unique(pp_sub$offence_division), "All offences")))
subs <- unique(pp_sub$suburb)
subs <- c(intersect(c("St Kilda", "Balaclava"), subs), sort(setdiff(subs, c("St Kilda", "Balaclava"))))
sub_col <- function(s) switch(s, "St Kilda" = "#D55E00", "Balaclava" = "#0072B2", "#BBBBBB")
p5 <- plot_ly()
for (d in divs) for (s in subs) {
dd <- pp_sub %>% filter(offence_division == d, suburb == s)
p5 <- p5 %>% add_trace(
data = dd, x = ~year, y = ~incidents, type = "scatter", mode = "lines+markers",
name = s, visible = (d == divs[1]), legendgroup = s,
line = list(color = sub_col(s), width = ifelse(s %in% c("St Kilda", "Balaclava"), 4, 1.5)),
marker = list(color = sub_col(s), size = 5),
hovertemplate = paste0("<b>", s, "</b><br>", d, "<br>Year ending June %{x}: %{y:,} incidents<extra></extra>"))
}
buttons <- map(seq_along(divs), function(i) list(
method = "restyle", label = divs[i],
args = list("visible", as.list(rep(divs == divs[i], each = length(subs))))))
p5 %>% layout(
title = list(text = "Chart 5. Incidents by suburb, Port Phillip", x = 0),
xaxis = list(title = "Year ending June", dtick = 1),
yaxis = list(title = "Recorded incidents", rangemode = "tozero"),
updatemenus = list(list(buttons = buttons, x = 1, y = 1.15, xanchor = "right", showactive = TRUE)),
margin = list(t = 80),
legend = list(orientation = "h", y = -0.25))
St Kilda alone accounts for about 27% of Port Phillip’s incidents, while Balaclava stays far lower across every offence type. In other words, the reputation of my council area is largely the reputation of one busy suburb. One honest caveat for my own street: Balaclava’s count has crept up from 518 in 2017 to 779 now, so the quiet end of my walk is getting a little less quiet.
Partly, yes. The data backs up the warnings more than I expected. Port Phillip is one of the three highest crime councils in metro Melbourne, crimes against the person run well above the metro average, and the street is the most common place it happens.
But the data also shows the risk is not spread evenly. Most incidents are property crime, and the people-related risk is packed into St Kilda’s busiest streets. For hospitality workers like me who finish late, that is the useful takeaway: the first part of the walk, through St Kilda’s nightlife core, is where attention matters most. By the time I reach Balaclava, the numbers drop sharply. The neighbourhood I come home to is not the neighbourhood people warn me about.
ozmaps R package. Merri-bek is shown under its current
name.Australian Bureau of Statistics. (2018). Australian Statistical Geography Standard (ASGS): Volume 3 - Non ABS structures, July 2018 (Cat. No. 1270.0.55.003). https://www.abs.gov.au/
Cheng, J., Schloerke, B., Karambelkar, B., & Xie, Y. (2026). leaflet: Create interactive web maps with the JavaScript ‘Leaflet’ library (Version 2.2.3) [Computer software]. https://CRAN.R-project.org/package=leaflet
Crime Statistics Agency. (2026). Data tables: LGA criminal incidents, year ending June 2026 [Data set]. https://files.crimestatistics.vic.gov.au/2026-09/Data_Tables_LGA_Criminal_Incidents_Year_Ending_June_2026.xlsx
Sievert, C. (2020). Interactive web-based data visualization with R, plotly, and shiny. Chapman and Hall/CRC. https://plotly-r.com
Sumner, M. (2026). ozmaps: Australia maps (Version 0.4.5) [Computer software]. https://CRAN.R-project.org/package=ozmaps
Wickham, H., Averick, M., Bryan, J., Chang, W., McGowan, L. D., François, R., Grolemund, G., Hayes, A., Henry, L., Hester, J., Kuhn, M., Pedersen, T. L., Miller, E., Bache, S. M., Müller, K., Ooms, J., Robinson, D., Seidel, D. P., Spinu, V., … Yutani, H. (2019). Welcome to the tidyverse. Journal of Open Source Software, 4(43), 1686. https://doi.org/10.21105/joss.01686
Data provided by the Crime Statistics Agency Victoria under a
Creative Commons Attribution 4.0 licence. Council boundaries from the
Australian Bureau of Statistics via the ozmaps package. AI
assistance (Claude, Anthropic) was used to help draft only. All the R
code; the story, interpretation and final edits are my own.