size_levels <- c("Small business", "Medium business", "Large business")
size_labels <- c("Small (under 20 jobs)", "Medium (20-199 jobs)", "Large (200+ jobs)")
size_colours <- setNames(c("#009E73", "#E69F00", "#0072B2"), size_labels)
tidy_plotly <- function(p, title) {
p |>
layout(title = list(text = paste0("<b>", title, "</b>"),
x = 0, xref = "paper", xanchor = "left",
y = 0.97, yref = "container", yanchor = "top",
font = list(size = 16)),
legend = list(orientation = "h", x = 0, xanchor = "left",
y = 1.02, yanchor = "bottom", title = list(text = "")),
margin = list(t = 95),
hoverlabel = list(font = list(size = 13)),
font = list(family = "Arial", size = 13)) |>
config(displaylogo = FALSE,
modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d"))
}
# Source 1: CLUE, whole municipality (2002-2024)
clue_muni <- read_csv("clue_business_data.csv")
# Source 2: CLUE, by small area (2002-2022).
# Drop the summary rows so nothing is counted twice.
clue_area <- read_csv("clue_small_area.csv") |>
filter(clue_industry != "All CLUE Industry",
clue_small_area != "City of Melbourne (total)")
# Source 3: ABS Australian Industry 2024-25.
read_abs_table <- function(sheet, measure) {
read_excel("81550DO007_202425.xlsx", sheet = sheet, range = "A9:F43",
col_names = c("label", "size_0_4", "size_5_19",
"size_20_199", "size_200_plus", "total")) |>
mutate(region = if_else(is.na(size_0_4), label, NA_character_)) |>
fill(region) |>
filter(!is.na(size_0_4)) |>
rename(financial_year = label) |>
mutate(`Small business` = size_0_4 + size_5_19,
`Medium business` = size_20_199,
`Large business` = size_200_plus,
region = str_to_title(region)) |>
select(region, financial_year, all_of(size_levels), total) |>
pivot_longer(all_of(size_levels), names_to = "business_size", values_to = "value") |>
mutate(share = value / total * 100, measure = measure)
}
abs_all <- bind_rows(
read_abs_table("Table_1", "Jobs"),
read_abs_table("Table_2", "Wages and salaries"),
read_abs_table("Table_3", "Sales and service income")
)
Small businesses are often called the backbone of the Australian economy, and in Melbourne’s city centre they are everywhere: cafes, shops and small offices make up around 71% of all businesses. But counting businesses is not the same as counting jobs. In 2024, large businesses with 200 or more employees made up only about 3% of businesses in the City of Melbourne, yet they provided 58% of its jobs (City of Melbourne, 2025).
This story asks whether larger businesses really do account for more jobs than smaller ones, and whether that has changed over time. It uses the City of Melbourne’s Census of Land Use and Employment (CLUE) from 2002 to 2024 to look at the city as a whole, its industries and its neighbourhoods (City of Melbourne, 2023, 2025). It then compares the city with Victoria and Australia using ABS data (Australian Bureau of Statistics, 2026). The charts move from the overall picture to change over time, then to industries, local areas and finally the wider state and national context
A note on the data: the City of Melbourne suppresses any figure that relates to two or fewer businesses, to protect commercial confidentiality. Those jobs appear as blanks and are left out of the totals below. Most suppressed cells belong to large businesses, so the large-business figures in this report are, if anything, slight underestimates.
size_2024 <- clue_muni |>
filter(census_year == 2024) |>
group_by(business_size) |>
summarise(establishments = sum(total_establishments),
jobs = sum(total_jobs, na.rm = TRUE), .groups = "drop") |>
mutate(`Share of businesses` = establishments / sum(establishments) * 100,
`Share of jobs` = jobs / sum(jobs) * 100)
chart1_data <- size_2024 |>
pivot_longer(c(`Share of businesses`, `Share of jobs`),
names_to = "measure", values_to = "share") |>
mutate(size_label = factor(business_size,
levels = c("Non employing", size_levels),
labels = c("Non-employing\n(0 jobs)", "Small\n(1-19 jobs)",
"Medium\n(20-199 jobs)", "Large\n(200+ jobs)")),
count = if_else(measure == "Share of jobs", jobs, establishments),
tooltip = paste0("<b>", str_replace(size_label, "\n", " "), "</b><br>",
measure, ": ", round(share, 1), "%<br>",
if_else(measure == "Share of jobs", "Jobs: ", "Businesses: "),
comma(count)))
p1 <- ggplot(chart1_data, aes(size_label, share, fill = measure, text = tooltip)) +
geom_col(position = position_dodge(width = 0.8), width = 0.75) +
scale_fill_manual(values = c("Share of businesses" = "#B8B8B8",
"Share of jobs" = "#2F3E4E")) +
scale_y_continuous(labels = label_percent(scale = 1), limits = c(0, 80),
expand = expansion(mult = c(0, 0.02))) +
labs(x = NULL, y = "Share of total", fill = NULL) +
theme_minimal(base_size = 13) +
theme(panel.grid.major.x = element_blank())
ggplotly(p1, tooltip = "text") |>
tidy_plotly("Large businesses are 3% of businesses but 58% of jobs (City of Melbourne, 2024)")
Source: City of Melbourne (2025), Business establishments and jobs data by business size and CLUE industry in municipality.
trend_base <- clue_muni |> filter(business_size %in% size_levels)
trend <- bind_rows(mutate(trend_base, clue_industry = "All industries"), trend_base) |>
group_by(clue_industry, census_year, business_size) |>
summarise(jobs = sum(total_jobs, na.rm = TRUE), .groups = "drop") |>
complete(clue_industry, census_year, business_size = size_levels,
fill = list(jobs = 0)) |>
group_by(clue_industry, census_year) |>
mutate(share = if (sum(jobs) > 0) jobs / sum(jobs) * 100 else NA_real_) |>
ungroup() |>
mutate(size_label = factor(business_size, levels = size_levels, labels = size_labels))
industries <- c("All industries",
sort(setdiff(unique(trend$clue_industry), "All industries")))
p2 <- plot_ly()
for (ind in industries) {
for (s in size_labels) {
d <- filter(trend, clue_industry == ind, size_label == s)
p2 <- p2 |>
add_lines(data = d, x = ~census_year, y = ~jobs, name = s,
line = list(color = size_colours[[s]], width = 3),
visible = (ind == "All industries"),
hovertemplate = paste0(s, ": %{y:,.0f} jobs<extra></extra>")) |>
add_lines(data = d, x = ~census_year, y = ~share, name = s, yaxis = "y2",
line = list(color = size_colours[[s]], width = 3),
showlegend = FALSE, visible = (ind == "All industries"),
hovertemplate = paste0(s, ": %{y:.1f}% of jobs<extra></extra>"))
}
}
# Dropdown: show only the 6 lines that belong to the chosen industry
industry_menu <- lapply(industries, function(i)
list(label = i, method = "restyle",
args = list("visible", rep(industries == i, each = 6))))
p2 |>
tidy_plotly("Jobs by business size, City of Melbourne, 2002-2024") |>
layout(
xaxis = list(title = "Census year", anchor = "y2"),
yaxis = list(title = "Number of jobs", tickformat = ",",
rangemode = "tozero", domain = c(0.55, 1)),
yaxis2 = list(title = "Share of jobs", ticksuffix = "%",
range = c(0, 100), domain = c(0, 0.45)),
hovermode = "x unified",
legend = list(x = 1, xanchor = "right"),
margin = list(t = 110),
updatemenus = list(list(type = "dropdown", x = 0, xanchor = "left",
y = 1.02, yanchor = "bottom", buttons = industry_menu))
)
Source: City of Melbourne (2025), Business establishments and jobs data by business size and CLUE industry in municipality.
heat_x <- c("Small (under 20)", "Medium (20-199)", "Large (200+)")
heat <- clue_muni |>
filter(business_size %in% size_levels) |>
group_by(census_year, clue_industry, business_size) |>
summarise(jobs = sum(total_jobs, na.rm = TRUE),
suppressed = any(is.na(total_jobs)), .groups = "drop") |>
complete(census_year, clue_industry, business_size = size_levels,
fill = list(jobs = 0, suppressed = FALSE)) |>
group_by(census_year, clue_industry) |>
mutate(share = if (sum(jobs) > 0) jobs / sum(jobs) * 100 else NA_real_) |>
ungroup() |>
mutate(share = if_else(suppressed, NA_real_, share),
size_label = as.character(factor(business_size, levels = size_levels,
labels = heat_x)),
na_label = if_else(suppressed, "n/a", ""),
tooltip = paste0("<b>", clue_industry, " (", census_year, ")</b><br>",
size_label, ": ",
if_else(suppressed, "suppressed (2 or fewer businesses)",
paste0(round(share, 1), "% of the industry's jobs<br>",
comma(jobs), " jobs"))))
# Sort industries by how much they relied on large businesses in 2024
industry_order <- heat |>
filter(census_year == 2024, business_size == "Large business") |>
arrange(coalesce(share, 0)) |>
pull(clue_industry)
heat_cols <- viridisLite::mako(100, direction = -1)
heat_scale <- lapply(seq_along(heat_cols),
function(i) list((i - 1) / (length(heat_cols) - 1), heat_cols[i]))
years3 <- sort(unique(heat$census_year))
# Two traces per year: the heatmap, and an "n/a" label layer for suppressed cells
p3 <- plot_ly()
for (yr in years3) {
d <- filter(heat, census_year == yr)
p3 <- p3 |>
add_trace(data = d, type = "heatmap", x = ~size_label, y = ~clue_industry,
z = ~share, text = ~tooltip, hoverinfo = "text",
texttemplate = "%{z:.0f}%", textfont = list(size = 12),
colorscale = heat_scale, zmin = 0, zmax = 100, xgap = 2, ygap = 2,
colorbar = list(title = list(text = "Share of<br>industry<br>jobs"),
ticksuffix = "%", len = 0.6),
visible = (yr == max(years3))) |>
add_trace(data = d, type = "scatter", mode = "text",
x = ~size_label, y = ~clue_industry, text = ~na_label,
hovertext = ~tooltip, hoverinfo = "text",
textfont = list(color = "#555555", size = 12),
showlegend = FALSE, visible = (yr == max(years3)))
}
p3 |>
tidy_plotly("Share of each industry's jobs by business size, City of Melbourne") |>
layout(
xaxis = list(title = "", side = "top", categoryorder = "array",
categoryarray = heat_x, showgrid = FALSE, zeroline = FALSE),
yaxis = list(title = "", categoryorder = "array", categoryarray = industry_order,
showgrid = FALSE, zeroline = FALSE),
margin = list(t = 110),
sliders = list(list(
active = length(years3) - 1,
currentvalue = list(prefix = "Census year: ", font = list(size = 14)),
pad = list(t = 20),
steps = lapply(years3, function(yr)
list(label = as.character(yr), method = "update",
args = list(list(visible = rep(years3 == yr, each = 2)))))
))
)
Source: City of Melbourne (2025), Business establishments and jobs data by business size and CLUE industry in municipality.
area <- clue_area |>
filter(business_size %in% size_levels) |>
group_by(census_year, clue_small_area, business_size) |>
summarise(jobs = sum(total_jobs, na.rm = TRUE),
suppressed_est = sum(total_establishments[is.na(total_jobs)]),
.groups = "drop") |>
complete(census_year, clue_small_area, business_size = size_levels,
fill = list(jobs = 0, suppressed_est = 0)) |>
group_by(census_year, clue_small_area) |>
mutate(area_jobs = sum(jobs), share = jobs / area_jobs * 100) |>
ungroup() |>
filter(area_jobs > 0) |>
mutate(size_label = factor(business_size, levels = size_levels, labels = size_labels),
tooltip = paste0("<b>", clue_small_area, " (", census_year, ")</b><br>",
size_label, ": ", round(share, 1), "% of jobs (",
comma(jobs), ")",
if_else(suppressed_est > 0,
paste0("<br>Jobs at ", suppressed_est,
" business(es) suppressed, not included"),
""),
"<br>Area total counted: ", comma(area_jobs), " jobs"))
# Sort areas by their large-business share in 2022
area_order <- area |>
filter(census_year == 2022, business_size == "Large business") |>
arrange(share) |>
pull(clue_small_area)
years4 <- sort(unique(area$census_year))
# Three stacked bar traces (small, medium, large) per year
p4 <- plot_ly()
for (yr in years4) {
for (s in size_labels) {
d <- filter(area, census_year == yr, size_label == s)
p4 <- p4 |>
add_trace(data = d, type = "bar", orientation = "h",
x = ~share, y = ~clue_small_area, name = s,
marker = list(color = size_colours[[s]]),
text = ~tooltip, hoverinfo = "text", textposition = "none",
visible = (yr == max(years4)))
}
}
p4 |>
tidy_plotly("Share of jobs by business size in each small area") |>
layout(
barmode = "stack",
xaxis = list(title = "Share of jobs in the area", ticksuffix = "%", range = c(0, 100)),
yaxis = list(title = "", categoryorder = "array", categoryarray = area_order),
sliders = list(list(
active = length(years4) - 1,
currentvalue = list(prefix = "Census year: ", font = list(size = 14)),
pad = list(t = 50),
steps = lapply(years4, function(yr)
list(label = as.character(yr), method = "update",
args = list(list(visible = rep(years4 == yr, each = 3)))))
))
)
Source: City of Melbourne (2023), Business establishments and jobs data by business size and industry.
measures <- c("Jobs", "Wages and salaries", "Sales and service income")
places <- c("Australia (ABS 2024-25)", "Victoria (ABS 2024-25)",
"City of Melbourne (CLUE 2024)")
benchmark <- bind_rows(
size_2024 |>
filter(business_size %in% size_levels) |>
transmute(place = places[3], business_size, measure = "Jobs",
share = jobs / sum(jobs) * 100,
detail = paste0(comma(jobs), " jobs")),
abs_all |>
filter(financial_year == "2024-25", region %in% c("Victoria", "Australia")) |>
transmute(place = paste0(region, " (ABS 2024-25)"), business_size, measure, share,
detail = if_else(measure == "Jobs",
paste0(comma(value * 1000), " jobs"),
paste0("$", comma(value), " million")))
) |>
mutate(size_label = factor(business_size, levels = size_levels, labels = size_labels),
tooltip = paste0("<b>", place, "</b><br>", size_label, ": ",
round(share, 1), "% of ", str_to_lower(measure),
"<br>", detail))
# For each measure: a grey connector per place, then one dot trace per business size.
# Wages and sales also get a note, because CLUE does not record them.
p5 <- plot_ly()
trace_measure <- c()
for (m in measures) {
d <- filter(benchmark, measure == m)
seg <- d |> group_by(place) |> summarise(lo = min(share), hi = max(share))
p5 <- p5 |>
add_segments(data = seg, x = ~lo, xend = ~hi, y = ~place, yend = ~place,
line = list(color = "#CCCCCC", width = 6),
hoverinfo = "none", showlegend = FALSE, visible = (m == "Jobs"))
trace_measure <- c(trace_measure, m)
for (s in size_labels) {
p5 <- p5 |>
add_markers(data = filter(d, size_label == s), x = ~share, y = ~place,
name = s, marker = list(color = size_colours[[s]], size = 16,
line = list(color = "white", width = 1)),
text = ~tooltip, hoverinfo = "text", visible = (m == "Jobs"))
trace_measure <- c(trace_measure, m)
}
if (m != "Jobs") {
p5 <- p5 |>
add_text(x = 30, y = places[3],
text = "No wages or sales data in CLUE for the City of Melbourne",
textfont = list(color = "#777777", size = 13),
hoverinfo = "none", showlegend = FALSE, visible = FALSE)
trace_measure <- c(trace_measure, m)
}
}
p5 |>
tidy_plotly("City centre vs Victoria and Australia: who provides the jobs, wages and sales?") |>
layout(
xaxis = list(title = "Share of jobs", ticksuffix = "%", range = c(0, 65)),
yaxis = list(title = "", categoryorder = "array", categoryarray = places),
margin = list(t = 140),
updatemenus = list(list(
type = "buttons", direction = "right",
x = 1, xanchor = "right", y = 1.17, yanchor = "bottom",
buttons = lapply(measures, function(m)
list(label = m, method = "update",
args = list(list(visible = trace_measure == m),
list(xaxis = list(title = paste("Share of", str_to_lower(m)),
ticksuffix = "%", range = c(0, 65))))))
))
)
Sources: City of Melbourne (2025), Business establishments and jobs data by business size and CLUE industry in municipality; Australian Bureau of Statistics (2026), Australian industry, 2024–25 financial year, Tables 1–3.
[Write your conclusion here: answer the question, using evidence from the five charts.]
Australian Bureau of Statistics. (2026). Australian industry, 2024–25 financial year [Data set]. ABS. https://www.abs.gov.au/statistics/industry/industry-overview/australian-industry/latest-release
City of Melbourne. (2025). Business establishments and jobs data by business size and CLUE industry in municipality [Data set]. City of Melbourne Open Data. https://data.melbourne.vic.gov.au/explore/dataset/business-establishments-and-jobs-data-by-business-size-and-clue-industry-in-municipality/
City of Melbourne. (2023). Business establishments and jobs data by business size and industry [Data set]. Data Vic. https://discover.data.vic.gov.au/dataset/55806b3a-9f14-4047-b640-b689626cb4ea
I used ChatGPT (OpenAI, 2026) to help debug my R code, identify and fix coding errors, and improve the grammar, spelling, and clarity of my writing. I reviewed the suggestions and checked the final code and written content myself.