Every year, Victoria’s crime figures are read as a report card on how people behave. But some offences only exist because a court has put conditions on someone, and Parliament decides whether breaking those conditions is a crime. Between March 2024 and March 2025, Parliament changed its mind about one of them, and then changed it back. This report follows that decision through ten years of official data from Victoria’s Crime Statistics Agency.
Who this is for: voters, journalists and anyone weighing “tough on bail” promises ahead of the November 2026 Victorian state election. How to use it: every chart is interactive. Hover for exact figures, click legend items to hide or show a series, and use the grey buttons above a chart to switch views.
Most recorded crime is theft, property damage and assault. But one category, justice procedures offences, has been climbing steadily. This category covers breaches of family violence orders, intervention orders and bail conditions, and resisting police. Between 2016–17 and 2025–26 it grew by 45%, against 13% for all other offences combined.
Justice procedures offences grew from 69,279 to 100,463, the fastest growth of any offence division.
On 25 March 2024, the Bail Amendment Act 2023 commenced. It repealed the offences of committing an indictable offence while on bail and breaching a bail conduct condition (Parsons & George, 2024; Victoria Legal Aid, 2024). From 26 March 2025, further amendments made committing an indictable offence on bail a crime again (Robinson Gill, n.d.).
Recorded breach-of-bail offences fell from 16,664 in 2022–23 to 11,723 in 2024–25, a 30% drop. They then rebounded to 16,986 in 2025–26, the highest in the decade. Police bail decisions and court applications involving a breach charge trace the same shape.
All three breach-of-bail measures fall across 2023-24 and 2024-25 and rise sharply in 2025-26.
The data can’t separate the legal change from other influences, such as police enforcement priorities, the wider tightening of the bail test in 2025, and court workloads. This is a strong descriptive association with a clear legal mechanism, not a controlled causal estimate.
Each bubble is a local government area (LGA). The horizontal position is its breach-of-bail rate per 100,000 residents before a law change, and the vertical position is the rate after. Bubbles above the diagonal rose. During the repeal period, 62 of 79 LGAs fell. In the reinstatement year, 72 of 79 rose. All four police regions increased, led by Eastern, where the rate per head rose 58%. Among larger councils (50,000+ residents), Maroondah rose most, from 361 to 684 per 100,000.
In the repeal period 62 of 79 LGAs fall below the diagonal; in the reinstatement year 72 of 79 rise above it.
Rates in small LGAs rest on few offences, and offences are counted where they are recorded, not where the person lives. Differences between areas may reflect policing practice and access to bail support services as much as offending.
The 2025 reforms did more than restore one offence. They also tightened the bail test. In the Magistrates’ Court, the share of women’s bail applications refused rose from 23.6% in 2024–25 to 31.3% in 2025–26. Men’s rate moved from 33.7% to 34.8%. The rise was steepest for women aged 18–24 (16.9% → 28.5%). Applications that involved no breach-of-bail offence were also refused more often: 29.2% in 2023–24 and 32.6% in 2025–26.
Women's bail refusal rate rose from 23.6% to 31.3%, nearly reaching men's rate of 34.8%.
Court bail tables start in 2020–21, and Indigenous status is only published from 2024–25, so a longer trend can’t be shown. Refusal rates don’t account for differences in the seriousness of the charges.
When a council area tops a crime league table, it is natural to assume more people there are offending. Yet justice procedures offences are 16% of all offences statewide, and 18 LGAs are at 25% or more. In Benalla the share reaches 35%, compared with 10% in the City of Melbourne. Comparing LGAs on headline totals partly compares how many court orders each area manages and how closely they are policed.
Justice procedures offences are 16% of offences statewide; 18 LGAs are at 25% or above, led by Benalla at 35%.
What the data shows.
What it can’t show.
The takeaway for election season: before using a crime figure as evidence that “crime is out of control”, or under control, ask how much of it the law itself created.
| Dataset (CSA, year ending June 2026) | Unit of observation | Period | Rows | Used in |
|---|---|---|---|---|
| LGA Recorded Offences (Tables 01, 02) | Offences recorded, by LGA × offence subgroup × financial year | 2016–17 to 2025–26 | 52,809 | Charts 1, 2, 3, 5 |
| Victoria Police bail data tables (Table 04) | Initial police bail decisions by breach-offence involvement × year | 2016–17 to 2025–26 | 50 | Chart 2 |
| Magistrates’ Court bail data tables (Tables 01, 02, 04) | Bail applications by outcome × sex × age group (and × Indigenous status, × breach involvement) × year | 2020–21 to 2025–26 | 270 | Charts 2, 4 |
All three tables come from one independent source, the Crime Statistics Agency (CSA) Victoria, published September 2026 under a CC BY 3.0 AU licence. The CSA compiles them from Victoria Police’s LEAP database and Court Services Victoria records.
Quality checks performed in the code:
| Chart | Interaction | Question it lets the reader ask |
|---|---|---|
| 1 | Growth ↔︎ counts toggle; legend filter | Is justice-procedure growth large in absolute terms as well as relative terms? |
| 2 | Counts ↔︎ index toggle; shaded law window | Do independent systems move together around the law change? |
| 3 | Period switch (repeal vs reinstatement); region filter; bubble hover | Which LGAs and regions drove each movement? |
| 4 | Linked legend across five panels; hover with refused/total | Which age and sex groups were refused more often? |
| 5 | Re-sort by share, change or name; region filter | Where does my council sit, and how has that changed? |
All code used to load, validate, preprocess, analyse and visualise the data. It is the same code that runs above, shown here in order.
# ---------------------------------------------------------------------------
# 0. SETUP
# Packages (install once if needed):
# install.packages(c("readxl", "dplyr", "tidyr", "stringr", "purrr",
# "scales", "plotly", "htmltools", "knitr", "rmarkdown"))
# ---------------------------------------------------------------------------
knitr::opts_chunk$set(echo = FALSE, message = FALSE, warning = FALSE,
fig.align = "center")
library(readxl) # read the official CSA .xlsx tables
library(dplyr) # data wrangling
library(tidyr) # reshaping
library(stringr) # string helpers
library(purrr) # iteration
library(scales) # number formatting
library(plotly) # interactive charts (all visualisations built in R)
library(htmltools) # accessible figure wrappers
# ---------------------------------------------------------------------------
# 1. DATA LOADING
# All data come from ONE publisher: the Crime Statistics Agency (CSA) Victoria,
# release "year ending June 2026" (published September 2026).
# Files are read from the local data/ folder. If a file is missing, it is
# downloaded directly from the CSA website, so the analysis is reproducible.
# ---------------------------------------------------------------------------
data_dir <- "data"
dir.create(data_dir, showWarnings = FALSE)
csa_base <- "https://files.crimestatistics.vic.gov.au/2026-09/"
sources <- tibble::tribble(
~key, ~local_file, ~remote_file,
"offences", "Data_Tables_LGA_Recorded_Offences_Year_Ending_June_2026.xlsx", "Data_Tables_LGA_Recorded_Offences_Year_Ending_June_2026.xlsx",
"police", "Victoria_Police_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_0.xlsx", "Victoria_Police_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_0.xlsx",
"court", "Magistrates_Court_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_1.xlsx", "Magistrates%27_Court_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_1.xlsx"
) %>%
mutate(path = file.path(data_dir, local_file),
url = paste0(csa_base, remote_file))
for (i in seq_len(nrow(sources))) {
if (!file.exists(sources$path[i])) {
# accept the browser's original file name (some keep the apostrophe in "Magistrates'")
on_disk <- list.files(data_dir)
match <- on_disk[str_remove_all(on_disk, "['\u2019]") == sources$local_file[i]]
if (length(match)) {
sources$path[i] <- file.path(data_dir, match[1])
} else {
download.file(sources$url[i], sources$path[i], mode = "wb")
}
}
}
path_of <- function(k) sources$path[sources$key == k]
# Recorded offences by LGA (Table 01) and by offence subgroup x LGA (Table 02)
lga_tot_raw <- read_excel(path_of("offences"), sheet = "Table 01")
lga_off_raw <- read_excel(path_of("offences"), sheet = "Table 02")
# Victoria Police initial bail decisions, by breach-of-bail involvement (Table 04)
vp_breach_raw <- read_excel(path_of("police"), sheet = "Table 04")
# Magistrates' Court bail applications: outcome x sex x age (Table 01),
# outcome x breach-of-bail involvement (Table 04), outcome x Indigenous status (Table 02)
mc_age_raw <- read_excel(path_of("court"), sheet = "Table 01")
mc_breach_raw <- read_excel(path_of("court"), sheet = "Table 04")
mc_ind_raw <- read_excel(path_of("court"), sheet = "Table 02")
# ---------------------------------------------------------------------------
# 2. VALIDATION — stop rendering if the published structure has changed
# ---------------------------------------------------------------------------
expect_cols <- function(df, cols, name) {
missing <- setdiff(cols, names(df))
if (length(missing)) stop(name, " is missing columns: ", paste(missing, collapse = ", "))
}
expect_cols(lga_tot_raw, c("Year", "Police Region", "Local Government Area",
"Offence Count", "Rate per 100,000 population"), "Offences Table 01")
expect_cols(lga_off_raw, c("Year", "Local Government Area", "Offence Division",
"Offence Subgroup", "Offence Count"), "Offences Table 02")
expect_cols(vp_breach_raw, c("Year", "Bail Offence Type", "Breach Offence", "Record Count"), "Police Table 04")
expect_cols(mc_age_raw, c("Year", "Outcome", "Sex", "Age Group", "Record Count"), "Court Table 01")
expect_cols(mc_breach_raw, c("Year", "Bail Outcome", "Breach of Bail Offence", "Record Count"), "Court Table 04")
stopifnot(
all(2017:2026 %in% lga_off_raw$Year), # ten financial years
!anyNA(lga_off_raw$`Offence Count`),
all(lga_off_raw$`Offence Count` >= 0),
any(str_starts(lga_off_raw$`Offence Subgroup`, "E23")), # breach bail subgroup exists
all(2021:2026 %in% mc_age_raw$Year)
)
# ---------------------------------------------------------------------------
# 3. PREPROCESSING
# ---------------------------------------------------------------------------
# Financial-year label: CSA "Year 2026, Year ending June" = July 2025 to June 2026
fy <- function(y) paste0(y - 1, "–", str_sub(y, 3, 4))
latest <- max(lga_off_raw$Year)
first <- min(lga_off_raw$Year)
lga_off <- lga_off_raw %>%
transmute(year = Year,
lga = `Local Government Area`,
division = `Offence Division`,
div_code = str_sub(`Offence Division`, 1, 1),
subgroup = `Offence Subgroup`,
sub_code = str_extract(`Offence Subgroup`, "^[A-F][0-9]+"),
n = `Offence Count`)
# LGA population is not published as a column, but CSA publishes the count and
# the rate per 100,000 for each LGA-year. Population = count / rate * 100,000.
# "Total", justice institutions and unincorporated areas have no rate and are dropped.
lga_pop <- lga_tot_raw %>%
transmute(year = Year,
lga = `Local Government Area`,
region = str_remove(`Police Region`, "^[0-9]+ "),
total_t01 = `Offence Count`,
rate_t01 = `Rate per 100,000 population`) %>%
filter(!is.na(rate_t01), rate_t01 > 0,
!lga %in% c("Total", "Justice Institutions and Immigration Facilities", "Unincorporated Vic")) %>%
mutate(pop = total_t01 / rate_t01 * 1e5)
# One row per LGA-year with the measures used in charts 3 and 5
lga_year <- lga_off %>%
group_by(year, lga) %>%
summarise(all_off = sum(n),
justice = sum(n[div_code == "E"]),
breach_bail = sum(n[sub_code == "E23"]),
.groups = "drop") %>%
inner_join(lga_pop, by = c("year", "lga")) %>%
mutate(breach_rate = breach_bail / pop * 1e5,
justice_share = justice / all_off)
# Quality check: Table 02 (summed) vs Table 01 totals per LGA-year
recon <- lga_year %>% summarise(abs_diff = sum(abs(all_off - total_t01)),
total = sum(total_t01))
# Statewide offence divisions (Chart 1). Division F "Other offences" is
# shown separately because a COVID-19 public-health spike distorts the scale.
div_year <- lga_off %>%
group_by(year, div_code, division) %>%
summarise(n = sum(n), .groups = "drop") %>%
group_by(division) %>%
mutate(index = n / n[year == first] * 100) %>%
ungroup()
split_year <- lga_off %>%
mutate(grp = if_else(div_code == "E", "justice", "other")) %>%
group_by(year, grp) %>% summarise(n = sum(n), .groups = "drop") %>%
pivot_wider(names_from = grp, values_from = n) %>%
mutate(share = justice / (justice + other))
covid_peak <- lga_off %>% filter(sub_code == "F92") %>%
group_by(year) %>% summarise(n = sum(n)) %>% slice_max(n, n = 1)
# Three independent measuring systems for breach of bail (Chart 2)
bail_series <- bind_rows(
lga_off %>% filter(sub_code == "E23") %>% group_by(year) %>%
summarise(n = sum(n)) %>%
mutate(series = "Breach-of-bail offences recorded by police"),
vp_breach_raw %>% filter(`Breach Offence` == "Breach bail offence") %>%
group_by(year = Year) %>% summarise(n = sum(`Record Count`)) %>%
mutate(series = "Police bail decisions involving a breach-of-bail charge"),
mc_breach_raw %>% filter(`Breach of Bail Offence` == "Breach bail offence") %>%
group_by(year = Year) %>% summarise(n = sum(`Record Count`)) %>%
mutate(series = "Magistrates' Court bail applications involving a breach-of-bail offence")
) %>%
group_by(series) %>%
mutate(index = n / n[year == 2023] * 100) %>% ungroup()
# Court outcomes by sex and age (Chart 4)
court_sexage <- mc_age_raw %>%
group_by(year = Year, sex = Sex, age = `Age Group`) %>%
summarise(refused = sum(`Record Count`[Outcome == "Bail refused"]),
applications = sum(`Record Count`),
.groups = "drop") %>%
mutate(refusal = refused / applications,
sex = recode(sex, Females = "Women", Males = "Men", Total = "All applicants"),
age = recode(age, "18-24* years" = "18–24", "25-34 years" = "25–34",
"35-44 years" = "35–44", "45+ years" = "45+", "Total" = "All ages"))
court_breach <- mc_breach_raw %>%
filter(`Breach of Bail Offence` != "Total") %>%
group_by(year = Year, breach = `Breach of Bail Offence`) %>%
summarise(refusal = sum(`Record Count`[`Bail Outcome` == "Bail refused"]) / sum(`Record Count`),
.groups = "drop")
court_ind <- mc_ind_raw %>%
filter(Sex == "Total", `Age Group` == "Total") %>%
group_by(year = Year, status = `Indigenous Status`) %>%
summarise(applications = sum(`Record Count`), .groups = "drop")
# ---------------------------------------------------------------------------
# 4. KEY NUMBERS used in the text — all calculated, none typed in by hand
# ---------------------------------------------------------------------------
pct <- function(x, acc = 1) percent(x, accuracy = acc)
num <- function(x) comma(round(x))
chg <- function(a, b) (b - a) / a
sy <- split_year
justice_growth <- chg(sy$justice[sy$year == first], sy$justice[sy$year == latest])
other_growth <- chg(sy$other[sy$year == first], sy$other[sy$year == latest])
share_first <- sy$share[sy$year == first]
share_latest <- sy$share[sy$year == latest]
one_in <- round(1 / share_latest)
e23 <- bail_series %>% filter(str_starts(series, "Breach-of-bail offences recorded"))
e23_2023 <- e23$n[e23$year == 2023]; e23_2025 <- e23$n[e23$year == 2025]
e23_2026 <- e23$n[e23$year == latest]
e23_is_max <- e23_2026 == max(e23$n)
vp <- bail_series %>% filter(str_starts(series, "Police bail"))
mcb <- bail_series %>% filter(str_starts(series, "Magistrates"))
w <- court_sexage %>% filter(sex == "Women", age == "All ages")
m <- court_sexage %>% filter(sex == "Men", age == "All ages")
a <- court_sexage %>% filter(sex == "All applicants", age == "All ages")
w25 <- w$refusal[w$year == 2025]; w26 <- w$refusal[w$year == latest]
m25 <- m$refusal[m$year == 2025]; m26 <- m$refusal[m$year == latest]
yw <- court_sexage %>% filter(sex == "Women", age == "18–24")
apps_growth <- chg(a$applications[a$year == 2025], a$applications[a$year == latest])
cb <- court_breach
nob_2024 <- cb$refusal[cb$year == 2024 & cb$breach == "No breach bail offence"]
nob_2026 <- cb$refusal[cb$year == latest & cb$breach == "No breach bail offence"]
ind_share <- court_ind %>% filter(year == latest) %>%
summarise(s = applications[status == "Aboriginal and/or Torres Strait Islander"] /
applications[status == "Total"]) %>% pull(s)
ly <- lga_year %>% filter(year == latest)
state_share <- share_latest
top_share <- ly %>% slice_max(justice_share, n = 1)
n_third <- sum(ly$justice_share >= 0.25)
melb_share <- ly$justice_share[ly$lga == "Melbourne"]
region_share <- ly %>% group_by(region) %>%
summarise(share = sum(justice) / sum(all_off)) %>% arrange(desc(share))
# ---------------------------------------------------------------------------
# 5. SHARED VISUAL SYSTEM
# Okabe–Ito colour-blind-safe palette; line dash and marker shape are used as
# redundant cues so no chart relies on colour alone.
# ---------------------------------------------------------------------------
pal <- list(accent = "#D55E00", blue = "#0072B2", green = "#009E73",
orange = "#E69F00", purple = "#CC79A7", sky = "#56B4E9",
ink = "#1f2328", muted = "#5b6168", grid = "#e9e9e9", band = "#f3e3d6")
region_cols <- c("North West Metro" = pal$blue, "Eastern" = pal$orange,
"Southern Metro" = pal$green, "Western" = pal$purple)
font_base <- list(family = "Source Sans 3, Helvetica, Arial, sans-serif", size = 14, color = pal$ink)
axis_style <- function(title, ...) list(
title = list(text = title, standoff = 12, font = list(size = 13)),
gridcolor = pal$grid, zeroline = FALSE, linecolor = "#bbbbbb", ticks = "outside",
tickcolor = "#bbbbbb", tickfont = list(size = 12), automargin = TRUE, ...)
year_axis <- function(years, title = "Financial year (July–June)", ...) {
axis_style(title, tickmode = "array", tickvals = years, ticktext = fy(years), tickangle = 0, ...)
}
# plotly needs customdata as a list of per-point rows (a bare matrix is dropped)
cd <- function(...) unname(asplit(cbind(...), 1))
btn <- function(label, method, args) list(label = label, method = method, args = args)
# frame(): applies the common layout — title + subtitle, toggle buttons,
# legend and source note positioned in pixels so nothing overlaps.
frame <- function(p, height, title, subtitle, source, buttons = NULL,
t = NULL, b = 190, l = 80, r = 30, legend = NULL,
button_px = 14, extra_annotations = list(), ...) {
if (is.null(t)) t <- if (is.null(buttons)) 100 else 135
h <- height - t - b
if (is.null(legend)) legend <- list(orientation = "h", x = 0, xanchor = "left",
y = -70 / h, yanchor = "top", font = list(size = 13))
p <- p %>% layout(
font = font_base, paper_bgcolor = "white", plot_bgcolor = "white",
hoverlabel = list(font = list(family = font_base$family, size = 13), align = "left"),
margin = list(l = l, r = r, t = t, b = b),
title = list(text = paste0("<b>", title, "</b><br><span style='font-size:14px;color:#5b6168'>",
subtitle, "</span>"),
x = 0, xanchor = "left", xref = "paper",
y = 1, yanchor = "top", yref = "container", pad = list(t = 14),
font = list(size = 19, family = font_base$family)),
legend = legend, ...)
p <- plotly_build(p)
src <- list(text = source, xref = "paper", yref = "paper", x = 0, y = 0,
xanchor = "left", yanchor = "top", yshift = -(b - 62), showarrow = FALSE,
align = "left", font = list(size = 11, color = pal$muted))
p$x$layout$annotations <- c(p$x$layout$annotations, extra_annotations, list(src))
if (!is.null(buttons)) {
p$x$layout$updatemenus <- list(list(
type = "buttons", direction = "right", showactive = TRUE, active = 0,
x = 0, xanchor = "left", y = 1 + button_px / h, yanchor = "bottom",
pad = list(r = 4, t = 0), bgcolor = "#f6f5f2", bordercolor = "#c9c9c9",
font = list(size = 13), buttons = buttons))
}
p$height <- height
p %>% config(displaylogo = FALSE, responsive = TRUE,
modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d"),
toImageButtonOptions = list(format = "png", scale = 2, filename = "csa_bail_chart"))
}
# Accessible wrapper: a <figure> with an aria-label, a screen-reader summary
# of the key pattern, and a visible caption carrying the full source note
chart_figure <- function(widget, label, caption, sr_text) {
tags$figure(class = "chart", role = "figure", `aria-label` = label,
widget,
tags$p(class = "sr-only", sr_text),
tags$figcaption(HTML(caption)))
}
# Law-change dates expressed on the financial-year axis
# (a point at x = 2024 represents the year ending 30 June 2024)
date_to_x <- function(d) {
d <- as.Date(d)
yr_end <- as.numeric(format(d, "%Y")) + ifelse(as.numeric(format(d, "%m")) > 6, 1, 0)
yr_end - as.numeric(as.Date(paste0(yr_end, "-06-30")) - d) / 365.25
}
repeal_x <- date_to_x("2024-03-25")
reinstate_x <- date_to_x("2025-03-26")
c1 <- div_year %>% filter(div_code != "F") %>%
mutate(label = recode(str_remove(division, "^[A-E] "),
"Property and deception offences" = "Property & deception",
"Public order and security offences" = "Public order & security",
"Justice procedures offences" = "Justice procedures"))
c1_cols <- c("Justice procedures" = pal$accent, "Crimes against the person" = pal$blue,
"Property & deception" = pal$orange, "Drug offences" = pal$green,
"Public order & security" = pal$purple)
c1_dash <- c("Justice procedures" = "solid", "Crimes against the person" = "dot",
"Property & deception" = "dash", "Drug offences" = "dashdot",
"Public order & security" = "longdash")
labs1 <- names(c1_cols)
p1 <- plot_ly()
for (mode in c("index", "n")) {
for (lb in labs1) {
d <- c1 %>% filter(label == lb)
hero <- lb == "Justice procedures"
p1 <- p1 %>% add_trace(
x = d$year, y = if (mode == "index") d$index else d$n,
type = "scatter", mode = if (hero) "lines+markers+text" else "lines+markers",
name = lb, legendgroup = lb, showlegend = (mode == "index"), visible = (mode == "index"),
line = list(color = c1_cols[[lb]], width = if (hero) 4 else 2, dash = c1_dash[[lb]]),
marker = list(color = c1_cols[[lb]], size = if (hero) 8 else 5),
opacity = if (hero) 1 else 0.8,
text = if (hero) ifelse(d$year == latest, paste0("<b>", if (mode == "index") round(d$index) else comma(d$n), "</b>"), "") else NULL,
textposition = "top center", textfont = list(color = pal$accent, size = 13),
customdata = cd(fy(d$year), comma(d$n), round(d$index)),
hovertemplate = paste0("<b>", lb, "</b><br>%{customdata[0]}<br>%{customdata[1]} offences<br>",
"Index: %{customdata[2]} (", fy(first), " = 100)<extra></extra>"))
}
}
vis_index <- rep(c(TRUE, FALSE), each = length(labs1))
p1 <- frame(p1, height = 620,
title = paste0("Justice procedures offences: +", pct(justice_growth), " in a decade"),
subtitle = paste0("Recorded offences in Victoria by offence division, ", fy(first), " to ", fy(latest)),
source = paste0("Source: Crime Statistics Agency (2026a), LGA Recorded Offences, Table 02. Division F 'Other offences' (under 1% of offences) omitted:<br>",
"COVID-19 public-health breaches peaked at ", comma(covid_peak$n), " in ", fy(covid_peak$year), ", another case of new law creating new recorded crime."),
buttons = list(
btn(paste0("Growth since ", fy(first)), "update",
list(list(visible = vis_index, showlegend = vis_index),
list("yaxis.title.text" = paste0("Index (", fy(first), " = 100)"), "shapes[0].visible" = TRUE))),
btn("Number of offences", "update",
list(list(visible = !vis_index, showlegend = !vis_index),
list("yaxis.title.text" = "Offences recorded per year", "shapes[0].visible" = FALSE)))),
xaxis = year_axis(first:latest, range = c(first - 0.4, latest + 0.4)),
yaxis = axis_style(paste0("Index (", fy(first), " = 100)"), tickformat = ",d"),
shapes = list(list(type = "line", x0 = first - 0.4, x1 = latest + 0.4, y0 = 100, y1 = 100,
line = list(color = "#8c8c8c", width = 1, dash = "dot"))),
hovermode = "closest")
chart_figure(p1, "Line chart of recorded offences by offence division, 2016-17 to 2025-26",
paste0("<b>Chart 1.</b> Each line shows how one offence division changed relative to ", fy(first),
". Justice procedures offences (solid line) rose ", pct(justice_growth), "; all other offences combined rose ",
pct(other_growth), ". Their share of all recorded offences moved from ", pct(share_first), " to ", pct(share_latest),
". Offences at justice institutions, in unincorporated Victoria and at unknown locations are excluded because CSA does not publish them by LGA."),
paste0("Justice procedures offences grew from ", comma(sy$justice[sy$year == first]), " to ",
comma(sy$justice[sy$year == latest]), ", the fastest growth of any offence division."))
s_cols <- c(pal$accent, pal$blue, pal$green)
s_sym <- c("circle", "square", "diamond")
s_names <- unique(bail_series$series)
s_short <- c("Recorded offences (police)", "Police bail decisions", "Court bail applications")
ns <- length(s_names)
p2 <- plot_ly()
for (mode in c("n", "index")) {
for (i in seq_along(s_names)) {
d <- bail_series %>% filter(series == s_names[i])
p2 <- p2 %>% add_trace(
x = d$year, y = if (mode == "n") d$n else d$index,
type = "scatter", mode = "lines+markers", name = s_names[i],
legendgroup = s_names[i], showlegend = (mode == "n"), visible = (mode == "n"),
line = list(color = s_cols[i], width = if (i == 1) 4 else 2.5),
marker = list(color = s_cols[i], symbol = s_sym[i], size = 9, line = list(color = "white", width = 1)),
customdata = cd(fy(d$year), comma(d$n), round(d$index)),
hovertemplate = paste0("<b>", s_short[i], "</b><br>%{customdata[0]}: %{customdata[1]}",
"<br>Index (2022–23 = 100): %{customdata[2]}<extra></extra>"))
}
}
p2 <- frame(p2, height = 640,
title = "Breach-of-bail numbers fell while the offence was repealed, then rebounded",
subtitle = paste0("Breach-of-bail activity in three Victorian justice record systems, ", fy(first), " to ", fy(latest)),
source = paste0("Sources: Crime Statistics Agency (2026a) Table 02, subgroup E23 Breach bail conditions; (2026c) Victoria Police bail tables, Table 04;<br>",
"(2026b) Magistrates' Court bail tables, Table 04, available from 2020–21. Counts are offences, decisions and applications, not unique people."),
b = 230, legend = list(orientation = "v", x = 0, xanchor = "left", y = -70 / (640 - 150 - 230), yanchor = "top", font = list(size = 13)),
buttons = list(
btn("Counts", "update", list(list(visible = rep(c(TRUE, FALSE), each = ns), showlegend = rep(c(TRUE, FALSE), each = ns)),
list("yaxis.title.text" = "Number per year"))),
btn("Index (2022–23 = 100)", "update", list(list(visible = rep(c(FALSE, TRUE), each = ns), showlegend = rep(c(FALSE, TRUE), each = ns)),
list("yaxis.title.text" = "Index (2022–23 = 100)")))),
xaxis = year_axis(first:latest, range = c(first - 0.4, latest + 0.4)),
yaxis = axis_style("Number per year", rangemode = "tozero", tickformat = ",d"),
shapes = list(list(type = "rect", xref = "x", yref = "paper", x0 = repeal_x, x1 = reinstate_x,
y0 = 0, y1 = 1, fillcolor = pal$band, opacity = 0.8, line = list(width = 0), layer = "below")),
extra_annotations = list(list(
x = (repeal_x + reinstate_x) / 2, y = 0.03, xref = "x", yref = "paper", yanchor = "bottom",
text = "<b>Offending on bail<br>not a crime</b><br>25 Mar 2024 –<br>26 Mar 2025",
showarrow = FALSE, font = list(size = 11, color = "#8a3b00"), bgcolor = "rgba(255,255,255,0.7)")),
hovermode = "closest")
chart_figure(p2, "Line chart of three breach of bail measures with the repeal period shaded",
paste0("<b>Chart 2.</b> All three series dip during or after the repeal window and rise once the offence returns. Recorded offences: ",
num(e23_2023), " → ", num(e23_2025), " → ", num(e23_2026),
". Police decisions with a breach charge: ", num(vp$n[vp$year == 2023]), " → ", num(vp$n[vp$year == 2025]), " → ",
num(vp$n[vp$year == latest]), ". Court applications: ", num(mcb$n[mcb$year == 2023]), " → ",
num(mcb$n[mcb$year == 2025]), " → ", num(mcb$n[mcb$year == latest]), " (", fy(2023), " → ", fy(2025), " → ", fy(latest), ")."),
"All three breach-of-bail measures fall across 2023-24 and 2024-25 and rise sharply in 2025-26.")
pair_data <- function(y0, y1) {
lga_year %>% filter(year %in% c(y0, y1)) %>%
select(year, lga, region, pop, breach_bail, breach_rate) %>%
pivot_wider(names_from = year, values_from = c(pop, breach_bail, breach_rate)) %>%
rename(pop = !!paste0("pop_", y1),
n0 = !!paste0("breach_bail_", y0), n1 = !!paste0("breach_bail_", y1),
r0 = !!paste0("breach_rate_", y0), r1 = !!paste0("breach_rate_", y1)) %>%
filter(!is.na(r0), !is.na(r1)) %>%
mutate(period = paste0(fy(y0), " → ", fy(y1)))
}
pairs <- list(pair_data(2023, 2025), pair_data(2025, latest))
n_fell <- sum(pairs[[1]]$r1 < pairs[[1]]$r0); n_lga1 <- nrow(pairs[[1]])
n_rose <- sum(pairs[[2]]$r1 > pairs[[2]]$r0); n_lga2 <- nrow(pairs[[2]])
reg_rebound <- pairs[[2]] %>% group_by(region) %>%
summarise(r0 = sum(n0) / sum(pop) * 1e5, r1 = sum(n1) / sum(pop) * 1e5) %>%
mutate(change = chg(r0, r1)) %>% arrange(desc(change))
big_riser <- pairs[[2]] %>% filter(pop >= 50000) %>% mutate(d = r1 - r0) %>% slice_max(d, n = 1)
regions <- names(region_cols)
nr <- length(regions)
max_r <- max(unlist(map(pairs, ~ c(.x$r0, .x$r1)))) * 1.05
p3 <- plot_ly()
for (k in seq_along(pairs)) {
for (rg in regions) {
d <- pairs[[k]] %>% filter(region == rg)
p3 <- p3 %>% add_trace(
x = d$r0, y = d$r1, type = "scatter", mode = "markers",
name = rg, legendgroup = rg, showlegend = (k == 2), visible = (k == 2),
marker = list(size = d$pop, sizemode = "area", sizeref = 2 * max(lga_year$pop) / (40^2), sizemin = 4,
color = region_cols[[rg]], opacity = 0.75, line = list(color = "white", width = 1)),
customdata = cd(d$lga, comma(d$n0), comma(d$n1), round(d$r0), round(d$r1),
comma(round(d$pop, -2)), d$period),
hovertemplate = paste0("<b>%{customdata[0]}</b> (", rg, ")<br>%{customdata[6]}<br>",
"Breach-of-bail offences: %{customdata[1]} → %{customdata[2]}<br>",
"Rate per 100,000: %{customdata[3]} → %{customdata[4]}<br>",
"Population ≈ %{customdata[5]}<extra></extra>"))
}
}
p3 <- p3 %>% add_trace(x = c(0, max_r), y = c(0, max_r), type = "scatter", mode = "lines",
line = list(color = "#8c8c8c", dash = "dot", width = 1.5),
hoverinfo = "skip", showlegend = FALSE, name = "No change")
axis_titles <- list(
list(x = paste0("Rate per 100,000 residents, ", fy(2023), " (before repeal)"),
y = paste0("Rate, ", fy(2025), " (repeal in force)")),
list(x = paste0("Rate per 100,000 residents, ", fy(2025), " (repeal in force)"),
y = paste0("Rate, ", fy(latest), " (offence reinstated)")))
mk_vis <- function(k) c(rep(k == 1, nr), rep(k == 2, nr), TRUE)
mk_btn <- function(k, label) btn(label, "update",
list(list(visible = mk_vis(k), showlegend = c(rep(k == 1, nr), rep(k == 2, nr), FALSE)),
list("xaxis.title.text" = axis_titles[[k]]$x, "yaxis.title.text" = axis_titles[[k]]$y)))
p3 <- frame(p3, height = 700,
title = "Breach-of-bail rates by LGA, before and after each law change",
subtitle = "Each bubble is an LGA, sized by population. Above the dotted line = rate rose; below = rate fell",
source = paste0("Source: Crime Statistics Agency (2026a), LGA Recorded Offences, Tables 01–02. Rate = E23 breach-of-bail offences ÷ population × 100,000;<br>",
"population derived from CSA's published count and rate. Justice institutions and unincorporated Victoria excluded."),
buttons = list(mk_btn(2, paste0("Reinstatement: ", fy(2025), " → ", fy(latest))),
mk_btn(1, paste0("Repeal: ", fy(2023), " → ", fy(2025)))),
xaxis = axis_style(axis_titles[[2]]$x, range = c(0, max_r)),
yaxis = axis_style(axis_titles[[2]]$y, range = c(0, max_r)),
extra_annotations = list(
list(x = max_r * 0.2, y = max_r * 0.92, text = "<b>ROSE</b> ↑", showarrow = FALSE,
font = list(color = pal$muted, size = 13)),
list(x = max_r * 0.88, y = max_r * 0.08, text = "<b>FELL</b> ↓", showarrow = FALSE,
font = list(color = pal$muted, size = 13))),
hovermode = "closest")
chart_figure(p3, "Bubble scatter of LGA breach of bail rates before and after each law change",
paste0("<b>Chart 3.</b> Default view: the reinstatement year. ", n_rose, " of ", n_lga2,
" LGAs recorded a higher breach-of-bail rate in ", fy(latest), " than in ", fy(2025),
". Change in rate per head by police region: ",
paste0(reg_rebound$region, " ", ifelse(reg_rebound$change > 0, "+", ""), pct(reg_rebound$change), collapse = "; "), "."),
paste0("In the repeal period ", n_fell, " of ", n_lga1, " LGAs fall below the diagonal; in the reinstatement year ",
n_rose, " of ", n_lga2, " rise above it."))
ages <- c("18–24", "25–34", "35–44", "45+", "All ages")
sex_cols <- c("Women" = pal$accent, "Men" = pal$blue, "All applicants" = "#8c8c8c")
sex_sym <- c("Women" = "circle", "Men" = "square", "All applicants" = "circle-open")
yr4 <- sort(unique(court_sexage$year))
panels <- map(seq_along(ages), function(j) {
pp <- plot_ly()
for (sx in names(sex_cols)) {
d <- court_sexage %>% filter(age == ages[j], sex == sx)
pp <- pp %>% add_trace(
x = d$year, y = d$refusal, type = "scatter", mode = "lines+markers",
name = sx, legendgroup = sx, showlegend = (j == 1),
line = list(color = sex_cols[[sx]], width = if (sx == "All applicants") 1.5 else 3,
dash = if (sx == "All applicants") "dot" else "solid"),
marker = list(color = sex_cols[[sx]], symbol = sex_sym[[sx]], size = 7),
customdata = cd(fy(d$year), comma(d$refused), comma(d$applications), percent(d$refusal, .1)),
hovertemplate = paste0("<b>", sx, ", ", ages[j], "</b><br>%{customdata[0]}<br>",
"Refused: %{customdata[1]} of %{customdata[2]} (%{customdata[3]})<extra></extra>"))
}
pp %>% layout(
xaxis = list(tickmode = "array", tickvals = range(yr4), ticktext = str_sub(fy(range(yr4)), 3),
tickangle = 0, gridcolor = pal$grid, tickfont = list(size = 11),
range = c(min(yr4) - 0.5, max(yr4) + 0.5), zeroline = FALSE),
yaxis = list(tickformat = ".0%", range = c(0, 0.40), gridcolor = pal$grid, zeroline = FALSE,
title = if (j == 1) list(text = "Bail applications refused", font = list(size = 13)) else NULL),
annotations = list(list(text = paste0("<b>", if (ages[j] == "All ages") "All ages" else paste("Aged", ages[j]), "</b>"),
x = mean(range(yr4)), y = 1.02, xref = "x", yref = "paper", yanchor = "bottom",
showarrow = FALSE, font = list(size = 13))))
})
p4 <- subplot(panels, nrows = 1, shareY = TRUE, titleX = FALSE, margin = 0.015)
p4 <- frame(p4, height = 560, t = 110,
title = paste0("Bail refusals rose in every age group in ", fy(latest), ", and fastest for women"),
subtitle = paste0("Share of Magistrates' Court bail applications refused, by age group and sex, ",
fy(min(yr4)), " to ", fy(latest)),
source = paste0("Source: Crime Statistics Agency (2026b), Magistrates' Court bail tables, Table 01. Counts are applications, not unique people; outcomes are<br>",
"granted, refused or revoked. Ticks show financial years (20–21 = 2020–21). 18–24 may rarely include people aged 10–17."),
hovermode = "closest")
chart_figure(p4, "Five small line charts of bail refusal rates by age group and sex",
paste0("<b>Chart 4.</b> Refusal rate = applications refused ÷ all applications decided (granted, refused or revoked). ",
"Women's refusal rate rose ", round(100 * (w26 - w25), 1), " percentage points in one year; men's rose ",
round(100 * (m26 - m25), 1), ". Total applications rose ", pct(apps_growth), " over the same year."),
paste0("Women's bail refusal rate rose from ", pct(w25, .1), " to ", pct(w26, .1), ", nearly reaching men's rate of ", pct(m26, .1), "."))
d5 <- lga_year %>% filter(year %in% c(first, latest)) %>%
select(year, lga, region, justice, all_off, justice_share) %>%
pivot_wider(names_from = year, values_from = c(justice, all_off, justice_share)) %>%
rename(s0 = !!paste0("justice_share_", first), s1 = !!paste0("justice_share_", latest),
j0 = !!paste0("justice_", first), j1 = !!paste0("justice_", latest),
a0 = !!paste0("all_off_", first), a1 = !!paste0("all_off_", latest)) %>%
filter(!is.na(s0), !is.na(s1)) %>%
mutate(change = s1 - s0)
ord_share <- d5 %>% arrange(s1) %>% pull(lga)
ord_change <- d5 %>% arrange(change) %>% pull(lga)
ord_alpha <- d5 %>% arrange(desc(lga)) %>% pull(lga)
seg_x <- as.vector(rbind(d5$s0, d5$s1, NA)); seg_y <- as.vector(rbind(d5$lga, d5$lga, NA))
p5 <- plot_ly() %>%
add_trace(x = seg_x, y = seg_y, type = "scatter", mode = "lines",
line = list(color = "#cfcfcf", width = 2), hoverinfo = "skip", showlegend = FALSE) %>%
add_trace(x = d5$s0, y = d5$lga, type = "scatter", mode = "markers",
name = paste0(fy(first), " (all LGAs)"),
marker = list(symbol = "circle-open", size = 8, color = "#6f6f6f", line = list(width = 1.5)),
customdata = cd(comma(d5$j0), comma(d5$a0)),
hovertemplate = paste0("<b>%{y}</b>, ", fy(first), "<br>Justice procedures: %{customdata[0]} of ",
"%{customdata[1]} offences (%{x:.1%})<extra></extra>"))
for (rg in names(region_cols)) {
d <- d5 %>% filter(region == rg)
p5 <- p5 %>% add_trace(x = d$s1, y = d$lga, type = "scatter", mode = "markers",
name = rg, legendgroup = rg,
marker = list(size = 10, color = region_cols[[rg]], line = list(color = "white", width = 1)),
customdata = cd(comma(d$j1), comma(d$a1), sprintf("%+.1f", 100 * d$change)),
hovertemplate = paste0("<b>%{y}</b> (", rg, "), ", fy(latest), "<br>Justice procedures: %{customdata[0]} of ",
"%{customdata[1]} offences (%{x:.1%})<br>Change since ", fy(first),
": %{customdata[2]} percentage points<extra></extra>"))
}
h5 <- 1750; t5 <- 235; b5 <- 95; hp5 <- h5 - t5 - b5
p5 <- frame(p5, height = h5, t = t5, b = b5, l = 150, button_px = 118,
title = "Share of recorded offences that are justice procedures offences, by LGA",
subtitle = paste0(fy(first), " (hollow) and ", fy(latest), " (filled, coloured by police region). Dotted line = Victoria in ", fy(latest)),
source = paste0("Source: Crime Statistics Agency (2026a), LGA Recorded Offences, Tables 01–02 (offence division E).<br>",
"Offences are counted where recorded; justice institutions and unincorporated Victoria excluded."),
legend = list(orientation = "h", x = 0, xanchor = "left", y = 1 + 62 / hp5, yanchor = "bottom", font = list(size = 13)),
buttons = list(
btn(paste0("Sort: share in ", fy(latest)), "relayout", list(list("yaxis.categoryarray" = ord_share))),
btn(paste0("Sort: change since ", fy(first)), "relayout", list(list("yaxis.categoryarray" = ord_change))),
btn("Sort: A–Z", "relayout", list(list("yaxis.categoryarray" = ord_alpha)))),
xaxis = axis_style("Justice procedures offences as a share of all recorded offences", tickformat = ".0%",
side = "top", range = c(0, max(d5$s1, d5$s0) * 1.08)),
yaxis = list(categoryorder = "array", categoryarray = ord_share, tickfont = list(size = 11),
title = "", automargin = TRUE, gridcolor = "#f3f3f3", range = c(-0.8, nrow(d5) - 0.2)),
shapes = list(list(type = "line", xref = "x", yref = "paper", x0 = state_share, x1 = state_share,
y0 = 0, y1 = 1, line = list(color = pal$ink, dash = "dot", width = 1))),
extra_annotations = list(list(x = state_share, y = 0, xref = "x", yref = "paper", yanchor = "top", xanchor = "left",
text = paste0(" Victoria ", pct(state_share)), showarrow = FALSE, font = list(size = 12))),
hovermode = "closest")
chart_figure(p5, "Dumbbell chart of justice procedures offence share by LGA",
paste0("<b>Chart 5.</b> ", nrow(d5), " LGAs. Share of recorded offences that are justice procedures offences, by police region in ", fy(latest), ": ",
paste0(region_share$region, " ", pct(region_share$share), collapse = "; "), "."),
paste0("Justice procedures offences are ", pct(state_share), " of offences statewide; ", n_third,
" LGAs are at 25% or above, led by ", top_share$lga, " at ", pct(top_share$justice_share), "."))
knitr::kable(tibble::tibble(
`Dataset (CSA, year ending June 2026)` = c("LGA Recorded Offences (Tables 01, 02)",
"Victoria Police bail data tables (Table 04)",
"Magistrates' Court bail data tables (Tables 01, 02, 04)"),
`Unit of observation` = c("Offences recorded, by LGA × offence subgroup × financial year",
"Initial police bail decisions by breach-offence involvement × year",
"Bail applications by outcome × sex × age group (and × Indigenous status, × breach involvement) × year"),
Period = c(paste(fy(first), "to", fy(latest)), paste(fy(min(vp_breach_raw$Year)), "to", fy(latest)),
paste(fy(min(mc_age_raw$Year)), "to", fy(latest))),
Rows = comma(c(nrow(lga_off_raw), nrow(vp_breach_raw), nrow(mc_age_raw))),
`Used in` = c("Charts 1, 2, 3, 5", "Chart 2", "Charts 2, 4")
))Bail Amendment Act 2023 (Vic). https://www.legislation.vic.gov.au/as-made/acts/bail-amendment-act-2023
Crime Statistics Agency. (2026a). Data tables: LGA recorded offences, year ending June 2026 [Data set]. https://files.crimestatistics.vic.gov.au/2026-09/Data_Tables_LGA_Recorded_Offences_Year_Ending_June_2026.xlsx
Crime Statistics Agency. (2026b). Magistrates’ Court data tables: Bail, year ending June 2026 [Data set]. https://files.crimestatistics.vic.gov.au/2026-09/Magistrates%27_Court_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_1.xlsx
Crime Statistics Agency. (2026c). Victoria Police data tables: Bail, year ending June 2026 [Data set]. https://files.crimestatistics.vic.gov.au/2026-09/Victoria_Police_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_0.xlsx
Okabe, M., & Ito, K. (2008). Color universal design (CUD): How to make figures and presentations that are friendly to colorblind people. https://jfly.uni-koeln.de/color/
O’Kearney, E. (2026, March 12). Inquest finds death of Aboriginal woman Veronica Nelson preventable and prompts calls for reform of Victoria’s bail laws. Human Rights Law Centre. https://www.hrlc.org.au/case-summaries/veronica-nelson-bail-laws/
Parsons, S., & George, M. (2024, February 29). Understanding the new Victorian bail legislation: Adults and First Nations people. Mondaq. https://mondaq.com/australia/crime/1432898/understanding-the-new-victorian-bail-legislation-adults-and-first-nations-people
R Core Team. (2023). R: A language and environment for statistical computing (Version 4.3.3) [Computer software]. R Foundation for Statistical Computing. https://www.R-project.org/
Robinson Gill. (n.d.). Significant amendments to the Bail Act 1977 effective from 26 March 2025. https://www.robinsongill.com.au/resource/significant-amendments-to-the-bail-act-1977-effective-from-26-march-2025/
Sievert, C. (2020). Interactive web-based data visualization with R, plotly, and shiny. Chapman and Hall/CRC. https://plotly-r.com
Victoria Legal Aid. (2024, April 2). Educating the sector on Victoria’s new bail laws. https://legalaid.vic.gov.au/educating-sector-victorias-new-bail-laws
Wickham, H., François, R., Henry, L., Müller, K., & Vaughan, D. (2023). dplyr: A grammar of data manipulation (R package version 1.1.4) [Computer software]. https://CRAN.R-project.org/package=dplyr
Wickham, H., & Bryan, J. (2023). readxl: Read Excel files (R package version 1.4.3) [Computer software]. https://CRAN.R-project.org/package=readxl
Data. Contains data from the Crime Statistics Agency Victoria, licensed under Creative Commons Attribution 3.0 Australia (https://creativecommons.org/licenses/by/3.0/au/). The data were aggregated, relabelled and converted to rates and shares as described above. The CSA has not reviewed or endorsed this analysis.
Built in R with readxl, dplyr, tidyr, stringr, purrr, scales, plotly and htmltools. Rendered with R Markdown.