# ---- Settings shared by every chart ----
# Our group's budget: $1,000 a week IN TOTAL for all five people (not per person)
budget_total  <- 1000                       # whole group, per week
group_size    <- 5
budget_each   <- budget_total / group_size  # equal share: $200 per person per week
budget_total_year <- budget_total * 52      # $52,000 per year for the group
budget_each_year  <- budget_each * 52       # $10,400 per year per person
budget_high   <- budget_total               # upper limit used by the charts
budget_low    <- 800    # only used to split "within budget" suburbs into two shades (Chart 4)

# Colour-blind safe colours (Okabe-Ito palette)
ring_colours <- c("Inner" = "#D55E00", "Middle" = "#0072B2",
                  "Outer" = "#009E73", "Regional" = "#CC79A7")
band_colours <- c("Within budget: under $800"    = "#56B4E9",
                  "Within budget: $800-$1,000"   = "#0072B2",
                  "Over budget: more than $1,000" = "#D55E00",
                  "No data"                       = "#D9D9D9")

source_text <- paste0("Source: Homes Victoria (2026), moving annual median rent by suburb, ",
                      "September quarter 2025.<br>New leases only; dollars not adjusted for inflation.")

# Adds the same source note to the bottom of every plotly chart
add_source <- function(p, y_pos = -0.18) {
  p %>% layout(annotations = list(
    list(text = source_text, x = 0, y = y_pos, xref = "paper", yref = "paper",
         xanchor = "left", yanchor = "top", showarrow = FALSE, align = "left",
         font = list(size = 11, color = "#555555"))))
}

# 4-bedroom house data only - this is what our group was looking for
house4 <- suburb_rents %>% filter(property_type == "4br house")
sep_2015 <- ymd("2015-09-01")
sep_2025 <- ymd("2025-09-01")
# ---- Numbers quoted in the text (calculated, not typed in) ----
inner_total <- house4 %>% filter(region == "Inner Melbourne", is_total)
inner_2015  <- inner_total %>% filter(date == sep_2015) %>% pull(median_rent)
inner_2025  <- inner_total %>% filter(date == sep_2025) %>% pull(median_rent)
inner_first_over <- inner_total %>% filter(median_rent > budget_high) %>%
  arrange(date) %>% slice(1)

inner_suburbs <- house4 %>%
  filter(region == "Inner Melbourne", !is_total, date %in% c(sep_2015, sep_2025))
inner_ok_2015 <- inner_suburbs %>% filter(date == sep_2015, median_rent <= budget_high) %>% nrow()
inner_n_2015  <- inner_suburbs %>% filter(date == sep_2015, !is.na(median_rent)) %>% nrow()
inner_ok_2025 <- inner_suburbs %>% filter(date == sep_2025, median_rent <= budget_high) %>% nrow()
inner_n_2025  <- inner_suburbs %>% filter(date == sep_2025, !is.na(median_rent)) %>% nrow()
inner_only_ok <- inner_suburbs %>% filter(date == sep_2025, median_rent <= budget_high) %>% pull(area)

# Like-for-like: only suburbs with a published figure in BOTH years
inner_both <- inner_suburbs %>%
  group_by(area) %>% filter(sum(!is.na(median_rent)) == 2) %>% ungroup()
both_n       <- n_distinct(inner_both$area)
both_ok_2015 <- inner_both %>% filter(date == sep_2015, median_rent <= budget_high) %>% nrow()
both_ok_2025 <- inner_both %>% filter(date == sep_2025, median_rent <= budget_high) %>% nrow()

leases_2025 <- house4 %>% filter(is_total, date == sep_2025) %>%
  mutate(share = lease_count / sum(lease_count))   # denominator = all 13 regions in this file
lease_total <- sum(leases_2025$lease_count)
inner_share <- leases_2025 %>% filter(region == "Inner Melbourne") %>% pull(share)
inner_leases <- leases_2025 %>% filter(region == "Inner Melbourne") %>% pull(lease_count)
ow_share    <- leases_2025 %>% filter(region == "Outer Western Melbourne") %>% pull(share)
ow_leases   <- leases_2025 %>% filter(region == "Outer Western Melbourne") %>% pull(lease_count)

dollars <- function(x) dollar(x, accuracy = 1)

Introduction

For the past month, five of us, all students, have been looking for a 4-bedroom house to rent together. I study at RMIT, which is right in the city, so the plan was to live close to campus. Our budget was up to $1,000 a week in total for all five of us, which is $200 each if we split it equally (about $52,000 a year for the group, or $10,400 a year each). We could not find anything near the city at that price. We then looked in the suburbs, but the further out the rent became reasonable, the longer the trip into the city got. While we keep searching I have been staying with my high school friends in a suburb about an hour from the city, and with part-time work four times a week, that travel is tiring.

That made me wonder whether this was always the case, or whether it has changed. So the question for this report is:

Ten years ago, could five students have shared a 4-bedroom house near the city on a combined $1,000 a week, and how far out does that budget reach now?

To answer it I used the Homes Victoria rental report, which is based on rental bonds lodged for new leases in Victoria. I compare the year ending September 2015 with the year ending September 2025 (the latest data available), and mainly look at 4-bedroom houses because that is what we were searching for. Throughout the report, “within budget” means a suburb’s median weekly rent for a 4-bedroom house is $1,000 or less for the whole house. A median is a summary of the leases that were signed, not a price anyone is guaranteed to find.

1. The line we crossed

The first chart shows the median weekly rent for a 4-bedroom house in each Homes Victoria region since 2000. The shaded area is everything at or under our $1,000 group budget, and the dashed line is the limit itself. Four regions are shown to start with, one from each distance “ring”, and the others can be turned on from the legend.

# One line per region, using each region's "Group Total" row
chart1_data <- house4 %>%
  filter(is_total) %>%
  arrange(date) %>%
  mutate(hover = paste0("<b>", region, "</b> (", ring, " ring)<br>",
                        "Year ending ", period, "<br>",
                        "Median rent: ", dollars(median_rent), " per week<br>",
                        "New leases: ", comma(lease_count)))

shown_first <- c("Inner Melbourne", "Inner Eastern Melbourne",
                 "Outer Western Melbourne", "Geelong")
# Order the legend Inner -> Middle -> Outer -> Regional
region_order <- chart1_data %>% distinct(region, ring) %>% arrange(ring) %>% pull(region)

chart1 <- plot_ly()
for (r in region_order) {
  d <- chart1_data %>% filter(region == r)
  chart1 <- chart1 %>%
    add_trace(data = d, x = ~date, y = ~median_rent,
              type = "scatter", mode = "lines",
              name = r, legendgroup = as.character(d$ring[1]),
              line = list(color = ring_colours[[as.character(d$ring[1])]],
                          width = ifelse(r == "Inner Melbourne", 4, 2),
                          dash  = ifelse(r %in% shown_first, "solid", "dot")),
              text = ~hover, hoverinfo = "text",
              visible = if (r %in% shown_first) TRUE else "legendonly")
}

chart1 <- chart1 %>%
  layout(
    title = list(text = paste0("<b>Inner-city 4-bedroom houses moved above our budget</b><br>",
                               "<sup>Median weekly rent for new 4-bedroom house leases, by region ",
                               "(12 months to each quarter)</sup>"),
                 x = 0, xanchor = "left"),
    xaxis = list(title = "", rangeslider = list(visible = TRUE, thickness = 0.06),
                 rangeselector = list(buttons = list(
                   list(count = 10, label = "Last 10 years", step = "year", stepmode = "backward"),
                   list(step = "all", label = "All years")))),
    yaxis = list(title = "Median weekly rent", tickprefix = "$", tickformat = ",",
                 rangemode = "tozero"),
    shapes = list(
      list(type = "rect", xref = "paper", x0 = 0, x1 = 1,
           y0 = 0, y1 = budget_high,
           fillcolor = "grey", opacity = 0.12, line = list(width = 0), layer = "below"),
      list(type = "line", xref = "paper", x0 = 0, x1 = 1,
           y0 = budget_high, y1 = budget_high,
           line = list(color = "#333333", dash = "dash", width = 1.5)),
      list(type = "line", x0 = sep_2015, x1 = sep_2015, yref = "paper", y0 = 0, y1 = 1,
           line = list(color = "#555555", dash = "dash", width = 1))),
    annotations = list(
      list(x = ymd("2001-01-01"), y = budget_high, text = "Group budget: $1,000/week ($200 each)",
           showarrow = FALSE, xanchor = "left", yanchor = "bottom",
           font = list(size = 12, color = "#333333")),
      list(x = sep_2015, y = 1, yref = "paper", text = "Sep 2015",
           showarrow = FALSE, xanchor = "right", yanchor = "top",
           font = list(size = 11, color = "#555555"))),
    legend = list(title = list(text = "<b>Region</b> (click to show/hide)")),
    hovermode = "closest",
    margin = list(t = 80, b = 110),
    font = list(family = "Arial")
  )

add_source(chart1, y_pos = -0.3)

Chart description: line chart of median 4-bedroom house rent from 2000 to 2025 for Victorian regions, coloured by distance ring, with a dashed line at the $1,000 group budget and the area below it shaded.

In the year to September 2015, the Inner Melbourne median for a 4-bedroom house was $900, $100 under our $1,000 limit. It stayed around $1,000 for a few years, then first went above it in the year to Mar 2023 and has climbed since. By September 2025 it was $1,250, $250 a week over our budget. The middle and outer regions also rose a lot, but they started much lower, so most of them are still under the $1,000 line. Using the range slider to zoom into the last ten years shows that most of the jump happened after 2021.

2. Then vs now

A single median for a whole region can hide a lot, so the second chart looks at the individual suburbs in Inner Melbourne, the ones closest to RMIT.

chart2_data <- house4 %>%
  filter(region == "Inner Melbourne", !is_total, date %in% c(sep_2015, sep_2025)) %>%
  select(area, date, median_rent, lease_count, low_count) %>%
  mutate(when = ifelse(date == sep_2015, "y2015", "y2025")) %>%
  select(-date) %>%
  pivot_wider(names_from = when, values_from = c(median_rent, lease_count, low_count))

# Suburbs with no 4-bedroom figure in either year are listed in the text instead
inner_missing <- chart2_data %>% filter(is.na(median_rent_y2025)) %>% pull(area)

chart2_data <- chart2_data %>%
  filter(!is.na(median_rent_y2025)) %>%
  mutate(
    band_2025 = cut(median_rent_y2025, c(-Inf, budget_high, Inf),
                    labels = c("Within budget", "Over budget")),
    change    = median_rent_y2025 - median_rent_y2015,
    hover = paste0("<b>", area, "</b><br>",
                   "Sep 2015: ", ifelse(is.na(median_rent_y2015), "no data",
                                       paste0(dollars(median_rent_y2015), " (",
                                              lease_count_y2015, " leases)")), "<br>",
                   "Sep 2025: ", dollars(median_rent_y2025), " (",
                   lease_count_y2025, " leases)",
                   ifelse(low_count_y2025, " - fewer than 30 leases, treat with care", ""), "<br>",
                   ifelse(is.na(change), "", paste0("Change: +", dollars(change), " (+",
                          percent(change / median_rent_y2015, accuracy = 1), ")")))) %>%
  arrange(median_rent_y2025) %>%
  mutate(area = factor(area, levels = area))

chart2 <- plot_ly(chart2_data, y = ~area) %>%
  add_segments(x = ~median_rent_y2015, xend = ~median_rent_y2025,
               yend = ~area, line = list(color = "#BBBBBB", width = 3),
               hoverinfo = "none", showlegend = FALSE) %>%
  add_markers(x = ~median_rent_y2015, name = "Sep 2015",
              marker = list(color = "#777777", size = 10),
              text = ~hover, hoverinfo = "text")

# 2025 points coloured by budget status; hollow if fewer than 30 leases
chart2_colours <- c("Within budget" = "#0072B2", "Over budget" = "#D55E00")
for (b in levels(chart2_data$band_2025)) {
  d <- chart2_data %>% filter(band_2025 == b)
  if (nrow(d) == 0) next
  chart2 <- chart2 %>%
    add_markers(data = d, x = ~median_rent_y2025, y = ~area,
                name = paste0("Sep 2025: ", b),
                marker = list(color = chart2_colours[[b]], size = 12,
                              symbol = ifelse(d$low_count_y2025, "circle-open", "circle"),
                              line = list(width = 2, color = chart2_colours[[b]])),
                text = ~hover, hoverinfo = "text")
}

chart2 <- chart2 %>%
  layout(
    title = list(text = paste0("<b>Only one inner suburb is still within our budget</b><br>",
                               "<sup>Median weekly rent for new 4-bedroom house leases, ",
                               "Inner Melbourne suburbs, year to Sep 2015 vs year to Sep 2025</sup>"),
                 x = 0, xanchor = "left"),
    xaxis = list(title = "Median weekly rent", tickprefix = "$", tickformat = ",",
                 range = c(500, max(chart2_data$median_rent_y2025) + 150)),
    yaxis = list(title = ""),
    shapes = list(list(type = "rect", yref = "paper", y0 = 0, y1 = 1,
                       x0 = 0, x1 = budget_high, fillcolor = "grey",
                       opacity = 0.12, line = list(width = 0), layer = "below"),
                  list(type = "line", yref = "paper", y0 = 0, y1 = 1,
                       x0 = budget_high, x1 = budget_high,
                       line = list(color = "#333333", dash = "dash", width = 1.5))),
    annotations = list(list(x = budget_high, y = 1.0, yref = "paper",
                            text = "$1,000 group budget", showarrow = FALSE,
                            xanchor = "right", yanchor = "bottom", font = list(size = 12))),
    legend = list(title = list(text = "<b>Point</b>  (hollow = under 30 leases)")),
    margin = list(t = 90, b = 130, l = 10),
    font = list(family = "Arial")
  )

add_source(chart2, y_pos = -0.13)

Chart description: dumbbell chart with one row per Inner Melbourne suburb. A grey dot shows the 2015 median and a coloured dot shows the 2025 median; the dashed line marks the $1,000 group budget.

In 2015, 12 of the 19 inner suburbs with 4-bedroom data had a median at or under $1,000 a week. In 2025 it was just 1 of 16 (Flemington-Kensington). Fewer suburbs had published data in 2025, so a fairer like-for-like comparison is the 16 suburbs that have data in both years: 10 of them were within budget in 2015, compared with 1 in 2025. Every line on the chart moves to the right, and most cross the $1,000 line. Suburbs like Carlton–Parkville, right next to RMIT, went from inside our budget to above it. CBD-St Kilda Rd, Docklands, East Melbourne, Fitzroy, South Melbourne, Southbank are not shown because so few 4-bedroom houses are leased there that Homes Victoria does not publish a figure. Many of the points are hollow, which means fewer than 30 leases, so individual suburb values should be read as rough guides rather than exact prices.

3. Where the houses actually are

The previous chart raised another problem: very few 4-bedroom houses are leased in the inner city in the first place. This chart plots every suburb by its median rent (across) and the number of new 4-bedroom leases signed that year (up). Press Play to see how suburbs moved between 2015 and 2025.

chart3_data <- house4 %>%
  filter(!is_total, month(date) == 9, year >= 2015, !is.na(median_rent)) %>%
  mutate(hover = paste0("<b>", area, "</b><br>", region, " (", ring, " ring)<br>",
                        "Year ending Sep ", year, "<br>",
                        "Median rent: ", dollars(median_rent), "<br>",
                        "New leases: ", comma(lease_count)))

chart3 <- plot_ly(chart3_data,
                  x = ~median_rent, y = ~lease_count,
                  color = ~ring, colors = ring_colours,
                  frame = ~year, ids = ~area,
                  type = "scatter", mode = "markers",
                  marker = list(size = 10, opacity = 0.8,
                                line = list(width = 0.5, color = "white")),
                  text = ~hover, hoverinfo = "text") %>%
  layout(
    title = list(text = paste0("<b>Most new 4-bedroom house leases are far from the city</b><br>",
                               "<sup>Each dot is a suburb: median weekly rent vs number of new ",
                               "4-bedroom house leases (12 months to September)</sup>"),
                 x = 0, xanchor = "left"),
    xaxis = list(title = "Median weekly rent", tickprefix = "$", tickformat = ",",
                 range = c(200, max(chart3_data$median_rent) + 150)),
    yaxis = list(title = "New 4-bedroom leases in the year (log scale)", type = "log",
                 tickvals = c(10, 20, 50, 100, 200, 500, 1000, 2000, 5000)),
    shapes = list(list(type = "rect", yref = "paper", y0 = 0, y1 = 1,
                       x0 = 0, x1 = budget_high, fillcolor = "grey",
                       opacity = 0.12, line = list(width = 0), layer = "below"),
                  list(type = "line", yref = "paper", y0 = 0, y1 = 1,
                       x0 = budget_high, x1 = budget_high,
                       line = list(color = "#333333", dash = "dash", width = 1.5))),
    annotations = list(list(x = budget_high, y = 1, yref = "paper",
                            text = "$1,000 group budget", showarrow = FALSE,
                            xanchor = "left", yanchor = "bottom", font = list(size = 12))),
    legend = list(title = list(text = "<b>Ring</b>")),
    margin = list(t = 90, b = 190),
    font = list(family = "Arial")
  ) %>%
  animation_opts(frame = 900, transition = 500, redraw = FALSE) %>%
  animation_slider(currentvalue = list(prefix = "Year ending September ",
                                       font = list(size = 14)))

add_source(chart3, y_pos = -0.42)

Chart description: animated scatter plot, one dot per suburb, with median rent on the horizontal axis and number of new 4-bedroom leases on a log scale on the vertical axis, coloured by ring. A slider steps through the years 2015 to 2025.

Two things stand out. First, the whole cloud of dots drifts to the right over the decade, and the inner (orange-red) dots move past the $1,000 line. Second, the inner suburbs sit near the bottom: in the year to September 2025 there were only 474 new 4-bedroom house leases in all of Inner Melbourne, about 1.3% of the 36,482 new 4-bedroom house leases across the suburbs and towns covered by this dataset (it does not cover every part of Victoria). Outer Western Melbourne alone had 12,196 (33%). Lease counts are not the same as the number of houses for rent at any one time, but they show how rarely a big house near the city changes hands. The suburbs with both lower medians and lots of leases are mostly in the outer ring (green) and the regional centres (pink), far from campus.

4. The shrinking map

To bring this together, the fourth chart counts how many Melbourne suburbs have a median within or over our $1,000 budget each year, split by ring. Suburbs within budget are split into two shades, under $800 and $800 to $1,000, to show how close they are getting to the limit.

chart4_data <- house4 %>%
  filter(!is_total, ring != "Regional", month(date) == 9) %>%
  mutate(band = coalesce(as.character(budget_band), "No data"),
         band = factor(band, levels = rev(names(band_colours)))) %>%
  count(ring, year, band, name = "suburbs") %>%
  group_by(ring, year) %>%
  mutate(total = sum(suburbs)) %>%
  ungroup() %>%
  mutate(hover = paste0("<b>", ring, " ring, year ending Sep ", year, "</b><br>",
                        band, ": ", suburbs, " of ", total, " suburbs"),
         ring_label = paste0(ring, " ring (", total, " suburbs)"))

chart4_gg <- ggplot(chart4_data,
                    aes(x = year, y = suburbs, fill = band, text = hover)) +
  geom_col(width = 0.85) +
  facet_wrap(~ ring_label, ncol = 3, scales = "free_y") +
  scale_fill_manual(values = band_colours, breaks = names(band_colours), name = "Median rent") +
  scale_x_continuous(breaks = seq(2000, 2025, 5)) +
  labs(x = NULL, y = "Number of suburbs") +
  theme_minimal(base_family = "Arial", base_size = 12) +
  theme(panel.grid.minor = element_blank(), panel.grid.major.x = element_blank(),
        strip.text = element_text(face = "bold", size = 12))

chart4 <- ggplotly(chart4_gg, tooltip = "text") %>%
  layout(
    title = list(text = paste0("<b>Our $1,000 budget now mostly reaches the middle ring and beyond</b><br>",
                               "<sup>Melbourne suburbs by median weekly rent for a new 4-bedroom ",
                               "house lease, 12 months to September each year</sup>"),
                 x = 0, xanchor = "left"),
    legend = list(orientation = "h", x = 0, y = -0.1, traceorder = "normal"),
    margin = list(t = 100, b = 120),
    font = list(family = "Arial")
  )

add_source(chart4, y_pos = -0.24)

Chart description: stacked bar charts, one panel each for the inner, middle and outer rings, showing how many suburbs each year were within the $1,000 budget (under $800, or $800 to $1,000), over budget, or had no data.

band_count <- function(r, y, b) {
  chart4_data %>% filter(ring == r, year == y, band == b) %>% pull(suburbs) %>% sum()
}
over_b  <- "Over budget: more than $1,000"
near_b  <- "Within budget: $800-$1,000"
under_b <- "Within budget: under $800"
mid_over_2015  <- band_count("Middle", 2015, over_b)
mid_over_2025  <- band_count("Middle", 2025, over_b)
mid_within_2025 <- band_count("Middle", 2025, near_b) + band_count("Middle", 2025, under_b)
mid_under_2015 <- band_count("Middle", 2015, under_b)
mid_under_2025 <- band_count("Middle", 2025, under_b)
out_near_2025  <- band_count("Outer", 2025, near_b)

The pattern moves outward like a wave. In the inner ring, the light blue (under $800) disappears first, then the dark blue ($800 to $1,000), until almost everything is orange (over budget). In the middle ring, the number of suburbs over $1,000 went from 1 in 2015 to 18 in 2025, and the suburbs under $800 fell from 45 to 16. The 37 middle-ring suburbs still within budget are where our search makes the most sense on price, although a median under $1,000 does not mean a suitable house will be available. The outer ring is still mostly under $800, but 4 outer suburbs are now between $800 and $1,000, so going further out does not save as much as it used to. Hovering on a bar shows the exact count for each year.

5. Is sharing the answer?

One way students deal with high rent is to share. This last chart estimates the weekly cost per person for three living arrangements in each region, by dividing the median rent by the number of people, assuming everyone pays an equal share. The dashed line is our own share: $1,000 split five ways is $200 each per week. This is rent only, not bills or other living costs, and it is an estimate for comparison, not a sign that a particular house is available or suitable for five people. Use the buttons to switch between 2015 and 2025.

arrangements <- tibble(
  property_type = c("1br flat", "2br flat", "4br house"),
  arrangement   = c("1-bedroom flat, living alone",
                    "2-bedroom flat, split 2 ways",
                    "4-bedroom house, split 5 ways"),
  people        = c(1, 2, group_size)
)
arrangement_colours <- c("#E69F00", "#56B4E9", "#0072B2")

chart5_data <- suburb_rents %>%
  filter(is_total, date %in% c(sep_2015, sep_2025),
         property_type %in% arrangements$property_type) %>%
  left_join(arrangements, by = "property_type") %>%
  mutate(per_person = median_rent / people,
         year_label = ifelse(date == sep_2015, "2015", "2025"),
         hover = paste0("<b>", region, "</b><br>", arrangement, "<br>",
                        "Year ending Sep ", year_label, "<br>",
                        "Median rent: ", dollars(median_rent), " per week<br>",
                        "Per person: ", dollars(per_person), " per week"))

# Order regions by ring, then by 2025 house-share cost (inner at the top)
region_levels <- chart5_data %>%
  filter(year_label == "2025", property_type == "4br house") %>%
  arrange(desc(ring), per_person) %>%
  pull(region)
chart5_data <- chart5_data %>% mutate(region = factor(region, levels = region_levels))

chart5 <- plot_ly()
for (yr in c("2015", "2025")) {
  for (i in seq_len(nrow(arrangements))) {
    d <- chart5_data %>% filter(year_label == yr, arrangement == arrangements$arrangement[i])
    chart5 <- chart5 %>%
      add_markers(data = d, x = ~per_person, y = ~region,
                  name = arrangements$arrangement[i],
                  legendgroup = arrangements$arrangement[i],
                  visible = (yr == "2025"),
                  marker = list(size = 12, color = arrangement_colours[i],
                                symbol = c("circle", "square", "diamond")[i]),
                  text = ~hover, hoverinfo = "text")
  }
}

# Buttons switch between the three 2015 traces and the three 2025 traces
chart5 <- chart5 %>%
  layout(
    title = list(text = paste0("<b>Sharing a house halves the cost, but not near the city</b><br>",
                               "<sup>Estimated weekly rent per person, by region and living ",
                               "arrangement (median rent divided by number of people)</sup>"),
                 x = 0, xanchor = "left"),
    xaxis = list(title = "Estimated weekly rent per person", tickprefix = "$",
                 range = c(0, max(chart5_data$per_person) * 1.1)),
    yaxis = list(title = ""),
    shapes = list(list(type = "rect", yref = "paper", y0 = 0, y1 = 1,
                       x0 = 0, x1 = budget_each,
                       fillcolor = "grey", opacity = 0.12, line = list(width = 0),
                       layer = "below"),
                  list(type = "line", yref = "paper", y0 = 0, y1 = 1,
                       x0 = budget_each, x1 = budget_each,
                       line = list(color = "#333333", dash = "dash", width = 1.5))),
    annotations = list(list(x = budget_each, y = 1, yref = "paper",
                            text = "Our share: $200 each", showarrow = FALSE,
                            xanchor = "left", yanchor = "bottom", font = list(size = 12))),
    updatemenus = list(list(
      type = "buttons", direction = "right", x = 1, xanchor = "right", y = 1.12,
      buttons = list(
        list(label = "2025", method = "restyle",
             args = list("visible", c(FALSE, FALSE, FALSE, TRUE, TRUE, TRUE))),
        list(label = "2015", method = "restyle",
             args = list("visible", c(TRUE, TRUE, TRUE, FALSE, FALSE, FALSE)))))),
    legend = list(orientation = "h", x = 0, y = -0.12),
    margin = list(t = 110, b = 160, l = 10),
    font = list(family = "Arial")
  )

add_source(chart5, y_pos = -0.22)

Chart description: dot plot with one row per region and three markers per row showing the estimated weekly cost per person of a 1-bedroom flat alone, a 2-bedroom flat shared by two, and a 4-bedroom house shared by five, with a dashed line at our $200 share. Buttons switch between 2015 and 2025.

pp <- function(reg, type, yr) {
  chart5_data %>% filter(region == reg, property_type == type, year_label == yr) %>%
    pull(per_person)
}
inner_share_2025 <- pp("Inner Melbourne", "4br house", "2025")
inner_alone_2025 <- pp("Inner Melbourne", "1br flat", "2025")
inner_share_2015 <- pp("Inner Melbourne", "4br house", "2015")
ow_share_2025    <- pp("Outer Western Melbourne", "4br house", "2025")
regions_in_share <- chart5_data %>%
  filter(year_label == "2025", property_type == "4br house",
         per_person <= budget_each) %>% nrow()
regions_over_share <- chart5_data %>%
  filter(year_label == "2025", property_type == "4br house",
         per_person > budget_each) %>% pull(region) %>% as.character()

Sharing clearly helps. In Inner Melbourne in 2025, a 1-bedroom flat on your own works out to about $530 a week, while a fifth of a 4-bedroom house is about $250. But $250 is still above our $200 each. In 2015 the same inner-city house share was about $180 each, under our share. In 2025, 11 of the 13 regions have an estimated house-share cost at or below $200 each; the ones that do not are Inner Melbourne and Southern Melbourne. In Outer Western Melbourne it is about $106 each, which is much cheaper, but most of that region is a long way from the city.

Conclusion

So, could five students have shared a 4-bedroom house near the city on a combined $1,000 a week ten years ago? Based on the medians, yes, in most inner suburbs. In 2015 the Inner Melbourne median was $900, and 12 of 19 inner suburbs with data were at or under $1,000. By 2025 the median was $1,250 and only 1 inner suburb was still within budget.

Our budget now reaches mainly into the middle ring and the outer suburbs. That matches what happened to us: the houses we could afford were the ones a long way from campus. The data also suggests it is not only about price. Very few 4-bedroom houses are leased near the city at all, so a group looking there has few options to choose from even before price is considered.

This does not mean the city is impossible for students. Smaller places or different sharing arrangements may still work, and because these are medians, about half of the new leases in each suburb were at or below the figure shown. But for a group like ours, living close to RMIT in a shared house has become much harder in the past ten years, and most of that change has happened since 2021.

Data and methods

  • Data source: Homes Victoria rental report, Moving annual rents by suburb, September quarter 2025, downloaded from DataVic (CC BY 4.0). Figures come from rental bonds lodged with the Residential Tenancies Bond Authority, so they describe new leases only, not what existing tenants pay.
  • Moving annual median: each value covers all new leases that started in the 12 months up to that quarter. “Sep 2025” means October 2024 to September
    1. This smooths the data but lags behind sudden changes.
  • Preparation: the seven worksheets (one per property type) were read into R, reshaped from wide to long format, and combined into one table. Region labels were filled down, “Group Total” rows were flagged as region totals, and a spelling error in the source (“Wanagaratta”) was fixed.
  • Missing values: Homes Victoria shows “-” when there are too few leases to publish a figure. These were treated as missing (not zero) and left out of charts, and are listed in the text where relevant.
  • Low counts: suburbs with fewer than 30 new leases in a year are shown as hollow points in Chart 2 because their medians can jump around.
  • Distance rings: I grouped Homes Victoria’s 13 regions into Inner (Inner Melbourne), Middle (Inner Eastern, Southern, North Western and North Eastern Melbourne), Outer (Outer Western, Outer Eastern and South Eastern Melbourne and Mornington Peninsula) and Regional (Geelong, Ballarat, Bendigo and other regional centres). This is a rough proxy for distance from the CBD, not measured travel time.
  • Budget: our budget is $1,000 a week in total for five people, so $200 each with an equal split ($52,000 a year for the group, $10,400 each). A suburb counts as “within budget” when its 4-bedroom house median is $1,000 or less (a median of exactly $1,000 counts as within budget).
  • Per-person cost (Chart 5): median rent divided by the number of people, assuming an equal split. It covers rent only and is an estimate for comparison.
  • Why one file: each Homes Victoria release contains the full history back to 2000, so both the 2015 and 2025 figures come from the September quarter 2025 release. I also downloaded the September quarter 2016 release, but older figures are revised in later releases as late bonds are added (for example, about one in five 4-bedroom suburb medians for September 2016 differ between the two releases). Taking both years from the same release keeps the comparison consistent. The LGA-level files were checked but not used, because the suburb file already gives the detail needed for every chart.
  • Dollars are nominal: values have not been adjusted for inflation.

The full data preparation code is below (click Code to show it).

# =============================================================================
# 01_clean_data.R
# Assignment 3 - Storytelling with Open Data
# Purpose: import the Homes Victoria "Moving annual median rent by suburb and
#          town" workbook (September quarter 2025) and turn it into one tidy
#          table that every chart in the report can use.
# Output:  data/suburb_rents_clean.csv
# =============================================================================

library(readxl)
library(dplyr)
library(tidyr)
library(lubridate)

suburb_file <- "data/Moving_annual_median_rent_by_suburb_and_town_-_September_quarter_2025.xlsx"

# -----------------------------------------------------------------------------
# 1. Read one worksheet and reshape it from wide to long
# -----------------------------------------------------------------------------
# Each sheet has a two-row header:
#   row 2 = period label ("Mar 2000"), repeated for Count and Median
#   row 3 = "Count" or "Median"
# Column A holds the region, but only on the first row of each group.
# Everything is read as text first so readxl does not guess types from the
# first few rows; "-" (suppressed small counts) is converted to NA afterwards.

read_rent_sheet <- function(path, sheet) {
  raw <- read_excel(path, sheet = sheet, col_names = FALSE,
                    col_types = "text", .name_repair = "unique_quiet")

  periods  <- unlist(raw[2, ])
  measures <- unlist(raw[3, ])
  keep     <- which(!is.na(periods) & measures %in% c("Count", "Median"))

  body <- raw[-(1:3), c(1, 2, keep)]
  names(body) <- c("region", "area", paste(periods[keep], measures[keep], sep = "_"))

  body %>%
    fill(region) %>%                     # copy region label down to every suburb
    filter(!is.na(area)) %>%             # drop blank rows and any notes
    pivot_longer(-c(region, area),
                 names_to  = c("period", "measure"),
                 names_sep = "_",
                 values_to = "value") %>%
    mutate(value = as.numeric(na_if(value, "-"))) %>%
    pivot_wider(names_from = measure, values_from = value) %>%
    rename(lease_count = Count, median_rent = Median) %>%
    mutate(property_type = sheet)
}

suburb_rents_raw <- bind_rows(
  lapply(excel_sheets(suburb_file), read_rent_sheet, path = suburb_file)
)

# -----------------------------------------------------------------------------
# 2. Clean labels and add the variables the story needs
# -----------------------------------------------------------------------------
# Rings: Homes Victoria's 13 regions grouped into four broad bands.
# These are an approximate proxy for distance from the CBD, NOT measured
# travel time. This grouping is my own and is explained in the report.
ring_lookup <- tibble(
  region = c("Inner Melbourne",
             "Inner Eastern Melbourne", "Southern Melbourne",
             "North Western Melbourne", "North Eastern Melbourne",
             "Outer Western Melbourne", "Outer Eastern Melbourne",
             "South Eastern Melbourne", "Mornington Peninsula",
             "Geelong", "Ballarat", "Bendigo", "Other Regional Centres"),
  ring   = c("Inner",
             "Middle", "Middle", "Middle", "Middle",
             "Outer", "Outer", "Outer", "Outer",
             "Regional", "Regional", "Regional", "Regional")
)

suburb_rents <- suburb_rents_raw %>%
  mutate(
    date          = my(period),                          # "Sep 2025" -> 2025-09-01
    year          = year(date),
    is_total      = area == "Group Total",               # region summary rows
    area          = recode(area, "Wanagaratta" = "Wangaratta"),   # typo in source
    property_type = recode(property_type,
                           "1 bedroom flat"  = "1br flat",
                           "2 bedroom flat"  = "2br flat",
                           "3 bedroom flat"  = "3br flat",
                           "2 bedroom house" = "2br house",
                           "3 bedroom house" = "3br house",
                           "4 bedroom house" = "4br house",
                           "All properties"  = "All properties"),
    # Our group's budget is $1,000 a week IN TOTAL for five people ($200 each).
    # "Within budget" = median of $1,000 or less. The $800 split only shows how
    # close a within-budget suburb is to the limit.
    budget_band   = cut(median_rent,
                        breaks = c(-Inf, 799.99, 1000, Inf),
                        labels = c("Within budget: under $800",
                                   "Within budget: $800-$1,000",
                                   "Over budget: more than $1,000")),
    # Flag thin data: fewer than 30 new leases in the 12-month window
    low_count     = !is.na(lease_count) & lease_count < 30
  ) %>%
  left_join(ring_lookup, by = "region") %>%
  mutate(ring = factor(ring, levels = c("Inner", "Middle", "Outer", "Regional"))) %>%
  select(property_type, ring, region, area, is_total, period, date, year,
         lease_count, median_rent, budget_band, low_count)

# -----------------------------------------------------------------------------
# 3. Validation checks - read these every time the script runs
# -----------------------------------------------------------------------------
cat("\n--- Rows per property type (expect 159 areas x 103 periods = 16,377) ---\n")
print(count(suburb_rents, property_type))

cat("\n--- Date range ---\n")
print(range(suburb_rents$date))

cat("\n--- Failed date conversions (expect 0) ---\n")
print(sum(is.na(suburb_rents$date)))

cat("\n--- Regions without a ring (expect 0) ---\n")
print(sum(is.na(suburb_rents$ring)))

cat("\n--- Duplicate area-period records (expect none) ---\n")
print(suburb_rents %>% count(property_type, region, area, date) %>% filter(n > 1))

cat("\n--- 4br house, Sep 2025: suburbs with data vs suppressed ---\n")
print(suburb_rents %>%
        filter(property_type == "4br house", date == ymd("2025-09-01"), !is_total) %>%
        summarise(suburbs = n(),
                  with_value = sum(!is.na(median_rent)),
                  suppressed = sum(is.na(median_rent)),
                  under_30_leases = sum(low_count)))

cat("\n--- Spot check: Inner Melbourne 4br house group total (expect $900 in Sep 2015, $1,250 in Sep 2025) ---\n")
print(suburb_rents %>%
        filter(property_type == "4br house", region == "Inner Melbourne", is_total,
               date %in% ymd(c("2015-09-01", "2025-09-01"))) %>%
        select(period, lease_count, median_rent))

# -----------------------------------------------------------------------------
# 4. Save the tidy table for the report
# -----------------------------------------------------------------------------
write.csv(suburb_rents, "data/suburb_rents_clean.csv", row.names = FALSE)
cat("\nSaved data/suburb_rents_clean.csv with", nrow(suburb_rents), "rows\n")

Limitations

  • The data ends in September 2025, but our search happened in 2026, so current rents are likely to be somewhat higher than shown here.
  • A median is not a listing. It summarises leases that were signed, so it does not show that a house was available at that price, or that a house is suitable for five people.
  • Medians do not show the full range of prices. There may be cheaper houses in expensive suburbs, and the median for a suburb with few leases can change a lot from year to year.
  • The rings are based on Homes Victoria’s regions. Some suburbs in the “Outer Western” region, such as Footscray and Yarraville, are actually quite close to the city, so the ring labels are approximate.
  • The data covers rent only. It says nothing about incomes, travel costs or commute times, so it cannot tell us whether a place is “affordable” in general, only whether it fits our group’s budget.
  • The data shows what changed, not why. I have not tried to explain the causes of the rent increases.
  • Dollar values are not adjusted for inflation, so part of the rise from 2015 to 2025 reflects general price increases.

Acknowledgements

I acknowledge the use of Claude (Anthropic, https://claude.ai) in preparing this report. Claude was used solely to assist with technical troubleshooting for my R code, provide suggestions for refining my own written work, and review spelling and grammar. All research, analysis, visualisations, and original draft text were produced independently, and I reviewed and verified all AI suggestions against the source data.

The data was provided by Homes Victoria under a Creative Commons Attribution 4.0 licence.

References

Anthropic. (2026). Claude (Opus 5.5 version) [Large language model]. https://claude.ai

Homes Victoria. (2026). Rental report – Quarterly: Moving annual rents by suburb, September quarter 2025 [Data set]. DataVic. https://discover.data.vic.gov.au/dataset/rental-report-quarterly-moving-annual-rents-by-suburb

R Core Team. (2024). R: A language and environment for statistical computing (Version 4.3.3) [Computer software]. R Foundation for Statistical Computing. https://www.R-project.org/

Sievert, C. (2020). Interactive web-based data visualization with R, plotly, and shiny. Chapman and Hall/CRC. https://plotly-r.com

Wickham, H. (2016). ggplot2: Elegant graphics for data analysis. Springer. https://ggplot2.tidyverse.org

Wickham, H., & Bryan, J. (2023). readxl: Read Excel files (Version 1.4.3) [Computer software]. https://CRAN.R-project.org/package=readxl

Wickham, H., François, R., Henry, L., Müller, K., & Vaughan, D. (2023). dplyr: A grammar of data manipulation (Version 1.1.4) [Computer software]. https://CRAN.R-project.org/package=dplyr

Wickham, H., Vaughan, D., & Girlich, M. (2024). tidyr: Tidy messy data (Version 1.3.1) [Computer software]. https://CRAN.R-project.org/package=tidyr