Skip to contents

This vignette explores South Carolina’s public school enrollment data, surfacing key trends and demographic patterns across a decade of data (2015-2025). Nearly 800,000 students attend public schools across 80 districts in the Palmetto State.


1. South Carolina is growing

Unlike many states facing enrollment decline, South Carolina has added approximately 40,000 students since 2015. The Palmetto State’s population growth is reflected in its schools.

enr <- fetch_enr_multi(c(2015, 2017, 2019, 2021, 2023, 2025), use_cache = TRUE)

state_totals <- enr |>
  filter(is_state, subgroup == "total_enrollment", grade_level == "TOTAL") |>
  select(end_year, n_students) |>
  mutate(
    change = n_students - lag(n_students),
    pct_change = round(change / lag(n_students) * 100, 2)
  )
stopifnot(nrow(state_totals) > 0)

state_totals
#>   end_year n_students change pct_change
#> 1     2015     753801     NA         NA
#> 2     2017     771756  17955       2.38
#> 3     2019     781493   9737       1.26
#> 4     2021     766819 -14674      -1.88
#> 5     2023     789231  22412       2.92
#> 6     2025     796780   7549       0.96
ggplot(state_totals, aes(x = end_year, y = n_students)) +
  geom_line(linewidth = 1.2, color = "#73000A") +
  geom_point(size = 3, color = "#73000A") +
  scale_y_continuous(labels = scales::comma) +
  labs(
    title = "South Carolina Public School Enrollment (2015-2025)",
    subtitle = "Steady growth with a pandemic dip in 2021",
    x = "School Year (ending)",
    y = "Total Enrollment"
  )


2. Greenville County is the giant

Greenville County Schools enrolls nearly 77,000 students, making it the largest district in the state and one of the largest in the Southeast.

enr_2025 <- fetch_enr(2025, use_cache = TRUE)

top_districts <- enr_2025 |>
  filter(is_district, subgroup == "total_enrollment", grade_level == "TOTAL") |>
  arrange(desc(n_students)) |>
  head(10) |>
  select(district_name, n_students)
stopifnot(nrow(top_districts) > 0)

top_districts
#>                   district_name n_students
#> 1                 Greenville 01      77774
#> 2                 Charleston 01      50856
#> 3                      Horry 01      48662
#> 4                   Berkeley 01      39423
#> 5                   Richland 02      28841
#> 6                  Lexington 01      27074
#> 7  Charter Institute at Erskine      26014
#> 8                 Dorchester 02      25928
#> 9                      Aiken 01      22916
#> 10                  Richland 01      21814
top_districts |>
  mutate(district_name = forcats::fct_reorder(district_name, n_students)) |>
  ggplot(aes(x = n_students, y = district_name, fill = district_name)) +
  geom_col(show.legend = FALSE) +
  geom_text(aes(label = scales::comma(n_students)), hjust = -0.1, size = 3.5) +
  scale_x_continuous(labels = scales::comma, expand = expansion(mult = c(0, 0.15))) +
  scale_fill_viridis_d(option = "mako", begin = 0.2, end = 0.8) +
  labs(
    title = "Top 10 South Carolina Districts by Enrollment (2025)",
    subtitle = "Greenville County leads with 10% of the state's students",
    x = "Number of Students",
    y = NULL
  )


3. Hispanic enrollment is surging

Hispanic student enrollment has risen steadily, growing from about 10% in 2019 to over 14% of total enrollment in 2025.

demographics <- enr_2025 |>
  filter(is_state, grade_level == "TOTAL",
         subgroup %in% c("white", "black", "hispanic", "asian",
                         "native_american", "pacific_islander", "multiracial")) |>
  mutate(pct = round(n_students / sum(n_students, na.rm = TRUE) * 100, 1)) |>
  select(subgroup, n_students, pct) |>
  arrange(desc(n_students))
stopifnot(nrow(demographics) > 0)

demographics
#>           subgroup n_students  pct
#> 1            white     367583 46.5
#> 2            black     240837 30.5
#> 3         hispanic     114594 14.5
#> 4      multiracial      48955  6.2
#> 5            asian      15242  1.9
#> 6  native_american       2311  0.3
#> 7 pacific_islander        959  0.1
demographics |>
  mutate(subgroup = forcats::fct_reorder(subgroup, n_students)) |>
  ggplot(aes(x = n_students, y = subgroup, fill = subgroup)) +
  geom_col(show.legend = FALSE) +
  geom_text(aes(label = paste0(pct, "%")), hjust = -0.1) +
  scale_x_continuous(labels = scales::comma, expand = expansion(mult = c(0, 0.15))) +
  scale_fill_brewer(palette = "Set2") +
  labs(
    title = "South Carolina Student Demographics (2025)",
    subtitle = "A changing student population reflects statewide demographic shifts",
    x = "Number of Students",
    y = NULL
  )


4. The I-85 Corridor is booming

Districts along the I-85 corridor from Greenville through Spartanburg are among the fastest-growing in the state, fueled by economic development and migration from other states.

i85_districts <- enr_2025 |>
  filter(
    grepl("Greenville|Spartanburg|Anderson", district_name),
    is_district,
    subgroup == "total_enrollment",
    grade_level == "TOTAL"
  ) |>
  arrange(desc(n_students)) |>
  select(district_name, n_students) |>
  head(8)
stopifnot(nrow(i85_districts) > 0)

i85_districts
#>    district_name n_students
#> 1  Greenville 01      77774
#> 2    Anderson 05      12131
#> 3 Spartanburg 02      11806
#> 4 Spartanburg 06      11714
#> 5 Spartanburg 05      11066
#> 6    Anderson 01      10886
#> 7 Spartanburg 07       7255
#> 8 Spartanburg 01       5364
i85_districts |>
  mutate(district_name = forcats::fct_reorder(district_name, n_students)) |>
  ggplot(aes(x = n_students, y = district_name, fill = district_name)) +
  geom_col(show.legend = FALSE) +
  geom_text(aes(label = scales::comma(n_students)), hjust = -0.1, size = 3.5) +
  scale_x_continuous(labels = scales::comma, expand = expansion(mult = c(0, 0.15))) +
  scale_fill_viridis_d(option = "plasma", begin = 0.2, end = 0.8) +
  labs(
    title = "I-85 Corridor Districts (2025)",
    subtitle = "The Upstate drives South Carolina's enrollment growth",
    x = "Number of Students",
    y = NULL
  )


5. The Lowcountry is expanding

Charleston, Berkeley, and Dorchester counties form South Carolina’s tri-county Lowcountry region, and all three have seen substantial enrollment growth.

lowcountry_enr <- fetch_enr_multi(c(2015, 2020, 2025), use_cache = TRUE)

lowcountry <- lowcountry_enr |>
  filter(
    grepl("Charleston|Berkeley|Dorchester", district_name),
    is_district,
    subgroup == "total_enrollment",
    grade_level == "TOTAL"
  ) |>
  select(end_year, district_name, n_students) |>
  pivot_wider(names_from = end_year, values_from = n_students) |>
  mutate(
    growth = `2025` - `2015`,
    pct_growth = round(growth / `2015` * 100, 1)
  ) |>
  arrange(desc(growth))
stopifnot(nrow(lowcountry) > 0)

lowcountry
#> # A tibble: 4 × 6
#>   district_name `2015` `2020` `2025` growth pct_growth
#>   <chr>          <dbl>  <dbl>  <dbl>  <dbl>      <dbl>
#> 1 Berkeley 01    32569  37192  39423   6854       21  
#> 2 Charleston 01  46916  50312  50856   3940        8.4
#> 3 Dorchester 02  25117  26283  25928    811        3.2
#> 4 Dorchester 04   2243   2265   2110   -133       -5.9
lowcountry |>
  mutate(district_name = forcats::fct_reorder(district_name, growth)) |>
  ggplot(aes(x = growth, y = district_name, fill = pct_growth)) +
  geom_col() +
  geom_text(aes(label = paste0("+", scales::comma(growth))), hjust = -0.1, size = 3.5) +
  scale_x_continuous(labels = scales::comma, expand = expansion(mult = c(0, 0.2))) +
  scale_fill_gradient(low = "#56B4E9", high = "#0072B2", name = "% Growth") +
  labs(
    title = "Lowcountry Enrollment Growth (2015-2025)",
    subtitle = "Berkeley County leads the tri-county region",
    x = "Student Growth",
    y = NULL
  )


6. South Carolina’s racial composition is shifting fast

In just 8 years, the white share of enrollment has dropped from over 51% to under 48%, while Hispanic enrollment has surged from 9% to nearly 15%.

demo_enr <- fetch_enr_multi(c(2017, 2020, 2025), use_cache = TRUE)

demo_shift <- demo_enr |>
  filter(is_state, grade_level == "TOTAL",
         subgroup %in% c("white", "black", "hispanic", "multiracial")) |>
  select(end_year, subgroup, n_students) |>
  group_by(end_year) |>
  mutate(pct = round(n_students / sum(n_students, na.rm = TRUE) * 100, 1)) |>
  ungroup()
stopifnot(nrow(demo_shift) > 0)

demo_shift |>
  select(end_year, subgroup, n_students, pct) |>
  pivot_wider(names_from = end_year, values_from = c(n_students, pct))
#> # A tibble: 4 × 7
#>   subgroup    n_students_2017 n_students_2020 n_students_2025 pct_2017 pct_2020
#>   <chr>                 <dbl>           <dbl>           <dbl>    <dbl>    <dbl>
#> 1 white                392398          389141          367583     52.4     50.9
#> 2 black                257750          253185          240837     34.4     33.1
#> 3 hispanic              69520           84834          114594      9.3     11.1
#> 4 multiracial           29119           36622           48955      3.9      4.8
#> # ℹ 1 more variable: pct_2025 <dbl>
demo_shift |>
  mutate(subgroup = factor(subgroup, levels = c("white", "black", "hispanic", "multiracial"))) |>
  ggplot(aes(x = factor(end_year), y = pct, fill = subgroup)) +
  geom_col(position = "dodge") +
  geom_text(aes(label = paste0(pct, "%")), position = position_dodge(width = 0.9),
            vjust = -0.5, size = 3) +
  scale_fill_brewer(palette = "Set2", name = "Subgroup",
                    labels = c("White", "Black", "Hispanic", "Multiracial")) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
  labs(
    title = "South Carolina's Shifting Demographics (2017-2025)",
    subtitle = "White share declining, Hispanic and multiracial shares rising",
    x = "School Year",
    y = "Percent of Enrollment"
  )


7. State-authorized charters are growing fast

The SC Public Charter School District (code 900) serves state-authorized charter schools and has grown to over 21,000 students.

charter_enr <- fetch_enr_multi(c(2015, 2020, 2025), use_cache = TRUE)

charter_trends <- charter_enr |>
  filter(
    grepl("Charter School District", district_name),
    is_district,
    subgroup == "total_enrollment",
    grade_level == "TOTAL"
  ) |>
  select(end_year, n_students)
stopifnot(nrow(charter_trends) > 0)

charter_trends
#>   end_year n_students
#> 1     2015      17024
#> 2     2020      20761
#> 3     2025      21383
# Charter as percent of state
charter_pct <- enr_2025 |>
  filter(is_state | grepl("Charter School District", district_name),
         subgroup == "total_enrollment", grade_level == "TOTAL") |>
  select(district_name, n_students) |>
  mutate(type = ifelse(is.na(district_name), "State Total", "Charter")) |>
  select(type, n_students)

charter_pct
#>           type n_students
#> 1  State Total     796780
#> 2      Charter      21383
#> 3      Charter        454
#> 4      Charter        507
#> 5      Charter       1672
#> 6      Charter        724
#> 7      Charter        431
#> 8      Charter        290
#> 9      Charter        181
#> 10     Charter        672
#> 11     Charter        115
#> 12     Charter       1472
#> 13     Charter        205
#> 14     Charter        755
#> 15     Charter       1208
#> 16     Charter        403
#> 17     Charter        408
#> 18     Charter        382
#> 19     Charter        279
#> 20     Charter        530
#> 21     Charter        258
#> 22     Charter        535
#> 23     Charter       1592
#> 24     Charter         92
#> 25     Charter        118
#> 26     Charter        204
#> 27     Charter        412
#> 28     Charter        301
#> 29     Charter        393
#> 30     Charter        155
#> 31     Charter        216
#> 32     Charter        390
#> 33     Charter        619
#> 34     Charter        169
#> 35     Charter        730
#> 36     Charter        277
#> 37     Charter         87
#> 38     Charter        998
#> 39     Charter       1786
#> 40     Charter        367
#> 41     Charter         79
#> 42     Charter         38
#> 43     Charter         59
#> 44     Charter        514
#> 45     Charter        306
charter_trends |>
  ggplot(aes(x = factor(end_year), y = n_students, group = 1)) +
  geom_line(linewidth = 1.2, color = "#E69F00") +
  geom_point(size = 3, color = "#E69F00") +
  geom_text(aes(label = scales::comma(n_students)), vjust = -0.5, size = 3.5) +
  scale_y_continuous(labels = scales::comma) +
  labs(
    title = "Charter District Growth (2015-2025)",
    subtitle = "State-authorized charters have more than doubled",
    x = "School Year",
    y = "Enrollment"
  )


8. Kindergarten has not recovered from COVID

Kindergarten enrollment dropped sharply during the pandemic and has not returned to pre-pandemic levels, declining each year since a partial rebound in 2022.

k_enr <- fetch_enr_multi(2019:2025, use_cache = TRUE)

k_trends <- k_enr |>
  filter(is_state, subgroup == "total_enrollment", grade_level == "K") |>
  select(end_year, n_students) |>
  mutate(
    change_from_2019 = n_students - first(n_students),
    pct_change = round(change_from_2019 / first(n_students) * 100, 1)
  )
stopifnot(nrow(k_trends) > 0)

k_trends
#>   end_year n_students change_from_2019 pct_change
#> 1     2019      55417                0        0.0
#> 2     2020      56188              771        1.4
#> 3     2021      51801            -3616       -6.5
#> 4     2022      54799             -618       -1.1
#> 5     2023      54526             -891       -1.6
#> 6     2024      54461             -956       -1.7
#> 7     2025      54278            -1139       -2.1
k_trends |>
  ggplot(aes(x = end_year, y = n_students)) +
  geom_line(linewidth = 1.2, color = "#009E73") +
  geom_point(size = 3, color = "#009E73") +
  geom_vline(xintercept = 2020.5, linetype = "dashed", color = "gray50") +
  annotate("text", x = 2020.5, y = max(k_trends$n_students) * 0.98,
           label = "Pandemic", hjust = 1.1, color = "gray30") +
  scale_y_continuous(labels = scales::comma) +
  labs(
    title = "Kindergarten Enrollment: Pandemic Impact & Continued Decline",
    subtitle = "COVID caused a sharp drop in 2021; enrollment has declined each year since 2022",
    x = "School Year",
    y = "Kindergarten Enrollment"
  )


9. Rural Pee Dee districts are declining

While much of South Carolina grows, rural districts in the Pee Dee region face persistent enrollment decline.

pee_dee_enr <- fetch_enr_multi(c(2015, 2025), use_cache = TRUE)

pee_dee <- pee_dee_enr |>
  filter(
    grepl("Marion|Dillon|Marlboro|Florence", district_name),
    is_district,
    subgroup == "total_enrollment",
    grade_level == "TOTAL"
  ) |>
  select(end_year, district_name, n_students) |>
  pivot_wider(names_from = end_year, values_from = n_students) |>
  mutate(
    change = `2025` - `2015`,
    pct_change = round(change / `2015` * 100, 1)
  ) |>
  filter(!is.na(`2015`), !is.na(`2025`)) |>
  arrange(pct_change) |>
  head(8)
stopifnot(nrow(pee_dee) > 0)

pee_dee
#> # A tibble: 8 × 5
#>   district_name `2015` `2025` change pct_change
#>   <chr>          <dbl>  <dbl>  <dbl>      <dbl>
#> 1 Marion 10       5029   3633  -1396      -27.8
#> 2 Florence 03     3732   2767   -965      -25.9
#> 3 Marlboro 01     4251   3380   -871      -20.5
#> 4 Florence 05     1413   1143   -270      -19.1
#> 5 Dillon 04       4308   3659   -649      -15.1
#> 6 Dillon 03       1688   1492   -196      -11.6
#> 7 Florence 02     1219   1114   -105       -8.6
#> 8 Florence 01    16434  15861   -573       -3.5
pee_dee |>
  mutate(district_name = forcats::fct_reorder(district_name, pct_change)) |>
  ggplot(aes(x = pct_change, y = district_name, fill = pct_change < 0)) +
  geom_col(show.legend = FALSE) +
  geom_text(aes(label = paste0(pct_change, "%")),
            hjust = ifelse(pee_dee$pct_change < 0, -0.1, 1.1), size = 3.5) +
  scale_x_continuous(labels = function(x) paste0(x, "%"),
                     expand = expansion(mult = c(0.15, 0.15))) +
  scale_fill_manual(values = c("TRUE" = "#D55E00", "FALSE" = "#009E73")) +
  labs(
    title = "Pee Dee Districts: A Decade of Decline",
    subtitle = "2015-2025 enrollment change in rural counties",
    x = "Percent Change",
    y = NULL
  )


10. District size varies dramatically

South Carolina’s 80 districts range from tiny rural systems to massive county-wide operations serving tens of thousands.

district_sizes <- enr_2025 |>
  filter(is_district, subgroup == "total_enrollment", grade_level == "TOTAL") |>
  mutate(size_bucket = case_when(
    n_students < 5000 ~ "Small (<5K)",
    n_students < 15000 ~ "Medium (5K-15K)",
    n_students < 30000 ~ "Large (15K-30K)",
    TRUE ~ "Very Large (30K+)"
  )) |>
  count(size_bucket) |>
  mutate(size_bucket = factor(size_bucket,
                              levels = c("Small (<5K)", "Medium (5K-15K)",
                                         "Large (15K-30K)", "Very Large (30K+)")))
stopifnot(nrow(district_sizes) > 0)

district_sizes
#>         size_bucket  n
#> 1   Large (15K-30K) 14
#> 2   Medium (5K-15K) 20
#> 3       Small (<5K) 43
#> 4 Very Large (30K+)  4
district_sizes |>
  ggplot(aes(x = size_bucket, y = n, fill = size_bucket)) +
  geom_col(show.legend = FALSE) +
  geom_text(aes(label = n), vjust = -0.5, size = 4) +
  scale_fill_brewer(palette = "Blues") +
  scale_y_continuous(labels = scales::comma, expand = expansion(mult = c(0, 0.15))) +
  labs(
    title = "South Carolina District Size Distribution",
    subtitle = "80 districts range from tiny rural systems to massive county-wide operations",
    x = "District Size",
    y = "Number of Districts"
  )


11. Richland vs Lexington: Columbia’s Suburban Divide

The Columbia metro area is split between Richland and Lexington counties, with very different enrollment trajectories. Lexington County districts have grown substantially while Richland districts have been more stable.

columbia_enr <- fetch_enr_multi(c(2015, 2020, 2025), use_cache = TRUE)

columbia_districts <- columbia_enr |>
  filter(
    grepl("Richland|Lexington", district_name),
    is_district,
    subgroup == "total_enrollment",
    grade_level == "TOTAL"
  ) |>
  select(end_year, district_name, n_students) |>
  pivot_wider(names_from = end_year, values_from = n_students) |>
  mutate(
    growth = `2025` - `2015`,
    pct_growth = round(growth / `2015` * 100, 1),
    county = ifelse(grepl("Richland", district_name), "Richland", "Lexington")
  ) |>
  filter(!is.na(`2015`), !is.na(`2025`)) |>
  arrange(desc(growth))
stopifnot(nrow(columbia_districts) > 0)

columbia_districts
#> # A tibble: 7 × 7
#>   district_name `2015` `2020` `2025` growth pct_growth county   
#>   <chr>          <dbl>  <dbl>  <dbl>  <dbl>      <dbl> <chr>    
#> 1 Lexington 01   24694  27353  27074   2380        9.6 Lexington
#> 2 Richland 02    27286  28589  28841   1555        5.7 Richland 
#> 3 Lexington 05   16749  17505  17053    304        1.8 Lexington
#> 4 Lexington 04    3462   3479   3474     12        0.3 Lexington
#> 5 Lexington 03    2015   2089   1961    -54       -2.7 Lexington
#> 6 Lexington 02    8991   9028   8465   -526       -5.9 Lexington
#> 7 Richland 01    24556  23386  21814  -2742      -11.2 Richland
columbia_districts |>
  mutate(district_name = forcats::fct_reorder(district_name, growth)) |>
  ggplot(aes(x = growth, y = district_name, fill = county)) +
  geom_col() +
  geom_text(aes(label = paste0(ifelse(growth > 0, "+", ""), scales::comma(growth))),
            hjust = ifelse(columbia_districts$growth > 0, -0.1, 1.1), size = 3.5) +
  scale_x_continuous(labels = scales::comma, expand = expansion(mult = c(0.15, 0.15))) +
  scale_fill_manual(values = c("Lexington" = "#2E86AB", "Richland" = "#A23B72"), name = "County") +
  labs(
    title = "Columbia Metro: Lexington Growth Outpaces Richland",
    subtitle = "Enrollment change 2015-2025",
    x = "Student Change",
    y = NULL
  )


12. Boys slightly outnumber girls statewide

South Carolina enrolls slightly more male than female students overall, a pattern consistent across most large districts.

gender_data <- enr_2025 |>
  filter(
    is_district,
    subgroup %in% c("male", "female"),
    grade_level == "TOTAL"
  ) |>
  select(district_name, subgroup, n_students) |>
  pivot_wider(names_from = subgroup, values_from = n_students) |>
  filter(!is.na(male), !is.na(female)) |>
  mutate(
    total = male + female,
    pct_male = round(male / total * 100, 1)
  ) |>
  arrange(desc(total)) |>
  head(15)
stopifnot(nrow(gender_data) > 0)

gender_data
#> # A tibble: 15 × 5
#>    district_name                      male female total pct_male
#>    <chr>                             <dbl>  <dbl> <dbl>    <dbl>
#>  1 Greenville 01                     39870  37904 77774     51.3
#>  2 Charleston 01                     26054  24802 50856     51.2
#>  3 Horry 01                          24977  23685 48662     51.3
#>  4 Berkeley 01                       20225  19198 39423     51.3
#>  5 Richland 02                       14566  14275 28841     50.5
#>  6 Lexington 01                      13975  13099 27074     51.6
#>  7 Charter Institute at Erskine      12643  13371 26014     48.6
#>  8 Dorchester 02                     13264  12664 25928     51.2
#>  9 Aiken 01                          11668  11248 22916     50.9
#> 10 Richland 01                       11070  10744 21814     50.7
#> 11 SC Public Charter School District 10587  10795 21382     49.5
#> 12 Beaufort 01                       10697  10353 21050     50.8
#> 13 York 04                            9467   8978 18445     51.3
#> 14 Lexington 05                       8672   8381 17053     50.9
#> 15 Pickens 01                         8447   7866 16313     51.8
gender_data |>
  mutate(district_name = forcats::fct_reorder(district_name, pct_male)) |>
  ggplot(aes(x = pct_male, y = district_name, fill = pct_male > 50)) +
  geom_col(show.legend = FALSE) +
  geom_vline(xintercept = 50, linetype = "dashed", color = "gray40") +
  geom_text(aes(label = paste0(pct_male, "%")), hjust = -0.1, size = 3) +
  scale_fill_manual(values = c("TRUE" = "#4575B4", "FALSE" = "#D73027")) +
  scale_x_continuous(limits = c(0, 60), labels = function(x) paste0(x, "%")) +
  labs(
    title = "Gender Balance in South Carolina's Largest Districts",
    subtitle = "Percent male enrollment (dashed line = 50/50)",
    x = "Percent Male",
    y = NULL
  )


13. High School Enrollment: The 9th Grade Bulge

High schools show a distinctive enrollment pattern: 9th grade is consistently the largest, with enrollment declining through 12th grade. This reflects retention, transfers, and dropouts.

hs_grades <- enr_2025 |>
  filter(
    is_state,
    subgroup == "total_enrollment",
    grade_level %in% c("09", "10", "11", "12")
  ) |>
  select(grade_level, n_students) |>
  mutate(
    grade_label = case_when(
      grade_level == "09" ~ "9th Grade",
      grade_level == "10" ~ "10th Grade",
      grade_level == "11" ~ "11th Grade",
      grade_level == "12" ~ "12th Grade"
    ),
    grade_label = factor(grade_label, levels = c("9th Grade", "10th Grade", "11th Grade", "12th Grade")),
    pct_of_9th = round(n_students / first(n_students) * 100, 1)
  )
stopifnot(nrow(hs_grades) > 0)

hs_grades
#>   grade_level n_students grade_label pct_of_9th
#> 1          09      69587   9th Grade      100.0
#> 2          10      63900  10th Grade       91.8
#> 3          11      56933  11th Grade       81.8
#> 4          12      55184  12th Grade       79.3
hs_grades |>
  ggplot(aes(x = grade_label, y = n_students, fill = grade_label)) +
  geom_col(show.legend = FALSE) +
  geom_text(aes(label = scales::comma(n_students)), vjust = -0.5, size = 4) +
  geom_text(aes(label = paste0(pct_of_9th, "% of 9th")), vjust = 1.5, color = "white", size = 3.5) +
  scale_y_continuous(labels = scales::comma, expand = expansion(mult = c(0, 0.15))) +
  scale_fill_viridis_d(option = "viridis", begin = 0.3, end = 0.9) +
  labs(
    title = "The 9th Grade Bulge",
    subtitle = "High school enrollment drops 15-20% from 9th to 12th grade",
    x = NULL,
    y = "Number of Students"
  )


14. South Carolina’s Smallest Districts

Not all South Carolina districts are massive county systems. Several rural districts serve fewer than 2,000 students.

smallest <- enr_2025 |>
  filter(
    is_district,
    subgroup == "total_enrollment",
    grade_level == "TOTAL",
    !grepl("Charter", district_name)
  ) |>
  arrange(n_students) |>
  select(district_name, n_students) |>
  head(10)
stopifnot(nrow(smallest) > 0)

smallest
#>                                              district_name n_students
#> 1  SC Governor's School for Agriculture at John de la Howe         81
#> 2                     SC School for the Deaf and the Blind        159
#> 3                                Department of Corrections        172
#> 4            Governor's School for the Arts and Humanities        235
#> 5            Governor's School for Science and Mathematics        296
#> 6                           Department of Juvenile Justice        417
#> 7                                             McCormick 01        523
#> 8                                             Allendale 01        855
#> 9                                             Greenwood 51        888
#> 10                                             Florence 02       1114
smallest |>
  mutate(district_name = forcats::fct_reorder(district_name, n_students)) |>
  ggplot(aes(x = n_students, y = district_name, fill = n_students)) +
  geom_col(show.legend = FALSE) +
  geom_text(aes(label = scales::comma(n_students)), hjust = -0.1, size = 3.5) +
  scale_x_continuous(labels = scales::comma, expand = expansion(mult = c(0, 0.2))) +
  scale_fill_gradient(low = "#FDE725", high = "#21918C") +
  labs(
    title = "South Carolina's Smallest Districts",
    subtitle = "Rural districts serving fewer than 3,000 students each",
    x = "Number of Students",
    y = NULL
  )


15. Charleston’s Decade of Transformation

Charleston 01 (Charleston County) has undergone significant demographic change in recent years, reflecting the city’s rapid growth and gentrification.

charleston_demo <- fetch_enr_multi(c(2017, 2020, 2025), use_cache = TRUE)

charleston_demo_trends <- charleston_demo |>
  filter(
    grepl("Charleston", district_name),
    is_district,
    grade_level == "TOTAL",
    subgroup %in% c("white", "black", "hispanic", "asian", "multiracial")
  ) |>
  select(end_year, subgroup, n_students) |>
  group_by(end_year) |>
  mutate(pct = round(n_students / sum(n_students, na.rm = TRUE) * 100, 1)) |>
  ungroup()
stopifnot(nrow(charleston_demo_trends) > 0)

charleston_demo_trends |>
  pivot_wider(names_from = end_year, values_from = c(n_students, pct))
#> # A tibble: 5 × 7
#>   subgroup    n_students_2017 n_students_2020 n_students_2025 pct_2017 pct_2020
#>   <chr>                 <dbl>           <dbl>           <dbl>    <dbl>    <dbl>
#> 1 white                 23173           24473           25122     47.9     48.8
#> 2 black                 18673           17777           13942     38.6     35.5
#> 3 hispanic               4436            5503            8535      9.2     11  
#> 4 asian                   759             788             818      1.6      1.6
#> 5 multiracial            1350            1579            2314      2.8      3.2
#> # ℹ 1 more variable: pct_2025 <dbl>
charleston_demo_trends |>
  mutate(subgroup = factor(subgroup, levels = c("white", "black", "hispanic", "asian", "multiracial"))) |>
  ggplot(aes(x = factor(end_year), y = pct, fill = subgroup)) +
  geom_col(position = "stack") +
  geom_text(aes(label = ifelse(pct > 5, paste0(pct, "%"), "")),
            position = position_stack(vjust = 0.5), color = "white", size = 3) +
  scale_fill_brewer(palette = "Set2", name = "Subgroup",
                    labels = c("White", "Black", "Hispanic", "Asian", "Multiracial")) +
  labs(
    title = "Charleston County: A Changing District",
    subtitle = "Demographic composition 2017-2025",
    x = "School Year",
    y = "Percentage of Enrollment"
  )


Summary

South Carolina’s school enrollment data reveals:

  • Growing state: Unlike many states, South Carolina continues to add students
  • Regional divergence: Upstate and Lowcountry boom while the Pee Dee declines
  • Increasing diversity: Hispanic enrollment has risen from about 10% to over 14% since 2019
  • Demographic shift: The white share of enrollment has fallen below 48%
  • Charter expansion: State-authorized charters now serve over 21,000 students

These patterns shape school funding, facility planning, and policy decisions across the Palmetto State.


Data sourced from the South Carolina Department of Education Active Student Headcounts.


Session Info

sessionInfo()
#> R version 4.5.0 (2025-04-11)
#> Platform: aarch64-apple-darwin22.6.0
#> Running under: macOS 26.1
#> 
#> Matrix products: default
#> BLAS:   /opt/homebrew/Cellar/openblas/0.3.30/lib/libopenblasp-r0.3.30.dylib 
#> LAPACK: /opt/homebrew/Cellar/r/4.5.0/lib/R/lib/libRlapack.dylib;  LAPACK version 3.12.1
#> 
#> locale:
#> [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
#> 
#> time zone: America/New_York
#> tzcode source: internal
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] ggplot2_4.0.1      tidyr_1.3.2        dplyr_1.2.0        scschooldata_0.1.1
#> 
#> loaded via a namespace (and not attached):
#>  [1] gtable_0.3.6       jsonlite_2.0.0     compiler_4.5.0     tidyselect_1.2.1  
#>  [5] jquerylib_0.1.4    systemfonts_1.3.1  scales_1.4.0       textshaping_1.0.4 
#>  [9] readxl_1.4.5       yaml_2.3.12        fastmap_1.2.0      R6_2.6.1          
#> [13] labeling_0.4.3     generics_0.1.4     curl_7.0.0         knitr_1.51        
#> [17] forcats_1.0.1      htmlwidgets_1.6.4  tibble_3.3.1       desc_1.4.3        
#> [21] RColorBrewer_1.1-3 bslib_0.9.0        pillar_1.11.1      rlang_1.1.7       
#> [25] utf8_1.2.6         cachem_1.1.0       xfun_0.55          S7_0.2.1          
#> [29] fs_1.6.6           sass_0.4.10        otel_0.2.0         viridisLite_0.4.2 
#> [33] cli_3.6.5          withr_3.0.2        pkgdown_2.2.0      magrittr_2.0.4    
#> [37] digest_0.6.39      grid_4.5.0         rappdirs_0.3.4     lifecycle_1.0.5   
#> [41] vctrs_0.7.1        evaluate_1.0.5     glue_1.8.0         cellranger_1.1.0  
#> [45] farver_2.1.2       codetools_0.2-20   ragg_1.5.0         httr_1.4.8        
#> [49] rmarkdown_2.30     purrr_1.2.1        tools_4.5.0        pkgconfig_2.0.3   
#> [53] htmltools_0.5.9