Introduction

K-pop’s idol system is often discussed in terms of who gets to debut; how old the trainees are, how large their groups are, and which companies produces them. This report uses the open, CC0-licensed kpopnet.json dataset to tell a structural story: debut age, group size, company structure, and group lifespan.

The five visualisations below use every primary (“non-sub-unit”) K-pop idol group with a known debut date in the dataset, 2005–2023, to ask: as the industry matured, did debuts get younger, bigger, and busier — and did the business consolidate around the four largest companies (HYBE, SM, YG, JYP) while doing so?

Data preprocessing

All data is loaded from the open-source kpopnet.json dataset (CC0-1.0 licence; kpopnet, n.d.). Preprocessing excludes sub-unit groups and filters out implausible debut ages, keeping the analysis focused on primary group debuts with clean, trustworthy data.

data_url <- "https://raw.githubusercontent.com/kpopnet/kpopnet.json/master/kpopnet.json"
raw <- fromJSON(data_url, simplifyDataFrame = FALSE)

groups_raw <- raw$groups
idols_raw  <- raw$idols

# ---- Helper: classify an agency string into "Big 4" sub-labels or everyone
# else. This is a documented, reproducible rule applied identically to every
# row - not a manual/hidden relabelling. ---------------------------------
classify_agency <- function(name) {
  name <- ifelse(is.na(name), "", name)
  case_when(
    str_detect(name, regex("Big Hit|Source Music|Belift|ADOR|Pledis|KOZ", ignore_case = TRUE)) ~ "HYBE label",
    str_detect(name, regex("^SM( Entertainment)?$|SM Entertainment", ignore_case = TRUE)) ~ "SM Entertainment",
    str_detect(name, regex("YG Entertainment", ignore_case = TRUE)) ~ "YG Entertainment",
    str_detect(name, regex("JYP Entertainment", ignore_case = TRUE)) ~ "JYP Entertainment",
    TRUE ~ "Independent / other"
  )
}
classify_tier <- function(name) {
  if_else(classify_agency(name) == "Independent / other", "Independent / other", "Big 4 label")
}

# ---- Flatten groups into a data frame -----------------------------------
groups_df <- tibble(
  id           = map_chr(groups_raw, ~ .x$id %||% NA_character_),
  name         = map_chr(groups_raw, ~ .x$name %||% NA_character_),
  agency_name  = map_chr(groups_raw, ~ .x$agency_name %||% NA_character_),
  parent_id    = map_chr(groups_raw, ~ .x$parent_id %||% NA_character_),
  debut_date   = map_chr(groups_raw, ~ .x$debut_date %||% NA_character_),
  disband_date = map_chr(groups_raw, ~ .x$disband_date %||% NA_character_),
  members      = map(groups_raw, ~ .x$members %||% list())
) %>%
  mutate(
    debut_date   = as.Date(debut_date),
    disband_date = as.Date(disband_date),
    n_members    = lengths(members),
    agency_tier  = classify_tier(agency_name)
  )

# Primary acts only: exclude sub-units (non-null parent_id) so a franchise's
# many spin-off units are not over-counted as separate "industry debuts" -
# and require a known debut date within the analysis window.
primary_groups <- groups_df %>%
  filter(is.na(parent_id) | parent_id == "", !is.na(debut_date)) %>%
  filter(debut_date <= as.Date("2023-10-18"))

# ---- Flatten idols into a lookup table (id -> birth_date) ----------------
idols_df <- tibble(
  id         = map_chr(idols_raw, ~ .x$id %||% NA_character_),
  birth_date = map_chr(idols_raw, ~ .x$birth_date %||% NA_character_)
) %>%
  mutate(birth_date = as.Date(birth_date))

# ---- One row per (group, member) with debut age --------------------------
member_rows <- primary_groups %>%
  select(group_id = id, group_name = name, agency_tier, n_members, debut_date) %>%
  mutate(member_idol_id = map(group_id, function(gid) {
    g <- groups_raw[[which(map_chr(groups_raw, ~ .x$id) == gid)]]
    map_chr(g$members %||% list(), ~ .x$idol_id)
  })) %>%
  unnest(member_idol_id) %>%
  left_join(idols_df, by = c("member_idol_id" = "id")) %>%
  mutate(debut_age = as.numeric(debut_date - birth_date) / 365.25) %>%
  filter(!is.na(debut_age), debut_age > 10, debut_age < 35)  # drop implausible data errors

# Chart 1: debut age over time (one row per idol-debut)
chart1_debut_age <- member_rows %>%
  transmute(debut_year = as.integer(format(debut_date, "%Y")), debut_date,
            debut_age, group_name, agency_tier, n_members)

# Chart 2: group size over time (one row per group)
chart2_group_size <- primary_groups %>%
  transmute(debut_year = as.integer(format(debut_date, "%Y")), debut_date,
            n_members, group_name = name, agency_tier)

# Chart 3: agency tier's share of debuts per year
chart3_tier_debuts <- primary_groups %>%
  transmute(debut_year = as.integer(format(debut_date, "%Y")), agency_tier, group_name = name)

# Chart 4: career lifespan for disbanded groups, by 5-year debut-era bin
chart4_lifespan <- primary_groups %>%
  filter(!is.na(disband_date)) %>%
  transmute(
    group_name = name, agency_tier,
    debut_year = as.integer(format(debut_date, "%Y")),
    debut_era = paste0(floor(debut_year / 5) * 5, "-", floor(debut_year / 5) * 5 + 4),
    years_active = as.numeric(disband_date - debut_date) / 365.25,
    debut_date, disband_date
  )

# Chart 5: age range (spread) within a group at debut, over time
chart5_age_range <- member_rows %>%
  group_by(group_id, group_name, debut_date, agency_tier, n_members) %>%
  summarise(age_range = max(debut_age) - min(debut_age), n_with_age = n(), .groups = "drop") %>%
  filter(n_with_age >= 2) %>%
  mutate(debut_year = as.integer(format(debut_date, "%Y")))

n_groups_total <- nrow(primary_groups)

150 primary idol groups with a known debut date (2005–2023) make up the analysis set.

Chart 1 — Debut age holds steady, even as the industry grew

sd1 <- SharedData$new(chart1_debut_age, key = ~group_name, group = "tier-filter")

p1 <- ggplot(sd1, aes(x = debut_date, y = debut_age, colour = agency_tier,
                      text = paste0(group_name, "<br>Debut: ", debut_date,
                                    "<br>Debut age: ", round(debut_age, 1), " yrs"))) +
  geom_point(alpha = 0.55, size = 1.8) +
  geom_smooth(method = "loess", colour = "#52514e", se = FALSE,
              linewidth = 0.8, inherit.aes = FALSE,
              data = chart1_debut_age,
              mapping = aes(x = debut_date, y = debut_age, group = 1)) +
  scale_colour_manual(values = pal_tier, name = "Company tier") +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  labs(x = "Debut year", y = "Idol's age at debut (years)") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top", panel.grid.minor = element_blank())

gg1 <- ggplotly(p1, tooltip = "text") %>%
  layout(legend = list(orientation = "h", y = 1.05, yanchor = "bottom"))

filter_box <- filter_select("tier_filter", "Filter by company tier", sd1, ~agency_tier)

bscols(
  widths = c(12),
  filter_box,
  gg1
)
datatable(
  sd1,
  colnames = c("Year", "Debut date", "Debut age", "Group", "Company tier", "Members"),
  options = list(pageLength = 5, order = list(list(1, "desc"))),
  rownames = FALSE
)

Figure 1: Idol age at debut by debut date, 2005–2023 (n = 864 idol-debuts across 149 groups), coloured by company tier, with a loess trend line. The dropdown filter and the table below are linked to the chart via crosstalk: selecting a tier re-draws the scatter plot and the table together. Data source: kpopnet (n.d.).

The trend line sits close to flat across the whole period — the average debut age in 2023 is barely different from 2007. The popular narrative that idols keep debuting younger is not well supported here; what has changed is the volume of debuts, not their age profile.

Chart 2 — Groups keep getting bigger

p2 <- ggplot(chart2_group_size, aes(x = debut_date, y = n_members, colour = agency_tier,
                                     text = paste0(group_name, "<br>Debut: ", debut_date,
                                                    "<br>Members: ", n_members))) +
  geom_jitter(width = 15, height = 0.15, alpha = 0.6, size = 1.8) +
  geom_smooth(method = "loess", colour = "#52514e", se = FALSE,
              linewidth = 0.8, inherit.aes = FALSE,
              data = chart2_group_size,
              mapping = aes(x = debut_date, y = n_members, group = 1)) +
  scale_colour_manual(values = pal_tier, name = "Company tier") +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  scale_y_continuous(breaks = pretty_breaks()) +
  labs(x = "Debut year", y = "Number of members") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top", panel.grid.minor = element_blank())

ggplotly(p2, tooltip = "text") %>% layout(legend = list(orientation = "h", y = 1.05, yanchor = "bottom"))

Figure 2: Number of members per group at debut, 2005–2023 (n = 150 groups). Interactive: hover any point for the group name, debut date, and member count; drag to zoom, double-click to reset. Data source: kpopnet (n.d.).

Unlike debut age, group size trends clearly upward — the loess line roughly doubles from the mid-2000s to the 2020s. Larger rosters have become a structural feature of how groups are built.

Chart 3 — The Big 4 consolidate their share of new debuts

tier_share <- chart3_tier_debuts %>%
  count(debut_year, agency_tier) %>%
  group_by(debut_year) %>%
  mutate(share = n / sum(n), total_year = sum(n)) %>%
  ungroup()

p3 <- ggplot(tier_share, aes(x = debut_year, y = share, fill = agency_tier,
                              text = paste0(debut_year, "<br>", agency_tier,
                                             "<br>Share: ", percent(share, accuracy = 1),
                                             " (", n, " of ", total_year, " debuts)"))) +
  geom_col(position = "fill", width = 0.75) +
  scale_fill_manual(values = pal_tier, name = "Company tier") +
  scale_y_continuous(labels = percent_format()) +
  scale_x_continuous(breaks = pretty_breaks()) +
  labs(x = "Debut year", y = "Share of that year's debuts") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top", panel.grid.minor = element_blank())

ggplotly(p3, tooltip = "text") %>% layout(legend = list(orientation = "h", y = 1.05, yanchor = "bottom"))

Figure 3: Each year’s debuts split by company tier (n = 150 groups total). Early years have very few groups per year, so single debuts can swing the bar substantially. Data source: kpopnet (n.d.).

The Big-4 share is noisy in the early, low-volume years but trends upward afterward, consistent with an industry where a handful of large labels increasingly dominate new-group output — even as the total number of yearly debuts (independent labels included) keeps growing.

Chart 4 — Careers are not getting obviously longer (survivorship caveat)

chart4_lifespan$debut_era <- fct_relevel(chart4_lifespan$debut_era,
                                          sort(unique(chart4_lifespan$debut_era)))

p4 <- ggplot(chart4_lifespan, aes(x = debut_era, y = years_active, colour = agency_tier,
                                   text = paste0(group_name, "<br>Debuted: ", debut_date,
                                                  "<br>Disbanded: ", disband_date,
                                                  "<br>Active: ", round(years_active, 1), " yrs"))) +
  geom_boxplot(aes(group = debut_era), colour = "#898781", outlier.shape = NA, width = 0.5) +
  geom_jitter(width = 0.15, alpha = 0.7, size = 1.8) +
  scale_colour_manual(values = pal_tier, name = "Company tier") +
  labs(x = "Debut era (5-year bin)", y = "Years active before disbanding") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top", panel.grid.minor = element_blank())

ggplotly(p4, tooltip = "text") %>% layout(legend = list(orientation = "h", y = 1.05, yanchor = "bottom"))

Figure 4: Years active (debut to disbandment) for the 63 groups in the dataset known to have disbanded, grouped into 5-year debut eras. Note: this chart only includes groups that have already disbanded, so groups from the most recent era are under-represented (many simply haven’t had the chance to disband yet). Data source: kpopnet (n.d.).

Chart 5 — Groups are not more age-diverse internally than they used to be

p5 <- ggplot(chart5_age_range, aes(x = debut_date, y = age_range, colour = agency_tier,
                                    text = paste0(group_name, "<br>Debut: ", debut_date,
                                                   "<br>Age range: ", round(age_range, 1), " yrs",
                                                   "<br>Members with known age: ", n_with_age))) +
  geom_point(alpha = 0.6, size = 1.8) +
  geom_smooth(method = "loess", colour = "#52514e", se = FALSE,
              linewidth = 0.8, inherit.aes = FALSE,
              data = chart5_age_range,
              mapping = aes(x = debut_date, y = age_range, group = 1)) +
  scale_colour_manual(values = pal_tier, name = "Company tier") +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  labs(x = "Debut year", y = "Age range within group (years)") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top", panel.grid.minor = element_blank())

ggplotly(p5, tooltip = "text") %>% layout(legend = list(orientation = "h", y = 1.05, yanchor = "bottom"))

Figure 5: Spread between the oldest and youngest member at debut, by debut date (n = 139 groups with ≥2 members of known age). Data source: kpopnet (n.d.).

The within-group age spread also stays roughly flat over time. Put together with Chart 1, this suggests companies have kept a fairly consistent internal “age mix” formula (a mix of younger and slightly older members) rather than either narrowing toward same-age groups or widening toward larger age gaps.

Conclusion

Across two decades of K-pop debut groups, the clearest structural trend is growth in group size and consolidation of debut volume toward the Big 4 labels — not a change in how old debuting idols are or how age-diverse their groups are internally. Career length shows no strong trend once the survivorship-bias caveat on Chart 4 is taken into account. Together the five charts tell a more structural story than the “ever-younger idols” narrative often repeated in fan and press commentary.

Acknowledgements

I used Claude (Anthropic, 2026) only to cross-check the quality and coverage of the candidate data sources before choosing which one to build this report on. All analysis, chart design, and writing are my own.

References

kpopnet. (n.d.). kpopnet.json: K-pop open data [Data set]. GitHub. https://github.com/kpopnet/kpopnet.json

Anthropic. (2026). Claude (Sonnet 5) [Large language model]. https://claude.ai