Mapping U.S. Rents by County in R with tidycensus, sf and ggplot2

The U.S. Census Bureau publishes the rent that a typical household pays in every county in the country, updated every year, and gives it away through a free API. With the tidycensus package you can pull that data — numbers and the map polygons to draw it — in a single function call, then turn it into a national map in about twenty lines. Here we map median gross rent by county to see where renting is expensive and where it is cheap.

Getting the data

The data comes from the American Community Survey (ACS), the Census Bureau’s rolling survey of about 3.5 million addresses a year. We use the 5-year estimates (here 2019–2023), which pool five years of responses so that even small, rural counties get a reliable number. The variable we want is B25064_001: median gross rent in dollars — contract rent plus utilities — for renter-occupied housing.

tidycensus talks to the ACS API, and the API needs a free key. Request one at api.census.gov/data/key_signup.html; it arrives by email in a minute. Activate it, then install it into R once — after that tidycensus finds it automatically in every session, so you never put it in a script you share:

library(tidycensus)
census_api_key("YOUR_KEY_HERE", install = TRUE)  # run once; writes to ~/.Renviron

With the key set, load the packages we need:

library(tidycensus)
library(tigris)     # shift_geometry(): repositions Alaska & Hawaii
library(dplyr)
library(sf)
library(ggplot2)
library(scales)     # dollar labels
library(patchwork)  # combine the small-multiple maps

options(tigris_use_cache = TRUE)  # cache boundary files after first download

Now one call does everything. We ask for the rent variable at geography = "county", and — this is what makes tidycensus special — set geometry = TRUE so it also downloads the county boundaries and returns them joined to the data as an sf object. Leaving state unset gives us every county in the country; shift_geometry() then tucks Alaska and Hawaii under the lower 48 so the whole nation fits one frame.

us <- get_acs(
  geography  = "county",
  variables  = "B25064_001",   # median gross rent (dollars)
  year       = 2023,
  survey     = "acs5",
  geometry   = TRUE,
  resolution = "20m"           # generalized boundaries: smaller, faster, fine for a national map
)

us <- shift_geometry(us)

The result is a tidy sf data frame — one row per county, the value in estimate, its 90% margin of error in moe, and a geometry column. Always look before drawing:

us                       # an sf object: data + polygons together
## Simple feature collection with 3222 features and 5 fields
## Geometry type: GEOMETRY
## Dimension:     XY
## Bounding box:  xmin: -3112200 ymin: -1697728 xmax: 2258154 ymax: 1558935
## Projected CRS: USA_Contiguous_Albers_Equal_Area_Conic
## First 10 features:
##    GEOID                         NAME   variable estimate moe
## 1  13027       Brooks County, Georgia B25064_001      752  79
## 2  31095   Jefferson County, Nebraska B25064_001      659  50
## 3  51683      Manassas city, Virginia B25064_001     1835  62
## 4  56021      Laramie County, Wyoming B25064_001     1080  29
## 5  13135     Gwinnett County, Georgia B25064_001     1713  16
## 6  20001         Allen County, Kansas B25064_001      685  60
## 7  27065    Kanabec County, Minnesota B25064_001     1003  83
## 8  28107   Panola County, Mississippi B25064_001      859  44
## 9  31185        York County, Nebraska B25064_001      885  49
## 10 42063 Indiana County, Pennsylvania B25064_001      786  28
##                          geometry
## 1  MULTIPOLYGON (((1163909 -64...
## 2  MULTIPOLYGON (((-115252.6 3...
## 3  MULTIPOLYGON (((1580860 292...
## 4  MULTIPOLYGON (((-765818.2 5...
## 5  MULTIPOLYGON (((1073286 -32...
## 6  MULTIPOLYGON (((41912.94 35...
## 7  MULTIPOLYGON (((192562.6 95...
## 8  MULTIPOLYGON (((527838.1 -3...
## 9  MULTIPOLYGON (((-152283 398...
## 10 MULTIPOLYGON (((1383131 460...
summary(us$estimate)     # the spread of county rents
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.     NAs 
##   253.0   742.0   848.5   928.4  1021.0  2893.0      10
sum(is.na(us$estimate))  # counties with no published estimate
## [1] 10

Two things to note. A handful of counties come back NA — mostly tiny populations the Bureau suppresses for privacy; we’ll let them render in grey. And that moe column is a reminder these are survey estimates, not a census: a county’s rent is “$1,200 ± $80,” not exactly $1,200. For a national map the uncertainty is small relative to the range, but it’s there.

Map it

Rent is a sequential quantity — low to high — so we fill with a sequential palette (viridis’s plasma), which is colorblind-safe and reads clearly in print. geom_sf() draws the polygons; everything else is labels and a clean, map-friendly theme.

ggplot(us) +
  geom_sf(aes(fill = estimate), color = NA) +
  scale_fill_viridis_c(
    option   = "plasma",
    labels   = label_dollar(),
    na.value = "grey85",
    name     = "Medianngross rent"
  ) +
  labs(
    title    = "Median gross rent by county, United States",
    subtitle = "American Community Survey, 2019–2023 5-year estimates",
    caption  = "Source: U.S. Census Bureau ACS, via the R tidycensus package"
  ) +
  theme_void(base_size = 13) +
  theme(plot.title = element_text(face = "bold"),
        legend.position = c(0.92, 0.3))
plot of chunk map

Reading the map

The typical county’s median rent is about $848 — but the map is a story of a few bright clusters against a wide, darker interior. Rent runs from $253 in Issaquena County up to $2,893 in San Mateo County, an elevenfold spread. Only about 6% of counties top $1,500, and they are not scattered randomly: they concentrate in coastal California and the Bay Area, the Washington–Boston corridor, and pockets around Seattle, Denver, and the mountain-resort West. Because every number in this paragraph is computed from the same object we mapped, the text always matches the picture — re-knit it next year and both update together.

The priciest counties make the coastal concentration concrete:

d |>
  arrange(desc(estimate)) |>
  transmute(County = NAME, `Median rent` = scales::dollar(estimate)) |>
  head(8) |>
  knitr::kable()
County Median rent
San Mateo County, California $2,893
Santa Clara County, California $2,814
Marin County, California $2,584
San Francisco County, California $2,419
Orange County, California $2,352
Contra Costa County, California $2,322
Alameda County, California $2,318
Loudoun County, Virginia $2,317

Zooming into the states

A national map flattens what happens inside each state. The state = argument fixes that — it accepts a vector, so one call pulls several states at once. Here we grab the four most populous, then draw each on its own colour scale to bring out where rent concentrates within each one.

states <- c("California", "Texas", "Florida", "New York")

sc <- get_acs(geography = "county", variables = "B25064_001", state = states,
              year = 2023, survey = "acs5", geometry = TRUE, resolution = "20m")
sc$state <- sub(".*, ", "", sc$NAME)   # pull the state name out of "County, State"

one_state <- function(st) {
  ggplot(filter(sc, state == st)) +
    geom_sf(aes(fill = estimate), color = "grey92", linewidth = 0.05) +
    scale_fill_viridis_c(option = "plasma", labels = label_dollar(),
                         na.value = "grey85", name = NULL) +
    labs(title = st) +
    theme_void(base_size = 12) +
    theme(plot.title = element_text(face = "bold", hjust = 0.5),
          legend.key.width = unit(0.35, "cm"), legend.text = element_text(size = 8))
}

(one_state("California") | one_state("Texas")) /
(one_state("Florida")   | one_state("New York")) +
  plot_annotation(
    title   = "Median gross rent by county — each state on its own colour scale",
    caption = "Source: U.S. Census Bureau ACS, via the R tidycensus package",
    theme   = theme(plot.title = element_text(face = "bold", size = 15)))
plot of chunk states

The pattern repeats with variations. California is expensive along almost its entire coast, with the Bay Area at the top of its range. New York is the sharpest split — downstate (New York City, Long Island, Westchester) against a uniformly inexpensive upstate. Florida’s money is on the water: the southeast around Miami, the southwest around Naples, the interior far cheaper. Texas is mostly its metros — Austin and Houston — but note the bright cluster out west, the Permian Basin oil counties, where a housing crunch has nothing to do with a big city.

One caution about reading these together: because each panel has its own scale, the same colour means different rents in different states — a “bright” Texas county (around $1,500) rents for far less than a bright California one (near (2,800). Per-state scales reveal internal *pattern*; for cross-state *levels*, use one shared scale (`limits = range(sc)estimate, na.rm = TRUE)`) instead.

Make it your own

Nothing above is specific to rent. Change one variable code and you map something else entirely; change geography and you change the resolution:

  • B19013_001 — median household income
  • B25077_001 — median home value
  • DP02_0154PE — households with a broadband subscription (percent)
  • geography = "tract" with a state and county — a neighborhood-level map of a single metro

To find a variable, browse the full ACS table with load_variables(2023, "acs5") and search it — there are tens of thousands. The pattern stays the same: get_acs() with geometry = TRUE, then geom_sf().

That’s all. You now have a script that pulls an authoritative national dataset, joins it to its own geography, and maps it — and re-points at any variable or place with a one-line change.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.