October 11, 2026
By JAMES EVANS
Middle Tennessee State
University
I selected Cheatham County for a median age analysis because I grew up there in the small town of Pegram and graduated from Harpeth High School in the neighboring town of Kingston Springs. In some ways, Cheatham is an anomaly because, among the “doughnut counties” that share a border with Davidson County — home to Nashville — Cheatham has the smallest population and growth rate, based on data from the U.S. Census 2024 American Community Survey five-year estimates. I analyzed the county population and growth trends in Midstate Tennessee in this earlier report.
The earlier report showed that Cheatham’s estimated population is 41,829 with a percentage change of growth of seven percent. The largest doughnut county, by comparison, is Rutherford County with a population of 360,646 people and a growth rate of 23 percent. It’s worth noting the doughnut county with the highest growth during this period is Williamson County, with a rate of 25 percent.
Using the same data source, I am now analyzing the median age of Cheatham County and those that share a border with it to determine how those counties compare and to propose topics for additional research or news analysis stories. For this report, I am not analyzing all counties in the Midstate area, but rather, only those that directly touch Cheatham County. In addition, I am examining the census tracts within Cheatham County for a similar comparison.
Cheatham County has an orange outline. Hover over a county to see its median age ± margin of error in years or click the county for additional details.
| County | Median age (years) | 90% MOE (years) |
|---|---|---|
| Cheatham County, Tennessee | 41.5 | 0.5 |
| Williamson County, Tennessee | 40.4 | 0.3 |
| Dickson County, Tennessee | 39.1 | 0.6 |
| Robertson County, Tennessee | 38.7 | 0.6 |
| Davidson County, Tennessee | 34.6 | 0.1 |
| Montgomery County, Tennessee | 31.9 | 0.2 |
Median age is the age that divides a population into two equal groups, with half the people younger and half older.
Cheatham’s estimated median age is 41.5 years. Its rank is 1 of six, from highest to lowest; tied estimates share a rank. This shows that median age for Cheatham County is higher than all the other counties immediately surrounding it. This includes Davidson, Dickson, Montgomery, Robertson, and Williamson counties.
The comparisons show Montgomery County has the lowest median age at 31.9 years. Montgomery County’s largest city is Clarksville, and the county is also home to Fort Campbell Army base, which may explain why its median age skews lower than other surrounding counties. This point would need to be confirmed with additional analysis of the ACS’ sub-county data.
It should be noted these are sample estimates. Small differences may reflect sampling uncertainty, and as such, an apparent rank does not establish a statistically significant difference. Each county’s median age estimate also provides a margin of error. The U.S. Census Bureau’s methodology for the ACS also acknowledges margins of error or confidence intervals at the 90 percent confidence level, which is reflected in each county’s information on the map.
Hover over a census tract to see its median age ± margin of error in years. Click for details. Both maps use the same five-year age intervals and colors. Gray indicates an unavailable estimate. Census tracts are statistical areas and do not necessarily match towns or neighborhoods.
| GEOID | Census tract | Median age (years) | 90% MOE (years) |
|---|---|---|---|
| 47021070401 | Census Tract 704.01; Cheatham County; Tennessee | 45.1 | 3.5 |
| 47021070203 | Census Tract 702.03; Cheatham County; Tennessee | 44.0 | 2.8 |
| 47021070402 | Census Tract 704.02; Cheatham County; Tennessee | 43.3 | 3.1 |
| 47021070104 | Census Tract 701.04; Cheatham County; Tennessee | 43.1 | 6.7 |
| 47021070102 | Census Tract 701.02; Cheatham County; Tennessee | 42.7 | 5.0 |
| 47021070300 | Census Tract 703; Cheatham County; Tennessee | 41.2 | 4.5 |
| 47021070201 | Census Tract 702.01; Cheatham County; Tennessee | 40.0 | 4.2 |
| 47021070103 | Census Tract 701.03; Cheatham County; Tennessee | 38.4 | 5.5 |
| 47021070202 | Census Tract 702.02; Cheatham County; Tennessee | 37.6 | 4.9 |
The highest tract estimate is 45.1 years, in Census Tract 704.01; Cheatham County; Tennessee. The lowest tract estimate is 37.6 years, in Census Tract 702.02; Cheatham County; Tennessee.
The difference between these estimates is 7.5 years, which is substantial because it shows a vast difference in population age profiles in specific communities within the larger county as a whole. The geography of Cheatham County has some notable characteristics that may explain some of these findings.
For example, my experience from living in Cheatham County has shown there are two major divisions within the county because of a large game preserve that exists between the northern county and southern county. The northern county has two major cities, the county seat of Ashland City and Pleasant View. The southern end of the county has two smaller cities, Pegram and Kingston Springs, which both have quick access to the Bellevue community in Nashville and then the mid-sized city of Dickson. In addition, the county is intersected by two interstates, I-24 along the northern border and I-40 on the southern border. Outside of the cities, there are lots of rural areas, including the Chapmansboro community, which, based on this report’s data, has the lowest median age (37.6 years) within the county. An explanation for this trend is not immediately clear, although I would speculate the area has more affordable housing and land because of its rural location. However, additional analysis is needed to test this theory.
This data analysis provides several opportunities for either additional research proposals or news stories for local journalists.
Those topics include:
The effects of land and home prices in Cheatham County as a whole and at the sub-county level correlated with the median age, income level, and population density.
An analysis of local government development regulations, such as impact fees, and their effects on population growth and median age. An interesting research question would be whether Cheatham County has developed into a retirement community for older adults who have more financial means. These regulations, development fees, and median home costs could be compared and contrasted against other doughnut counties to explain why growth and population figures in Cheatham are the lowest around Nashville.
With a median age difference of 7.5 years within the county, which suggests different population age profiles within the county, an interesting news story would focus on the different types of community and business services that are needed in different areas of the county. An area with a higher concentration of younger adults, for example, may need additional services such as daycares, schools, and recreation options. Areas with a concentration of older adults may need services such as retirement communities, nursing or skilled-care homes, or senior services.
tidycensus; basemap supplied by Esri through Leaflet.############################################################
# Report 7: Median age in Cheatham County and its neighbors
# One-time setup (run in the Console, never include your key
# in a script or report):
# tidycensus::census_api_key("YOUR_KEY", install = TRUE)
# readRenviron("~/.Renviron")
# Install packages once in the Console if needed:
# install.packages(c("tidycensus", "dplyr", "sf", "leaflet",
# "knitr", "rmarkdown"))
############################################################
# STEP 1: Load packages and settings
library(tidycensus)
library(dplyr)
library(sf)
library(leaflet)
options(tigris_use_cache = TRUE)
acs_year <- 2024
my_variable <- "DP05_0018"
county_names <- c("Cheatham", "Davidson", "Dickson", "Montgomery",
"Robertson", "Williamson")
# STEP 2: Verify the API key without printing it
if (!nzchar(Sys.getenv("CENSUS_API_KEY"))) {
stop("Save your Census API key with tidycensus::census_api_key(), then restart R or run readRenviron('~/.Renviron').")
}
# STEP 3: Download and check the Profile Tables codebook
ProfileTables <- load_variables(acs_year, "acs5/profile", cache = TRUE)
selected_variable <- filter(ProfileTables, name == my_variable)
if (nrow(selected_variable) != 1L ||
!grepl("Median age", selected_variable$label, ignore.case = TRUE)) {
stop("Check the median-age variable in the ACS Profile Tables codebook.")
}
# STEP 4: Download county estimates and Cheatham census tracts
county_data <- get_acs(
geography = "county", state = "TN", year = acs_year,
survey = "acs5", variables = my_variable, geometry = TRUE
) |>
filter(NAME %in% paste0(county_names, " County, Tennessee"))
if (nrow(county_data) != length(county_names)) {
stop("One or more required counties is missing.")
}
tract_data <- get_acs(
geography = "tract", state = "TN", county = "Cheatham",
year = acs_year, survey = "acs5", variables = my_variable,
geometry = TRUE
)
# STEP 5: Keep identifiers, estimates, margins of error, and geometry
prepare_data <- function(x) {
x |>
select(GEOID, Area = NAME, Estimate = estimate,
Margin_of_Error = moe, geometry) |>
mutate(
Estimate = if_else(Estimate < 0, NA_real_, Estimate),
Margin_of_Error = if_else(Margin_of_Error < 0,
NA_real_, Margin_of_Error)
) |>
st_transform(4326) |>
arrange(desc(Estimate))
}
county_data <- prepare_data(county_data)
tract_data <- prepare_data(tract_data)
if (!any(is.finite(county_data$Estimate)) ||
!any(is.finite(tract_data$Estimate))) {
stop("No usable median-age estimates were returned.")
}
# STEP 6: Use the same colors and breaks on BOTH maps
all_ages <- c(county_data$Estimate, tract_data$Estimate)
valid_ages <- all_ages[is.finite(all_ages)]
lo <- floor(min(valid_ages) / 5) * 5
hi <- ceiling(max(valid_ages) / 5) * 5
if (lo == hi) hi <- lo + 5
age_breaks <- seq(lo, hi, by = 5)
pal <- colorBin("Blues", domain = valid_ages, bins = age_breaks,
pretty = FALSE, na.color = "#bdbdbd")
# STEP 7: Make interactive maps with age and uncertainty in pop-ups
format_age <- function(x) {
ifelse(is.na(x), "Unavailable", sprintf("%.1f", x))
}
make_map <- function(data, highlight_county = FALSE) {
data$popup <- paste0(
"<strong>", data$Area, "</strong><br>",
"GEOID: ", data$GEOID, "<br>",
"Median age: ", format_age(data$Estimate), " years<br>",
"90% margin of error: ±",
format_age(data$Margin_of_Error), " years<br>",
"2020–2024 ACS five-year estimates"
)
bounds <- st_bbox(data)
map <- leaflet(data, height = 500) |>
addProviderTiles(providers$Esri.WorldTopoMap) |>
addPolygons(
fillColor = ~pal(Estimate), fillOpacity = 0.7,
color = "#444444", weight = 1, popup = ~popup,
label = ~paste0(
Area, ": median age ", format_age(Estimate), " \u00b1 ",
format_age(Margin_of_Error), " years"
),
highlightOptions = highlightOptions(weight = 3,
bringToFront = TRUE)
) |>
addLegend(pal = pal, values = valid_ages,
title = "Median age (years)", opacity = 0.7,
na.label = "Unavailable") |>
addScaleBar(position = "bottomleft") |>
fitBounds(as.numeric(bounds["xmin"]), as.numeric(bounds["ymin"]),
as.numeric(bounds["xmax"]), as.numeric(bounds["ymax"]))
if (highlight_county) {
map <- map |>
addPolygons(data = filter(data, GEOID == "47021"),
fill = FALSE, color = "#d95f02", weight = 4,
label = "Cheatham County", options = pathOptions(interactive = FALSE))
}
map
}
# Save the county map before generating the sub-county map.
AreaMap <- make_map(county_data, highlight_county = TRUE)
CountyMap <- AreaMap
# Census tracts provide the sub-county geography.
# AreaMap now holds the latest map; CountyMap remains available.
AreaMap <- make_map(tract_data)
SubCountyMap <- AreaMap
# STEP 8: Build sorted tables and factual summaries for your narrative
county_table <- st_drop_geometry(county_data) |>
select(Area, Estimate, Margin_of_Error)
tract_table <- st_drop_geometry(tract_data) |>
select(GEOID, Area, Estimate, Margin_of_Error)
cheatham_age <- county_table$Estimate[
county_table$Area == "Cheatham County, Tennessee"]
county_rank <- 1L + sum(county_table$Estimate > cheatham_age, na.rm = TRUE)
tract_valid <- filter(tract_table, !is.na(Estimate))
highest_tracts <- filter(tract_valid, Estimate == max(Estimate))
lowest_tracts <- filter(tract_valid, Estimate == min(Estimate))
tract_range <- diff(range(tract_valid$Estimate))
# STEP 9: Display maps when this script runs directly
# R Markdown sources this file with echo=FALSE, then places the
# maps and tables in the appropriate sections.
if (!isTRUE(getOption("knitr.in.progress"))) {
print(county_table)
print(tract_table)
print(CountyMap)
print(SubCountyMap)
}