In the fifth of Victorian postcodes with the fewest renters, there are 51 rooftop solar installations for every 100 homes. In the fifth with the most renters, that number drops to just 19. That’s a big difference, especially when nearly three in ten Victorian households rent.

For homeowners, installing solar can be a way to save money on electricity. But renters usually don’t get to make that choice. Even if they want solar, the decision often rests with their landlord. With electricity being an everyday expense, the ability to reduce that cost shouldn’t depend entirely on whether someone owns their home.

So, are renters getting the same opportunity to benefit from solar? And is this difference simply about how much people earn, or is something else going on? The numbers across Victoria suggest there’s more to this story than just money.

1. A city divided by rooftops

Inner Melbourne has surprisingly little rooftop solar, while the outer suburbs are covered in orange. The pattern becomes clearer when you compare solar installations with renting: search your postcode or click a dot to see where your area sits.

Click a postcode on the scatter to find it on the map; double-click to reset. Postcodes with fewer than 1,000 occupied dwellings are excluded. Source: Clean Energy Regulator (2026); Australian Bureau of Statistics (2021, 2022).

Text description of this chart Interactive map of Victorian postcodes coloured by rooftop solar installation rates, alongside a scatterplot comparing the percentage of rented dwellings with solar installations per 100 occupied dwellings, with dots coloured by median household income. Inner Melbourne generally has lower solar installation rates than the outer suburbs, and postcodes with higher rental shares tend to have fewer installations. This shows a geographic relationship between renting and rooftop solar adoption.

2. Two solar booms. The same gap.

Victoria’s rooftop solar installations surged in 2011–12 and again in 2020–21. But during both booms, postcodes with the most renters reached only around 37–40% of the installation rate in postcodes with the fewest renters. Hover over the lines to compare the years.

Installers have 12 months to register a system, so the shaded years will rise. Event dates: Department of Energy, Environment and Climate Action (n.d.); D’Ambrosio (2018). Source: Clean Energy Regulator (2026); Australian Bureau of Statistics (2021, 2022).

Text description of this chart Two line charts showing annual new solar installations per 1,000 occupied dwellings and average solar system size over time, grouped into five categories based on postcode rental share. Installations rose sharply around 2011–12 and 2020–21, but the highest-renter group reached only about 37–40% of the lowest-renter group’s installation rate during both booms. The gap persisted across both periods of rapid solar growth.

3. Is it really just about money?

Even when we compare postcodes within similar income groups, the solar gap remains. But income may not tell the whole story. Click the second tab to explore another possible explanation.

Within income groups

Within housing-type groups

Each panel is one fifth of Victorian postcodes, grouped by median household income (first tab) or by the share of dwellings that are separate houses (second tab). Combinations with fewer than 3 postcodes are not shown. Source: Clean Energy Regulator (2026); Australian Bureau of Statistics (2021, 2022).

Text description of this chart Two interactive views comparing rooftop solar installation rates across postcode rental-share groups, first within income categories and then within housing-type categories. The rental-share gap remains visible across income groups but becomes much smaller when postcodes with similar housing types are compared. Housing type appears more closely associated with the difference in solar installation rates than income alone.

4. A new technology. A familiar pattern.

Home batteries are the next big step in Australia’s energy transition, but early installation data shows a familiar pattern: fewer installations in areas with more renters. Switch between the Solar and Battery buttons to see how the patterns compare.

Battery data covers installations approved from 1 July 2025 to August 2026. The solar view counts every system since 2001, so compare the pattern between views, not the size of the numbers. Source: Clean Energy Regulator (2026); Australian Bureau of Statistics (2021, 2022).

Text description of this chart Interactive scatterplot of Victorian postcodes that switches between rooftop solar installations (all available years) and home battery installations (since July 2025) per 100 occupied dwellings, plotted against the percentage of rented dwellings and coloured by median household income. Both views show lower installation rates in postcodes with higher proportions of renters. Early battery installation data suggests that the pattern seen in rooftop solar is also appearing in battery adoption.

The problem isn’t just money. It’s having a roof of your own.

It’s easy to assume renters miss out on rooftop solar because they earn less. But the data points to something else. Even when we compare postcodes with similar incomes, the solar gap remains. When we compare areas with similar types of housing, however, that gap becomes much smaller.

This suggests the bigger issue isn’t simply income or whether someone rents or owns. It’s the type of home people live in. Renters are more likely to live in apartments and units, where installing rooftop solar is much harder than on a detached house.

There is one important limit to these findings. The data compares postcodes, not individual households. It shows where solar installations are less common, but it cannot tell us exactly which renters or homeowners have solar panels.

So what needs to change? I think solar should become easier to access for people living in apartments and rental properties. That could mean supporting shared solar systems in apartment buildings, giving landlords more reasons to install panels, and making community solar a realistic option.

With Victoria heading to the polls on 28 November, this is something worth asking our political leaders about. If the goal is cleaner and more affordable energy, shouldn’t people without their own roof have a fair chance to benefit too?


About the data

References

Australian Bureau of Statistics. (2021). Australian Statistical Geography Standard (ASGS) Edition 3: Postal areas, July 2021–June 2026 [Data set]. https://www.abs.gov.au/statistics/standards/australian-statistical-geography-standard-asgs-edition-3/jul2021-jun2026

Australian Bureau of Statistics. (2022). 2021 Census general community profile: Postal areas [Data set]. https://www.abs.gov.au/census/find-census-data/datapacks

Clean Energy Regulator. (2026). Small-scale installation postcode data [Data set]. Retrieved October 14, 2026, from https://cer.gov.au/markets/reports-and-data/small-scale-installation-postcode-data

D’Ambrosio, L. (2018, September 14). Applications now open for half-price solar panel rebates [Media release]. Premier of Victoria. https://www.premier.vic.gov.au/site-4/applications-now-open-half-price-solar-panel-rebates

Department of Energy, Environment and Climate Action. (n.d.). Solar premium feed-in tariff. Victorian Government. Retrieved October 15, 2026, from https://www.energy.vic.gov.au/households/solar-premium-feed-in-tariff

Acknowledgements

[Write your GenAI acknowledgement following RMIT Library guidelines: name the tool, what you used it for, and how you checked the output. Add the matching reference above.]

Appendix: R code

knitr::opts_chunk$set(echo = FALSE, message = FALSE, warning = FALSE,
                      fig.width = 9, fig.height = 5.5)

library(tidyverse)
library(lubridate)
library(sf)
library(rmapshaper)
library(leaflet)
library(plotly)
library(crosstalk)

# One palette per variable, used consistently across every chart:
#   renter share  -> viridis, darkest = most renters (the group the story is about)
#   income        -> ColorBrewer Blues
#   solar rate    -> ColorBrewer YlOrBr (sun colours; pale = "in the shade")
renter_pal <- viridisLite::viridis(5, end = 0.85, direction = -1)
income_pal <- RColorBrewer::brewer.pal(7, "Blues")[3:7]

base_font <- list(family = "Source Sans Pro, Arial, sans-serif", size = 14)
src_cer <- "Source: Clean Energy Regulator (2026); Australian Bureau of Statistics (2021, 2022)."
data_dir <- "data"

if (!dir.exists(data_dir)) {
  stop("Create a folder called 'data' next to this .Rmd file and put the downloads in it.")
}

abs_downloads <- c(
  "2021_GCP_POA_for_AUS_short-header.zip" =
    "https://www.abs.gov.au/census/find-census-data/datapacks/download/2021_GCP_POA_for_AUS_short-header.zip",
  "POA_2021_AUST_GDA2020_SHP.zip" =
    "https://www.abs.gov.au/statistics/standards/australian-statistical-geography-standard-asgs-edition-3/jul2021-jun2026/access-and-downloads/digital-boundary-files/POA_2021_AUST_GDA2020_SHP.zip"
)

options(timeout = 900)
for (f in names(abs_downloads)) {
  dest <- file.path(data_dir, f)
  if (!file.exists(dest)) {
    download.file(abs_downloads[[f]], dest, mode = "wb")
  }
}

for (z in list.files(data_dir, pattern = "\\.zip$", full.names = TRUE, recursive = TRUE)) {
  out <- file.path(data_dir, tools::file_path_sans_ext(basename(z)))
  if (!dir.exists(out)) unzip(z, exdir = out)
}

find_file <- function(pattern, exclude = NULL) {
  hits <- list.files(data_dir, pattern = pattern, recursive = TRUE,
                     full.names = TRUE, ignore.case = TRUE)
  if (!is.null(exclude)) hits <- hits[!grepl(exclude, hits, ignore.case = TRUE)]
  if (length(hits) == 0) {
    stop("No file matching '", pattern, "' found in the data folder.")
  }
  hits
}

read_cer <- function(path) {
  read_csv(path, col_types = cols(.default = col_character()))
}

tidy_cer <- function(df, value_name) {
  names(df)[1] <- "postcode"
  df |>
    select(postcode, matches("^[A-Z][a-z]{2} \\d{4} - ")) |>
    pivot_longer(-postcode, names_to = "period", values_to = "value") |>
    transmute(
      postcode = str_pad(str_trim(postcode), 4, pad = "0"),
      month    = my(str_extract(period, "^[A-Z][a-z]{2} \\d{4}")),
      !!value_name := replace_na(parse_number(value), 0)
    )
}

solar_installs <- find_file("sgu.?solar.*install.*\\.csv$") |>
  map(read_cer) |>
  map(tidy_cer, value_name = "installs") |>
  bind_rows()

solar_capacity <- find_file("sgu.?solar.*capacity.*\\.csv$") |>
  map(read_cer) |>
  map(tidy_cer, value_name = "kw") |>
  bind_rows()

battery_installs <- find_file("sgu.?battery.*install.*\\.csv$") |>
  map(read_cer) |>
  map(tidy_cer, value_name = "batteries") |>
  bind_rows()

solar_monthly <- solar_installs |>
  left_join(solar_capacity, by = c("postcode", "month")) |>
  filter(between(as.integer(postcode), 3000, 3999))

cer_data_month     <- max(solar_monthly$month[solar_monthly$installs > 0])
battery_data_month <- max(battery_installs$month[battery_installs$batteries > 0])
pick_col <- function(df, pattern) {
  hit <- grep(pattern, names(df), value = TRUE)
  if (length(hit) != 1) {
    stop("Expected one column matching '", pattern, "'. Found: ",
         paste(hit, collapse = ", "))
  }
  hit
}

g37 <- read_csv(find_file("2021Census_G37_AUST_POA\\.csv$")[1],
                show_col_types = FALSE)
g02 <- read_csv(find_file("2021Census_G02_AUST_POA\\.csv$")[1],
                show_col_types = FALSE)

census <- tibble(
  postcode   = str_remove(g37$POA_CODE_2021, "^POA"),
  dwellings  = g37[[pick_col(g37, "^Total_Total$")]],
  rented     = g37[[pick_col(g37, "^R_Tot_Total$")]],
  sep_houses = g37[[pick_col(g37, "^Total_DS_Sep_house$")]]
) |>
  left_join(
    tibble(postcode = str_remove(g02$POA_CODE_2021, "^POA"),
           med_inc  = g02$Median_tot_hhd_inc_weekly),
    by = "postcode"
  ) |>
  mutate(pct_rent  = 100 * rented / dwellings,
         pct_house = 100 * sep_houses / dwellings)

poa_shapes <- read_sf(find_file("POA_2021_AUST_GDA2020\\.shp$")[1]) |>
  filter(str_starts(POA_CODE21, "3")) |>
  ms_simplify(keep = 0.05, keep_shapes = TRUE) |>
  st_transform(4326) |>
  select(postcode = POA_CODE21)
solar_totals <- solar_monthly |>
  group_by(postcode) |>
  summarise(solar = sum(installs), kw = sum(kw, na.rm = TRUE))

battery_totals <- battery_installs |>
  filter(month >= ymd("2025-07-01")) |>
  group_by(postcode) |>
  summarise(batteries = sum(batteries))

stopifnot(
  !anyDuplicated(solar_monthly[c("postcode", "month")]),
  !anyDuplicated(battery_installs[c("postcode", "month")])
)

vic <- census |>
  filter(str_starts(postcode, "3"), dwellings >= 1000, med_inc > 0) |>
  left_join(solar_totals, by = "postcode") |>
  left_join(battery_totals, by = "postcode") |>
  mutate(solar = replace_na(solar, 0),
         batteries = replace_na(batteries, 0)) |>
  mutate(
    solar_rate   = 100 * solar / dwellings,
    battery_rate = 100 * batteries / dwellings,
    rent_q = factor(ntile(pct_rent, 5),
                    labels = c("Fewest renters", "2", "3", "4", "Most renters")),
    inc_q  = factor(ntile(med_inc, 5),
                    labels = c("Lowest income", "2", "3", "4", "Highest income")),
    house_q = factor(ntile(pct_house, 5),
                     labels = c("Fewest houses", "2", "3", "4", "Most houses")),
    hover = sprintf(
      "<b>Postcode %s</b><br>%.0f solar systems per 100 dwellings<br>%.0f%% of dwellings rented<br>%.0f%% separate houses<br>Median household income $%s/wk<br>%s occupied dwellings",
      postcode, solar_rate, pct_rent, pct_house, scales::comma(med_inc),
      scales::comma(dwellings)
    )
  )

vic_sf <- poa_shapes |> inner_join(vic, by = "postcode")
shared_map  <- SharedData$new(vic_sf, key = ~postcode, group = "vic_pc")
shared_scat <- SharedData$new(st_drop_geometry(vic_sf), key = ~postcode,
                              group = "vic_pc")

map_pal <- colorBin("YlOrBr", domain = vic_sf$solar_rate,
                    bins = c(0, 15, 30, 45, 60, 75, Inf))

solar_map <- leaflet(shared_map, height = 520) |>
  addProviderTiles(providers$CartoDB.Positron) |>
  setView(lng = 145.0, lat = -37.85, zoom = 9) |>
  addPolygons(
    fillColor = ~map_pal(solar_rate), fillOpacity = 0.8,
    color = "white", weight = 0.4,
    label = ~lapply(hover, htmltools::HTML),
    highlightOptions = highlightOptions(weight = 2, color = "#000000",
                                        bringToFront = TRUE)
  ) |>
  addLegend(pal = map_pal, values = ~solar_rate, position = "bottomright",
            title = "Solar systems<br>per 100 dwellings",
            labFormat = labelFormat(between = "–"))

solar_scatter <- plot_ly(
  shared_scat, x = ~pct_rent, y = ~solar_rate,
  color = ~inc_q, colors = income_pal,
  text = ~hover, hoverinfo = "text",
  type = "scatter", mode = "markers",
  marker = list(size = 9, opacity = 0.8,
                line = list(color = "white", width = 0.5)),
  height = 520
) |>
  layout(
    font = base_font,
    xaxis = list(title = "Share of dwellings rented (%)", zeroline = FALSE),
    yaxis = list(title = "Solar systems per 100 dwellings", zeroline = FALSE),
    legend = list(title = list(text = "Median household income", side = "top"),
                  orientation = "h", y = -0.22)
  ) |>
  highlight(on = "plotly_click", off = "plotly_doubleclick",
            color = "#D55E00", selectize = FALSE)

bscols(
  widths = c(12, 6, 6), device = "md",
  filter_select("pc_search", "Find a postcode", shared_scat, ~postcode),
  solar_map,
  solar_scatter
)
annual <- solar_monthly |>
  inner_join(select(vic, postcode, rent_q, dwellings), by = "postcode") |>
  mutate(year = year(month)) |>
  filter(year >= 2008) |>
  group_by(rent_q, year) |>
  summarise(installs = sum(installs), kw = sum(kw, na.rm = TRUE),
            .groups = "drop") |>
  left_join(vic |> group_by(rent_q) |> summarise(dwellings = sum(dwellings)),
            by = "rent_q") |>
  mutate(rate   = 1000 * installs / dwellings,
         avg_kw = kw / installs)

events <- tribble(
  ~year, ~label,
  2011, "Premium feed-in tariff closes",
  2018, "Solar Homes rebates begin"
)

gap <- annual |>
  filter(year %in% c(2012, 2021),
         rent_q %in% c("Fewest renters", "Most renters")) |>
  select(year, rent_q, rate) |>
  pivot_wider(names_from = rent_q, values_from = rate) |>
  mutate(pct = 100 * `Most renters` / `Fewest renters`)

most_2021 <- filter(annual, rent_q == "Most renters", year == 2021)$rate

rate_panel <- plot_ly(annual, x = ~year, y = ~rate, color = ~rent_q,
                      colors = renter_pal, type = "scatter",
                      mode = "lines+markers", legendgroup = ~rent_q,
                      hovertemplate = "%{y:.1f} per 1,000 dwellings<extra>%{fullData.name}</extra>") |>
  add_lines(data = filter(annual, rent_q == "Most renters"),
            x = ~year, y = ~rate, inherit = FALSE,
            line = list(color = renter_pal[5], width = 4),
            showlegend = FALSE, hoverinfo = "skip") |>
  layout(yaxis = list(title = "New systems per<br>1,000 dwellings"))

size_panel <- plot_ly(annual, x = ~year, y = ~avg_kw, color = ~rent_q,
                      colors = renter_pal, type = "scatter",
                      mode = "lines", legendgroup = ~rent_q,
                      showlegend = FALSE,
                      hovertemplate = "%{y:.1f} kW average<extra>%{fullData.name}</extra>") |>
  layout(yaxis = list(title = "Average system<br>size (kW)"))

subplot(rate_panel, size_panel, nrows = 2, shareX = TRUE,
        heights = c(0.65, 0.35), titleY = TRUE) |>
  layout(
    font = base_font, hovermode = "x unified",
    margin = list(t = 70, b = 40),
    xaxis = list(title = ""),
    legend = list(title = list(text = "Postcodes grouped by share of homes rented", side = "top"),
                  orientation = "h", x = 0, y = -0.12),
    shapes = list(list(type = "rect", xref = "x", yref = "paper",
                       x0 = year(cer_data_month) - 1.5,
                       x1 = year(cer_data_month) + 0.5, y0 = 0, y1 = 1,
                       fillcolor = "#999999", opacity = 0.2, line = list(width = 0))),
    annotations = c(
      pmap(events, \(year, label) list(
        x = year, y = 1, xref = "x", yref = "paper", text = label,
        showarrow = TRUE, arrowhead = 0, ax = 0, ay = -30,
        font = list(size = 12)
      )),
      list(list(x = year(cer_data_month) - 0.5, y = 0.02, xref = "x",
                yref = "paper", text = "Still being counted",
                showarrow = FALSE, font = list(size = 11, color = "#555555"))),
      list(list(x = 2021, y = most_2021, xref = "x", yref = "y",
                text = sprintf("Postcodes with the most renters installed<br>%.0f%% of the fewest-renter rate in 2012,<br>and %.0f%% in 2021",
                               gap$pct[gap$year == 2012], gap$pct[gap$year == 2021]),
                showarrow = TRUE, arrowhead = 0, ax = 0, ay = 55,
                align = "left", font = list(size = 12, color = renter_pal[5])))
    )
  )
within_plot <- function(group_var, group_title) {
  d <- vic |>
    group_by(group = {{ group_var }}, rent_q) |>
    summarise(rate = 100 * sum(solar) / sum(dwellings),
              n_pc = n(), .groups = "drop") |>
    filter(n_pc >= 3) |>
    mutate(tip = sprintf("%s: %s<br>Renter group: %s<br>%.0f solar systems per 100 dwellings<br>%d postcodes",
                         group_title, group, rent_q, rate, n_pc))

  p <- ggplot(d, aes(x = rent_q, y = rate, colour = rent_q, text = tip)) +
    geom_segment(aes(xend = rent_q, y = 0, yend = rate), linewidth = 0.8) +
    geom_point(size = 3) +
    facet_wrap(~group, nrow = 1, drop = FALSE) +
    scale_colour_manual(values = renter_pal, guide = "none", drop = FALSE) +
    scale_x_discrete(labels = 1:5, drop = FALSE) +
    labs(x = "Share of homes rented (1 = fewest renters, 5 = most)",
         y = "Solar systems per 100 dwellings") +
    theme_minimal(base_size = 14) +
    theme(panel.grid.major.x = element_blank(),
          panel.grid.minor = element_blank(),
          strip.text = element_text(face = "bold"))

  ggplotly(p, tooltip = "text", height = 430) |>
    layout(font = base_font) |>
    config(displayModeBar = FALSE)
}
within_plot(inc_q, "Income group")
within_plot(house_q, "Housing group")
marker_style <- list(size = 9, opacity = 0.8,
                     line = list(color = "white", width = 0.5),
                     color = ~med_inc, coloraxis = "coloraxis")

plot_ly(vic, x = ~pct_rent, height = 560) |>
  add_markers(y = ~solar_rate, name = "Rooftop solar",
              marker = marker_style,
              text = ~hover, hoverinfo = "text") |>
  add_markers(y = ~battery_rate, name = "Home batteries", visible = FALSE,
              marker = marker_style,
              text = ~sprintf("<b>Postcode %s</b><br>%.1f batteries per 100 dwellings<br>%.0f%% of dwellings rented<br>Median household income $%s/wk",
                              postcode, battery_rate, pct_rent, scales::comma(med_inc)),
              hoverinfo = "text") |>
  layout(
    font = base_font, showlegend = FALSE,
    margin = list(l = 70, r = 20, t = 80, b = 60),
    coloraxis = list(colorscale = list(c(0, income_pal[1]), c(1, income_pal[5])),
                     colorbar = list(title = list(text = "Median weekly<br>household<br>income ($)"))),
    xaxis = list(title = "Share of dwellings rented (%)", zeroline = FALSE),
    yaxis = list(title = "Solar systems per 100 dwellings", zeroline = FALSE),
    updatemenus = list(list(
      type = "buttons", direction = "right",
      x = 0, xanchor = "left", y = 1.04, yanchor = "bottom",
      buttons = list(
        list(label = "Rooftop solar (all years)", method = "update",
             args = list(list(visible = c(TRUE, FALSE)),
                         list(yaxis = list(title = "Solar systems per 100 dwellings",
                                           autorange = TRUE, zeroline = FALSE)))),
        list(label = "Home batteries (since July 2025)", method = "update",
             args = list(list(visible = c(FALSE, TRUE)),
                         list(yaxis = list(title = "Batteries per 100 dwellings",
                                           autorange = TRUE, zeroline = FALSE))))
      )
    ))
  )