County Selection and Analysis Variable

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.

County-Level 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.

Variation Within Cheatham County

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.

Opportunities for Additional Research and News Stories

This data analysis provides several opportunities for either additional research proposals or news stories for local journalists.

Those topics include:

Data Sources

Code

############################################################
# 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: &plusmn;",
    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)
}