---
title: "2025: The year Edmonton built the missing middle"
date: "2026-01-05"
author: "Jacob Dawang"
categories: [housing, edmonton, zoning]
description: "Edmonton's zoning reform is working. In 2025, newly legalized eight-home rowhomes drove a record increase in homebuilding, achieved by redeveloping only 0.39% of properties in mature neighbourhoods."
link-external-newwindow: true
link-external-icon: false
---

2025 is over and what a year it was for housing in Edmonton! In 2025, we found the missing middle, permitted a record number of homes, and permitted a record proportion of homes near our LRT stations. This is a testament to the power of zoning reform to drive equitable and sustainable growth.

Without a doubt, the data shows that this is the result of Edmonton's Zoning Bylaw Renewal (ZBR) that came into force in 2024, with the newly legalized five-to-eight-unit rowhomes in mature neighbourhoods driving a ton of the increase. At the same time, the magic of density has ensured that less than 0.4% of RS-zoned properties in mature neighbourhoods were redeveloped into five-to-eight-unit rowhomes. While a lot of *homes* have been permitted, not many *properties* have been affected. It's not the "avalanche" of infill you often hear about in the media.

![An eight home rowhouse under construction near me. It was legalized in this form across much of Edmonton by ZBR in 2024.](example_eight_unit.jpg){#fig-example-eight width="80%"}

However, we can't just say "mission accomplished" and let things backslide. This is just one year of homebuilding and is a drop in the bucket for our housing stock. With City Council once again considering reducing the number of homes permitted per lot in February, we have to ensure that we keep our foot on the gas. What we're doing is working: we just need to continue. If you agree, consider joining [Grow Together Edmonton](https://www.growtogetheryeg.com/) to help us advocate for more housing choice, and a better way to grow.

In the rest of this blog post, I summarize the key facts, briefly describe the data sources, then go into the details.

## Key facts

-   Edmonton issued building permits for a record number of homes in 2025, over 17,500.

-   For the first time since the beginning of the dataset, more homes were permitted in rowhomes between 5 and 8 units than single family homes.

-   The number of homes permitted in mature neighbourhoods almost equaled the number of homes permitted outside the Henday. It has never been close before.

-   The magic of density means few properties need to be redeveloped to build a ton of homes: only 0.2% of properties across the RS Zone, and 0.4% within mature neighbourhoods were permitted to redevelop into 5 to 8 unit townhomes.

-   Allowing eight homes per lot is critical: Projects with exactly eight units accounted for the vast majority of homes in the 5-8 unit category in the RS Zone.

-   2025 was the best year on record for building homes near transit. 30% of homes were permitted within 800m, approximately a 15 minute walk, of transit.

```{r}
#| label: libaries

library(tidyverse)
library(sf)
library(gt)
library(mountainmathHelpers)
library(gganimate)
library(tidytransit)
options(
  cancensus.api_key = Sys.getenv("CM_API_KEY"),
  cancensus.cache_path = Sys.getenv("CM_CACHE_PATH"),
  tongfen.cache_path=Sys.getenv("TONGFEN_CACHE_PATH"),
  nextzen_API_key=Sys.getenv("NEXTZEN_API_KEY")
)
```

## Data

```{r}
#| label: data

mature_neighbourhood <- read_sf("data/Mature Neighbourhoods_20241222.geojson") %>% 
  rename(neighbourhood=neighbourh) %>%
  filter(neighbourhood != "Downtown")

henday <- read_sf("data/henday.geojson")

bp <- read_sf("data/General Building Permits_20260104/") %>%
  filter(year < 2026)
assessment <- read_sf("data/Property Information (Current Calendar Year)_20250510.geojson")

crs <- lambert_conformal_conic_at(bp)
henday <- st_transform(henday, crs)
mature_neighbourhood <- st_transform(mature_neighbourhood, crs)

bp <- bp %>%
  st_transform(crs) %>%
  select(-c(count, latitude, longitude, time_permi, time_issue, row_id, date_permi, neighbourh, permit_num), -contains("location")) %>%
  rename(
    construction_value=constructi,
    month_number=month_numb, 
    building_type=building_t, 
    units_added=units_adde, 
    neighbourhood=neighbou_2, 
    job_category=job_catego, 
    job_description=job_descri, 
    date_issued=date_issue
  ) %>%
  filter(
    units_added >= 1,
    building_type %in% c(
      "Apartment (310)",
      "Apartment Condos (315)",
      "Apartments (310)",
      "Backyard House (110)",
      "Duplex (210)",
      "Garden Suite (110)",
      "Mixed Use (522)",
      "Row House (330)",
      "Row House Condo (335)",
      "Row House Condos (335)",
      "Row Houses (330)",
      "Semi-Detached Condo (215)",
      "Semi-Detached House (210)",
      "Single Detached Condo (115)",
      "Single Detached House (110)"
    ),
    work_type != "(04) Excavation",
    #job_category != "Commercial Final"
  )  %>%
  mutate(
    project_type=fct(
      case_when(
        (building_type %in% c("Backyard House (110)", "Garden Suite (110)")) ~ "Backyard House",
        (str_detect(work_type, coll("new", ignore_case=TRUE))) & (units_added == 1) ~ "New SFH",
         (work_type %in% c(
        "(03) Interior Alterations", 
        "(03) Exterior Alterations", 
        "(07) Add Suites to Single Dwelling", 
        "(08) Add Suites to Multi-Dwelling", 
        "(09) Convert Non-Res to Residential",
        "(02) Addition",
        "(12) Move Building on to Site"
      )) ~ "Addition/Conversion",
      between(units_added, 2, 4) ~ "Duplex to Fourplex",
      between(units_added, 5, 8) ~ "Fiveplex to Eightplex",
      str_detect(building_type, fixed("Row House", ignore_case=TRUE)) ~ "9+ Row House",
      units_added >= 9 ~ "9+ Apartment",
      .default = "I messed up"
    ),
    levels=c("New SFH", "Addition/Conversion", "Backyard House", "Duplex to Fourplex", "Fiveplex to Eightplex", "9+ Row House", "9+ Apartment")
  ),
  mature_neighbourhood=st_contains(st_union(mature_neighbourhood), ., sparse=FALSE)[1,] & !st_is_empty(.) & !is.na(neighbourhood),
  outside_henday=!st_contains(henday, ., sparse=FALSE)[1,] & !st_is_empty(.),
  between_henday_mature=!st_is_empty(.) & !mature_neighbourhood & !outside_henday,
  secondary_suite_desc=str_trim(
    str_extract(
      job_description,
      regex("(\\d|a)? ?secondary suites?", ignore_case=TRUE)
    )
  ),
  num_secondary_suites=case_when(
    str_detect(secondary_suite_desc, "\\d") ~ as.numeric(str_extract(secondary_suite_desc, "\\d")),
    str_to_lower(secondary_suite_desc) %in% c(
      "a secondary suite",
      "secondary suite"
    ) ~ 1,
    str_to_lower(secondary_suite_desc) %in% c(
      "secondary suites",
      "a secondary suites"
    )~ units_added / 2,
    .default=0
  ),
  has_backyard_home=(
    str_detect(job_description, regex("garden suites?", ignore_case=TRUE))
    | str_detect(job_description, regex("backyard houses?", ignore_case=TRUE))
  )
) %>%
  mutate(
    mature_neighbourhood=(
      mature_neighbourhood | (
        !is.na(neighbourhood) &
        (
          neighbourhood %in% unique(
            pull(
              filter(., mature_neighbourhood), 
              neighbourhood
            )
          )
        )
      )
    ),
    outside_henday=(
      outside_henday | (
        !is.na(neighbourhood) &
        (
          neighbourhood %in% unique(
            pull(
              filter(., outside_henday), 
              neighbourhood
            )
          )
        )
      )
    ),
    between_henday_mature=(
      between_henday_mature | (
        !is.na(neighbourhood) &
        (
          neighbourhood %in% unique(
            pull(
              filter(., between_henday_mature), 
              neighbourhood
            )
          )
        )
      )
    ),
    neighbourhood_type=fct(
      case_when(
        neighbourhood == "DOWNTOWN" ~ "Downtown",
        mature_neighbourhood ~ "Mature",
        outside_henday ~ "Outside Henday",
        between_henday_mature ~ "Between mature and Henday",
        neighbourhood %in% c(
          "THE HAMPTONS, GRANVILLE",
          "RUTHERFORD, RUTHERFORD",
          "THE UPLANDS, RIVER'S EDGE",
          "ELLERSLIE INDUSTRIAL"
        ) ~ "Outside Henday",
        neighbourhood %in% c(
          "ELSINORE, ELSINORE",
          "MICHAELS PARK",
          "BRANDER GARDENS",
          "PILOT SOUND AREA WEST PORTION, MCCONACHIE",
          "YOUNGSTOWN INDUSTRIAL"
        ) ~ "Between mature and Henday",
        neighbourhood %in% c(
          "CENTRAL MCDOUGALL, QUEEN MARY PARK",
          "WOODCROFT",
          "BOYLE STREET",
          "ELMWOOD"
        )~ "Mature",
        address %in% c(
          "8944 - 145 STREET NW",
          "11216 - 122 STREET NW",
          "9509 - 99B STREET NW"
        ) ~ "Mature",
        .default = NA_character_
    ),
    levels=c("Downtown", "Mature", "Between mature and Henday", "Outside Henday")
    ),
    date_issued=as_date(date_issued)
  )


assessment <- assessment %>%
  st_transform(crs) %>%
  filter(!is.na(house_number), !is.na(longitude), !is.na(latitude)) %>%
  mutate(
    mature_neighbourhood=st_contains(st_union(mature_neighbourhood), ., sparse=FALSE)[1,] & !st_is_empty(.) & !is.na(neighbourhood),
    outside_henday=!st_contains(henday, ., sparse=FALSE)[1,] & !st_is_empty(.),
    between_henday_mature=!st_is_empty(.) & !mature_neighbourhood & !outside_henday
  ) %>%
  mutate(
    mature_neighbourhood=(
      mature_neighbourhood | (
        !is.na(neighbourhood) &
        (
          neighbourhood %in% unique(
            pull(
              filter(., mature_neighbourhood), 
              neighbourhood
            )
          )
        )
      )
    ),
    outside_henday=(
      outside_henday | (
        !is.na(neighbourhood) &
        (
          neighbourhood %in% unique(
            pull(
              filter(., outside_henday), 
              neighbourhood
            )
          )
        )
      )
    ),
    between_henday_mature=(
      between_henday_mature | (
        !is.na(neighbourhood) &
        (
          neighbourhood %in% unique(
            pull(
              filter(., between_henday_mature), 
              neighbourhood
            )
          )
        )
      )
    ),
    neighbourhood_type=fct(
      case_when(
        neighbourhood == "DOWNTOWN" ~ "Downtown",
        mature_neighbourhood ~ "Mature",
        outside_henday ~ "Outside Henday",
        between_henday_mature ~ "Between mature and Henday",
        neighbourhood %in% c(
          "THE HAMPTONS, GRANVILLE",
          "RUTHERFORD, RUTHERFORD",
          "THE UPLANDS, RIVER'S EDGE",
          "ELLERSLIE INDUSTRIAL"
        ) ~ "Outside Henday",
        neighbourhood == "ELSINORE, ELSINORE" ~ "Between mature and Henday",
        neighbourhood == "CENTRAL MCDOUGALL, QUEEN MARY PARK" ~ "Mature",
        .default = NA_character_
    ),
    levels=c("Downtown", "Mature", "Between mature and Henday", "Outside Henday")
    )
  )

future_lrt_stops <- read_sf("../../2024/all-lrt-stops-updated/data/future_lrt_stops.geojson") %>%
  mutate(status="future")

lrt_stops <- read_gtfs("../../2024/all-lrt-stops/data/ca-alberta-edmonton-transit-system-gtfs-714.zip") %>%
  gtfs_as_sf() %>%
  filter_stops(
    route_ids=c("021R", "022R", "023R"),
    service_ids=filter(.$calendar, date=="2023-11-09") %>% pull(service_id)
  ) %>%
  mutate(
    stop_name=case_match(
      stop_name,
      "Bay Enterprise Square  Station" ~ "Bay Enterprise Square Station",
      "Churchill Stop" ~ "Churchill Station",
      .default=stop_name
    ),
    status="existing"
  ) %>%
  filter(!(stop_name %in% c("Metro Line JTTl Track", "DL MacDonald Platform", "NAIT Station"))) %>%
  bind_rows(
    future_lrt_stops %>%
      filter(!(stop_name %in% c("Castle Downs Station", "145 Ave Station", "137 Ave Station", "132 Ave Station")))
  ) %>%
  st_transform(crs) %>%
  group_by(stop_name, status) %>%
  summarize(geometry=st_union(geometry), .groups="drop") %>%
  mutate(geometry=st_centroid(geometry), stop_name_short=str_remove(stop_name, " (Stop|Station)"))
```

```{r}
#| label: population-data-load

wards <- read_csv("../../2025/population-growth-rowhouses/data/City_of_Edmonton_-_Neighbourhoods_20250704.csv") %>%
  select(neighbourhood=`Neighbourhood Name`, Ward=`Civic Ward`) %>%
  mutate(neighbourhood=str_to_upper(neighbourhood))
population_1971 <- read_csv("data/population_1971.csv") %>%
  select(neighbourhood=Neighbourhood, n_1971=`Total - Age`)
population_2021 <- read_csv("data/population_2021.csv") %>%
  distinct() %>%
  rename(neighbourhood=NEIGHBOURHOOD, n_2021=`2021`)

population_change <- population_2021 %>%
  full_join(population_1971, by=join_by(neighbourhood)) %>%
  left_join(wards, by=join_by(neighbourhood)) %>%
  replace_na(list(n_2021=0, n_1971=0)) %>%
  mutate(pct_change=n_2021 / n_1971 - 1)
```

Edmonton's [general building permit data](https://data.edmonton.ca/Urban-Planning-Economy/General-Building-Permits/24uj-dj8v/about_data) has tracked building permits since 2009. It contains columns for building type, work type, location, units added, and more for each building permit. This data and processing is the same as in [previous posts](../../2025/real-rs-story/index.qmd), so you can read those for more information.

Edmonton's [property assessment data](https://data.edmonton.ca/City-Administration/Property-Assessment-Data-Current-Calendar-Year-/q7d6-ambg/about_data) contains one row per property assessed with columns for neighbourhood, and zoning. I use it to count the number of properties in mature neighbourhoods under RS zoning.

As well, Edmonton's LRT and future LRT network, definition of mature neighbourhoods, and the Henday boundaries are easily downloadable online.

## RS Zone primer

The RS Zone was introduced in January 2024 as the primary residential zone for Edmonton’s established neighborhoods. It replaced several restrictive zones (RF1, RF2, RF3, and RF4) to simplify the development process for "missing middle" housing, such as duplexes, row houses, and small apartment-style builds. The key restrictions in this zone are:

-   75 m^2^ lot area per unit. E.g., if you want to build six homes on a lot, you need a $6 \times 75 = 450$ m^2^ lot.

-   An 8 home per lot hard cap on non-corner/interior/midblock lots.

After 18 months of implementation, Edmonton City Council enacted a number of changes in July 2025. While the size of permitted buildings was [significantly reduced](https://www.growtogetheryeg.com/blog/the-most-important-public-hearing-this-year), the potentially most damaging change proposed, reducing the hard cap on number of homes per lot to 6, failed.

Unfortunately, Edmonton City Council will be reconsidering reducing the number of homes per lot to 6 once more this year.

## We found the missing middle

Alright, back to the data. First, let's look at the timeseries of building permits and units added by project type. @fig-project-type-raw-bp shows the raw number of building permits issued and @fig-project-type-units-added shows the corresponding number of homes added.

It's been a huge year for Edmonton. We broke records. We permitted more homes in 2025 than ever before, and increasingly of "missing middle" type, providing options between large-scale apartments and single-family homes. For the first time ever, more homes were permitted in the five-to-eight-unit rowhouse category than single family homes, as the lines cross in @fig-project-type-units-added-1.

::: callout-important
The axes are different between the line plots and area plots below.
:::

::: panel-tabset
## Number of building permits

```{r}
#| label: fig-project-type-raw-bp
#| fig-cap: "Number of new residential building permits by project type, 2009-2025"
#| fig-subcap: ["Line plot", "Area plot"]

project_type_dist <- bp %>%
  st_drop_geometry() %>%
  group_by(year, project_type, .drop=FALSE) %>% 
  summarize(total=sum(units_added), num_projects=n(), .groups="drop")

ggplot(project_type_dist) +
  geom_line(aes(x=year, y=num_projects, colour=project_type), linewidth=1) +
  geom_point(aes(x=year, y=num_projects, colour=project_type)) +
  scale_colour_viridis_d(option="magma") +
  scale_y_continuous(labels=scales::label_comma()) +
  labs(
    title="Number of building permits by project type",
    subtitle="Building permits 2009-2025",
    colour="Project type",
    y="Number of Building Permits",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark()

ggplot(project_type_dist) +
  geom_area(aes(x=year, y=num_projects, fill=project_type)) +
  scale_fill_viridis_d(option="magma") +
  scale_y_continuous(labels=scales::label_comma()) +
  labs(
    title="Number of building permits by project type",
    subtitle="Building permits 2009-2025",
    fill="Project type",
    y="Number of Building Permits",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark()
```

## Total homes added

```{r}
#| label: fig-project-type-units-added
#| fig-cap: "Total number of homes by project type, 2009-2025"
#| fig-subcap: ["Line plot", "Area plot"]

ggplot(project_type_dist) +
  geom_line(aes(x=year, y=total, colour=project_type), linewidth=1) +
  geom_point(aes(x=year, y=total, colour=project_type)) +
  scale_colour_viridis_d(option="magma") +
  scale_y_continuous(labels=scales::label_comma()) +
  labs(
    title="Total number of homes by project type",
    subtitle="Building permits 2009-2025",
    colour="Project type",
    y="Total number of homes",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark()

ggplot(project_type_dist) +
  geom_area(aes(x=year, y=total, fill=project_type)) +
  scale_fill_viridis_d(option="magma") +
  scale_y_continuous(labels=scales::label_comma()) +
  labs(
    title="Total number of homes by project type",
    subtitle="Building permits 2009-2025",
    fill="Project type",
    y="Total number of homes",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark()
```
:::

In @fig-units-added-nbhd-prj-type, we can see that 5-8 unit rowhomes and 9+ apartments drove the increase in homes in 2025. While 9+ apartments have historically reached the same scale as in 2025, the rise of 5-8 unit rowhomes is astounding. There is no other explanation except that Edmonton legalized 5-8 unit rowhomes in mature neighbourhoods at the exact right time, when demand was highest.

```{r}
#| label: fig-units-added-nbhd-prj-type
#| fig-cap: "Total number of homes by neighbourhood and project type"
#| fig-subcap: ["Line plot", "Area plot"]

bp %>%
  st_drop_geometry() %>%
  group_by(year, neighbourhood_type, project_type, .drop=FALSE) %>% 
  summarize(total=sum(units_added), num_projects=n(), .groups="drop") %>%
  filter(!is.na(neighbourhood_type)) %>%
ggplot() +
  geom_line(aes(x=year, y=total, colour=project_type), linewidth=1) +
  geom_point(aes(x=year, y=total, colour=project_type)) +
  scale_colour_viridis_d(option="magma") +
  scale_y_continuous(labels=scales::label_comma()) +
  labs(
    title="Total number of homes by neighbourhood and project type",
    subtitle="Building permits 2009-2025",
    colour="Project type",
    x="Year",
    y="Total number of homes",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark() +
  facet_wrap(vars(neighbourhood_type))

bp %>%
  st_drop_geometry() %>%
  group_by(year, neighbourhood_type, project_type, .drop=FALSE) %>% 
  summarize(total=sum(units_added), num_projects=n(), .groups="drop") %>%
  filter(!is.na(neighbourhood_type)) %>%
ggplot() +
  geom_area(aes(x=year, y=total, fill=project_type)) +
  scale_fill_viridis_d(option="magma") +
  scale_y_continuous(labels=scales::label_comma()) +
  labs(
    title="Total number of homes by neighbourhood and project type",
    subtitle="Building permits 2009-2025",
    fill="Project type",
    x="Year",
    y="Total number of homes",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark() +
  facet_wrap(vars(neighbourhood_type))
```

This should also put the myth of "everyone wants ownership of a single-detached homes with a yard" to bed. Clearly, there is a huge number of people who would prefer to rent a townhome in a mature, central neighbourhood than be forced into a single-detached home in a new suburb. We permitted so many homes to be built in mature neighbourhoods, not because it's cheaper or easier than building a new single-detached home in greenfield, but because people desperately want to live in mature neighbourhoods, no matter the housing type. Providing people the option to live in their preferred neighbourhood at an affordable rate is a good thing, and Edmonton's zoning changes have started to enable this.

So far, I have grouped 5 to 8 unit rowhomes together. How many are exactly eight units? Looking at the RS Zone specifically, we can see in @tbl-unit-breakdown exactly eight homes is the most commonly permitted, and is the driver of the increase in the category of 5 to 8 unit rowhomes.

```{r}
#| label: tbl-unit-breakdown
#| tbl-cap: Most housing production in the RS Zone comes from projects with exactly eight homes

bp %>%
  st_drop_geometry() %>%
  filter(project_type=="Fiveplex to Eightplex", year == 2025, zoning == "RS") %>%
  group_by(units_added, .drop=FALSE) %>% 
  summarize(total=sum(units_added), num_projects=n(), .groups="drop") %>%
  gt() %>%
  cols_label(
    units_added="Number of homes added in permit",
    num_projects="Number of permits issued",
    total="Total number of homes added"
  ) %>%
  cols_move(total, num_projects) %>%
  tab_header("Number of permits and homes added by number of units permitted", subtitle="2025 building permits") %>%
  tab_source_note("Jacob Dawang, City of Edmonton Open Data") %>%
  opt_stylize(style=5, color="green")
```

Besides the direct effect on housing supply and affordability, Edmonton's zoning changes have enabled more people than ever to live close to transit. That means fewer people driving, less congestion, and less greenhouse gas emissions. In 2025, more homes were permitted close to transit than ever before. @fig-lrt-stop-distance, shows just how differentiated 2025 is. Besides permitting 30% of homes within 800m of transit, a record high for this threshold, the entire cumulative distribution is bowed up and to the left. That means we're building substantially closer to transit than ever before. Even if people don't live within walking distance, more people are going to have options that are a short bus ride or bike ride away.

```{r}
#| label: fig-lrt-stop-distance
#| fig-cap: "Cumulative distribution of homes added by distance from the closest existing or under construction LRT stop"

bp %>% 
  filter(!st_is_empty(.)) %>%
  mutate(
    distance_from_lrt=apply(
        st_distance(
          ., 
          lrt_stops, 
        ),
        1,
        min
    ) / 1000,
  ) %>%
  group_by(year) %>%
  arrange(distance_from_lrt, .by_group=TRUE) %>%
  mutate(
    cum_units=cumsum(units_added),
    ecdf_values=cum_units / sum(units_added)
  ) %>%
  ggplot() +
  geom_step(aes(x=distance_from_lrt, y=ecdf_values, colour=factor(year)), linewidth=0.8) + 
  scale_colour_viridis_d(option="magma", guide=guide_legend(ncol=2)) +
  geom_vline(xintercept=0.8, linetype="dashed") +
  scale_x_continuous(limits=c(0,8), oob=scales::squish) +
  scale_y_continuous(labels=scales::label_percent(accuracy=1)) +
  annotate("text", x=1.3, y=0.9, label="800m") +
  theme_dark() +
  labs(
    title="Cumulative distribution of homes added by distance from closest LRT stop",
    subtitle="Existing, under construction, and funded stops",
    x="Distance from closest LRT stop (km)",
    y="Cumulative proportion of units added",
    colour="Year",
    caption="Jacob Dawang, City of Edmonton Open Data"
  )
```

Once again, it is 5-8 unit rowhomes in the RS Zone that is driving more homes to be built near transit. Breaking out a map in @fig-rowhome-lrt-dist-map, we can see that the 5-8 unit rowhomes follow the path of the LRT almost religiously, especially the Valley Line West.

```{r}
#| label: fig-rowhome-lrt-dist-map
#| fig-cap: "Most 5-8 unit rowhomes are found within a couple kilometres of the LRT"
buffer1 <- lrt_stops %>% 
  st_buffer(units::as_units("1km")) %>%
  st_union() %>%
  st_boundary() %>%
  st_sf() %>%
  mutate(radius="1km") 

buffer2 <- lrt_stops %>% 
  st_buffer(units::as_units("2km")) %>%
  st_union() %>%
  st_boundary() %>%
  st_sf() %>%
  mutate(radius="2km")

buffer1_half <- lrt_stops %>% 
  st_buffer(units::as_units("1.5km")) %>%
  st_union() %>%
  st_boundary() %>%
  st_sf() %>%
  mutate(radius="1.5km")

buffers <- bind_rows(buffer1, buffer1_half, buffer2) %>%
  mutate(radius=fct_relevel(radius, c("1km", "1.5km", "2km")))

ggplot(buffer2) + 
  geom_water() + 
  geom_roads(alpha=0.5, color="gray") + 
  geom_sf(data=buffers, mapping=aes(linetype=radius), colour="firebrick") +
  scale_linetype_manual(values=c("1km"="dotted", "1.5km"="dashed", "2km"="solid")) +
  geom_sf(
    data=filter(
      bp, 
      zoning=="RS", 
      year==2025, 
      between(units_added, 5, 8)
    ),
    size=0.5,
    colour="steelblue"
  ) + 
  geom_sf(data=lrt_stops, shape=21, colour="firebrick", fill=NA) +
  theme(
    axis.text.x = element_blank(),
    axis.text.y = element_blank(),
    axis.ticks = element_blank(),
    rect = element_blank()
  ) +
  labs(
    title="Map of all 5-8 unit building permits in the RS Zone in 2025",
    caption="Jacob Dawang, City of Edmonton Open Data"
  )
```

@fig-ecdf-rowhomes-transit confirms around 50% of 5-8 unit rowhomes permitted in 2025 were within 800m of an LRT stop. 2025 is even an improvement over 2024, with the distribution bowed out and to the left.

```{r}
#| label: fig-ecdf-rowhomes-transit
#| fig-cap: "Over half of 5-8 unit rowhomes are located within 1km of LRT, and over 90% are located within 2km"

plexes_only <-  bp %>% 
  filter(!st_is_empty(.), year>=2024, between(units_added, 5, 8), zoning == "RS") %>%
  select(date_issued, job_description, address, neighbourhood, units_added, year)  %>%
  mutate(
    distance_from_lrt=apply(
        st_distance(
          ., 
          lrt_stops, 
        ),
        1,
        min
    ) / 1000
  )

ecdf_transit <- plexes_only %>%
  group_by(year) %>%
  arrange(distance_from_lrt) %>%
  mutate(ecdf_values=ecdf(distance_from_lrt)(distance_from_lrt))

ggplot(ecdf_transit) +
  geom_step(aes(x=distance_from_lrt, y=ecdf_values, colour=factor(year)), linewidth=0.8) + 
  scale_y_continuous(labels=scales::label_percent()) +
  scale_colour_viridis_d(option="magma") +
  geom_vline(xintercept=0.8, linetype="dashed") +
  annotate("text", x=1, y=0.8, label="800m") +
  labs(
    title="Cumulative distribution of 5-8 unit rowhomes\nby distance from closest LRT stop",
    subtitle="Existing, under construction, and funded stops",
    x="Distance from closest LRT stop (km)",
    y="Cumulative proportion of building permits",
    colour="Year",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark()
```

## The magic of density

Despite the impressive number of homes built, the number of properties redeveloped for this increase in housing produced is nowhere near as significant. That's the magic of density: a few hundred redeveloped properties can produce thousands of homes for people. Let's check the rate of redevelopment using property assessment data.

In @tbl-probs, we see that: there is no "avalanche" of infill. Even in mature neighbourhoods, less than 1 in 200 homes have been redeveloped into a 5 to 8 unit rowhome. Compared to the total housing and property stock, the impressive building in 2025 is a drop in the bucket. It will take many years of building under these rules to truly provide a balance of housing options for people in all neighbourhoods.

```{r}
#| label: tbl-probs
#| tbl-cap: "Less than 0.5% of properties redeveloped in the RS Zone creates a over 2,300 homes in 2025"

n_plexes_rs <- bp %>%
  st_drop_geometry() %>%
  filter(
    zoning == "RS",
    between(units_added, 5, 8),
    year == 2025
  ) %>%
  group_by(neighbourhood_type) %>%
  summarize(n_permits=n(), total_units_added=sum(units_added)) %>%
  ungroup()

n_assessed_rs <- assessment %>%
  st_drop_geometry() %>%
  filter(zoning == "RS") %>%
  distinct(house_number, street_name, neighbourhood_type) %>%
  group_by(neighbourhood_type) %>%
  summarize(n_properties=n())

gt(
  n_assessed_rs %>%
    left_join(n_plexes_rs, join_by(neighbourhood_type)) %>%
    replace_na(list(n_permits=0, total_units_added=0)) %>%
    mutate(pct=n_permits / n_properties) 
) |>
  cols_move(n_properties, after=n_permits) |>
  cols_move(pct, after=n_properties) |>
  cols_align("left", columns=neighbourhood_type) |>
  fmt_percent(pct, decimals=2) |>
  fmt_number(columns=c(n_permits, n_properties, total_units_added), decimals=0) |>
  cols_label(
    neighbourhood_type="Neighbourhood type",
    n_permits="Number of 5-8 unit permits",
    n_properties="Number of properties",
    pct="Percent of properties redeveloped",
    total_units_added="Number of homes added"
  ) |>
  tab_source_note("Jacob Dawang, City of Edmonton Open Data") %>% 
  tab_header("RS Zone intensity: comparing property redevelopment rates to housing gains", subtitle="2025 building permits") |>
  opt_stylize(style=5, color="green")
```

We can drill down deeper into the rate of redevelopment by individual neighbourhood in @tbl-selected-neighbourhood-profile. Concentrating on a handful of neighbourhoods that have been held up as examples of new rowhomes "destroying the neighbourhood", we can see some variation in the proportion of properties redeveloped. Nevertheless, rowhomes remain relatively rare in these neighbourhoods. Furthermore, these are all neighbourhoods that have lost population from 50 years ago. Some are as much as 30% less populated, which is yet another reason why infrastructure capacity is [not a barrier](https://www.growtogetheryeg.com/blog/infrastructure) to adding more homes to existing neighbourhoods. The new rowhomes are simply repopulating these neighbourhoods back to their design capacity.

```{r}
#| label: tbl-selected-neighbourhood-profile
#| tbl-cap: "Selected neighbourhood 5-8 unit rowhome profiles in the RS Zone"
#| column: page


n_plexes_rs <- bp %>%
  st_drop_geometry() %>%
  #filter(neighbourhood %in% nbhd_list) %>%
  group_by(neighbourhood, .drop=FALSE) %>%
  filter(
    zoning == "RS",
    year>=2024,
    between(units_added, 5, 8),
  ) %>%
  group_by(nbhd_f=fct(neighbourhood, levels=unique(bp$neighbourhood)), year_f=fct(as.character(year)), .drop=FALSE) %>%
  summarize(n_permits=n(), units_added=sum(units_added, na.rm=TRUE), .groups="drop") %>%
  mutate(neighbourhood=as.character(nbhd_f), year=as.character(year_f), .keep="unused")

n_assessed_rs_by_nbhd <- assessment %>%
  st_drop_geometry() %>%
  filter(zoning == "RS") %>%
  distinct(house_number, street_name, .keep_all=TRUE) %>%
  group_by(neighbourhood) %>%
  summarize(n_properties=n())

merged <- n_plexes_rs %>% 
  right_join(n_assessed_rs_by_nbhd, by=join_by(neighbourhood)) %>% 
  left_join(population_change, by=join_by(neighbourhood)) %>% 
  filter(!is.na(n_properties)) %>%
  mutate(neighbourhood=str_to_title(neighbourhood), pct_redevelop=n_permits/n_properties) %>%
  relocate(Ward, .after=neighbourhood) %>%
  relocate(pct_redevelop, .after=n_properties)

nbhd_list <- c(
  "GLENWOOD",
  "GROVENOR",
  "BRITANNIA YOUNGSTOWN",
  "WESTMOUNT",
  "MCKERNAN",
  "BELGRAVIA",
  "CRESTWOOD",
  "PARKVIEW"
)

merged %>% 
  filter(
    str_to_upper(neighbourhood) %in% nbhd_list,
    year==2025
  ) %>%
  arrange(neighbourhood) %>%
  gt() %>%
  cols_move(n_permits, after=Ward) %>%
  cols_move(units_added, after=pct_redevelop) %>%
  cols_hide(year) %>%
  cols_label(
    n_2021="Population 2021",
    n_1971="Population 1971",
    n_permits="Number of 5-8 unit permits",
    n_properties="Number of properties",
    pct_redevelop="Percent of properties redeveloped",
    units_added="Number of homes added",
    neighbourhood="Neighbourhood",
    pct_change="Population change"
  ) %>%
  fmt_integer(columns=c(n_2021, n_1971, n_properties)) %>%
  fmt_percent(columns=pct_change,decimals=0) %>%
  fmt_percent(columns=pct_redevelop, decimals=2) %>%
  tab_source_note("Jacob Dawang, City of Edmonton Open Data, StatCan Census") %>% 
  tab_header("Selected neighbourhood 5-8 unit development profiles", subtitle="RS Zone, 2025 building permits") %>%
  opt_stylize(style=5, color="green")
```

I've created an interactive version in @tbl-selected-neighbourhood-profile so you can search up your own neighbourhood or any other you may be interested in. Pay attention to just how many neighbourhoods had zero 5-8 unit rowhomes permitted in 2025.

```{r}
#| label: tbl-full-neighbourhood-profile
#| tbl-cap: "Interactive Profile: 5–8 Unit Rowhome Permits by Neighbourhood (2025)"
#| column: page

merged %>% 
  filter(year==2025) %>%
  arrange(neighbourhood) %>%
  gt() %>%
  cols_move(n_permits, after=Ward) %>%
  cols_move(units_added, after=pct_redevelop) %>%
  cols_hide(year) %>%
  cols_label(
    n_2021="Population 2021",
    n_1971="Population 1971",
    n_permits="Number of 5-8 unit permits",
    n_properties="Number of properties",
    pct_redevelop="Percent of properties redeveloped",
    units_added="Number of homes added",
    neighbourhood="Neighbourhood",
    pct_change="Population change"
  ) %>%
  fmt_integer(columns=c(n_2021, n_1971, n_properties)) %>%
  fmt_percent(columns=pct_change,decimals=0) %>%
  fmt_percent(columns=pct_redevelop, decimals=2) %>%
  tab_source_note("Jacob Dawang, City of Edmonton Open Data, StatCan Census") %>% 
  tab_header("Interactive Profile: 5–8 Unit Rowhome Permits by Neighbourhood (2025)") %>%
  #opt_stylize(style=3, color="blue")  %>%
  opt_interactive(use_search=TRUE, use_highlight=TRUE, use_sorting=TRUE) %>%
  sub_missing()
```

In case you're interested in the cumulative statistics since ZBR was implemented in 2024, you can look it up in @tbl-full-neighbourhood-profile-2024-25.

```{r}
#| label: tbl-full-neighbourhood-profile-2024-25
#| tbl-cap: "Cumulative Profile: 5–8 Unit Rowhome Permits (2024–2025)"
#| column: page


n_plexes_rs_cum <- bp %>%
  st_drop_geometry() %>%
  #filter(neighbourhood %in% nbhd_list) %>%
  group_by(neighbourhood, .drop=FALSE) %>%
  filter(
    zoning == "RS",
    year>=2024,
    between(units_added, 5, 8),
  ) %>%
  group_by(nbhd_f=fct(neighbourhood, levels=unique(bp$neighbourhood)), .drop=FALSE) %>%
  summarize(n_permits=n(), units_added=sum(units_added, na.rm=TRUE), .groups="drop") %>%
  mutate(neighbourhood=as.character(nbhd_f), .keep="unused")

n_assessed_rs_by_nbhd_cum <- assessment %>%
  st_drop_geometry() %>%
  filter(zoning == "RS") %>%
  distinct(house_number, street_name, .keep_all=TRUE) %>%
  group_by(neighbourhood) %>%
  summarize(n_properties=n())

merged_cum <- n_plexes_rs_cum %>% 
  right_join(n_assessed_rs_by_nbhd, by=join_by(neighbourhood)) %>% 
  left_join(population_change, by=join_by(neighbourhood)) %>% 
  filter(!is.na(n_properties)) %>%
  mutate(neighbourhood=str_to_title(neighbourhood), pct_redevelop=n_permits/n_properties) %>%
  relocate(Ward, .after=neighbourhood) %>%
  relocate(pct_redevelop, .after=n_properties)

merged_cum %>% 
  arrange(neighbourhood) %>%
  gt() %>%
  cols_move(n_permits, after=Ward) %>%
  cols_move(units_added, after=pct_redevelop) %>%
  cols_label(
    n_2021="Population 2021",
    n_1971="Population 1971",
    n_permits="Number of 5-8 unit permits",
    n_properties="Number of properties",
    pct_redevelop="Percent of properties redeveloped",
    units_added="Number of homes added",
    neighbourhood="Neighbourhood",
    pct_change="Population change"
  ) %>%
  fmt_integer(columns=c(n_2021, n_1971, n_properties)) %>%
  fmt_percent(columns=pct_change,decimals=0) %>%
  fmt_percent(columns=pct_redevelop, decimals=2) %>%
  tab_source_note("Jacob Dawang, City of Edmonton Open Data, StatCan Census") %>% 
  tab_header("Cumulative Profile: 5–8 Unit Rowhome Permits (2024–2025)") %>%
  #opt_stylize(style=3, color="blue")  %>%
  opt_interactive(use_search=TRUE, use_highlight=TRUE, use_sorting=TRUE) %>%
  sub_missing()
```

## Protecting the progress

The data from 2025 tells a story of a city finally finding its balance. For decades, Edmonton’s growth was pointed one direction: outwards into greenfield. By legalizing the "missing middle," we haven't just changed the zoning code, we've given people more choice to live their lives where it makes sense for them. We've done this through building townhomes near transit, walkable infill, and diverse housing choices in the neighborhoods people love most.

We have proven that we don't need to "destroy" neighborhoods to house our neighbors. By redeveloping just **0.4% of RS-zoned properties** in mature areas, we added thousands of homes, which will start to bring life back to communities that have seen their populations dwindle by 20% to 40% since the 1970s. This is the definition of sustainable, efficient growth.

Despite this record-breaking success, our progress is fragile. In February, City Council is expected to once again consider a proposal to reduce the hard cap on homes per lot from eight down to six, along with multiple other red

If the data shows us anything, it’s that the 8 home rowhome is the primary engine of our current success. To cap it now, just as it has begun to outpace single-family home construction, would be to intentionally stall Edmonton's momentum. It would mean fewer homes near transit, higher infrastructure costs for the city, and fewer options for the next generation of Edmontonians.

We cannot afford to let the narrative of an "infill avalanche" win when the data clearly shows a measured, successful transition.

-   **Share this analysis:** Use these numbers to show your neighbours and your Councillor that the Zoning Bylaw Renewal is working exactly as intended.

-   **Get involved:** This February, Council needs to hear from the people who benefit from these new homes. Join [**Grow Together Edmonton**](https://www.growtogetheryeg.com/) to stay updated on how to advocate for housing choice.

2025 was the year we found the missing middle. In 2026, let's make sure we have the courage to keep it.
