# SETUP: load packages, read the three data sources, tidy and join them, and prepare the
# numbers used in the text. Everything here runs once, before any chart is drawn.

knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE)

library(tidyverse)    # dplyr, tidyr, stringr, forcats, readr
library(readxl)       # reads the DFFH Excel files
library(lubridate)    # my() turns "Dec 2025" into a date
library(sf)           # spatial data
library(arcgislayers) # downloads boundaries from the ABS web service
library(leaflet)      # interactive map
library(plotly)       # interactive charts

# 1. DATA SOURCES
# Source A: Department of Families, Fairness and Housing (DFFH), Rental Report, quarterly median
#           rents by LGA (lga) and moving annual median rents by suburb group (suburb), to Dec 2025.
#           File locations: the two .xlsx files in the data folder.
# Source B: ABS Census of Population and Housing 2021, table G02, Victorian LGAs (g02).
#           File location: data/2021Census_G02_VIC_LGA.csv
# Source C: ABS ASGS Edition 3 (2021) LGA boundaries, downloaded from the ABS web service below.

# The first row of each rent sheet is only a title; the next two rows hold the dates and
# "Count"/"Median" labels, which tidy_rent() below uses to rebuild the columns.
lga <- read_excel("C:/Users/rdeep/Downloads/quarterly-median-rents-by-local-government-area_december-quarter-2025.xlsx", 
                  sheet = "All Properties", col_names = FALSE, 
                  skip = 1)
suburb <- read_excel("C:/Users/rdeep/Downloads/moving-annual-median-rent-by-suburb-town_december-quarter-2025.xlsx", 
                     sheet = "All properties", col_names = FALSE, 
                     skip = 1)
g02 <- read_csv("C:/Users/rdeep/Downloads/2021 Census GCP All Geographies for VIC/LGA/VIC/2021Census_G02_VIC_LGA.csv")

# 2. TIDY THE RENT SPREADSHEETS
# The DFFH sheets have two header rows (date, then Count/Median) and "-" for suppressed cells.
tidy_rent <- function(raw, name_col) {
  dates <- unlist(raw[1, ], use.names = FALSE)
  meas  <- unlist(raw[2, ], use.names = FALSE)
  dates <- fill(data.frame(d = dates), d, .direction = "down")$d

  keep  <- c(TRUE, TRUE, !is.na(meas[-(1:2)]))
  raw   <- raw[, keep]; dates <- dates[keep]; meas <- meas[keep]

  names(raw) <- c("region", name_col, paste(dates[-(1:2)], meas[-(1:2)], sep = "|"))

  raw[-(1:2), ] |>
    fill(region, .direction = "down") |>
    filter(!is.na(.data[[name_col]]), .data[[name_col]] != "Group Total") |>
    pivot_longer(-c(region, all_of(name_col)),
                 names_to = "key", values_to = "value",
                 values_transform = list(value = as.character)) |>
    separate(key, c("quarter", "measure"), sep = "\\|") |>
    pivot_wider(names_from = measure, values_from = value) |>
    mutate(date   = my(quarter),
           median = suppressWarnings(as.numeric(Median)),   # "-" becomes NA (missing, not zero)
           count  = suppressWarnings(as.numeric(Count)))
}
rent_lga    <- tidy_rent(lga,    "lga")
rent_suburb <- tidy_rent(suburb, "suburb")

# 3. CENSUS INCOME AND BOUNDARIES
census <- g02 |>
  select(LGA_CODE_2021,
         income_2021 = Median_tot_hhd_inc_weekly,
         rent_2021   = Median_rent_weekly) |>
  mutate(lga_code_2021 = str_remove(LGA_CODE_2021, "^LGA"))

lga_layer <- arc_open("https://geo.abs.gov.au/arcgis/rest/services/ASGS2021/LGA/FeatureServer/1")
lga_sf <- arc_select(lga_layer, where = "state_name_2021 = 'Victoria'") |>
  st_transform(4326)

# 4. JOIN RENT, INCOME AND BOUNDARIES
# Council names are spelled differently in the sources, so they are matched by a cleaned key.
clean <- function(x) x |> str_remove("\\s*\\(.*\\)") |> str_to_lower() |> str_trim()

latest <- rent_lga |>
  filter(date == max(date)) |>
  mutate(key = clean(lga)) |>
  filter(!key %in% c("victoria", "metro", "non-metro")) |>
  mutate(key = case_when(
    key == "colac-otway"        ~ "colac otway",
    key == "merri-bek"          ~ "moreland",            # ABS 2021 boundaries still say Moreland
    key == "mornington penin'a" ~ "mornington peninsula",
    TRUE ~ key)) |>
  select(key, region, rent_now = median, lettings = count)

d <- lga_sf |>
  left_join(census, by = "lga_code_2021") |>
  mutate(key = clean(lga_name_2021)) |>
  left_join(latest, by = "key") |>
  mutate(ratio = rent_now / income_2021)

d_map <- d |>
  filter(!is.na(ratio)) |>
  mutate(display_name = lga_name_2021 |>
           str_remove("\\s*\\(Vic\\.\\)") |>
           (\(x) if_else(x == "Moreland", "Merri-bek (was Moreland)", x))())

# 5. NUMBERS USED IN THE TEXT
pc <- function(x) round(x * 100)                       # ratio -> whole percent

shares     <- d_map |> st_drop_geometry()
n_councils <- nrow(shares)
n_over30   <- sum(shares$ratio > 0.30)                 # councils above the 30% line
hi_ratio   <- shares |> slice_max(ratio, n = 1, with_ties = FALSE)
lo_ratio   <- shares |> slice_min(ratio, n = 1, with_ties = FALSE)

rent_at <- function(council, dt) {                     # median rent for one council and month
  rent_lga |> filter(lga == council, date == as.Date(dt)) |> pull(median)
}
mel_now <- rent_at("Melbourne", "2025-12-01")
wyn_now <- rent_at("Wyndham",   "2025-12-01")
mel_low <- rent_lga |>
  filter(lga == "Melbourne", !is.na(median),
         date >= as.Date("2019-01-01"), date <= as.Date("2022-12-01")) |>
  slice_min(median, n = 1, with_ties = FALSE)          # Melbourne's low point around 2020-21

sub_latest <- rent_suburb |> filter(date == max(date), !is.na(median)) |> arrange(median)
cheapest   <- sub_latest |> slice_min(median, n = 1, with_ties = FALSE)
dearest    <- sub_latest |> slice_max(median, n = 1, with_ties = FALSE)
gap        <- dearest$median - cheapest$median

growth <- rent_lga |>
  filter(date %in% as.Date(c("2021-12-01", "2025-12-01")), !is.na(median),
         !str_detect(lga, regex("^(victoria|metro|non-metro|group total)$", ignore_case = TRUE))) |>
  mutate(lga = recode(lga, "Mornington Penin'a" = "Mornington Peninsula"),
         year = format(date, "%Y")) |>
  select(lga, year, median) |>
  pivot_wider(names_from = year, values_from = median, names_prefix = "y") |>
  filter(!is.na(y2021), !is.na(y2025)) |>
  mutate(pct = round((y2025 / y2021 - 1) * 100),
         lga = fct_reorder(lga, pct))
top_growth <- growth |> slice_max(pct, n = 1, with_ties = FALSE)
n_growth   <- nrow(growth)

# 6. SOURCE LINES shown under the charts
src_map   <- "Data sources: DFFH Rental Report (December 2025); ABS Census of Population and Housing 2021; ABS ASGS Edition 3 LGA boundaries."
src_rent  <- "Data source: Department of Families, Fairness and Housing (DFFH) Rental Report, December 2025."

Introduction

Rents in Victoria have increased rapidly, while household incomes have not moved at the same speed. This report shows the difference by comparing new rental prices in December 2025 with household incomes recorded in the 2021 Census for each council area. Using the standard 30% income threshold to identify potential housing stress, 54 out of 78 councils exceed this benchmark based on the comparison.

The story has five steps: a map highlighting areas under the rental pressure, a scatter plot showing the relationship between rent and income, line charts tracking rental trends over time, ranked dot plots comparing the most and least expensive areas, and a dumbbell chart showing how much rents have increased since 2021.

1. Where is rent heaviest compared with income?

Rent takes the smallest share of income in Nillumbik (23%) and the largest in Mount Alexander (42%). Hover over a council to see its figures.

# Chart 1: map of rent as a share of household income
pal <- colorBin("viridis", domain = d_map$ratio,
                bins = c(0, .20, .25, .30, .35, .40, 1), reverse = TRUE)

labels <- sprintf(
  "<strong>%s</strong><br/>Median rent (Dec 2025): $%s per week<br/>Household income (2021): $%s per week<br/>Rent as share of income: %s%%",
  d_map$display_name, d_map$rent_now, d_map$income_2021, round(d_map$ratio * 100, 1)
) |> lapply(htmltools::HTML)

map1 <- leaflet(d_map) |>
  addProviderTiles(providers$Esri.WorldGrayCanvas) |>
  addPolygons(fillColor = ~pal(ratio), weight = 1, color = "white", fillOpacity = 0.85,
              label = labels,
              highlightOptions = highlightOptions(weight = 3, color = "#333333", bringToFront = TRUE)) |>
  addLegend(pal = pal, values = ~ratio, position = "bottomright",
            title = "Rent as share of<br>household income",
            labFormat = labelFormat(suffix = "%", transform = function(x) 100 * x)) |>
  addControl("<b>Rent as a share of household income, by council</b><br><small>Median rent (Dec 2025) divided by median household income (2021)</small>",
             position = "topright") |>
  addControl("<small>Sources: DFFH Rental Report; ABS Census 2021; ABS ASGS Edition 3</small>",
             position = "bottomleft")
map1

What the chart shows: A map of Victorian councils shaded by median weekly rent (December 2025) as a share of median weekly household income (2021). Darker colours mean rent takes a bigger share of income.

How to use the chart: Hover over a council to see its rent, income and share. Zoom with the buttons or the mouse wheel. Grey areas have no rent data.

Data sources: DFFH Rental Report (December 2025); ABS Census of Population and Housing 2021; ABS ASGS Edition 3 LGA boundaries.

2. Does a higher income protect against high rent?

Each bubble is a council. Councils above the dashed line would spend more than 30% of the 2021 median household income on the median new-lease rent.

# Chart 2: income vs rent, with the 30% line
scatter_data <- d_map |> st_drop_geometry()
x_rng <- range(scatter_data$income_2021, na.rm = TRUE)

okabe <- c("#E69F00", "#56B4E9", "#009E73", "#F0E442",
           "#0072B2", "#D55E00", "#CC79A7", "#999999")

highlight <- scatter_data |>
  filter(display_name %in% c("Melbourne", "Bayside", "Wyndham")) |>
  mutate(ax = case_when(display_name == "Melbourne" ~ -50,
                        display_name == "Bayside"   ~ -60,
                        TRUE                        ~  50),
         ay = case_when(display_name == "Melbourne" ~ -35,
                        display_name == "Bayside"   ~  40,
                        TRUE                        ~  40))

chart2 <- plot_ly(scatter_data,
                  x = ~income_2021, y = ~rent_now,
                  type = "scatter", mode = "markers",
                  color = ~region, colors = okabe,
                  size = ~lettings, sizes = c(10, 45),
                  text = ~paste0("<b>", display_name, "</b>",
                                 "<br>Household income (2021): $", income_2021,
                                 "<br>Median rent (Dec 2025): $", rent_now,
                                 "<br>Rent as share of income: ", round(ratio * 100, 1), "%",
                                 "<br>New lettings: ", lettings),
                  hoverinfo = "text",
                  marker = list(opacity = 0.85)) |>
  add_lines(x = x_rng, y = 0.30 * x_rng, inherit = FALSE,
            line = list(dash = "dash", color = "#333333"),
            name = "Rent = 30% of income", hoverinfo = "none") |>
  add_annotations(data = highlight, x = ~income_2021, y = ~rent_now,
                  text = ~display_name, showarrow = TRUE, arrowhead = 0,
                  ax = ~ax, ay = ~ay, font = list(size = 12)) |>
  layout(title = list(text = "<b>Where rent outpaces 2021 incomes</b><br><sup>Councils above the dashed line spend more than 30% of 2021 household income on rent</sup>",
                      x = 0.02, xanchor = "left"),
         xaxis = list(title = list(text = "Median weekly household income, 2021 ($)<br><sup>Sources: DFFH Rental Report (Dec 2025); ABS Census 2021. Bubble size = number of new lettings.</sup>",
                                   standoff = 10)),
         yaxis = list(title = list(text = "Median weekly rent, December 2025 ($)", standoff = 10)),
         margin = list(t = 100, b = 100, l = 70))

chart2 <- suppressWarnings(plotly_build(chart2))
chart2

What the chart shows: Each bubble is a council. Its position shows median household income in 2021 (across) and median weekly rent in December 2025 (up). The dashed line marks where rent equals 30% of income. Colours are DFFH regions and bubble size is the number of new lettings.

How to use the chart: Hover over a bubble for the council’s figures. Click a region in the legend to hide or show it.

Data sources: DFFH Rental Report (December 2025); ABS Census of Population and Housing 2021; ABS ASGS Edition 3 LGA boundaries.

3. How have rents changed since 2000?

By December 2025 the median rent in Melbourne was $650 a week, against $490 in Wyndham. Melbourne’s rent hit a low of $360 in June 2021, then climbed quickly.

# Chart 3: rent trends for five councils
councils <- c("Wyndham", "Melbourne", "Casey", "Greater Dandenong", "Whittlesea")

trend <- rent_lga |>
  filter(lga %in% councils, !is.na(median)) |>
  arrange(lga, date)

# end-of-line labels, spread apart so they don't overlap
last_pts <- trend |>
  group_by(lga) |>
  filter(date == max(date)) |>
  ungroup() |>
  arrange(median) |>
  mutate(label_y = median)

for (i in 2:nrow(last_pts)) {
  last_pts$label_y[i] <- max(last_pts$label_y[i], last_pts$label_y[i - 1] + 55)
}

pal3 <- c("Wyndham"           = "#D55E00",
          "Melbourne"         = "#0072B2",
          "Casey"             = "#009E73",
          "Greater Dandenong" = "#E69F00",
          "Whittlesea"        = "#CC79A7")

chart3 <- plot_ly(trend, x = ~date, y = ~median, color = ~lga, colors = pal3,
                  type = "scatter", mode = "lines",
                  line = list(width = 2.5),
                  text = ~paste0(lga, ": $", median),
                  hoverinfo = "text") |>
  add_annotations(data = last_pts, x = ~date, y = ~label_y,
                  text = ~paste0(lga, " $", median),
                  xanchor = "left", xshift = 8, showarrow = FALSE,
                  font = list(size = 12)) |>
  layout(title = list(text = "<b>How rents have climbed since 2000</b><br><sup>Median weekly rent for all rental properties in five councils</sup>",
                      x = 0.02, xanchor = "left"),
         showlegend = FALSE,
         hovermode = "x unified",
         shapes = list(list(type = "line", x0 = "2021-08-10", x1 = "2021-08-10",
                            y0 = 0, y1 = 1, yref = "paper",
                            line = list(color = "grey50", dash = "dot", width = 1))),
         xaxis = list(type = "date", range = c("1999-10-01", "2026-03-01"),
                      title = list(text = "<sup>Sources: DFFH Rental Report (Dec 2025). Dotted line = 2021 Census.</sup>",
                                   standoff = 10)),
         yaxis = list(title = list(text = "Median weekly rent ($)", standoff = 10),
                      tickprefix = "$"),
         margin = list(t = 100, b = 90, l = 70, r = 190))

chart3 <- suppressWarnings(plotly_build(chart3))
chart3

What the chart shows: Median weekly rent for all rental properties in five councils, from 2000 to December 2025. The dotted vertical line marks the 2021 Census, the year of the income data.

How to use the chart: Hover over the lines to compare the five councils in any quarter. The labels at the right-hand end show the latest values.

Data source: Department of Families, Fairness and Housing (DFFH) Rental Report, December 2025.

4. The cheapest and most expensive areas

Across DFFH’s suburb groups, the year-to-December-2025 median rent ranges from $380 a week in Hamilton to $850 in Brighton, a gap of $470 a week.

# Chart 4: cheapest and most expensive areas to rent
sub_plot <- bind_rows(head(sub_latest, 15), tail(sub_latest, 15)) |>
  mutate(group  = if_else(median <= median(sub_latest$median), "Cheapest 15", "Most expensive 15"),
         suburb = fct_reorder(suburb, median))

chart4 <- plot_ly(sub_plot, height = 750, x = ~median, y = ~suburb, color = ~group,
                  colors = c("Cheapest 15" = "#0072B2", "Most expensive 15" = "#D55E00"),
                  type = "scatter", mode = "markers",
                  marker = list(size = 11),
                  text = ~paste0("<b>", suburb, "</b>",
                                 "<br>Median rent: $", median, " per week",
                                 "<br>Region: ", region,
                                 "<br>Leases in the year: ", count),
                  hoverinfo = "text") |>
  layout(title = list(text = paste0("<b>A $", gap, " a week gap between the cheapest and dearest areas</b>",
                                    "<br><sup>Median weekly rent, all properties, year to December 2025</sup>"),
                      x = 0.02, xanchor = "left", font = list(size = 16)),
         xaxis = list(title = list(text = "Median weekly rent ($)<br><sup>Source: DFFH Rental Report (Dec 2025). Areas are DFFH suburb groups.</sup>",
                                   standoff = 10),
                      tickprefix = "$"),
         yaxis = list(title = "", tickfont = list(size = 10), dtick = 1),
         legend = list(orientation = "h", x = 0, y = 1.03),
         margin = list(t = 110, b = 90, l = 210))

chart4 <- suppressWarnings(plotly_build(chart4))
chart4

What the chart shows: The 15 cheapest (blue) and 15 most expensive (orange) DFFH areas by median weekly rent. Several of the dearest areas share the same median, so their dots stack.

How to use the chart: Hover over a dot for the area’s rent, region and number of leases. Click a group in the legend to hide it.

Data source: Department of Families, Fairness and Housing (DFFH) Rental Report, December 2025.

5. How fast have rents risen since 2021?

Between December 2021 and December 2025, median rents rose in 78 councils, by 14% to 71%. The largest rise was in Melbourne (71%). Some of the biggest rises start from an unusually low December 2021 figure (see Visual 3), so part of the jump is a rebound rather than new growth.

# Chart 5: rent growth Dec 2021 -> Dec 2025 by council
chart5 <- plot_ly(growth, height = 1500) |>
  add_segments(x = ~y2021, xend = ~y2025, y = ~lga, yend = ~lga,
               color = I("grey70"), line = list(width = 2),
               hoverinfo = "none", showlegend = FALSE) |>
  add_markers(x = ~y2021, y = ~lga, name = "Dec 2021",
              marker = list(color = "#0072B2", size = 9),
              text = ~paste0("<b>", lga, "</b><br>Dec 2021: $", y2021, " per week"),
              hoverinfo = "text") |>
  add_markers(x = ~y2025, y = ~lga, name = "Dec 2025",
              marker = list(color = "#D55E00", size = 9),
              text = ~paste0("<b>", lga, "</b><br>Dec 2025: $", y2025,
                             " per week<br>Change: +", pct, "%"),
              hoverinfo = "text") |>
  layout(title = list(text = paste0("<b>Rents rose in every council, by ", min(growth$pct),
                                    "% to ", max(growth$pct), "%</b>",
                                    "<br><sup>Median weekly rent, all properties, Dec 2021 vs Dec 2025</sup>"),
                      x = 0.02, xanchor = "left", font = list(size = 16)),
         xaxis = list(title = list(text = "Median weekly rent ($)<br><sup>Source: DFFH Rental Report (Dec 2025). Councils sorted by % change, largest at top.</sup>",
                                   standoff = 10),
                      tickprefix = "$"),
         yaxis = list(title = "", tickfont = list(size = 10), dtick = 1),
         legend = list(orientation = "h", x = 0, y = 1.02),
         margin = list(t = 110, b = 90, l = 170))

chart5 <- suppressWarnings(plotly_build(chart5))
chart5

What the chart shows: For each council, a blue dot for its December 2021 median weekly rent and an orange dot for December 2025, joined by a grey line. Councils are sorted by percentage rise, largest at the top.

How to use the chart: Scroll down to see all councils. Hover over a dot for the rent and, on the orange dot, the percentage change.

Data source: Department of Families, Fairness and Housing (DFFH) Rental Report, December 2025.

Conclusion

Rental pressure varies across Victoria. A comparison of median rents in December 2025 with median household incomes from the 2021 Census shows that 54 of 78 councils exceed the 30% housing stress benchmark. The highest rent-to-income ratio is recorded in Mount Alexander (42%), while the lowest is found in Nillumbik (23%). Rental costs also differ substantially between areas, ranging from $380 per week in Hamilton to $850 per week in Brighton.

Median rents increased across all councils between 2021 and December 2025, with growth ranging from 14% to 71%. The largest increase occurred in Melbourne. However, this growth partly reflects a recovery in rental prices, as Melbourne’s median rent dropped to $360 in June 2021 before rising again to $650 in December 2025.

Overall, the findings shows areas where rental costs are relatively high to household incomes and shows changes in rents over time, but they do not explain the reasons behind these patterns. The comparison also has limitations because it uses recent rents for new leases alongside household income data from the 2021 Census, which covers all households. Therefore, the results indicates where housing affordability pressure may be greatest rather than the actual rental burden experienced by individual households.

Data and method

The three sources are listed in the References. The DFFH spreadsheets were reshaped from wide to long format, “-” cells were treated as missing (not zero), and councils were matched to Census and boundary data by LGA name after fixing spelling differences (for example Merri-bek, which the 2021 boundaries still call Moreland). Queenscliffe has no rent data because there are too few lettings.

#Limitations

References

Australian Bureau of Statistics. (2022). Census of Population and Housing: General Community Profile, Victoria, 2021 (Table G02, Selected medians and averages. https://www.abs.gov.au/census/find-census-data/datapacks

Australian Bureau of Statistics. (2021). Local Government Areas, ASGS Edition 3. https://geo.abs.gov.au/arcgis/rest/services/ASGS2021/LGA/FeatureServer/1

Department of Families, Fairness and Housing. (2026). Rental report: Quarterly and moving annual median rents. State of Victoria. https://www.dffh.vic.gov.au/publications/rental-report

Acknowledgements

The charts were built in R, using the tidyverse, readxl, lubridate, sf, arcgislayers, leaflet and plotly packages, in RStudio. Generative AI statement: I used RMIT Val and ChatGPT to help me learn R, identify the error and fix the code. I used for the understanding and for the clarification of any mathematical steps used for analyzing the data. I chose the topic, data sources and story, and I checked the results with the original sources.