Introduction

The American Community Survey (ACS) is the Census Bureau’s yearly sample survey of about 3.5 million addresses. Because it is a sample, every estimate comes with a margin of error (MOE). This report uses two ACS measures: the share of adults 25 and older with a graduate or professional degree in Washington’s 39 counties (Part A), and the share of renter households paying 30% or more of their income on rent in King County’s 494 census tracts (Part B).

Part A ranks the counties and examines whether the margins of error are large enough to make the ranking questionable, while Part B maps rent burden across King County and compares cities, to see whether the region’s housing costs are felt most strongly in Seattle or in its suburbs. King County is the state’s largest county, has the highest graduate degree share, and has some of the highest rents in the country.

Data preparation

All data come from the ACS 2020-2024 5-year estimates, requested from the Census API with the tidycensus package. The 5-year release covers every county and tract, but the 1-year release only covers areas with 65,000 or more people.

Packages and settings

library(tidyverse) # data wrangling and ggplot2
library(tidycensus) # Census API requests
library(sf) # spatial data
library(scales) # percent axis labels
library(plotly) # interactive plot
library(mapview) # interactive map

# keep downloaded Census boundaries on disk so later knits don't download them again
options(tigris_use_cache = TRUE)
study_state <- "WA"
study_county <- "King"
acs_year <- 2024
map_crs <- 32148 # NAD83 / Washington North, the state plane zone for King County

# the 5-year ACS labeled 2024 covers survey years 2020 to 2024
acs_period <- str_glue("{acs_year - 4}-{acs_year}")

Part A: graduate degrees by county

Variable DP02_0066P from the ACS Data Profile is the percent of adults 25 and older whose highest degree is a graduate or professional degree. The request returns one row per county with the estimate and its 90% MOE.

# percent of adults 25+ with a graduate or professional degree for every county in the state
county_grad_degree <- get_acs(
  geography = "county",
  variables = c(pct_grad_degree = "DP02_0066P"),
  state = study_state,
  year = acs_year,
  survey = "acs5"
)

The county names are shortened here for the plot labels, and the ends of each 90% confidence interval (estimate minus and plus the MOE) are computed for use in every plot following this block.

county_grad_degree <- county_grad_degree |>
  mutate(
    # "King County, Washington" -> "King", for axis labels
    county = str_remove(NAME, " County, .*$"),
    # ends of the 90% confidence interval, used by every plot below
    lower_90 = estimate - moe,
    upper_90 = estimate + moe
  )

Part B: rent burden by census tract

A household is rent-burdened when gross rent (rent plus utilities) is 30% or more of household income according to the standard HUD definition. ACS table B25070 counts renter households by this ratio:

# list the B25070 variables from the ACS variable table
acs_variables <- load_variables(acs_year, "acs5", cache = TRUE)
## Warning: `cache` is deprecated and ignored. tidycensus no longer writes
## variable metadata to a local cache.
acs_variables |>
  filter(str_detect(name, "^B25070_")) |>
  select(name, label) |>
  knitr::kable(col.names = c("Variable", "Label"))
Variable Label
B25070_001 Estimate!!Total:
B25070_002 Estimate!!Total:!!Less than 10.0 percent
B25070_003 Estimate!!Total:!!10.0 to 14.9 percent
B25070_004 Estimate!!Total:!!15.0 to 19.9 percent
B25070_005 Estimate!!Total:!!20.0 to 24.9 percent
B25070_006 Estimate!!Total:!!25.0 to 29.9 percent
B25070_007 Estimate!!Total:!!30.0 to 34.9 percent
B25070_008 Estimate!!Total:!!35.0 to 39.9 percent
B25070_009 Estimate!!Total:!!40.0 to 49.9 percent
B25070_010 Estimate!!Total:!!50.0 percent or more
B25070_011 Estimate!!Total:!!Not computed

Burdened households are the sum of the four categories at 30% or more (_007 to _010). The denominator is all renter households (_001) minus those whose ratio is “not computed” (_011, likely households with no income or no cash rent). The request returns tract boundaries with the data (geometry = TRUE).

rent_burden_variables <- c(
  rent_30_to_35 = "B25070_007",
  rent_35_to_40 = "B25070_008",
  rent_40_to_50 = "B25070_009",
  rent_50_plus = "B25070_010",
  not_computed = "B25070_011"
)

# renter households by rent burden, every census tract in King County, with boundaries
tract_rent <- get_acs(
  geography = "tract",
  variables = rent_burden_variables,
  summary_var = "B25070_001", # all renter households
  state = study_state,
  county = study_county,
  year = acs_year,
  geometry = TRUE,
  output = "wide" # one column per variable: estimate ends in E, MOE in M
)

According to (ACS General Handbook, Chapter 8), the MOE of a sum is the square root of the sum of the squared MOEs, and moe_prop() gives the MOE of a proportion. Countywide shares are computed here from summed counts, not by averaging tract percents.

min_renters <- 50 # tracts with fewer renter households will get their own gray class

tract_rent_burden <- tract_rent |>
  mutate(
    # renter households paying 30% or more of income on rent
    burdened = rent_30_to_35E + rent_35_to_40E + rent_40_to_50E + rent_50_plusE,
    # margin of error of a sum: square root of the sum of the squared MOEs
    burdened_moe = sqrt(rent_30_to_35M^2 + rent_35_to_40M^2 + rent_40_to_50M^2 + rent_50_plusM^2),
    # renter households with a computed rent-to-income ratio
    renters = summary_est - not_computedE,
    renters_moe = sqrt(summary_moe^2 + not_computedM^2),
    # share burdened and its margin of error, in percent
    pct_burdened = 100 * burdened / renters,
    pct_burdened_moe = 100 * moe_prop(burdened, renters, burdened_moe, renters_moe)
  ) |>
  select(GEOID, NAME, burdened, burdened_moe, renters, renters_moe, pct_burdened, pct_burdened_moe, rent_50_plusE)

# countywide shares: sum the counts, then divide
county_pct_burdened <- 100 * sum(tract_rent_burden$burdened) / sum(tract_rent_burden$renters)
county_pct_severe <- 100 * sum(tract_rent_burden$rent_50_plusE) / sum(tract_rent_burden$renters)

Countywide, 46.8% of renter households are burdened and 22.3% are severely burdened (50% or more of income).

Census tract boundaries extend into Lake Washington and Puget Sound, which is not useful for this analysis. erase_water() removes large water areas so the maps show land only. It needs the planar geometry engine (sf_use_s2(FALSE)), which is switched back on afterward.

sf_use_s2(FALSE)
tract_rent_burden <- tigris::erase_water(tract_rent_burden, area_threshold = 0.9, year = acs_year)
sf_use_s2(TRUE)

Tracts are grouped into four classes with breaks at 35, 50, and 65%. The classes are 15 points wide, about the size of a typical tract MOE, which is designed that way so a tract’s class is less likely to change from sampling error alone. Quintile breaks (33, 43, 52, 61) would have classes only about 9 points wide. The countywide share falls in the 35 to 50% class, so the two darker classes mean “more burdened than the county as a whole”. Tracts with fewer than 50 renter households get a separate gray class.

tract_rent_burden <- tract_rent_burden |>
  mutate(
    burden_class = cut(
      pct_burdened,
      breaks = c(0, 35, 50, 65, 100),
      labels = c("Under 35%", "35 to 50%", "50 to 65%", "65% or more"),
      right = FALSE, # 35.0 goes in "35 to 50%", not "Under 35%"
      include.lowest = TRUE
    ),
    # too few renters for a stable share: set aside in a labeled gray class
    burden_class = if_else(renters < min_renters, NA, burden_class),
    burden_class = fct_na_value_to_level(burden_class, level = "Fewer than 50 renters")
  )

# tracts per class
tract_rent_burden |>
  st_drop_geometry() |>
  count(burden_class) |>
  knitr::kable(col.names = c("Class", "Tracts"))
Class Tracts
Under 35% 114
35 to 50% 153
50 to 65% 150
65% or more 71
Fewer than 50 renters 6

The tract MOEs are large. For tracts with 50 or more renters, the median MOE is about ±17 percentage points, and more than half the tracts are above ±15:

tract_rent_burden |>
  st_drop_geometry() |>
  filter(renters >= min_renters) |>
  summarize(
    tracts = n(),
    median_moe = median(pct_burdened_moe),
    moe_over_15 = sum(pct_burdened_moe > 15),
    median_moe_500_plus = median(pct_burdened_moe[renters >= 500])
  ) |>
  knitr::kable(
    digits = 1,
    col.names = c("Tracts", "Median MOE (±)", "Tracts with MOE above ±15", "Median MOE, 500+ renters (±)")
  )
Tracts Median MOE (±) Tracts with MOE above ±15 Median MOE, 500+ renters (±)
488 16.8 296 14.7

Analysis

Which counties have the most and fewest graduate degree holders?

# five highest and five lowest counties; slice_max() and slice_min() keep ties
highest_counties <- county_grad_degree |>
  slice_max(estimate, n = 5) |>
  select(county, estimate, moe)

lowest_counties <- county_grad_degree |>
  slice_min(estimate, n = 5) |>
  select(county, estimate, moe)

knitr::kable(highest_counties, col.names = c("County", "Percent", "MOE (±)"), caption = "Highest five (with a tie for fifth)")
Highest five (with a tie for fifth)
County Percent MOE (±)
King 24.2 0.3
San Juan 23.4 1.4
Whitman 22.4 2.3
Jefferson 19.6 1.5
Thurston 14.9 0.7
Whatcom 14.9 0.7
knitr::kable(lowest_counties, col.names = c("County", "Percent", "MOE (±)"), caption = "Lowest five")
Lowest five
County Percent MOE (±)
Adams 5.4 1.4
Grant 5.6 0.9
Cowlitz 5.7 0.5
Grays Harbor 6.0 0.7
Ferry 6.1 2.0

King (24.2%), San Juan, Whitman, and Jefferson counties have the highest shares, and Thurston and Whatcom tie for fifth at 14.9%. King holds Seattle and its tech employers, while Whitman, Thurston, and Whatcom hold Washington State University, the state capital, and Western Washington University respectively. San Juan and Jefferson are small counties with many retirees. The lowest shares are in agricultural and timber counties: Adams (5.4%), Grant, Cowlitz, Grays Harbor, and Ferry.

How certain are the rankings?

# does each county's interval overlap the next county down the ranking?
rank_overlap <- county_grad_degree |>
  arrange(desc(estimate)) |>
  mutate(overlaps_next = lower_90 <= lead(upper_90))

# number of overlapping neighbor pairs, out of 38
sum(rank_overlap$overlaps_next, na.rm = TRUE)
## [1] 37
# the pair that doesn't overlap
rank_overlap |>
  filter(!overlaps_next) |>
  select(county, estimate, moe)
## # A tibble: 1 × 3
##   county    estimate   moe
##   <chr>        <dbl> <dbl>
## 1 Jefferson     19.6   1.5
# scales, labels, and theme shared by the static and plotly versions
moe_plot_style <- list(
  scale_x_continuous(labels = label_percent(scale = 1)),
  labs(
    title = "Graduate degrees are concentrated in King County and a few college and island counties",
    subtitle = "Share of adults 25 and older with a graduate or professional degree, with 90% margins of error",
    x = str_glue("ACS {acs_period} estimate"),
    y = NULL,
    caption = "Source: U.S. Census Bureau, ACS 5-year estimates, variable DP02_0066P"
  ),
  theme_minimal(base_size = 11),
  theme(
    panel.grid.major.y = element_blank(),
    plot.title.position = "plot"
  )
)

grad_moe_plot <- ggplot(
  county_grad_degree,
  aes(x = estimate, y = reorder(county, estimate)) # counties sorted by estimate
) +
  # draw the bars first so the points sit on top of them
  geom_errorbar(aes(xmin = lower_90, xmax = upper_90), width = 0.4, color = "gray60") +
  geom_point(size = 2.5, color = "#1B6E7E") +
  moe_plot_style

grad_moe_plot
King (24.2%), San Juan (23.4%), Whitman (22.4%), and Jefferson (19.6%) counties have the highest share of adults 25 and older with a graduate or professional degree, and Adams (5.4%) the lowest. Points are ACS 2020-2024 5-year estimates for all 39 Washington counties; bars show 90% margins of error. The intervals of 37 of 38 neighboring counties overlap, so the plot supports the broad pattern but not exact ranks.

King (24.2%), San Juan (23.4%), Whitman (22.4%), and Jefferson (19.6%) counties have the highest share of adults 25 and older with a graduate or professional degree, and Adams (5.4%) the lowest. Points are ACS 2020-2024 5-year estimates for all 39 Washington counties; bars show 90% margins of error. The intervals of 37 of 38 neighboring counties overlap, so the plot supports the broad pattern but not exact ranks.

# add hover text to the static plot, then convert it to an interactive plotly chart
grad_moe_plotly <- grad_moe_plot +
  aes(text = str_glue("{county} County<br>{estimate}% +/- {moe} percentage points")) +
  # plotly doesn't wrap titles, so split the long one across two lines
  labs(title = "Graduate degrees are concentrated in King County\nand a few college and island counties")

# fixed size to match the static figure above (600 px wide on the page), centered
htmltools::div(
  style = "display: flex; justify-content: center;",
  ggplotly(grad_moe_plotly, tooltip = "text", width = 600, height = 700) |>
    layout(margin = list(t = 70)) # room for the two-line title
)

Interactive version of the figure above. Hover over a county to see its estimate and 90% margin of error. Share of adults 25 and older with a graduate or professional degree, ACS 2020-2024 5-year estimates, 39 Washington counties. Source: U.S. Census Bureau, DP02_0066P.

The margin-of-error plot works well for the broad pattern in Washington. With 39 counties every county gets a row, and there is one clear break: Jefferson’s interval (4th, 19.6%) does not overlap those of Thurston and Whatcom, tied for 5th at 14.9%, which separates the top four from everyone else. Because the error bars are so wide everywhere else, however, it does not support exact ranks. Every other pair of counties adjacent in the ranking overlaps, so “King first, San Juan second” is not supported. The widest bars belong to the smallest counties (Garfield ±4.2, Columbia ±3.2), where the survey has few responses, and King, the largest county, has the smallest (±0.3).

Where are renters most burdened?

# four colors from light (low burden) to dark (high burden), plus gray for the small-tract class
burden_colors <- c(viridisLite::magma(4, direction = -1, begin = 0.1), "gray80")

# keep only the columns a reader should see in the pop-up, with readable names
burden_display <- tract_rent_burden |>
  transmute(
    tract = NAME,
    `Share of renters burdened` = burden_class,
    `Burdened renters (%)` = round(pct_burdened, 1),
    `Margin of error (+/-)` = round(pct_burdened_moe, 1),
    `Renter households` = renters
  )

rent_burden_map <- mapview(
  burden_display,
  zcol = "Share of renters burdened", # color tracts by class
  col.regions = burden_colors,
  map.types = c("Esri.WorldGrayCanvas", "OpenStreetMap"), # basemaps that need no API key
  label = "tract", # tract name on hover
  lwd = 0.3, # thin tract outlines
  layer.name = "Rent burden" # legend and layer list title
)

# the underlying leaflet map, set to the static map's size (600 x 600 px on the page), centered
rent_burden_leaflet <- rent_burden_map@map
rent_burden_leaflet$width <- 600
rent_burden_leaflet$height <- 600

htmltools::div(
  style = "display: flex; justify-content: center;",
  rent_burden_leaflet
)

Rent burden is highest in South King County: in many tracts around SeaTac, Kent, Federal Way, and Auburn, more than half of renter households pay 30% or more of their income on rent. Share of renter households with gross rent at 30% or more of household income, by census tract, ACS 2020-2024 5-year estimates (B25070), 494 tracts, water removed. Gray tracts have fewer than 50 renter households. Tract margins of error are large (median ±17 points); click a tract for its estimate, margin of error, and number of renter households. Source: U.S. Census Bureau. Basemap: Esri World Gray Canvas.

The static map crops to urban western King County, where nearly all renter households live, and outlines Seattle for reference. It uses the same classes and colors as the interactive map.

# generalized city and census-designated place boundaries for the state
king_places <- tigris::places(study_state, cb = TRUE, year = acs_year, progress_bar = FALSE)

seattle_boundary <- king_places |>
  filter(NAME == "Seattle")

label_cities <- c(
  "Seattle", "Shoreline", "Bellevue", "Redmond", "Renton", "Kent", "Federal Way",
  "Auburn", "SeaTac", "Des Moines", "Sammamish", "Issaquah", "Woodinville", "Union Hill-Novelty Hill"
)

# one label point per city, at the center of its largest piece
city_labels <- king_places |>
  filter(NAME %in% label_cities) |>
  # break the longest name onto two lines so it doesn't crowd its neighbors
  mutate(label = str_replace(NAME, "Union Hill-", "Union Hill-\n")) |>
  st_transform(map_crs) |>
  st_set_agr("constant") |>
  st_centroid(of_largest_polygon = TRUE)

# the University District is a neighborhood, not a Census place, so mark it with a point
uw_point <- st_sf(
  label = "University District",
  geometry = st_sfc(st_point(c(-122.313, 47.661)), crs = 4269) # near NE 45th St and University Way
) |>
  st_transform(map_crs)

# city names and the University District share one label layer, so ggrepel keeps them apart
map_labels <- bind_rows(select(city_labels, label), uw_point)

# urban western King County, in longitude/latitude (NAD83), converted to map units
map_extent <- st_bbox(c(xmin = -122.45, xmax = -121.95, ymin = 47.25, ymax = 47.78), crs = 4269) |>
  st_as_sfc() |>
  st_transform(map_crs) |>
  st_bbox()

ggplot() +
  geom_sf(data = tract_rent_burden, aes(fill = burden_class), color = "white", linewidth = 0.05) +
  geom_sf(data = seattle_boundary, fill = NA, color = "black", linewidth = 0.4) +
  geom_sf(data = uw_point, shape = 21, fill = "white", color = "black", size = 1.5) +
  # bold place names with a white halo
  ggrepel::geom_text_repel(
    data = map_labels,
    aes(label = label, geometry = geometry),
    stat = "sf_coordinates",
    size = 2.2,
    fontface = "bold",
    lineheight = 0.9, # spacing for the two-line label
    color = "gray10",
    bg.color = "white",
    bg.r = 0.15, # halo width
    min.segment.length = Inf, # don't draw leader lines
    seed = 588 # same label positions every knit
  ) +
  scale_fill_manual(
    name = "Renter households paying\n30% or more of income on rent",
    values = burden_colors
  ) +
  coord_sf(
    crs = map_crs,
    xlim = map_extent[c("xmin", "xmax")],
    ylim = map_extent[c("ymin", "ymax")],
    expand = FALSE, # no margin around the crop box
    datum = NA # no graticule
  ) +
  labs(
    title = "South King County renters carry the heaviest rent burden",
    subtitle = str_glue("Census tracts in urban King County, ACS {acs_period} 5-year estimates"),
    caption = "Black outline: City of Seattle. White point: University District. Source: U.S. Census Bureau, ACS table B25070"
  ) +
  theme_void() +
  theme(plot.title.position = "plot")
Rent burden is highest in the South King County suburbs and lowest on the Eastside. Share of renter households paying 30% or more of household income on gross rent, by census tract, ACS 2020-2024 5-year estimates (B25070_007 to _010 over B25070_001 minus _011). Classes are 15 points wide, close to the median tract margin of error (±17 points), so individual tracts may belong one class up or down. Gray tracts have fewer than 50 renter households. Map cropped to urban western King County; water removed. NAD83 / Washington North (EPSG:32148).

Rent burden is highest in the South King County suburbs and lowest on the Eastside. Share of renter households paying 30% or more of household income on gross rent, by census tract, ACS 2020-2024 5-year estimates (B25070_007 to _010 over B25070_001 minus _011). Classes are 15 points wide, close to the median tract margin of error (±17 points), so individual tracts may belong one class up or down. Gray tracts have fewer than 50 renter households. Map cropped to urban western King County; water removed. NAD83 / Washington North (EPSG:32148).

Because single tracts are uncertain, tracts are also added up by city. Each tract is assigned to the incorporated city that contains a point inside it (st_point_on_surface()), counts and MOEs are summed, and the share is recomputed. Tracts that cross a city line count entirely in one city, so small cities’ figures are approximations. Census-designated places that are notable here such as Union Hill-Novelty Hill are not incorporated, so their tracts count as Unincorporated King County. The table shows cities with 3,000 or more renter households.

# incorporated cities only (LSAD "25"); everything else is unincorporated King County
king_cities <- king_places |>
  filter(LSAD == "25") |>
  st_transform(map_crs) |>
  select(city = NAME)

# assign each tract to the city that contains a point inside it
tract_points <- tract_rent_burden |>
  st_transform(map_crs) |>
  st_set_agr("constant") |>
  st_point_on_surface()

city_rent_burden <- tract_points |>
  st_join(king_cities) |>
  st_drop_geometry() |>
  mutate(city = replace_na(city, "Unincorporated King County")) |>
  # sum counts by city; MOEs combine as the square root of the sum of squares
  summarize(
    tracts = n(),
    renters = sum(renters),
    burdened = sum(burdened),
    renters_moe = sqrt(sum(renters_moe^2)),
    burdened_moe = sqrt(sum(burdened_moe^2)),
    .by = city
  ) |>
  mutate(
    pct_burdened = 100 * burdened / renters,
    pct_burdened_moe = 100 * moe_prop(burdened, renters, burdened_moe, renters_moe)
  ) |>
  filter(renters >= 3000) |> # large enough for a useful MOE
  arrange(desc(pct_burdened))

city_rent_burden |>
  select(city, tracts, renters, pct_burdened, pct_burdened_moe) |>
  knitr::kable(
    digits = 1,
    col.names = c("City", "Tracts", "Renter households", "Burdened (%)", "MOE (±)")
  )
City Tracts Renter households Burdened (%) MOE (±)
Des Moines 6 4956 66.6 9.1
SeaTac 6 6681 64.4 8.9
Federal Way 21 15577 62.0 4.7
Auburn 15 10369 58.1 4.8
Shoreline 12 7132 54.9 5.3
Kent 29 19651 54.6 3.8
Bothell 5 4595 53.9 6.8
Kenmore 5 3104 53.3 9.3
Renton 23 19694 53.2 3.7
Burien 12 8198 51.8 5.3
Unincorporated King County 67 19881 50.5 3.3
Mercer Island 6 3124 45.5 9.1
Seattle 177 197454 43.9 1.1
Kirkland 18 13653 43.7 4.0
Sammamish 12 3467 43.6 11.1
Issaquah 5 5010 37.0 5.7
Bellevue 34 29473 36.4 2.6
Redmond 12 18727 35.5 3.9

Nearly half of King County renter households (46.8%) are rent-burdened. The share is highest in the South King County suburbs (Des Moines 66.6%, SeaTac 64.4%, Federal Way 62.0%) and lowest on the Eastside (Bellevue 36.4%, Redmond 35.5%), with Seattle (43.9%) below the county share. Federal Way’s interval (57 to 67%) does not overlap Bellevue’s (34 to 39%), so the contrast is valid even after accounting for sampling error. Shoreline (54.9%), on Seattle’s northern border, is an exception to the north-south pattern, as is the area surrounding the University of Washington, where many students live.

North and east of Redmond, a few tracts are as burdened as South King County. The most reliable is in Woodinville (61 ± 14%, about 2,000 renter households), with another in Union Hill-Novelty Hill. Most of the dark tracts there, including several in Sammamish, have only a few hundred renters and MOEs of ±25 points or more, and Sammamish as a whole (44 ± 11%) is close to the county share. These are pockets within a mostly lower-burden area, not as much a second cluster like South King County.

Discussion

King County has the highest share of graduate degree holders in Washington (Part A), and the high-wage economy that comes with them is part of why its rents are high. Part B shows the burden of those rents falls hardest on renters in the cities south of Seattle, not in Seattle itself. Both results are descriptive, and the maps do not show that one causes the other (more likely these are all just effects of having large urban areas, universities, and large tech employers).

The main limitation of this analysis is reliability. County MOEs are small enough to separate the top four counties from the rest, but not to rank neighbors one-by-one. Tract MOEs (median ±17 points) are too large to trust any single tract’s class, so the tract maps show more of the broad pattern while the city table, with MOEs of about ±1 to ±11 points, supports the comparisons between cities.

Data sources and packages

  • U.S. Census Bureau, American Community Survey 2020-2024 5-year estimates: Data Profile variable DP02_0066P and detailed table B25070, via the Census API.
  • U.S. Census Bureau, 2024 TIGER/Line and cartographic boundary files (tracts, places, water), via tigris.
  • Basemaps in the interactive map: Esri World Gray Canvas and OpenStreetMap.
  • R packages: tidycensus, tigris, tidyverse, sf, scales, plotly, mapview, ggrepel, viridisLite.
  • Lab materials adapted from Kyle Walker’s workshop on the 2021 ACS in R.