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.
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).
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).
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.
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).
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).
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?
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
[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.]
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))))
)
))
)