---
title: "Sydney's Housing Weakness Was Deepest in Expensive Houses"
description: "Sydney's established-house median fell 4.0% in the June quarter, versus 1.8% for attached dwellings. Higher-value houses had fallen further from peak; the rest was less weak, not rising."
date: "2026-06-11"
date-modified: "2026-10-20"
categories: [housing, cost-of-living, australia, property-prices, abs]
author: "Aydin"
---
```{r}
#| label: setup
#| include: false
library(readxl)
library(readr)
library(dplyr)
library(tidyr)
library(ggplot2)
library(stringr)
library(forcats)
library(scales)
library(knitr)
source(file.path("..", "..", "R", "data_sources.R"))
theme_set(
theme_minimal(base_size = 12) +
theme(
plot.title.position = "plot",
plot.caption.position = "plot",
panel.grid.minor = element_blank()
)
)
raw_dir <- file.path("data-raw", "housing")
source_registry <- read_source_registry("sources.csv")
source_files <- lapply(
seq_len(nrow(source_registry)),
function(index) download_registered_source(source_registry[index, , drop = FALSE], raw_dir)
)
source_manifest <- write_source_manifest(source_files, file.path(raw_dir, "source-manifest.csv"))
source_path <- function(source_id) {
index <- match(source_id, source_registry$source_id)
if (is.na(index)) stop("Missing source in sources.csv: ", source_id, call. = FALSE)
source_files[[index]]$path
}
as_number <- function(x) {
parse_number(as.character(x), na = c("", "NA", "n/a", "-"))
}
assert_true <- function(condition, message) {
if (!isTRUE(condition)) stop(message, call. = FALSE)
}
assert_close <- function(actual, expected, tolerance, label) {
if (length(actual) != 1 || !is.finite(actual) || abs(actual - expected) > tolerance) {
stop(sprintf("%s did not reconcile. Expected %s; found %s.", label, expected, actual), call. = FALSE)
}
}
quarter_date_from_excel <- function(x) {
as.Date(as.numeric(x), origin = "1899-12-30")
}
last_semicolon_field <- function(item) {
item |>
str_remove(";\\s*$") |>
str_extract("[^;]+$") |>
str_squish()
}
stock_measure <- function(item) {
case_when(
str_detect(item, fixed("Value of dwelling stock; Owned by All Sectors")) ~ "Total dwelling stock value",
str_detect(item, fixed("Mean price of residential dwellings")) ~ "Mean dwelling price",
str_detect(item, fixed("Number of residential dwellings")) ~ "Number of dwellings",
TRUE ~ NA_character_
)
}
parse_abs_stock <- function(path) {
raw <- read_excel(path, sheet = "Data1", col_names = FALSE)
metadata <- tibble(
column = seq_along(raw),
item = as.character(unlist(raw[1, ])),
unit = as.character(unlist(raw[2, ])),
measure = stock_measure(item),
state = last_semicolon_field(item)
) |>
filter(!is.na(measure), !is.na(state), state != "")
expected_units <- c(
"Total dwelling stock value" = "$ Millions",
"Mean dwelling price" = "$'000",
"Number of dwellings" = "000"
)
assert_true(
nrow(metadata) == 27 && all(metadata$unit == unname(expected_units[metadata$measure])),
"The ABS stock workbook series or units have changed."
)
rows <- raw |> slice(-(1:10))
names(rows) <- paste0("column_", seq_along(rows))
series <- rows |>
mutate(quarter_date = quarter_date_from_excel(column_1)) |>
select(quarter_date, -column_1, everything()) |>
pivot_longer(-quarter_date, names_to = "column", names_prefix = "column_", values_to = "value") |>
mutate(column = as.integer(column), value = as_number(value)) |>
left_join(metadata, by = "column") |>
filter(!is.na(value), !is.na(quarter_date), !is.na(measure)) |>
mutate(
value = case_when(
unit == "$ Millions" ~ value / 1000,
unit %in% c("$'000", "000") ~ value * 1000,
TRUE ~ value
)
) |>
select(quarter_date, state, measure, value)
assert_true(
nrow(series) == n_distinct(series$quarter_date) * 27,
"The parsed stock workbook does not contain 27 complete series."
)
series
}
transfer_measure <- function(item) {
case_when(
str_starts(item, "Median Price") ~ "Median transfer price",
str_starts(item, "Number") ~ "Number of transfers",
TRUE ~ NA_character_
)
}
transfer_dwelling_type <- function(item) {
case_when(
str_detect(item, "Established House") ~ "Established house",
str_detect(item, "Attached Dwelling") ~ "Attached dwelling",
TRUE ~ NA_character_
)
}
parse_abs_transfers <- function(path) {
raw <- read_excel(path, sheet = "Data1", col_names = FALSE)
metadata <- tibble(
column = seq_along(raw),
item = as.character(unlist(raw[1, ])),
unit = as.character(unlist(raw[2, ])),
measure = transfer_measure(item),
dwelling_type = transfer_dwelling_type(item),
geography = last_semicolon_field(item)
) |>
filter(!is.na(measure), !is.na(dwelling_type), !is.na(geography))
assert_true(
nrow(metadata) == 60 &&
all(metadata$unit[metadata$measure == "Median transfer price"] == "$'000") &&
all(metadata$unit[metadata$measure == "Number of transfers"] == "Number"),
"The ABS transfer workbook series or units have changed."
)
rows <- raw |> slice(-(1:10))
names(rows) <- paste0("column_", seq_along(rows))
rows |>
mutate(quarter_date = quarter_date_from_excel(column_1)) |>
select(quarter_date, -column_1, everything()) |>
pivot_longer(-quarter_date, names_to = "column", names_prefix = "column_", values_to = "value") |>
mutate(column = as.integer(column), value = as_number(value)) |>
left_join(metadata, by = "column") |>
filter(!is.na(value), !is.na(quarter_date), !is.na(measure)) |>
mutate(value = if_else(unit == "$'000", value * 1000, value)) |>
select(quarter_date, geography, dwelling_type, measure, value)
}
stock <- parse_abs_stock(source_path("abs_total_value_dwellings"))
transfers <- parse_abs_transfers(source_path("abs_dwelling_transfers"))
cotality_figures <- read_csv("cotality-published-figures.csv", show_col_types = FALSE) |>
mutate(
publication_date = as.Date(publication_date),
coverage_end = as.Date(coverage_end),
retrieved_date = as.Date(retrieved_date)
)
latest_quarter <- as.Date("2026-06-01")
previous_quarter <- as.Date("2026-03-01")
expected_states <- c(
"New South Wales", "Victoria", "Queensland", "South Australia",
"Western Australia", "Tasmania", "Northern Territory",
"Australian Capital Territory", "Australia"
)
expected_transfer_geographies <- c(
"Sydney", "Rest of NSW", "Melbourne", "Rest of Vic.",
"Brisbane", "Rest of Qld.", "Adelaide", "Rest of SA",
"Perth", "Rest of WA", "Hobart", "Rest of Tas.",
"Darwin", "Rest of NT", "Canberra"
)
assert_true(max(stock$quarter_date) == latest_quarter, "The stock workbook does not end in June 2026.")
assert_true(max(transfers$quarter_date) == latest_quarter, "The transfer workbook does not end in June 2026.")
assert_true(setequal(unique(stock$state), expected_states), "The stock workbook geographies have changed.")
assert_true(!anyDuplicated(stock[c("quarter_date", "state", "measure")]), "The stock series contains duplicate keys.")
assert_true(!anyDuplicated(transfers[c("quarter_date", "geography", "dwelling_type", "measure")]), "The transfer series contains duplicate keys.")
assert_true(all(is.finite(stock$value) & stock$value >= 0), "The stock series contains invalid values.")
assert_true(all(is.finite(transfers$value) & transfers$value >= 0), "The transfer series contains invalid values.")
assert_true(setequal(unique(transfers$geography), expected_transfer_geographies), "The transfer workbook geographies have changed.")
stock_coverage <- stock |>
group_by(state, measure) |>
summarise(
latest = max(quarter_date),
complete = identical(sort(quarter_date), seq(min(quarter_date), max(quarter_date), by = "3 months")),
.groups = "drop"
)
assert_true(nrow(stock_coverage) == 27 && all(stock_coverage$latest == latest_quarter), "Not every stock series reaches June 2026.")
assert_true(all(stock_coverage$complete), "At least one stock series has a missing quarter.")
transfer_coverage <- transfers |>
group_by(geography, dwelling_type, measure) |>
summarise(
latest = max(quarter_date),
complete = identical(sort(quarter_date), seq(min(quarter_date), max(quarter_date), by = "3 months")),
.groups = "drop"
)
assert_true(nrow(transfer_coverage) == 60 && all(transfer_coverage$latest == latest_quarter), "Not every transfer series reaches June 2026.")
assert_true(all(transfer_coverage$complete), "At least one transfer series has a missing quarter.")
latest_transfer_snapshot <- transfers |> filter(quarter_date == latest_quarter)
assert_true(
nrow(latest_transfer_snapshot) == 60 &&
sum(latest_transfer_snapshot$measure == "Median transfer price") == 30 &&
sum(latest_transfer_snapshot$measure == "Number of transfers") == 30,
"The latest transfer snapshot must contain 30 medians and 30 counts."
)
headline <- stock |>
filter(quarter_date == latest_quarter, state == "Australia") |>
select(measure, value)
assert_close(headline$value[headline$measure == "Total dwelling stock value"], 12688.8568, 0.0001, "National stock value ($ billions)")
assert_close(headline$value[headline$measure == "Number of dwellings"], 11531100, 1, "National dwelling count")
assert_close(headline$value[headline$measure == "Mean dwelling price"], 1100400, 1, "National mean dwelling price")
latest_stock <- stock |>
filter(quarter_date == latest_quarter) |>
pivot_wider(names_from = measure, values_from = value)
assert_true(
abs(sum(filter(latest_stock, state != "Australia")$`Total dwelling stock value`) -
filter(latest_stock, state == "Australia")$`Total dwelling stock value`) < 0.01,
"State stock values do not reconcile to Australia."
)
state_price_change <- stock |>
filter(
measure == "Mean dwelling price",
quarter_date %in% c(previous_quarter, latest_quarter)
) |>
group_by(state) |>
arrange(quarter_date, .by_group = TRUE) |>
summarise(
previous_price = first(value),
latest_price = last(value),
change = latest_price / previous_price - 1,
.groups = "drop"
)
assert_true(nrow(state_price_change) == 9 && all(is.finite(state_price_change$change)), "State mean-price changes are incomplete.")
falling_jurisdictions <- state_price_change |>
filter(state != "Australia", change < 0) |>
summarise(value = n()) |>
pull(value)
focus_geographies <- c("Sydney", "Rest of NSW", "Melbourne", "Rest of Vic.")
focus_transfer_source <- transfers |>
filter(
measure == "Median transfer price",
geography %in% focus_geographies,
quarter_date %in% c(previous_quarter, latest_quarter)
)
assert_true(
nrow(focus_transfer_source) == 16 &&
all((focus_transfer_source |> count(geography, dwelling_type))$n == 2),
"Each focus segment must contain March and June 2026."
)
focus_transfers <- focus_transfer_source |>
group_by(geography, dwelling_type) |>
arrange(quarter_date, .by_group = TRUE) |>
summarise(
previous_price = first(value),
latest_price = last(value),
change = latest_price / previous_price - 1,
.groups = "drop"
) |>
mutate(
state = if_else(geography %in% c("Sydney", "Rest of NSW"), "New South Wales", "Victoria"),
area = if_else(str_starts(geography, "Rest"), "Rest of state", "Capital city"),
segment = paste(area, dwelling_type, sep = " / ")
)
expected_focus <- tibble(
geography = c("Sydney", "Sydney", "Rest of NSW", "Rest of NSW", "Melbourne", "Melbourne", "Rest of Vic.", "Rest of Vic."),
dwelling_type = rep(c("Established house", "Attached dwelling"), 4),
expected_price = c(1487600, 840000, 810000, 710000, 850000, 590000, 625000, 467800)
)
focus_check <- focus_transfers |>
left_join(expected_focus, by = c("geography", "dwelling_type"))
assert_true(
nrow(focus_check) == 8 && all(focus_check$latest_price == focus_check$expected_price),
"The focus-state June transfer medians did not match the checked ABS values."
)
latest_transfer_counts <- transfers |>
filter(measure == "Number of transfers", quarter_date == latest_quarter)
assert_true(nrow(latest_transfer_counts) == 30 && all(latest_transfer_counts$value >= 0), "Latest transfer counts are incomplete or invalid.")
assert_true(
nrow(cotality_figures) == 12 && !anyDuplicated(cotality_figures[c("source_id", "metric_id")]),
"The Cotality published-figure audit must contain twelve unique metrics."
)
assert_true(
all(cotality_figures$unit == "share") &&
all(is.finite(cotality_figures$value)) &&
all(cotality_figures$retrieved_date == as.Date("2026-10-20")),
"The Cotality published-figure audit has invalid units, values or retrieval dates."
)
cotality_value <- function(metric_id) {
value <- cotality_figures |> filter(.data$metric_id == !!metric_id) |> pull(value)
assert_true(length(value) == 1, paste("Missing Cotality metric:", metric_id))
value
}
# Lower-quartile declines are derived from each published upper-quartile decline
# plus the published upper-minus-lower gap, then asserted against the chart values.
tier_declines <- tibble(
city = rep(c("Sydney", "Melbourne"), each = 2),
tier = rep(c("Upper quartile", "Lower quartile"), 2),
decline = c(
cotality_value("sydney_upper_quartile_decline"),
cotality_value("sydney_upper_quartile_decline") + cotality_value("sydney_upper_minus_lower_gap"),
cotality_value("melbourne_upper_quartile_decline"),
cotality_value("melbourne_upper_quartile_decline") + cotality_value("melbourne_upper_minus_lower_gap")
)
)
assert_true(
identical(round(tier_declines$decline, 3), c(-0.107, -0.054, -0.105, -0.039)),
"Derived Cotality lower-quartile values no longer match the published figures."
)
breadth_evidence <- tibble(
panel = c(
"Markets recording a fall",
"Markets recording a fall",
"September-quarter value change",
"September-quarter value change",
"September-quarter value change",
"Annual dwelling-value change",
"Annual dwelling-value change"
),
market = c(
"Capital-city suburbs\nthree months to September",
"Regional SA3 markets\nSeptember",
"Australia",
"Combined capitals",
"Regional markets",
"Regional markets",
"Combined capitals"
),
value = c(
cotality_value("capital_suburbs_falling_three_months"),
cotality_value("regional_sa3_falling_month"),
cotality_value("national_quarterly_change"),
cotality_value("combined_capitals_quarterly_change"),
cotality_value("regional_quarterly_change"),
cotality_value("regional_annual_change"),
cotality_value("combined_capitals_annual_change")
)
) |>
mutate(
panel = factor(
panel,
levels = c("Markets recording a fall", "September-quarter value change", "Annual dwelling-value change")
)
)
assert_true(
identical(round(breadth_evidence$value, 3), c(0.970, 0.710, -0.037, -0.043, -0.019, 0.056, -0.018)),
"The Cotality breadth chart no longer matches the audited published figures."
)
```
::: {.callout-note title="Executive summary"}
- The ABS mean dwelling price fell in **`r falling_jurisdictions` out of 8 jurisdictions** in the June quarter: New South Wales, Victoria and the ACT. NSW recorded the largest fall at `r percent(abs(filter(state_price_change, state == "New South Wales")$change), accuracy = 0.1)`.
- Within NSW, Sydney established houses had the largest fall among the ABS's four broad capital/rest-of-state and house/attached segments. Every segment fell, but the falls were smaller outside that segment.
- Separate Cotality evidence shows the downturn was deepest in upper-quartile Sydney and Melbourne houses and, by September, had broadened across most capital-city suburbs and many regional markets.
:::
**Data through:** ABS whole-stock estimates and transaction medians to the June quarter 2026; Cotality price-tier evidence to August 2026; Cotality market-breadth evidence to September 2026.
## The June-quarter fall was concentrated in NSW, Victoria and the ACT
The national mean dwelling price fell `r percent(abs(filter(state_price_change, state == "Australia")$change), accuracy = 0.1)` between March and June 2026. But that national result was not a uniform fall across the country. NSW fell `r percent(abs(filter(state_price_change, state == "New South Wales")$change), accuracy = 0.1)`, Victoria fell `r percent(abs(filter(state_price_change, state == "Victoria")$change), accuracy = 0.1)` and the ACT fell `r percent(abs(filter(state_price_change, state == "Australian Capital Territory")$change), accuracy = 0.1)`. Mean prices rose in the other five states and territories.
```{r}
#| label: fig-state-price-change
#| fig-cap: "Quarterly change in the ABS mean price of the residential dwelling stock, March to June 2026. These whole-stock estimates are not transaction medians or quality-adjusted price indexes; June estimates are preliminary."
#| fig-alt: "Horizontal bars show mean dwelling prices falling 2.4 percent in New South Wales, 2.1 percent in Victoria, 1.3 percent in the ACT and 0.7 percent nationally, while the other five jurisdictions rose."
#| fig-width: 9
#| fig-height: 5.6
state_price_change |>
mutate(
state = fct_reorder(state, change),
emphasis = case_when(
state %in% c("New South Wales", "Victoria") ~ "Focus states",
state == "Australia" ~ "Australia",
TRUE ~ "Other jurisdictions"
),
label_hjust = if_else(change < 0, 1.15, -0.15)
) |>
ggplot(aes(change, state, fill = emphasis)) +
geom_vline(xintercept = 0, colour = "grey55", linewidth = 0.6) +
geom_col(width = 0.68) +
geom_text(
aes(label = percent(change, accuracy = 0.1), hjust = label_hjust),
colour = "grey20",
fontface = "bold",
size = 3.3
) +
scale_fill_manual(
values = c("Focus states" = "#B8472D", "Australia" = "#40464B", "Other jurisdictions" = "#6E8794"),
guide = "none"
) +
scale_x_continuous(labels = percent_format(accuracy = 1), limits = c(-0.04, 0.055), breaks = seq(-0.04, 0.04, 0.02)) +
labs(
title = "Mean dwelling prices fell most in NSW and Victoria",
subtitle = "Change in estimated mean dwelling price",
x = NULL,
y = NULL
)
```
This establishes why the next cut focuses on Sydney and Melbourne. NSW and Victoria were the two large states recording declines, while the rest of the country was not yet moving in one direction.
## Sydney houses led the fall; Victoria's pattern was less tidy
The ABS transaction medians split the two focus states by capital city versus rest of state, and by established houses versus attached dwellings. Sydney's established-house median fell `r percent(abs(filter(focus_transfers, geography == "Sydney", dwelling_type == "Established house")$change), accuracy = 0.1)` in the June quarter, compared with `r percent(abs(filter(focus_transfers, geography == "Sydney", dwelling_type == "Attached dwelling")$change), accuracy = 0.1)` for attached dwellings. Outside Sydney, both declines were smaller.
Victoria is the counterexample. Melbourne houses fell, but Melbourne attached dwellings were close to flat; outside Melbourne, attached dwellings fell more than houses. The NSW pattern—capital-city houses weakening most—was therefore not a mechanical rule everywhere.
```{r}
#| label: fig-focus-segments
#| fig-cap: "Change in unstratified median transfer price, March to June 2026. 'Attached' generally includes apartments, units, terraces, semis and townhouses, although some NSW terraces and townhouses are classified as established houses. Source: ABS Table 2; June estimates are preliminary."
#| fig-alt: "Two-panel dot plot for New South Wales and Victoria. Sydney established houses fell 4.0 per cent, Sydney attached dwellings 1.8 per cent, rest-of-NSW houses 2.4 per cent and rest-of-NSW attached dwellings 0.6 per cent. Melbourne houses fell 2.9 per cent, Melbourne attached dwellings 0.1 per cent, rest-of-Victoria houses 0.8 per cent and rest-of-Victoria attached dwellings 2.5 per cent."
#| fig-width: 10
#| fig-height: 5.2
segment_levels <- c(
"Rest of state / Attached dwelling",
"Rest of state / Established house",
"Capital city / Attached dwelling",
"Capital city / Established house"
)
focus_transfers |>
mutate(
segment = factor(segment, levels = segment_levels),
emphasis = geography == "Sydney" & dwelling_type == "Established house"
) |>
ggplot(aes(change, segment)) +
geom_vline(xintercept = 0, colour = "grey55", linewidth = 0.6) +
geom_segment(aes(x = 0, xend = change, yend = segment), colour = "grey75", linewidth = 1.1) +
geom_point(aes(colour = emphasis), size = 4, show.legend = FALSE) +
geom_text(aes(label = percent(change, accuracy = 0.1)), nudge_x = -0.002, hjust = 1, colour = "grey20", fontface = "bold", size = 3.2) +
facet_wrap(~state, nrow = 1) +
scale_colour_manual(values = c(`FALSE` = "#6E8794", `TRUE` = "#B8472D")) +
scale_x_continuous(labels = percent_format(accuracy = 1), limits = c(-0.065, 0.005), breaks = seq(-0.06, 0, 0.01)) +
labs(
title = "Sydney houses had the largest fall among four broad NSW segments",
subtitle = "Victoria shown as a comparator; quarterly change in median transfer price",
x = NULL,
y = NULL
)
```
“Attached dwelling” is broader than apartment: it generally includes apartments, units, terraces, semis and townhouses. “Rest of state” is also not the same as regional or outer-metropolitan. The categories are useful, but they do not yet answer a precise apartment-versus-house or inner-versus-outer question.
## Upper-quartile houses fell further in Sydney and Melbourne
The evidence for the value-tier story in Australia's two largest cities comes from Cotality's quality-adjusted indexes, not the ABS transaction medians. In data through August, upper-quartile houses were 10.7% below their peak in Sydney and 10.5% below in Melbourne. The upper-to-lower decline gaps were 5.3 and 6.6 percentage points respectively.
```{r}
#| label: fig-price-tiers
#| fig-cap: "Published Cotality hedonic-index results through August 2026. Lower-quartile values are derived from the published upper-quartile declines and upper-minus-lower gaps. The underlying tier series is commercial and was not independently reproduced."
#| fig-alt: "Dumbbell chart comparing house-value declines from peak by price tier. Sydney's upper quartile fell 10.7 per cent versus 5.4 per cent for the lower quartile. Melbourne's upper quartile fell 10.5 per cent versus 3.9 per cent for the lower quartile."
#| fig-width: 8.5
#| fig-height: 4.5
tier_wide <- tier_declines |>
pivot_wider(names_from = tier, values_from = decline)
ggplot(tier_wide, aes(y = city)) +
geom_segment(aes(x = `Upper quartile`, xend = `Lower quartile`, yend = city), colour = "grey70", linewidth = 1.5) +
geom_point(aes(x = `Upper quartile`), colour = "#B8472D", size = 4) +
geom_point(aes(x = `Lower quartile`), colour = "#6E8794", size = 4) +
geom_text(aes(x = `Upper quartile`, label = percent(`Upper quartile`, accuracy = 0.1)), hjust = 1.25, fontface = "bold", colour = "#8F3522") +
geom_text(aes(x = `Lower quartile`, label = percent(`Lower quartile`, accuracy = 0.1)), hjust = -0.25, fontface = "bold", colour = "#536873") +
annotate("text", x = -0.107, y = 2.38, label = "Upper quartile", colour = "#8F3522", hjust = 0, size = 3.4) +
annotate("text", x = -0.04, y = 2.38, label = "Lower quartile", colour = "#536873", hjust = 0.5, size = 3.4) +
scale_x_continuous(labels = percent_format(accuracy = 1), limits = c(-0.125, -0.02), breaks = seq(-0.12, -0.02, 0.02)) +
labs(
title = "Upper-quartile houses had fallen much further by August",
subtitle = "Cumulative decline in house values from each tier's cyclical peak",
x = NULL,
y = NULL
)
```
This supports the narrower hypothesis that higher-value houses led the downturn in Sydney and Melbourne. It is not a result about the full Australian price distribution, does not mean every expensive property fell, and does not identify the cause. It is also a cited commercial estimate: [Cotality publishes the headline tier findings](https://www.cotality.com/au/insights/articles/high-end-homes-lead-market-downturn-as-affordable-properties-prove-resilient), but not the raw tier series needed to reproduce the index independently.
## The downturn later broadened beyond the expensive end
The June data describe where the national fall was concentrated, not the latest breadth of the downturn. In September, [Cotality reported](https://www.cotality.com/au/insights/articles/australian-housing-values-down-for-sixth-straight-month-in-september) a national index fall of `r percent(abs(cotality_value("national_monthly_change")), accuracy = 0.1)`. Over the preceding three months, `r percent(cotality_value("capital_suburbs_falling_three_months"), accuracy = 1)` of capital-city suburbs had fallen, and `r percent(cotality_value("regional_sa3_falling_month"), accuracy = 1)` of regional SA3 markets fell in September alone.
```{r}
#| label: fig-market-breadth
#| fig-cap: "Published Cotality evidence through September 2026. The breadth measures use different windows: three months for capital-city suburbs and one month for regional SA3 markets. Quarterly and annual value changes use Cotality's quality-adjusted Home Value Index."
#| fig-alt: "Three-panel bar chart. Ninety-seven percent of capital-city suburbs fell over three months and 71 percent of regional SA3 markets fell in September. Over the September quarter, values fell 3.7 percent nationally, 4.3 percent across the combined capitals and 1.9 percent regionally. Regional values were 5.6 percent higher over the year while combined-capital values were 1.8 percent lower."
#| fig-width: 8.5
#| fig-height: 8.2
breadth_evidence |>
mutate(
market = factor(
market,
levels = c(
"Combined capitals",
"Regional markets",
"Australia",
"Regional SA3 markets\nSeptember",
"Capital-city suburbs\nthree months to September"
)
),
label_hjust = if_else(value < 0, 1.15, -0.15)
) |>
ggplot(aes(value, market)) +
geom_vline(xintercept = 0, colour = "grey55", linewidth = 0.6) +
geom_col(fill = "#6E8794", width = 0.68) +
geom_text(
aes(label = percent(value, accuracy = 0.1), hjust = label_hjust),
fontface = "bold",
colour = "grey20",
size = 3.4
) +
facet_wrap(~panel, scales = "free", ncol = 1) +
scale_x_continuous(labels = percent_format(accuracy = 1), expand = expansion(mult = c(0.10, 0.18))) +
labs(
title = "The fall broadened; regions remained stronger over the year",
x = NULL,
y = NULL
)
```
Regions were still more resilient over the year—regional values were `r percent(cotality_value("regional_annual_change"), accuracy = 0.1)` higher while the combined capitals were `r percent(abs(cotality_value("combined_capitals_annual_change")), accuracy = 0.1)` lower—but they were no longer uniformly stable or rising. The fall was both **concentrated at the expensive end** and **broadening across locations**.
The newer October chart pack reinforces the difference in intensity. Over the September quarter, national values fell `r percent(abs(cotality_value("national_quarterly_change")), accuracy = 0.1)`, with a `r percent(abs(cotality_value("combined_capitals_quarterly_change")), accuracy = 0.1)` fall across the combined capitals versus `r percent(abs(cotality_value("regional_quarterly_change")), accuracy = 0.1)` across regional markets.
## Inner versus outer Sydney remains unanswered
The national ABS workbook stops at capital city versus rest of state; it cannot tell us whether the weakness sits in Sydney's inner, middle or outer rings. NSW's [property-sales information files](https://www.valuergeneral.nsw.gov.au/design/bulk_psi_content/bulk_psi) could eventually support that analysis, but complete 2026 registrations are not yet available in one annual archive and recent weekly files arrive with lags and revisions.
A defensible inner/outer comparison needs complete transactions mapped to consistent rings and, ideally, an adjustment for the changing mix of homes sold. Until then, “rest of NSW” should not be relabelled “outer Sydney” or “regional.”
## Three-line takeaway
- The June-quarter national fall was concentrated in NSW, Victoria and the ACT—not spread evenly across every state.
- Within NSW, Sydney established houses fell most among the four broad ABS segments; Cotality separately shows deeper peak-to-August falls among upper-quartile houses.
- By September, the downturn had broadened substantially, while the public data still could not resolve inner versus outer Sydney.
## Appendix {#appendix}
::: {.callout-note collapse="true" title="Measures, caveats and reproducible method"}
The analysis deliberately uses three different measures for three different questions:
- **ABS stock estimates** measure the mean value of the full residential dwelling stock and establish which jurisdictions moved in the June quarter.
- **ABS transfer medians** describe the middle property sold in a quarter. They split established houses from attached dwellings and capital cities from the rest of each state, but an unstratified median can move when the mix of sales changes.
- **Cotality's hedonic index** adjusts for property characteristics and supplies the price-tier and later market-breadth evidence. The tier results are published commercial findings; the underlying tier series is not publicly downloadable, so they are not presented as independently reproduced estimates.
The measures use different methods and time horizons. They corroborate the direction and shape of the story, but their magnitudes should not be compared as if they were the same index.
The primary ABS calculations use Tables 1 and 2 from [Total Value of Dwellings, June Quarter 2026](https://www.abs.gov.au/statistics/economy/price-indexes-and-inflation/total-value-dwellings/jun-quarter-2026), released 8 September 2026. Table 1 contains whole-stock estimates; Table 2 contains unstratified transfer medians and counts. The latest-quarter estimates are preliminary and can be revised. The release-specific [ABS methodology](https://www.abs.gov.au/methodologies/total-value-dwellings-methodology/jun-quarter-2026) defines dwelling types, geographic coverage, estimation and revision rules.
The build verifies both ABS workbooks' file sizes and SHA-256 checksums, expected units and series counts, June 2026 coverage, unique keys, national headline values, state-to-national reconciliation and the eight focus-state transfer medians. To deliberately refresh the registered downloads, render with `BLOG_REFRESH_DATA=true`; a changed upstream file fails checksum validation before replacing the cached copy. The three Cotality articles are recorded with publication, coverage and retrieval dates in a version-controlled figure audit. The code derives the lower-quartile values from the published upper-quartile declines and gaps, then checks all four tier values and all seven later-market values before plotting them.
:::
::: {.callout-note collapse="true" title="Focus-state transfer values"}
```{r}
#| label: tbl-focus-values
#| tbl-cap: "Focus-state median transfer prices, June quarter 2026"
focus_transfers |>
arrange(state, geography, dwelling_type) |>
transmute(
State = state,
Geography = geography,
`Dwelling type` = dwelling_type,
`June median` = dollar(latest_price, accuracy = 1000),
`Quarterly change` = percent(change, accuracy = 0.1)
) |>
kable(align = c("l", "l", "l", "r", "r"))
```
:::
::: {.callout-note collapse="true" title="Source register"}
```{r}
#| label: tbl-sources
bind_rows(
source_registry |>
transmute(
Publisher = publisher,
Source = paste0("[", dataset, "](", landing_url, ")"),
`Publication/revision date` = release_date,
`Coverage end` = coverage_end,
Retrieved = NA_character_,
Audit = paste0("Downloaded file; SHA-256 ", substr(sha256, 1, 12), "…")
),
cotality_figures |>
distinct(source_id, publisher, title, publication_date, coverage_end, retrieved_date, source_url) |>
transmute(
Publisher = publisher,
Source = paste0("[", title, "](", source_url, ")"),
`Publication/revision date` = as.character(publication_date),
`Coverage end` = as.character(coverage_end),
Retrieved = as.character(retrieved_date),
Audit = "Published figures transcribed to version-controlled CSV"
)
) |>
kable(escape = FALSE, align = c("l", "l", "l", "l", "l", "l"))
```
:::