I chose Rutherford County, Tennessee, because I am familiar with the Murfreesboro area and wanted to investigate whether a countywide economic statistic can conceal meaningful neighborhood differences. My variable is median household income, measured in dollars using the U.S. Census Bureau’s 2024 American Community Survey five-year estimates (covering 2020–2024). Median household income describes the midpoint of household incomes, not the earnings of an average individual. Before making the maps, I expected income to vary within Rutherford County, particularly between different residential areas, but I wanted to see how much variation the data actually showed. These estimates describe households and should not be interpreted as exact incomes for every resident.
The first map compares Rutherford County with every Tennessee county sharing its border. Hover over or click a county to see its median household income. Darker shading indicates higher estimated income.
Rutherford County’s estimated median household income was $85,470. Among Rutherford and its seven bordering Tennessee counties, it ranked 3 out of 8 when ordered from highest to lowest. Williamson County had the highest estimate ($135,594), while Cannon County had the lowest ($59,443). The difference between Rutherford and the highest-income county was $50,124. I was interested in how a group of neighboring counties can differ despite being part of the same broader Middle Tennessee region. These county figures offer a useful comparison, but each county is still represented by only one number. They do not reveal whether income is distributed evenly across the communities inside each county.
The second map displays the same measure for individual census tracts in Rutherford County. Hover over or click a tract to see its estimate. Tracts are statistical areas rather than official neighborhood boundaries.
The tract-level results reveal a wider range than the single county estimate suggests. Among tracts with a published estimate, Tract Census Tract 413.01; Rutherford County; Tennessee had the highest median household income ($149,368) and Tract Census Tract 403.05; Rutherford County; Tennessee had the lowest ($34,961). Their difference was $114,407, showing substantial variation within the same county. The pattern is not simply that everyone in Rutherford County has an income close to its overall median. Instead, individual tracts can differ sharply from that figure. The geographic distribution could reflect differences in housing prices, household composition, development patterns, and proximity to employment opportunities. These explanations are possibilities rather than causes established by this map. I would also examine the local street and city boundaries before assigning a tract to a named neighborhood, since tract borders do not necessarily match familiar community names.
Before completing the analysis, I expected Rutherford County to contain economic differences, but the tract-level map made those differences more concrete. The results support my expectation of substantial variation: the gap between the highest- and lowest-estimate tracts was $114,407. The county-level map could tell me where Rutherford stood relative to its neighbors, but it could not identify which smaller areas were above or below the countywide figure. That is the most important information gained by changing geographic scale. Looking at the county alone could encourage a misleading assumption that households throughout the area have similar economic circumstances.
As a journalist, I would pursue a local story about how housing affordability differs across Rutherford County. I would compare tract-level incomes with median rents or home values and interview renters, homeowners, housing advocates, and local planners. I would ask whether residents in lower-income tracts face different housing pressures than those in higher-income tracts. Importantly, I would not claim that income alone proves unequal access to services or opportunities. I would use the map to identify questions, then verify the story with additional data and reporting. ACS values are survey estimates with margins of error, especially at the tract level, so small differences should not be treated as definitive. Overall, this exercise demonstrates why geographic scale matters: a countywide median is informative, but it cannot tell the complete story of the people and places within a county.
The following is the full analysis and mapping code used above. The setup chunk only configures the report and is shown here as well.
knitr::opts_chunk$set(echo = FALSE, warning = FALSE, message = FALSE)
# Install these once in the RStudio Console if needed:
# install.packages(c("tidycensus", "dplyr", "sf", "leaflet", "scales", "htmltools", "knitr"))
library(tidycensus)
library(dplyr)
library(sf)
library(leaflet)
library(scales)
library(htmltools)
library(knitr)
# ACS 2024 five-year profile median household income: DP03_0062E.
# tidycensus's standard B19013_001 variable measures the same concept.
acs_year <- 2024
income_var <- "B19013_001"
# If Census API requests fail, obtain a free key from census.gov/developers
# and run census_api_key("YOUR_KEY", install = TRUE) once in the Console.
county_all <- get_acs(geography = "county", variables = income_var,
state = "TN", year = acs_year, survey = "acs5",
geometry = TRUE, cache_table = TRUE) |>
st_transform(4326)
rutherford <- county_all |> filter(GEOID == "47149")
stopifnot(nrow(rutherford) == 1)
# Bordering counties are selected from actual Census polygon adjacency.
neighbor_ids <- st_touches(st_geometry(rutherford), st_geometry(county_all))[[1]]
county_map_data <- county_all[c(which(county_all$GEOID == "47149"), neighbor_ids), ] |>
distinct(GEOID, .keep_all = TRUE) |>
mutate(place = sub(", Tennessee$", "", NAME),
income = ifelse(estimate < 0, NA_real_, estimate),
label = paste0(place, "<br>Median household income: ",
ifelse(is.na(income), "Unavailable", dollar(income))))
tracts <- get_acs(geography = "tract", variables = income_var,
state = "TN", county = "Rutherford", year = acs_year,
survey = "acs5", geometry = TRUE, cache_table = TRUE) |>
st_transform(4326) |>
mutate(place = paste0("Tract ", sub(".*Census Tract ([^,]+),.*", "\\1", NAME)),
income = ifelse(estimate < 0, NA_real_, estimate),
label = paste0(place, "<br>Median household income: ",
ifelse(is.na(income), "Unavailable", dollar(income))))
county_sorted <- county_map_data |> st_drop_geometry() |>
filter(!is.na(income)) |> arrange(desc(income))
tract_sorted <- tracts |> st_drop_geometry() |>
filter(!is.na(income)) |> arrange(desc(income))
stopifnot(nrow(tract_sorted) > 1, nrow(county_sorted) > 1)
rc_income <- county_sorted$income[county_sorted$place == "Rutherford County"]
rc_rank <- match("Rutherford County", county_sorted$place)
rc_high <- county_sorted$place[1]
rc_low <- tail(county_sorted$place, 1)
rc_top_tract <- tract_sorted$place[1]
rc_bottom_tract <- tail(tract_sorted$place, 1)
rc_top_income <- tract_sorted$income[1]
rc_bottom_income <- tail(tract_sorted$income, 1)
# Use one common palette for both maps to make values comparable.
all_income <- c(county_map_data$income, tracts$income)
pal <- colorNumeric("YlGnBu", domain = all_income, na.color = "#cccccc")
leaflet(county_map_data) |>
addProviderTiles(providers$CartoDB.Positron) |>
addPolygons(fillColor = ~pal(income), fillOpacity = 0.8,
color = "white", weight = 2,
label = ~lapply(label, HTML),
popup = ~label, highlightOptions = highlightOptions(weight = 4)) |>
addLegend(pal = pal, values = all_income, title = "Median household income",
labFormat = labelFormat(prefix = "$"), opacity = 0.8) |>
fitBounds(lng1 = st_bbox(county_map_data)$xmin,
lat1 = st_bbox(county_map_data)$ymin,
lng2 = st_bbox(county_map_data)$xmax,
lat2 = st_bbox(county_map_data)$ymax)
leaflet(tracts) |>
addProviderTiles(providers$CartoDB.Positron) |>
addPolygons(fillColor = ~pal(income), fillOpacity = 0.8,
color = "white", weight = 1,
label = ~lapply(label, HTML),
popup = ~label, highlightOptions = highlightOptions(weight = 3)) |>
addLegend(pal = pal, values = all_income, title = "Median household income",
labFormat = labelFormat(prefix = "$"), opacity = 0.8) |>
fitBounds(lng1 = st_bbox(tracts)$xmin,
lat1 = st_bbox(tracts)$ymin,
lng2 = st_bbox(tracts)$xmax,
lat2 = st_bbox(tracts)$ymax)
U.S. Census Bureau, 2024 ACS five-year estimates (2020–2024), median
household income (variable B19013_001), accessed using
tidycensus. County and tract boundaries are supplied
through Census geographic data. Estimates are not inflation-adjusted
across different releases because both maps use the same release.