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?
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.
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.
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.
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.).
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.
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.
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.
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