---
title: "Zoning By-Law Renewal: One Year Review"
date: "2025-01-04"
author: "Jacob Dawang"
categories: [housing, transit, edmonton]
description: "Edmonton's housing policy continued its march forward this past year with the new zoning by-law renewal coming into effect and district plans in force. Let's take a look at where and what type of homes are being built in this new policy regime."
format:
  html:
    code-fold: true
link-external-newwindow: true
link-external-icon: false
---

## Background

In 2023, Edmonton City Council approved a complete rewrite of the zoning by-law, called the zoning by-law renewal (ZBR). ZBR was overwhelming a good pro-housing policy. It vastly simplified the number of standard zones, removed [restrictive zoning overlays](/blog/2023/edmonton-mature-neighbourhoods/index.qmd) applied to the most in-demand neighbourhoods, and allowed building up to 8 units at a height of 10.5m on any residential lot in the city[^1]. ZBR came into force on January 1, 2024.

[^1]: Number of units allowed is still dependent on lot size. The base zone in the "mature" neighbourhoods is RS. The new neighbourhoods have more permissive base zones that allow more height and more units per lot.

Since it's been a full year since builders could use ZBR to build more housing forms as-of-right, I thought it would be fun to look at building permit data to see if we can start to see any effects. And yes, yes we can.

```{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

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. There is a bit of cleaning I do, as you can see in the code. In short, I:

-   Include building permits that added at least 1 unit. Building permits that did not add units of housing are not adding to the supply of housing. However, this does exclude demolitions (negative `units_added`), which I am just accepting as a limitation of this blog post.

-   Include building types in a list of specified residential types (e.g. Apartment, Row House, Single Detached, Mixed Use). It's possible that I'm counting commercial units in mixed use.

-   Create a new variable *project type* that categorizes building permits as follows, in increasing order of density:

    -   New single-family home (SFH).

    -   Backyard house (i.e. garden suite). Note that a garden suite can have multiple units in it.

    -   Addition/conversion - either additions to existing buildings that add housing or conversion of commercial to residential.

    -   Duplex to fourplex.

    -   Fiveplex to eightplex.

    -   Rowhouses of 9 or more units.

    -   Apartments of 9 or more units.

-   Add location categories for *neighbourhood type* which take the following values: downtown (literally just the downtown neighbourhood), mature, between mature and Henday, and outside the Henday.

Both the *project type* and *neighbourhood type* categories are arbitrary, but I think they strike a good balance for examining the effect of ZBR, the ascendance of backyard homes, and Edmonton's development pattern.

Rowhouses are the biggest question. Depending on how they are constructed, stacked or not, with secondary suites or not, they are sort of between single-family homes and multiplex apartments. In this case, I decided to split them out from 9+ apartments, but to keep them with the multiplexes.

```{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_20250101/geo_export_8b0d195f-4e0c-41ca-a629-304f2d5a824e.shp")

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"
  )  %>%
  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
) %>%
  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",
        address == "8944 - 145 STREET NW" ~ "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)"))
```

## Apartments are important

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 units added.

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

```{r}
#| label: fig-project-type-raw-bp
#| fig-cap: "Number of building permits by project type, 2009-2024"

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) +
  scale_colour_viridis_d(option="magma") +
  scale_y_continuous(limits=c(0, 5000)) +
  labs(
    title="Number of building permits by project type",
    subtitle="Building permits 2009-2024",
    colour="Project type",
    y="Number of Building Permits",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark()
```

## Total units added

```{r}
#| label: fig-project-type-units-added
#| fig-cap: "Total number of units by project type, 2009-2024"

ggplot(project_type_dist) +
  geom_line(aes(x=year, y=total, colour=project_type), linewidth=1) +
  scale_colour_viridis_d(option="magma") +
  scale_y_continuous(limits=c(0, 5000)) +
  labs(
    title="Total number of units by project type",
    subtitle="Building permits 2009-2024",
    colour="Project type",
    y="Total number of units",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark()
```
:::

Comparing the two figures, we can see that the raw number of building permits and the number of units added are two separate concepts. Single-family homes have a building permit for each building, whereas a large apartment building could have a single building permit for hundreds of units. Despite having relatively few building permits year-over-year, 9+ apartments consistently create a similar number of housing units as new SFH year over year. If Edmonton is to succeed at both curbing suburban sprawl, and densifying our existing neighbourhoods, larger apartment buildings will have to continue to play a leading role. It will be important for us to support the construction of more apartments through the [Priority Growth Area rezonings](https://www.edmonton.ca/city_government/city_vision_and_strategic_plan/priority-growth-area-rezoning).

## Missing middle still missing?

There is also an uptick in multiplexes in 2024 (duplex to fourplex plus fiveplex to eightplex), as well as a smaller, but notable uptick in backyard homes. On the face of it, this seems like good news and proof that ZBR is working. Let's look a little closer at *where* these "missing middle" units are being built in @fig-units-added-nbhd-prj-type.

```{r}
#| label: fig-units-added-nbhd-prj-type
#| fig-cap: "Total number of units by neighbourhood and project type"

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

There's both good news and bad news. Starting with the good news: ZBR is having a significant effect on housing supply in the mature neighbourhoods, mostly through the increase in fiveplex to eightplexes permitted in 2024. And what a dramatic increase it is. Over 1,000 units of Fiveplex to Eightplex type had building permits issued in mature neighbourhoods.

Onto the bad news: we're still relying a lot on single-family homes, duplexes, and apartments built outside the Henday for our housing supply. Yes, it's good that new greenfield communities are building a range of housing types so that they are denser than older suburbs. But, those communities will still inevitably be [more car-oriented](/blog/2024/transit-usage-peds) than the mature neighbourhoods and cost more to serve infrastructure-wise. More on this later, but @fig-units-added-nbhd-type underscores just how much of a shift we need to make to reach a better growth pattern.

```{r}
#| label: fig-units-added-nbhd-type
#| fig-cap: "Total number of units added by neighbourhood type"

bp %>%
  st_drop_geometry() %>%
  group_by(year, neighbourhood_type, .drop=FALSE) %>% 
  summarize(total=sum(units_added), num_projects=n(), .groups="drop") %>%
ggplot() +
  geom_line(aes(x=year, y=total, colour=neighbourhood_type), linewidth=1) +
  scale_colour_viridis_d(option="magma") +
  labs(
    title="Total number of units by neighbourhood type",
    subtitle="Building permits 2009-2024",
    colour="Neighbourhood type",
    x="Year",
    y="Total number of units",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark()

```

Here are my other takeaways from the plots so far.

We need more apartments in mature neighbourhoods. Hopefully, multiplexes will continue to increase as a source of housing supply in mature neighbourhoods. But, even a few apartment projects can contribute enormously to housing supply. There is clearly a demand for apartment living as shown by just how many units in apartments are being built outside the Henday. Imagine how successful they could be in mature neighbourhoods, if they were more widely permitted.

Backyard homes are great, but even with this year's increase, they are not and will not be a significant source of housing supply. The scale of multiplexes and especially apartments can just not be beaten.

If you prefer maps to the line charts I have shown so far, @fig-bp-map-2024 shows the 2024 building permits with magenta lines drawn around the borders of the Henday and mature neighbourhoods.

```{r}
#| label: fig-bp-map-2024
#| fig-cap: "Map of Edmonton building permits issued in 2024 by project type and units added"


bp %>% 
  filter(year==2024) %>%
  ggplot() + 
  geom_water() +
  geom_roads(transform=function(d) filter(d, kind %in% c("highway", "major_road"))) +
  geom_sf(aes(colour=project_type, size=units_added), alpha=0.5) +
  scale_colour_viridis_d(option="magma") +
  geom_sf(data=st_boundary(st_union(mature_neighbourhood)), colour="magenta") +
  geom_sf(data=st_boundary(henday), colour="magenta") +
  scale_size(breaks=c(1, 8, 20, 100)) +
  theme_dark() +
  theme(
    panel.grid=element_blank(), 
    axis.text = element_blank(), 
    axis.ticks = element_blank()
  ) +
  labs(
    title="Edmonton building permits 2024 by project type",
    size="Units added",
    colour="Project type",
    caption="Jacob Dawang, City of Edmonton Open Data"
  )
```

## Distance from the LRT

Rapid transit is like a superpower: it allows people to move around the city faster than buses and is competitive with driving. When people live close to the LRT, it allows them to live car-free or car-lite. Optimally, people live within 800m walking distance - about 15 minutes of a station. If not, hopefully they don't live too far away so that bus feeders can bring people to stations. To achieve City Council's endorsed goal of 50% of all trips by transit and active transit, we will need to shift our building pattern towards transit.

Let's bring in the LRT stations dataset. Using the collection of LRT stops, we can calculate how far each building permit is from its closest LRT stop. @fig-lrt-stop-distance-existing shows the cumulative distribution of units added with respect to distance from the closest existing LRT stop.

```{r}
#| label: fig-lrt-stop-distance-existing
#| fig-cap: "Cumulative distribution of units added by distance from the closest existing LRT stop"
bp %>% 
  filter(!st_is_empty(.)) %>%
  mutate(
    distance_from_lrt=apply(
        st_distance(
          ., 
          filter(lrt_stops, status == "existing"), 
        ),
        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)) +
  scale_x_continuous(limits=c(0, 14), oob=scales::squish) +
  theme_dark() +
  labs(
    title="Cumulative distribution of units added by distance from closest LRT stop",
    subtitle="Existing only, building permits 2009-2024",
    x="Distance from closest LRT stop (km)",
    y="Cumulative proportion of units added",
    colour="Year",
    caption="Jacob Dawang, City of Edmonton Open Data"
  )
```

We can see that when it comes to distances closest to the LRT, we have generally improved over the years, shown by the curves bowing out near the 0km mark. As we get further out from the LRT, we can see the effect of suburban sprawl, where we are building further and further out. In my opinion, there is still work to be done on transit-oriented development and we should be aiming for far more housing to be built near our existing rapid transit stations. Having only 25% of housing built near rapid transit will not be good enough.

The picture looks better if we include future LRT stops as well, as in @fig-lrt-stop-distance-future. It's amazing just how much rapid transit expansion can shrink the size of the city. However, even with expansion, I would like to see us push the proportion of housing built near transit even higher. In a city like Edmonton, where driving is fast and the buses are good, but pretty slow, building high-density housing next to the LRT so that more people have access to the train will be important.

```{r}
#| label: fig-lrt-stop-distance-future
#| fig-cap: "Cumulative distribution of units added by distance from the closest existing or future 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)
  ) %>%
  ungroup() %>%
  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)) +
  scale_x_continuous(limits=c(0, 14), oob=scales::squish) +
  theme_dark() +
  labs(
    title="Cumulative distribution of units added by distance from closest LRT stop",
    subtitle="Existing, under construction, and funded, building permits 2009-2024",
    x="Distance from closest LRT stop (km)",
    y="Cumulative proportion of units added",
    colour="Year",
    caption="Jacob Dawang, City of Edmonton Open Data"
  )
```

## Summary

In short, I'd say that Edmonton's policy reforms so far, especially ZBR, have been a good step forward, but there is still more work to be done. Multiplexes/row houses in mature neighbourhoods is a big success story for ZBR. Backyard homes are a smaller success story for ZBR, but we still see an increase in that category. However, the increase in small infill in existing neighbourhoods still pales in comparison to just how much we are building outside the Henday.

To develop our housing supply sustainably, we need to double down on making it easier to build infill housing. Backyard homes and multiplexes are great, but an excellent infill policy would make it way easier to build larger apartment buildings in mature neighbourhoods, especially near the LRT. If apartments are being built outside the Henday in large numbers, I have no doubt that they would be successful in mature neighbourhoods, which is where they are better suited anyways.

## Additional plots for the interested

```{r}
#| label: fig-units-nbhd-bar
#| fig-cap: "Units added by neighbourhood and project type, 2024"

bp %>% 
  st_drop_geometry() %>%
  filter(year==2024) %>% 
  group_by(neighbourhood_type, project_type, .drop=FALSE) %>% 
  summarize(total=sum(units_added)) %>%
  ggplot() +
  geom_col(aes(x=neighbourhood_type, y=total, fill=project_type), position=position_dodge()) + 
  scale_fill_viridis_d(option="magma") +
  theme_dark() +
  labs(
    title="Units added by neighbourhood and project type",
    subtitle="Building permits 2024",
    y="Total units added",
    x="Neighbourhood type",
    fill="Project type",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme(legend.position="bottom")
```

```{r}
#| label: fig-project-type-timeseries-mature
#| fig-cap: "Total number of units added by project type in mature neighbourhoods"

bp %>%
  st_drop_geometry() %>%
  filter(mature_neighbourhood) %>%
  group_by(year, project_type, .drop=FALSE) %>% 
  summarize(total=sum(units_added), num_projects=n(), .groups="drop") %>%
ggplot() +
  geom_line(aes(x=year, y=total, colour=project_type), linewidth=1) +
  scale_colour_viridis_d(option="magma") +
  labs(
    title="Total number of units added by project type in mature neighbourhoods",
    subtitle="Building permits 2009-2024",
    colour="Project type",
    x="Year",
    y="Total units added",
    caption="Jacob Dawang, City of Edmonton Open Data"
  ) +
  theme_dark()

```

```{r}
#| label: fig-churchill-dist
#| fig-cap: "Cumulative distribution of units added by distance from Churchill Station"

bp %>% 
  filter(!st_is_empty(.)) %>%
  mutate(
    distance_from_churchill=apply(
      st_distance(., lrt_stops %>% filter(stop_name_short == "Churchill")), 1, min
    ) / 1000,
  ) %>%
  group_by(year) %>%
  arrange(distance_from_churchill, .by_group=TRUE) %>%
  mutate(
    cum_units=cumsum(units_added),
    ecdf_values=cum_units / sum(units_added)
  ) %>%
  ggplot() +
  geom_step(aes(x=distance_from_churchill, y=ecdf_values, colour=factor(year)), linewidth=0.8) + 
  scale_colour_viridis_d(option="magma", guide=guide_legend(ncol=2)) +
  theme_dark() +
  labs(
    title="Cumulative distribution of units added by distance from Churchill Station",
    x="Distance from Churchill Station",
    y="Cumulative proportion of units added",
    colour="Year",
    caption="Jacob Dawang, City of Edmonton Open Data"
  )
```
