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.
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.
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}")
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
)
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 |
# 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)")
| 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")
| 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.
# 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.
# 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).
# 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).
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.
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.
tigris.tidycensus, tigris,
tidyverse, sf, scales,
plotly, mapview, ggrepel,
viridisLite.