1. Summary

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:

  1. How often do employers disclose pay?
  2. How much experience do postings demand?
  3. How fresh are the postings candidates are applying to?
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

  • Only 0.5% of postings state a salary, and 503 of 549 companies disclose pay in none of their postings.
  • In Bengaluru, 82% of postings that state a number ask for 3+ years of experience (median: 5 years).
  • 79% of postings stating an experience bar ask for 3+ years across all metros, so entry-level roles are scarce.

2. Where the postings are

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.

3. Experience: the “entry-level” problem

3.1 Experience asked by metro

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.

3.2 The full distribution of experience bars

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.

4. Pay transparency

4.1 By metro

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)

4.2 By applicant tracking system (ATS)

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.

4.3 The companies that do disclose

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.

4.4 Large employers

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

5. Posting freshness

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.

6. Takeaways for Bengaluru job seekers

  1. Don’t expect a salary figure. Under 1% of postings include one, so ask early or use external salary data.
  2. Entry-level is a small slice of the market. Under 1 in 10 postings with a stated bar target 0-1 years.
  3. Apply to fresh postings first. Roughly one in four true-date postings is over 90 days old.
  4. Platform matters. Workable and Lever postings skew older, and Workday dominates volume.

7. Limitations

  • The data is pre-aggregated, so I cannot model at the individual posting level or analyse job titles, skills, or salary ranges.
  • Experience bars are only available for postings that state them (about 73%).
  • “Pay stated” says nothing about the amount, and many disclosures are in non-rupee currencies.
  • Company-level rows only include companies with 10+ postings, so small employers are excluded.
  • The ATS-level analysis reflects which companies use each platform, not a causal effect of the platform.

Reproducibility

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