1. Listed fees with no guaranteed jobs

library(scales)   # rescale(), dollar()

c1_data <- fee_summary %>%
  inner_join(gos_by_area, by = c("level", "area")) %>%
  filter(!is.na(fte_2025)) %>%
  mutate(
    size  = rescale(sqrt(n_courses), to = c(10, 38)),
    hover = paste0(
      "<b>", area, "</b>",
      "<br>Median annual tuition: ", dollar(round(fee_annual_med, -2)),
      "<br>Middle 50% of courses: ", dollar(round(fee_annual_q1, -2)), " – ",
      dollar(round(fee_annual_q3, -2)),
      "<br>Courses offered in Victoria: ", n_courses,
      "<br><b>International grads in full-time work: ", pct(fte_2025), "</b>",
      "<br>Median full-time salary: ",
      if_else(is.na(sal_2025), "not published (sample too small)", dollar(sal_2025))
    )
  )


sal_range <- range(c1_data$sal_2025, na.rm = TRUE)

p1 <- plot_ly()
for (lv in c("PGC", "UG")) {
  for (has_sal in c(TRUE, FALSE)) {
    d <- c1_data %>% filter(level == lv, (!is.na(sal_2025)) == has_sal)

    marker <- if (has_sal) {
      list(size = d$size, color = d$sal_2025, colorscale = "Viridis",
           cmin = sal_range[1], cmax = sal_range[2], showscale = TRUE,
           colorbar = list(title = list(text = "Median salary<br>(2025)"),
                           tickprefix = "$", tickformat = ",.0f", len = 0.6),
           line = list(color = "white", width = 1), opacity = 0.9)
    } else {
      list(size = d$size, color = "rgba(0,0,0,0)",
           line = list(color = col_grey, width = 1.5))
    }

    p1 <- p1 %>% add_trace(
      data = d, x = ~fee_annual_med, y = ~fte_2025,
      type = "scatter", mode = "markers",
      marker = marker, text = ~hover, hoverinfo = "text",
      error_x = list(type = "data", symmetric = FALSE,
                     array = d$fee_annual_q3 - d$fee_annual_med,
                     arrayminus = d$fee_annual_med - d$fee_annual_q1,
                     color = "#767676", thickness = 1, width = 0),
      name = if (has_sal) "Salary published" else "Salary not published (hollow)",
      legendgroup = if (has_sal) "sal" else "nosal",
      showlegend = !has_sal,
      visible = (lv == "PGC")
    )
  }
}

dom_line <- function(lv) {
  dom <- get_trend(lv, "fte", "Domestic")
  list(
    shapes = list(list(type = "line", xref = "paper", x0 = 0, x1 = 1, y0 = dom, y1 = dom,
                       line = list(color = col_dom, dash = "dash", width = 2))),
    annotations = list(list(xref = "paper", x = 0.01, y = dom, yanchor = "bottom",
                            xanchor = "left", showarrow = FALSE,
                            text = paste0("Domestic graduates: ", pct(dom), " in full-time work"),
                            font = list(color = col_dom, size = 12)))
  )
}

p1 <- p1 %>%
  layout(
    title = list(text = "<b>Listed Fees with No Guaranteed Jobs</b>", x = 0.1, y = 1.20),
    xaxis = list(title = "Median yearly tuition fee (lines = middle 50% of courses)",
                 tickprefix = "$", tickformat = ",.0f", zeroline = FALSE),
    yaxis = list(title = "International graduates in full-time work, 2025 (%)",
                 range = c(0, 100), ticksuffix = "%"),
    shapes = dom_line("PGC")$shapes,
    annotations = dom_line("PGC")$annotations,
    legend = list(orientation = "h", x = 0, y = -0.18),
    margin = list(t = 100, l = 80, b = 90),
    updatemenus = list(list(
      type = "buttons", direction = "right", x = 0, y = 1.15,
      xanchor = "left", yanchor = "top", showactive = TRUE,
      buttons = list(
        list(label = "Masters (coursework)", method = "update",
             args = list(list(visible = as.list(c(TRUE, TRUE, FALSE, FALSE))), dom_line("PGC"))),
        list(label = "Bachelor", method = "update",
             args = list(list(visible = as.list(c(FALSE, FALSE, TRUE, TRUE))), dom_line("UG")))
      )
    ))
  ) %>%
  plotly_config()

p1

Source: CRICOS register of courses as at 1 September 2026 (fees); QILT Graduate Outcomes Survey 2025, International report (job rate and salary); QILT Graduate Outcomes Survey 2025, domestic graduates (dashed line). Victorian courses only; fees are annual.

Chart description:The x-axis shows the median annual tuition fee for courses in each study area, and the y-axis shows the percentage of international graduates in full-time work 4–6 months after graduation, being the early outcomes. Each dot is a study area: dot size shows number of courses offered, colour shows median salary, hollow dots mean the salary is not published, grey lines show the middle 50% of fees, and the dashed line marks the domestic graduate rate of 88.3%. In the Masters view, most dots sit well below the dashed line, between about 35% and 65% at fees of roughly $40,000 to $65,000 a year, apart from Rehabilitation at about 82%; only Medicine (about $115,000 a year) sits above the line. The Bachelor view shows a similar pattern, though more areas reach the domestic rate of 75.4%: four areas sit above that line, namely Medicine, Pharmacy, Rehabilitation and Dentistry.

2. A gap that has remained since 2016

library(ggplot2)

trend <- qilt_trend |>
  filter(subgroup == "Total",
         group %in% c("Domestic", "International"),
         measure %in% c("full_time_employment_pct", "median_salary"),
         course_level %in% c("Undergraduate", "Postgraduate coursework")) |>
  transmute(level = if_else(course_level == "Undergraduate", "UG", "PGC"),
            metric = if_else(measure == "median_salary", "salary", "fte"),
            citizenship = group, year, value)

level_labels <- c(UG = "Bachelor", PGC = "Masters (coursework)")
col_int <- col_intl

trend_wide <- trend %>%
  pivot_wider(names_from = citizenship, values_from = value) %>%
  mutate(gap = Domestic - International)

metric_labels <- c(fte = "In full-time work (%)", salary = "Median full-time salary ($)")
fmt_val <- function(x, metric) ifelse(metric == "salary", dollar(x), pct(x))

trend_plot <- trend %>%
  left_join(trend_wide %>% select(level, metric, year, gap), by = c("level", "metric", "year")) %>%
  mutate(level_lab  = factor(level_labels[level], levels = level_labels),
         metric_lab = factor(metric_labels[metric], levels = metric_labels),
         hover = paste0("<b>", citizenship, ", ", year, "</b><br>",
                        metric_labels[metric], ": ", fmt_val(value, metric),
                        "<br>Gap (domestic − international): ",
                        if_else(metric == "salary", dollar(gap), paste0(round(gap, 1), " pts"))))

trend_wide <- trend_wide %>%
  mutate(level_lab  = factor(level_labels[level], levels = level_labels),
         metric_lab = factor(metric_labels[metric], levels = metric_labels))

g2 <- ggplot() +
  geom_ribbon(data = trend_wide,
              aes(x = year, ymin = International, ymax = Domestic),
              fill = "grey70", alpha = 0.35) +
  geom_line(data = trend_plot,
            aes(x = year, y = value, colour = citizenship, group = citizenship),
            linewidth = 1) +
  geom_point(data = trend_plot,
             aes(x = year, y = value, colour = citizenship, text = hover),
             size = 1.8) +
  facet_grid(metric_lab ~ level_lab, scales = "free_y") +
  scale_colour_manual(values = c(Domestic = col_dom, International = col_int)) +
  scale_x_continuous(breaks = seq(2016, 2025, 2)) +
  scale_y_continuous(labels = function(x) ifelse(is.na(x), "",
                                                 ifelse(x > 1000, dollar(x), paste0(x, "%")))) +
  labs(x = "Graduate Outcomes Survey year", y = NULL, colour = NULL) +
  theme_minimal(base_size = 12) +
  theme(panel.grid.minor = element_blank(),
        strip.text = element_text(face = "bold"))

p2 <- ggplotly(g2, tooltip = "text") %>% plotly_build()
p2$x$data <- lapply(p2$x$data, function(tr) {
  if (!identical(tr$mode, "markers")) tr$hoverinfo <- "skip"
  tr
})
p2 <- p2 %>%
  layout(title = list(text = "A Gap that has Remained since 2016", x = 0, xanchor = "left"),
       hovermode = "x unified",
       legend = list(orientation = "h", x = 0, y = 1.10),
       margin = list(t = 100))
p2

Source: QILT Graduate Outcomes Survey 2025, International report, 2016–2025 trend tables (domestic and international graduates, all Australian providers). Figures are nationwide, not just for Victoria.

Chart description: This chart has four panels showing survey years 2016 to 2025 on the x-axis. The top row shows the share of graduates in full-time work 4–6 months after graduation, so these are early outcomes, and the bottom row shows median full-time salary. The left column is Bachelor graduates and the right column is Masters (coursework) graduates. Blue lines are domestic graduates, orange lines are international graduates, and the grey band between them is the gap. Domestic graduates are ahead on both measures in every year shown. For Masters graduates in 2025, 88.3% of domestic graduates were in full-time work compared with 52.2% of international graduates, and the median salary was $104,700 compared with $69,400. The job-rate gap for Masters graduates grew from 30.5 points in 2016 to 36.1 points in 2025. For Bachelor graduates the salary gap is smaller ($77,000 compared with $70,000 in 2025), but the job-rate gap is still large: 75.4% compared with 50.8%.

3. 16 of 19 study areas fell in a single year

Outcomes for a field can swing in a single year. The arrows show how each study area moved between 2024 and 2025. Use the buttons to switch level and measure.

gos_by_area <- qilt_area |>
  mutate(area  = clean_area(study_area),
         level = if_else(course_level == "Undergraduate", "UG", "PGC")) |>
  filter(year %in% c(2024, 2025), area != "Total") |>
  mutate(var = case_when(
    measure == "employment_outcomes" & series == "Full-time employment" ~ paste0("fte_", year),
    measure == "median_salary"       & series == "Total"                ~ paste0("sal_", year),
    TRUE ~ NA_character_)) |>
  filter(!is.na(var)) |>
  select(level, area, var, value) |>
  pivot_wider(names_from = var, values_from = value)
short_area <- function(x) {
  x <- str_replace(x, "Tourism, hospitality, personal services, sport and recreation", "Tourism, hospitality & sport")
  str_trunc(x, 34)
}

change_df <- bind_rows(
  gos_by_area %>% transmute(level, area, metric = "fte", y2024 = fte_2024, y2025 = fte_2025),
  gos_by_area %>% transmute(level, area, metric = "sal", y2024 = sal_2024, y2025 = sal_2025)
) %>%
  filter(!is.na(y2024), !is.na(y2025)) %>%
  mutate(change = y2025 - y2024,
         area_s = short_area(area),
         col    = if_else(change >= 0, col_dom, col_int),
         sym    = if_else(change >= 0, "triangle-right", "triangle-left"),
         hover  = paste0("<b>", area, "</b>",
                         "<br>2024: ", if_else(metric == "fte", pct(y2024), dollar(y2024)),
                         "<br>2025: ", if_else(metric == "fte", pct(y2025), dollar(y2025)),
                         "<br>Change: ", if_else(change >= 0, "+", "−"),
                         if_else(metric == "fte", paste0(round(abs(change), 1), " pts"),
                                 dollar(abs(change)))))

combos <- tibble(
  level  = c("PGC", "UG", "PGC", "UG"),
  metric = c("fte", "fte", "sal", "sal"),
  label  = c("Masters · job rate", "Bachelor · job rate",
             "Masters · salary", "Bachelor · salary"),
  xtitle = c("International graduates in full-time work (%)",
             "International graduates in full-time work (%)",
             "Median full-time salary of international graduates",
             "Median full-time salary of international graduates")
)

p3 <- plot_ly()
cat_arrays <- list()
titles <- character(nrow(combos))
for (i in seq_len(nrow(combos))) {
  d <- change_df %>%
    filter(level == combos$level[i], metric == combos$metric[i]) %>%
    arrange(change)
  cat_arrays[[i]] <- d$area_s
  titles[i] <- paste0("<b>", sum(d$change < 0), " of ", nrow(d),
                      " study areas fell in a single year</b>")
  vis <- (i == 1)
  p3 <- p3 %>%
    add_segments(data = d, x = ~y2024, xend = ~y2025, y = ~area_s, yend = ~area_s,
                 line = list(color = "#C8C8C8", width = 3),
                 hoverinfo = "skip", showlegend = FALSE, visible = vis) %>%
    add_trace(data = d, x = ~y2024, y = ~area_s, type = "scatter", mode = "markers",
              marker = list(color = col_grey, size = 9),
              text = ~hover, hoverinfo = "text",
              name = "2024", legendgroup = "2024", showlegend = (i == 1), visible = vis) %>%
    add_trace(data = d, x = ~y2025, y = ~area_s, type = "scatter", mode = "markers",
              marker = list(color = d$col, symbol = d$sym, size = 13),
              text = ~hover, hoverinfo = "text",
              name = "2025 (blue = rose, orange = fell)", legendgroup = "2025",
              showlegend = (i == 1), visible = vis)
}

n_tr <- 3 * nrow(combos)
buttons3 <- lapply(seq_len(nrow(combos)), function(i) {
  vis <- rep(FALSE, n_tr); vis[(3 * i - 2):(3 * i)] <- TRUE
  is_sal <- combos$metric[i] == "sal"
  list(label = combos$label[i], method = "update",
       args = list(list(visible = vis),
                   list(title = list(text = titles[i], x = 0.02, font = list(size = 15)),
                        `yaxis.categoryarray` = I(cat_arrays[[i]]),
                        `xaxis.title.text` = combos$xtitle[i],
                        `xaxis.tickprefix` = if (is_sal) "$" else "",
                        `xaxis.ticksuffix` = if (is_sal) "" else "%",
                        `xaxis.tickformat` = if (is_sal) ",.0f" else "")))
})

p3 <- p3 %>%
  layout(title = list(text = titles[1], x = 0.02, font = list(size = 15)),
         xaxis = list(title = combos$xtitle[1], ticksuffix = "%", zeroline = FALSE),
         yaxis = list(title = "", categoryorder = "array",
                      categoryarray = cat_arrays[[1]], automargin = TRUE),
         legend = list(orientation = "h", x = 0, y = -0.1),
         margin = list(t = 110),
         updatemenus = list(list(type = "buttons", direction = "right",
                                 x = -1, y = 1.09, xanchor = "left", yanchor = "top",
                                 buttons = buttons3))) %>%
  plotly_config()
p3

Source: QILT Graduate Outcomes Survey 2025, International report, study-area tables for 2024 and 2025 (international graduates, all Australian providers). Figures are national, not Victoria only. Study areas with too few responses to publish are not shown.

Chart description: This dumbbell chart shows how each study area changed between the 2024 and 2025 surveys, which ask graduates 4–6 months after graduation, so these are early outcomes. Each row is one study area. A grey dot marks the 2024 value and a triangle marks the 2025 value; a blue right-pointing triangle means the value rose and an orange left-pointing triangle means it fell. Rows are sorted from the biggest fall at the bottom to the biggest rise at the top. In the default view (Masters, job rate), 16 of 19 study areas fell; the largest drop was Rehabilitation, from 89.9% to 81.7%, and only Veterinary science, Agriculture and environmental studies, and Science and mathematics rose. Buttons switch to the other views. For Bachelor job rates, 12 of 21 areas fell. Salaries fell in 6 of 15 Masters areas and 3 of 15 Bachelor areas, compared with 16 of 19 and 12 of 21 for job rates.

4. Adding up the whole bill

#prep
pct <- function(x, digits = 1) paste0(round(x, digits), "%")

fee_extra <- cricos_courses |>
  semi_join(vic_courses, by = c("provider_code", "course_code")) |>
  mutate(level = if_else(qilt_level == "Undergraduate", "UG", "PGC"),
         area  = area_from_code(str_sub(field_narrow, 1, 4))) |>
  filter(!is.na(area)) |>
  group_by(level, area) |>
  summarise(weeks_med = median(duration_weeks),
            cost_total_med = median(tuition_fee), .groups = "drop")

fee_summary <- fee_summary |>
  select(-any_of(c("weeks_med", "cost_total_med"))) |>
  left_join(fee_extra, by = c("level", "area"))

metro_rent <- rent_lga |> filter(is_metro_melbourne, !is.na(median_rent))
metro_1bf  <- median(metro_rent$median_rent[metro_rent$property_type == "1 Bed Flat"])
metro_2bf  <- median(metro_rent$median_rent[metro_rent$property_type == "2 Bed Flat"])

col_fees <- "#56B4E9"
col_rent <- "#D55E00"


scenarios <- tibble(scenario    = c("share", "alone"),
                    scen_lab    = c("sharing a 2-bed flat", "renting a 1-bed flat alone"),
                    weekly_rent = c(metro_2bf / 2, metro_1bf))

pay <- fee_summary %>%
  inner_join(gos_by_area %>% select(level, area, sal_2025, fte_2025), by = c("level", "area")) %>%
  filter(!is.na(sal_2025)) %>%
  crossing(scenarios) %>%
  mutate(area_s     = short_area(area),
         rent_total = weekly_rent * weeks_med,
         total_bill = cost_total_med + rent_total,
         yrs_fees   = cost_total_med / sal_2025,
         yrs_rent   = rent_total / sal_2025,
         yrs_total  = total_bill / sal_2025,
         end_label  = paste0(formatC(yrs_total, format = "f", digits = 1), " yrs · FT job: ",
                             round(fte_2025), "%"),
         hover = paste0("<b>", area, "</b>",
                        "<br>Course fees (median): ", dollar(round(cost_total_med, -2)),
                        "<br>Rent over ", round(weeks_med / 52, 1), " years (", scen_lab, "): ",
                        dollar(round(rent_total, -2)),
                        "<br><b>Total: ", dollar(round(total_bill, -2)), "</b>",
                        "<br>Median graduate salary: ", dollar(sal_2025),
                        "<br>= ", formatC(yrs_total, format = "f", digits = 1),
                        " years of gross salary",
                        "<br>Only ", pct(fte_2025), " of international grads in this field had a full-time job"))

combos4 <- tibble(level    = c("PGC", "PGC", "UG", "UG"),
                  scenario = c("share", "alone", "share", "alone"),
                  label    = c("Masters · sharing", "Masters · living alone",
                               "Bachelor · sharing", "Bachelor · living alone"))
x_max <- max(pay$yrs_total) * 1.45

p4 <- plot_ly()
cat4 <- list()
for (i in seq_len(nrow(combos4))) {
  d <- pay %>% filter(level == combos4$level[i], scenario == combos4$scenario[i]) %>%
    arrange(yrs_total)
  cat4[[i]] <- d$area_s
  vis <- (i == 1)
  p4 <- p4 %>%
    add_bars(data = d, x = ~yrs_fees, y = ~area_s, orientation = "h",
             marker = list(color = col_fees), name = "Course fees",
             legendgroup = "fees", showlegend = (i == 1),
             text = ~hover, hoverinfo = "text", textposition = "none", visible = vis) %>%
    add_bars(data = d, x = ~yrs_rent, y = ~area_s, orientation = "h",
             marker = list(color = col_rent), name = "Rent during the course",
             legendgroup = "rent", showlegend = (i == 1),
             hovertext = ~hover, hoverinfo = "text",
             text = ~end_label, textposition = "outside", cliponaxis = FALSE,
             textfont = list(size = 11, color = "#333333"), visible = vis)
}

buttons4 <- lapply(seq_len(nrow(combos4)), function(i) {
  vis <- rep(FALSE, 2 * nrow(combos4)); vis[(2 * i - 1):(2 * i)] <- TRUE
  list(label = combos4$label[i], method = "update",
       args = list(list(visible = vis),
                   list(`yaxis.categoryarray` = I(cat4[[i]]))))
})

p4 <- p4 %>%
  layout(barmode = "stack",
         xaxis = list(title = "No. of Years of a median graduate's gross salary required to cover the bill",
                      range = c(0, x_max), dtick = 1),
         yaxis = list(title = "", categoryorder = "array", categoryarray = cat4[[1]],
                      automargin = TRUE),
         shapes = list(list(type = "line", x0 = 1, x1 = 1, yref = "paper", y0 = 0, y1 = 1,
                            line = list(color = col_grey, dash = "dot"))),
         annotations = list(list(x = 1, y = 1, yref = "paper", yanchor = "bottom",
                                 showarrow = FALSE, text = "1 full year of salary",
                                 font = list(size = 11, color = "#555555"))),
         legend = list(orientation = "h", x = 0, y = -0.06),
         margin = list(t = 80, r = 30),
         updatemenus = list(list(type = "buttons", direction = "left",
                                 x = -1, y = 1.20, xanchor = "left", yanchor = "top",
                                 buttons = buttons4))) %>%
  plotly_config()
p4

Source: CRICOS register of courses as at 1 September 2026 (course fees and course length); Homes Victoria Rental Report, December quarter 2025 (Melbourne median weekly rents for new leases); QILT Graduate Outcomes Survey 2025, International report (median salary and full-time job rate). Victorian courses and Melbourne rents only; salaries are Gross and nationwide.

chart description: This stacked bar chart shows, for each study area, how many years of a median international graduate’s full gross salary it would take to cover the cost of the degree. The salary figure comes from a survey that asks graduates 4–6 months after graduation, so it reflects early pay. Each bar is one study area. The blue part is the median total course fee and the orange part is rent paid over the median course length, and the label at the end of each bar gives the total in years and the share of international graduates in that field who had a full-time job. A dotted line marks one full year of salary, and bars are sorted from the largest total at the top to the smallest at the bottom. In the default view (Masters, sharing a two-bedroom flat), 14 of 15 study areas need more than one year of salary, most between 1.2 and 1.9 years. Medicine is highest at 3.2 years and Law is lowest at 0.9 years. In several fields, such as Architecture and Computing, fewer than half of international graduates (44%) had a full-time job. Buttons switch to living alone in a one-bedroom flat, which lengthens every bar, and to Bachelor degrees.

5. Closing note

I have measured everything in numbers just to show an experience that I had personally, about how difficult it is to be an international student. Yet, all these charts and data do not measure the amount of stress that one can face. I left my home, my people behind, so that I can make a career out of the skills and the knowledge that I have, and I can grow but this journey is not just about that. I need to give more than a hundred percent academically, just to stay average in the marks, work part-time to survive and pay my bills and then there is an added stress about what is going to be the new educational trend and the demands that I will require to land a well-paying job.

Acknowledgements

used Gen AI for fixing my codes little bit

References

Department of Education. (2026). Commonwealth Register of Institutions and Courses for Overseas Students (CRICOS) [Data set]. Australian Government. https://data.gov.au/data/dataset/cricos

Homes Victoria Rental Report - December Quarter 2025 [Data set]. State of Victoria. https://discover.data.vic.gov.au/dataset/rental-report-quarterly-data-tables

The Social Research Centre 2025 Graduate Outcomes Survey: International report [Data set]. Quality Indicators for Learning and Teaching (QILT). https://qilt.edu.au/surveys/graduate-outcomes-survey-(gos)#anchor-3