I analysed four aggregated tables built from roughly 30,000 job postings across 16 Indian metros, with a focus on Bengaluru, the largest market in the data. Three questions drive the report:
metros <- read_csv("data/metros.csv")
exp_bar <- read_csv("data/experience-bar-distribution.csv")
age_ats <- read_csv("data/posting-age-by-ats.csv")
salary_co <- read_csv("data/salary-disclosure-by-company.csv")
Headline findings
metros %>%
mutate(metro = str_to_title(metro),
is_blr = metro == "Bengaluru") %>%
slice_max(postings, n = 10) %>%
ggplot(aes(x = postings, y = fct_reorder(metro, postings), fill = is_blr)) +
geom_col(show.legend = FALSE) +
geom_text(aes(label = comma(postings)), hjust = -0.1, size = 3.8) +
scale_fill_manual(values = c(`TRUE` = col_blr, `FALSE` = col_other)) +
scale_x_continuous(labels = comma, expand = expansion(mult = c(0, 0.15))) +
labs(title = "Bengaluru has more postings than any other metro",
subtitle = "Top 10 metros by number of postings",
x = "Postings", y = NULL)
Bengaluru accounts for 36.4% of the pool, about
three times Delhi-NCR, the next largest. Note that
unresolved postings (location could not be mapped to a
metro) make up 11% of the data.
metros %>%
filter(postings >= 1000, metro != "unresolved") %>%
mutate(metro = str_to_title(metro), is_blr = metro == "Bengaluru") %>%
ggplot(aes(x = pct_asking_3y_plus / 100, y = fct_reorder(metro, pct_asking_3y_plus),
fill = is_blr)) +
geom_col(show.legend = FALSE) +
geom_text(aes(label = percent(pct_asking_3y_plus / 100, accuracy = 1)), hjust = -0.1) +
scale_fill_manual(values = c(`TRUE` = col_blr, `FALSE` = col_other)) +
scale_x_continuous(labels = percent, limits = c(0, 1), expand = expansion(mult = c(0, 0.05))) +
labs(title = "Share of postings asking for 3+ years of experience",
subtitle = "Among postings that state an experience figure; metros with 1,000+ postings",
x = NULL, y = NULL)
Bengaluru is among the most experience-hungry markets, alongside Hyderabad and Pune.
bar_levels <- c("0 years", "under 1 year", "1-1.9", "2-2.9", "3-3.9",
"4-4.9", "5-7.9", "8-9.9", "10+")
stated %>%
mutate(lowest_stated_bar = factor(lowest_stated_bar, levels = bar_levels),
share = postings / sum(postings),
entry_level = lowest_stated_bar %in% c("0 years", "under 1 year", "1-1.9")) %>%
ggplot(aes(x = lowest_stated_bar, y = share, fill = entry_level)) +
geom_col(show.legend = FALSE) +
geom_text(aes(label = percent(share, accuracy = 1)), vjust = -0.4) +
scale_fill_manual(values = c(`TRUE` = "#2E86AB", `FALSE` = col_other)) +
scale_y_continuous(labels = percent, expand = expansion(mult = c(0, 0.12))) +
labs(title = "The most common minimum bar is 5-7.9 years",
subtitle = "Share of postings with a stated bar; blue = entry-level (under 2 years)",
x = "Lowest stated experience requirement", y = NULL)
Only 7.8% of postings with a stated bar are entry-level (under 2 years). A further 27.3% of all postings state no years figure at all.
metros %>%
filter(postings >= 1000) %>%
mutate(metro = str_to_title(metro), is_blr = metro == "Bengaluru") %>%
ggplot(aes(x = pct_stating_pay / 100, y = fct_reorder(metro, pct_stating_pay), fill = is_blr)) +
geom_col(show.legend = FALSE) +
geom_text(aes(label = paste0(pct_stating_pay, "%")), hjust = -0.1) +
scale_fill_manual(values = c(`TRUE` = col_blr, `FALSE` = col_other)) +
scale_x_continuous(labels = percent, expand = expansion(mult = c(0, 0.15))) +
labs(title = "Pay disclosure is under 1% in almost every major metro",
subtitle = "Share of postings stating pay, metros with 1,000+ postings",
x = NULL, y = NULL)
Pay disclosure depends heavily on which ATS a company uses, probably because of the company profiles that use each platform (global startups on Workable/Greenhouse versus large enterprises on Workday).
ats_pay <- salary_co %>%
group_by(ats) %>%
summarise(companies = n(),
postings = sum(postings_with_text),
stating = sum(postings_stating_pay),
pct = stating / postings,
.groups = "drop") %>%
arrange(desc(pct))
ats_pay %>%
mutate(pct_label = percent(pct, accuracy = 0.1)) %>%
select(ATS = ats, Companies = companies, Postings = postings,
`Stating pay` = stating, `% stating pay` = pct_label)
ggplot(ats_pay, aes(x = pct, y = fct_reorder(ats, pct))) +
geom_col(fill = "#2E86AB") +
geom_text(aes(label = percent(pct, accuracy = 0.1)), hjust = -0.1) +
scale_x_continuous(labels = percent, expand = expansion(mult = c(0, 0.15))) +
labs(title = "Workable-hosted postings disclose pay far more often",
subtitle = "Share of postings stating pay, by ATS (company-level data)",
x = NULL, y = NULL)
Is the difference statistically significant? A chi-squared test of independence on (stating pay vs. not) by ATS:
tab <- salary_co %>%
group_by(ats) %>%
summarise(stating = sum(postings_stating_pay),
not_stating = sum(postings_with_text) - sum(postings_stating_pay)) %>%
column_to_rownames("ats") %>%
as.matrix()
# Monte Carlo p-value, because some cells have small expected counts
set.seed(42)
chi <- chisq.test(tab, simulate.p.value = TRUE, B = 10000)
chi
##
## Pearson's Chi-squared test with simulated p-value (based on 10000
## replicates)
##
## data: tab
## X-squared = 372.07, df = NA, p-value = 9.999e-05
wk <- salary_co %>% mutate(is_workable = ats == "workable") %>%
group_by(is_workable) %>%
summarise(stating = sum(postings_stating_pay), n = sum(postings_with_text))
pt <- prop.test(x = wk$stating, n = wk$n)
pt
##
## 2-sample test for equality of proportions with continuity correction
##
## data: wk$stating out of wk$n
## X-squared = 312.15, df = 1, p-value < 2.2e-16
## alternative hypothesis: two.sided
## 95 percent confidence interval:
## -0.04145171 -0.02311176
## sample estimates:
## prop 1 prop 2
## 0.003242242 0.035523979
Both p-values are far below 0.001, so the gap between ATS platforms is not noise. Workable-hosted postings disclose pay at roughly 3.6% versus 0.3% elsewhere.
salary_co %>%
filter(pct_stating_pay > 0) %>%
slice_max(pct_stating_pay, n = 20, with_ties = FALSE) %>%
mutate(rupee_share = if_else(postings_stating_pay > 0,
postings_stating_rupee_pay / postings_stating_pay, 0),
currency = if_else(rupee_share >= 0.5, "Mostly rupee pay", "Mostly non-rupee pay")) %>%
ggplot(aes(x = pct_stating_pay / 100, y = fct_reorder(company, pct_stating_pay), colour = currency)) +
geom_segment(aes(x = 0, xend = pct_stating_pay / 100, yend = company), linewidth = 1) +
geom_point(size = 3) +
scale_x_continuous(labels = percent) +
scale_colour_manual(values = c("Mostly rupee pay" = col_blr, "Mostly non-rupee pay" = "grey50")) +
labs(title = "Top 20 companies by share of postings stating pay",
subtitle = "Companies with 10+ postings. Many disclosures are in foreign currency",
x = "% of postings stating pay", y = NULL, colour = NULL) +
theme(legend.position = "bottom")
Of all postings that state pay, only 44% state it in rupees, so more than half of the disclosures are in other currencies and say little about pay for roles in India.
salary_co %>%
slice_max(postings_with_text, n = 15, with_ties = FALSE) %>%
transmute(Company = company, ATS = ats, Postings = postings_with_text,
`% stating pay` = pct_stating_pay) %>%
arrange(desc(Postings))
age_plot <- age_ats %>%
filter(!str_detect(ats, "ALL"), true_publish_date == "yes") %>%
select(ats, `Over 90 days` = pct_over_90d, `Over 180 days` = pct_over_180d,
`Over 365 days` = pct_over_365d) %>%
pivot_longer(-ats, names_to = "threshold", values_to = "pct") %>%
mutate(threshold = factor(threshold, levels = c("Over 90 days", "Over 180 days", "Over 365 days")))
ggplot(age_plot, aes(x = pct / 100, y = fct_reorder(ats, pct, .fun = max), fill = threshold)) +
geom_col(position = "dodge") +
scale_x_continuous(labels = percent) +
scale_fill_manual(values = c("#9ecae1", "#4292c6", "#08519c")) +
labs(title = "Lever and Workable postings are the most likely to be stale",
subtitle = "Share of postings older than 90 / 180 / 365 days (sources with true publish dates only)",
x = NULL, y = NULL, fill = NULL) +
theme(legend.position = "bottom")
age_ats %>%
transmute(ATS = ats, `True publish date?` = true_publish_date, Postings = postings,
`Median age (days)` = median_age_days)
Greenhouse and SmartRecruiters do not expose a true publish date, so their ages are less reliable and are excluded from the chart. Among the sources with true dates, the median posting is 32 days old, and about 24% are over three months old. A large share of listings may be “ghost jobs” or simply left open.
sessionInfo()
## R version 4.6.1 (2026-06-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26300)
##
## Matrix products: default
## LAPACK version 3.12.1
##
## locale:
## [1] LC_COLLATE=English_India.utf8 LC_CTYPE=English_India.utf8
## [3] LC_MONETARY=English_India.utf8 LC_NUMERIC=C
## [5] LC_TIME=English_India.utf8
##
## time zone: Asia/Calcutta
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] scales_1.4.0 lubridate_1.9.5 forcats_1.0.1 stringr_1.6.0
## [5] dplyr_1.2.1 purrr_1.2.2 readr_2.2.0 tidyr_1.3.2
## [9] tibble_3.3.1 ggplot2_4.0.3 tidyverse_2.0.0
##
## loaded via a namespace (and not attached):
## [1] bit_4.6.0 gtable_0.3.6 jsonlite_2.0.0 crayon_1.5.3
## [5] compiler_4.6.1 tidyselect_1.2.1 parallel_4.6.1 jquerylib_0.1.4
## [9] yaml_2.3.12 fastmap_1.2.0 R6_2.6.1 labeling_0.4.3
## [13] generics_0.1.4 knitr_1.52 bslib_0.12.0 pillar_1.11.1
## [17] RColorBrewer_1.1-3 tzdb_0.5.0 rlang_1.3.0 stringi_1.8.9
## [21] cachem_1.1.0 xfun_0.61 sass_0.4.10 S7_0.2.2
## [25] bit64_4.8.6 otel_0.2.0 timechange_0.4.0 cli_3.6.6
## [29] withr_3.0.3 magrittr_2.0.5 digest_0.6.39 grid_4.6.1
## [33] vroom_1.7.1 rstudioapi_0.19.0 hms_1.1.4 lifecycle_1.0.5
## [37] vctrs_0.7.3 evaluate_1.0.5 glue_1.8.1 farver_2.1.2
## [41] rmarkdown_2.32 tools_4.6.1 pkgconfig_2.0.3 htmltools_0.5.9