At a glance
- The fortnightly work-hour cap for international students sits at 48 hours, up from 40 before 2023: a +20% rise once the pandemic-era waiver ended.
- A rostered hour is not worth a fixed amount. The same hour can pay $33 or $70, roughly double, depending only on which award and which day it falls on.
- Even the worst-case, all-weekday roster at the current cap still clears the government’s own $29,710 student financial-capacity benchmark, comfortably, on paper.
- The bigger constraint turns out to be logistical: three separate employers’ rosters, squeezed around one class timetable that does not move for any of them.
Three jobs appear in this report: a convenience retail role, a hospitality role, and a casual catering role. Those are deliberately generalised descriptions, not the names of the actual businesses involved. Everything else (the visa condition, the award rates, the financial capacity benchmark) is real, current, Australian Government and Fair Work Ombudsman data, loaded directly from the CSV files behind this document rather than typed into the charts by hand. Chart 5, the weekly calendar, is the one exception: it is a composite, illustrative fortnight built from those same real award rates and the real current hour cap, not a literal copy of a real roster. That distinction is flagged again where it matters.
Every chart below is interactive: hovering over any point, bar or shift block reveals the exact date, rate, hours and government source behind it, and the legend can be clicked to isolate a single series. That matters for a story built on contested numbers. A reader does not have to take the prose’s arithmetic on faith; they can check any claim in this report against the specific data point it came from, on the spot.
I am one of the over 620,000 international students currently studying in Australia (Department of Education, 2026), and like a lot of them, I don’t have one job. I have a rotation of casual shifts stitched together across a convenience retail role, a hospitality role, and casual catering work, fitted in around a full-time course load. The number that quietly governs all of it is not my timetable. It is a condition on my visa: how many hours I am legally allowed to work in a fortnight.
That number has been set three different ways since the subclass 500 visa began in 2016: 40 hours a fortnight, then no limit at all during the pandemic, then 48 hours a fortnight from 1 July 2023, where it still sits (Tertiary Education Quality and Standards Agency [TEQSA], n.d.). Public debate about that cap tends to collapse into one of two flat claims: that it protects students from exploitation, or that it does not let them cover their own costs. Neither claim holds up well once you actually price out what 48 hours a fortnight is worth, because the answer depends enormously on which 48 hours a student can get, in which job, on which day of the week.
The deeper objective here is not just to show what the cap is, but to make a policy abstraction, “48 hours per fortnight,” legible as lived income, so the people who argue about that number have to argue about what it actually buys. Three audiences get something different from that. A prospective or current international student can use Chart 3 and Chart 4 to work out, before they ever sign a roster, roughly what a given job mix is worth against their own visa financial-capacity obligation. A policymaker or student-services researcher revisiting condition 8105 can use Chart 1 and Chart 5 to see that the binding constraint is often rostering access, not the legal ceiling itself; a costed 2025 election proposal to raise the cap to 60 hours from 1 July 2026 ultimately did not proceed, which shows this is still live policy terrain (Parliamentary Budget Office, 2025). An employer or rostering manager can use Chart 2 to see, concretely, how much a weekday-versus-weekend rostering decision is worth to a casual student employee. Specifically, the five charts together answer: how has the legal limit itself changed over time, and for how long did each regime last (Chart 1); how much does the day a shift falls on change its value (Chart 2); what is the realistic dollar range the cap actually represents (Chart 3); does that range clear the government’s own benchmark for a student’s cost of living (Chart 4); and what does hitting that cap actually require, in practice, across multiple employers and a fixed class timetable (Chart 5).
cap <- read_csv("data/visa_work_hour_cap_history.csv", show_col_types = FALSE) %>%
mutate(period_start = as.Date(period_start),
period_end = as.Date(period_end))
today_line <- Sys.Date()
numeric_cap <- cap %>% filter(!is.na(hours_per_fortnight))
uncapped <- cap %>% filter(is.na(hours_per_fortnight))
# Duration (in years) is a third encoded variable beyond date and hours-limit:
# it is shown as a small label on each regime and drives point size, so the
# chart reads as regime timing x magnitude x how-long-it-lasted, not just a
# two-variable step line.
seg <- numeric_cap %>%
mutate(
end_label = if_else(period_end >= today_line, "present", format(period_end, "%d %b %Y")),
duration_years = round(as.numeric(if_else(period_end >= today_line, today_line, period_end) - period_start) / 365.25, 1),
duration_label = if_else(period_end >= today_line,
paste0("in force ", duration_years, " yrs so far"),
paste0("in force ", duration_years, " yrs")),
tooltip = paste0(
format(period_start, "%d %b %Y"), " to ", end_label, "<br>",
"Limit in force: ", hours_per_fortnight, " hours per fortnight (", duration_label, ")<br>",
limit_description
)
)
band <- uncapped %>%
mutate(
duration_years = round(as.numeric(period_end - period_start) / 365.25, 1),
tooltip = paste0(
format(period_start, "%d %b %Y"), " to ", format(period_end, "%d %b %Y"), "<br>",
"No fortnightly limit (cap removed economy-wide, lasted ", duration_years, " yrs)<br>",
limit_description
)
)
y_top <- max(numeric_cap$hours_per_fortnight) * 1.25
p1 <- ggplot() +
geom_rect(data = band,
aes(xmin = period_start, xmax = period_end, ymin = 0, ymax = y_top, text = tooltip),
fill = "#E69F00", alpha = 0.22) +
geom_segment(data = seg,
aes(x = period_start, xend = period_end,
y = hours_per_fortnight, yend = hours_per_fortnight, text = tooltip),
colour = "#0072B2", linewidth = 1.8) +
geom_point(data = seg, aes(x = period_start, y = hours_per_fortnight, text = tooltip),
colour = "#0072B2", size = 2.4) +
geom_point(data = seg, aes(x = period_end, y = hours_per_fortnight, text = tooltip),
colour = "#0072B2", size = 2.4) +
geom_text(data = seg,
aes(x = period_start + (period_end - period_start) / 2, y = hours_per_fortnight + y_top * 0.06,
label = paste0(hours_per_fortnight, " hrs/fortnight")),
size = 3.3, colour = "#0072B2", fontface = "bold") +
annotate("text", x = band$period_start + (band$period_end - band$period_start) / 2,
y = y_top * 0.55, label = "No fortnightly\nlimit", size = 3.1, colour = "#8a5c00",
fontface = "italic") +
geom_vline(xintercept = as.numeric(as.Date("2023-07-01")), linetype = "dotted", colour = "grey45") +
scale_x_date(date_labels = "%Y", date_breaks = "2 years", expand = expansion(mult = c(0.03, 0.03))) +
scale_y_continuous(limits = c(0, y_top), breaks = c(0, 20, 40, 48, 60),
labels = label_number(suffix = " hrs")) +
labs(
title = "The work-hour cap has moved three times since 2016",
subtitle = "Maximum hours a student visa (subclass 500) holder may legally work per fortnight while their course is in session",
caption = "Source: Tertiary Education Quality and Standards Agency (n.d.); Department of Home Affairs (2020); Minister for Immigration et al. (2022)",
x = NULL,
y = "Limit (hours / fortnight)"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 14),
plot.subtitle = element_text(colour = "grey30", size = 10, margin = margin(b = 10)),
plot.caption = element_text(colour = "grey55", size = 8, hjust = 0, margin = margin(t = 10)),
panel.grid.minor = element_blank(),
panel.grid.major.x = element_blank()
)
# A second reading of the same chart: the same regime history, but with the
# y-axis re-expressed as percentage change from the original 2016 cap (40
# hrs/fortnight) instead of an absolute hour count. The step to 48 hours
# looks modest on an absolute scale but is a +20% change, which is worth
# seeing on its own terms -- the same instinct behind converting hours into
# dollars in Charts 2-4. ggplotly() has no built-in way to re-scale an
# existing axis, so this is built as a second, separate chart and the two
# are merged into one widget below, toggled with plotly buttons rather than
# crosstalk (there is nothing to filter here -- both views show the same two
# regimes, just on different scales).
baseline_hrs <- numeric_cap$hours_per_fortnight[which.min(numeric_cap$period_start)]
seg_pct <- seg %>%
mutate(
pct_change = round((hours_per_fortnight / baseline_hrs - 1) * 100),
pct_label = paste0(if_else(pct_change >= 0, "+", ""), pct_change, "%"),
tooltip_pct = paste0(
format(period_start, "%d %b %Y"), " to ", end_label, "<br>",
pct_label, " vs. the original 2016 cap of ", baseline_hrs, " hours/fortnight<br>",
"(", hours_per_fortnight, " hours per fortnight, ", duration_label, ")<br>",
limit_description
)
)
band_pct <- band %>%
mutate(tooltip_pct = paste0(
format(period_start, "%d %b %Y"), " to ", format(period_end, "%d %b %Y"), "<br>",
"No fortnightly limit (cap removed economy-wide, lasted ", duration_years, " yrs)<br>",
limit_description
))
y_top_pct <- max(seg_pct$pct_change) * 1.6
p1_pct <- ggplot() +
geom_rect(data = band_pct,
aes(xmin = period_start, xmax = period_end, ymin = -5, ymax = y_top_pct, text = tooltip_pct),
fill = "#E69F00", alpha = 0.22) +
geom_segment(data = seg_pct,
aes(x = period_start, xend = period_end,
y = pct_change, yend = pct_change, text = tooltip_pct),
colour = "#009E73", linewidth = 1.8) +
geom_point(data = seg_pct, aes(x = period_start, y = pct_change, text = tooltip_pct),
colour = "#009E73", size = 2.4) +
geom_point(data = seg_pct, aes(x = period_end, y = pct_change, text = tooltip_pct),
colour = "#009E73", size = 2.4) +
geom_text(data = seg_pct,
aes(x = period_start + (period_end - period_start) / 2, y = pct_change + y_top_pct * 0.07,
label = pct_label),
size = 3.3, colour = "#009E73", fontface = "bold") +
annotate("text", x = band$period_start + (band$period_end - band$period_start) / 2,
y = y_top_pct * 0.55, label = "No fortnightly\nlimit", size = 3.1, colour = "#8a5c00",
fontface = "italic") +
geom_vline(xintercept = as.numeric(as.Date("2023-07-01")), linetype = "dotted", colour = "grey45") +
scale_x_date(date_labels = "%Y", date_breaks = "2 years", expand = expansion(mult = c(0.03, 0.03))) +
scale_y_continuous(limits = c(-5, y_top_pct), labels = label_number(suffix = "%")) +
labs(x = NULL, y = "Change from the 2016 cap (%)") +
theme_minimal(base_size = 12) +
theme(panel.grid.minor = element_blank(), panel.grid.major.x = element_blank())
p1_ly <- ggplotly(p1, tooltip = "text")
p1_pct_ly <- ggplotly(p1_pct, tooltip = "text")
n_abs <- length(p1_ly$x$data)
n_pct <- length(p1_pct_ly$x$data)
for (i in seq_along(p1_pct_ly$x$data)) p1_pct_ly$x$data[[i]]$visible <- FALSE
p1_combined <- p1_ly
p1_combined$x$data <- c(p1_ly$x$data, p1_pct_ly$x$data)
vis_abs <- c(rep(TRUE, n_abs), rep(FALSE, n_pct))
vis_pct <- c(rep(FALSE, n_abs), rep(TRUE, n_pct))
p1_combined %>%
layout(
hoverlabel = list(bgcolor = "white", font = list(size = 12)),
margin = list(t = 130, b = 70),
# A paper-referenced subtitle_annotation() here sat at a fixed pixel
# distance from the very top of the widget regardless of margin.t, so it
# did not reliably clear the panel below it (the same class of rendering
# fragility found and fixed for Chart 3). Plotly's native multi-line
# `title` is positioned by the renderer relative to the actual margin, so
# it clears the panel correctly -- used here for the same reason.
title = list(
text = paste0(
"<b>The work-hour cap has moved three times since 2016</b><br>",
"<span style='font-size:12px;color:rgb(89,89,89)'>",
"Maximum hours a student visa (subclass 500) holder may legally work per fortnight while their course is in session",
"</span>"
),
x = 0, xanchor = "left", y = 0.97, yanchor = "top",
font = list(size = 16)
),
annotations = list(
caption_annotation(
"Source: Tertiary Education Quality and Standards Agency (n.d.); Department of Home Affairs (2020); Minister for Immigration et al. (2022)"
)
),
updatemenus = list(
list(
type = "buttons", direction = "right",
x = 0, xanchor = "left", y = 1.1, yanchor = "bottom",
buttons = list(
# Both buttons fully re-specify the y-axis (title, range, AND the
# explicit tick marks/suffix), rather than just title + range.
# Letting plotly's relayout() auto-generate ticks for whichever
# axis config isn't explicitly listed drops the original chart's
# irregular, deliberate tick marks (0/20/40/48/60, not a round
# multiple of 10) the first time a button is clicked, even when
# clicking back to the view that looked correct on first load --
# found by actually clicking both buttons in sequence and
# comparing screenshots, not by reading the layout JSON.
list(method = "update",
args = list(list(visible = vis_abs),
list(yaxis = list(title = "Limit (hours / fortnight)", range = c(0, y_top),
tickvals = c(0, 20, 40, 48, 60),
ticksuffix = " hrs"))),
label = "Hours per fortnight"),
list(method = "update",
args = list(list(visible = vis_pct),
list(yaxis = list(title = "Change from the 2016 cap (%)", range = c(-5, y_top_pct),
tickvals = seq(0, floor(y_top_pct / 10) * 10, by = 10),
ticksuffix = "%"))),
label = "% change since 2016")
)
)
)
)
Try: use the buttons above the chart to switch between the absolute hour limit and how much it has grown, in percentage terms, since the cap was introduced in 2016.
The condition itself, generally known by its regulation number, condition 8105, has applied to every subclass 500 student visa since that visa category was introduced on 1 July 2016, and for most of that time it capped work at 40 hours a fortnight. That is the number the Department of Home Affairs removed entirely, for all sectors of the economy, from 19 January 2022, as part of a pandemic-era response to workforce shortages (Minister for Immigration, Citizenship, Migrant Services and Multicultural Affairs, 2022). A narrower version of that relaxation had already been phased in from April 2020, covering students working in a short list of critical sectors: aged care, disability support (including NDIS providers) and agriculture (Department of Home Affairs, 2020). General retail (outside the major supermarket chains), hospitality and catering were not on that list, so for the jobs in this report, the 40-hour condition stayed in force right up until the January 2022 economy-wide waiver. When that waiver ended on 30 June 2023, the government did not simply restore the old 40-hour limit. It set a new one, 8 hours higher, at 48 hours a fortnight (TEQSA, n.d.), where it has stayed since.
The next three charts all compare the same two awards, so they are linked: selecting one below narrows all three at once, rather than making you re-find the same award in each chart separately.
rates <- read_csv("data/award_pay_rates.csv", show_col_types = FALSE) %>%
mutate(
award_short = if_else(grepl("Retail", award_name), "Retail (MA000004)", "Hospitality (MA000009)"),
day_type = fct_reorder(day_type, day_type_order),
tooltip = paste0(
award_short, "<br>",
day_type, ": $", number(hourly_rate_aud, accuracy = 0.01), " / hour<br>",
"Effective from ", format(as.Date(effective_date), "%d %b %Y"), "<br>",
"Source: ", source_name
)
)
# Charts 2, 3 and 4 below all share one crosstalk group, keyed on award_short,
# so that selecting an award here narrows all three charts together rather
# than requiring the reader to isolate it separately in each one.
rates_shared <- SharedData$new(rates, key = ~award_short, group = "award-select")
filter_checkbox("award_filter", "Focus on one award (narrows Charts 2, 3 and 4):",
rates_shared, ~award_short, inline = TRUE)
Try: tick a box above to isolate Retail or Hospitality across the next three charts; untick it to bring both back.
pal2 <- c("Retail (MA000004)" = "#0072B2", "Hospitality (MA000009)" = "#E69F00") # Okabe-Ito colourblind-safe palette, used consistently across all five charts
p2 <- ggplot(rates_shared, aes(x = day_type, y = hourly_rate_aud, fill = award_short, text = tooltip)) +
geom_col(position = position_dodge(width = 0.72), width = 0.65) +
geom_text(aes(label = paste0("$", number(hourly_rate_aud, accuracy = 0.01))),
position = position_dodge(width = 0.72), vjust = -0.5, size = 2.9,
fontface = "plain", colour = "grey20") +
scale_fill_manual(values = pal2, name = NULL) +
scale_y_continuous(labels = label_dollar(), expand = expansion(mult = c(0, 0.14))) +
labs(
title = "A rostered hour is not a fixed unit of pay",
subtitle = "Level 1 adult casual hourly rate by day-type, two awards common in student casual work",
caption = "Source: Fair Work Ombudsman (2026a, 2026b) award pay guides, rates effective 1 Jul 2026",
x = NULL,
y = "Hourly rate (AUD)"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 14),
plot.subtitle = element_text(colour = "grey30", size = 10, margin = margin(b = 10)),
plot.caption = element_text(colour = "grey55", size = 8, hjust = 0, margin = margin(t = 10)),
axis.text.x = element_text(angle = 20, hjust = 1),
panel.grid.minor = element_blank(),
panel.grid.major.x = element_blank(),
legend.position = "top"
)
ggplotly(p2, tooltip = "text") %>%
layout(
legend = list(orientation = "h", y = -0.28, x = 0.5, xanchor = "center"),
hoverlabel = list(bgcolor = "white", font = list(size = 12)),
margin = list(t = 85, b = 150),
annotations = list(
subtitle_annotation(
"Level 1 adult casual hourly rate by day-type, two awards common in student casual work"
),
caption_annotation(
"Source: Fair Work Ombudsman (2026a, 2026b) award pay guides, rates effective 1 Jul 2026",
yshift = -115
)
)
)
The cap counts hours, but the award system that sets casual pay does not treat them equally. Under the General Retail Industry Award and the Hospitality Industry (General) Award, the two awards that cover the roles in this report, a Level 1 adult casual’s hourly rate depends heavily on when the shift falls (Fair Work Ombudsman, 2026a, 2026b). A weekday shift before 6pm pays close to the base casual rate in both sectors, around $33 to $35 an hour; shift the same hour to a Sunday or a public holiday and the rate roughly doubles, reaching about $66 to $70 (Fair Work Ombudsman, 2026a, 2026b). The 200% public holiday loading built into both awards is designed to land almost exactly on double time, and it does: dividing each award’s public holiday rate by its weekday rate lands within a cent of 2.00 in both cases. Practically, it means a fortnight spent working mostly weekday daytime shifts and a fortnight of the same length spent working mostly weekend and public holiday shifts can be worth close to twice as much money, even though both used up the identical slice of the 48-hour cap.
rates_award <- rates %>% distinct(award_short, day_type, hourly_rate_aud)
caps_numeric <- cap %>%
filter(!is.na(hours_per_fortnight)) %>%
mutate(cap_label = if_else(hours_per_fortnight == max(hours_per_fortnight),
paste0(hours_per_fortnight, " hrs - current cap"),
paste0(hours_per_fortnight, " hrs - pre-Jul 2023 cap")))
rate_range <- rates_award %>%
group_by(award_short) %>%
summarise(
min_rate = min(hourly_rate_aud),
max_rate = max(hourly_rate_aud),
min_day = day_type[which.min(hourly_rate_aud)],
max_day = day_type[which.max(hourly_rate_aud)],
.groups = "drop"
)
scenarios <- crossing(rate_range, caps_numeric %>% select(hours_per_fortnight, cap_label)) %>%
mutate(
floor_earnings = min_rate * hours_per_fortnight,
ceiling_earnings = max_rate * hours_per_fortnight,
row_label = paste0(award_short, "\n", cap_label),
floor_tooltip = paste0(
award_short, " - ", cap_label, "<br>Floor: $", number(floor_earnings, accuracy = 1),
" (", hours_per_fortnight, " hrs all at the ", tolower(min_day), " rate of $", min_rate, "/hr)"
),
ceiling_tooltip = paste0(
award_short, " - ", cap_label, "<br>Ceiling: $", number(ceiling_earnings, accuracy = 1),
" (", hours_per_fortnight, " hrs all at the ", tolower(max_day), " rate of $", max_rate, "/hr)"
)
) %>%
arrange(award_short, hours_per_fortnight) %>%
mutate(row_label = factor(row_label, levels = rev(unique(row_label))))
# Same crosstalk group as the award filter introduced before Chart 2, so
# ticking an award there also narrows this chart.
scenarios_shared <- SharedData$new(scenarios, key = ~award_short, group = "award-select")
# geom_text()'s hjust (a text-width-relative offset) does not reliably survive
# ggplotly() conversion once the widget's margins are customised -- the same
# issue fixed for Chart 1's point labels. Here we instead push the label's
# anchor point itself sideways by a fixed share of the x-axis data range, then
# right/left-justify (hjust = 1 / 0) at that already-offset point, which is
# immune to the pixel-to-data remapping problem.
x_span3 <- max(scenarios$ceiling_earnings) - min(scenarios$floor_earnings)
label_gap3 <- x_span3 * 0.055
pal3 <- c("Floor (all weekday hours)" = "#0072B2", "Ceiling (all public holiday hours)" = "#E69F00")
p3 <- ggplot(scenarios_shared) +
geom_segment(aes(x = floor_earnings, xend = ceiling_earnings, y = row_label, yend = row_label),
colour = "grey70", linewidth = 2.2) +
geom_point(aes(x = floor_earnings, y = row_label, text = floor_tooltip,
colour = "Floor (all weekday hours)"), size = 3.6) +
geom_point(aes(x = ceiling_earnings, y = row_label, text = ceiling_tooltip,
colour = "Ceiling (all public holiday hours)"), size = 3.6) +
geom_text(aes(x = floor_earnings - label_gap3, y = row_label,
label = paste0("$", number(floor_earnings, accuracy = 1))),
hjust = 1, size = 3.1, colour = "#0072B2") +
geom_text(aes(x = ceiling_earnings + label_gap3, y = row_label,
label = paste0("$", number(ceiling_earnings, accuracy = 1))),
hjust = 0, size = 3.1, colour = "#8a5c00") +
scale_colour_manual(values = pal3, name = NULL) +
scale_x_continuous(labels = label_dollar(), expand = expansion(mult = c(0.12, 0.14))) +
labs(
title = "The same hour cap is worth very different amounts",
subtitle = "Fortnightly earnings at each cap: an all-weekday roster (floor) vs. an all-public-holiday roster (ceiling)",
caption = "Sources: TEQSA (n.d.) cap history; Fair Work Ombudsman (2026a, 2026b) award rates",
x = "Fortnightly earnings (AUD)",
y = NULL
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 14),
plot.subtitle = element_text(colour = "grey30", size = 9.5, margin = margin(b = 10)),
plot.caption = element_text(colour = "grey55", size = 8, hjust = 0, margin = margin(t = 10)),
panel.grid.minor = element_blank(),
panel.grid.major.y = element_blank(),
legend.position = "top",
axis.text.y = element_text(size = 9, lineheight = 0.9)
)
ggplotly(p3, tooltip = "text") %>%
layout(
legend = list(orientation = "h", y = -0.24, x = 0.5, xanchor = "center"),
hoverlabel = list(bgcolor = "white", font = list(size = 12)),
margin = list(t = 110, b = 140, l = 180),
# Chart 3's horizontal/flipped layout with a very large left margin (l = 180)
# causes the paper-referenced subtitle_annotation() to mis-render over the
# first data row in the interactive widget, even though its JSON is
# identical in structure to the subtitle annotations used in charts 1, 2
# and 4 (confirmed by inspecting the knitted HTML's embedded widget JSON).
# Plotly's native multi-line `title` object is positioned by the renderer
# itself rather than as a floating paper-coordinate annotation, so it does
# not share that bug -- used here instead, purely for this chart.
title = list(
text = paste0(
"<b>The same hour cap is worth very different amounts</b><br>",
"<span style='font-size:12px;color:rgb(89,89,89)'>",
"Fortnightly earnings at each cap: an all-weekday roster (floor) vs. an all-public-holiday roster (ceiling)",
"</span>"
),
x = 0, xanchor = "left", y = 0.97, yanchor = "top",
font = list(size = 16)
),
annotations = list(
caption_annotation(
"Sources: TEQSA (n.d.) cap history; Fair Work Ombudsman (2026a, 2026b) award rates",
yshift = -105
)
)
)
Chart 3 applies the award’s pay-rate range to both the old and the new fortnightly cap, which turns the gap between them into a dollar figure rather than just an hour count. At the old 40-hour cap, a fortnight of nothing but weekday retail shifts was worth about $1,390; the same 40 hours, if every one of them had landed on a public holiday, would have been worth about $2,781, roughly double, for the same number of hours against the same cap. At the current 48-hour cap the floor rises to about $1,668 and the ceiling to about $3,337. The cap sets how many hours a student can work; it says nothing about how many of those hours they can actually get rostered onto a Sunday or a public holiday, which is the part of the equation that moves the dollar figure the most.
current_cap_hours <- cap %>%
filter(!is.na(hours_per_fortnight)) %>%
filter(hours_per_fortnight == max(hours_per_fortnight)) %>%
pull(hours_per_fortnight)
benchmark <- read_csv("data/financial_capacity_benchmark.csv", show_col_types = FALSE)
current_benchmark <- benchmark %>% filter(effective_date == max(effective_date))
previous_benchmark <- benchmark %>% filter(effective_date == min(effective_date))
annual <- rate_range %>%
mutate(
floor_annual = min_rate * current_cap_hours * 26,
ceiling_annual = max_rate * current_cap_hours * 26
) %>%
pivot_longer(cols = c(floor_annual, ceiling_annual), names_to = "scenario", values_to = "annual_earnings") %>%
mutate(
scenario = if_else(scenario == "floor_annual", "Floor (every rostered hour at the weekday rate)",
"Ceiling (every rostered hour at the public holiday rate)"),
tooltip = paste0(
award_short, "<br>", scenario, "<br>",
"Estimated annual earnings at the current ", current_cap_hours, " hrs/fortnight cap: $",
number(annual_earnings, big.mark = ",", accuracy = 1)
)
)
pal4 <- c(
"Floor (every rostered hour at the weekday rate)" = "#0072B2",
"Ceiling (every rostered hour at the public holiday rate)" = "#E69F00"
)
# Same crosstalk group as the award filter introduced before Chart 2.
annual_shared <- SharedData$new(annual, key = ~award_short, group = "award-select")
p4 <- ggplot(annual_shared, aes(x = award_short, y = annual_earnings, fill = scenario, text = tooltip)) +
geom_col(position = position_dodge(width = 0.65), width = 0.55) +
# The two benchmark lines are NOT added as geom_hline() layers here. Any
# geom layer -- even one explicitly given the plain `annual` data frame --
# still gets swallowed by the crosstalk filter above once a trace is built
# from this ggplotly() object: crosstalk hides every trace that lacks its
# own key, not just the ones tied to the filtered award. They are added
# as native plotly layout shapes instead, directly after ggplotly()
# conversion (see p4_ly below) -- layout shapes aren't traces, so
# crosstalk's filtering never touches them and they stay visible for both
# awards and under any filter state, which is correct: the benchmark
# itself never changes.
# The two benchmark-line labels are added after ggplotly() conversion, as
# mixed paper/data-referenced plotly annotations (xref = "paper" for
# horizontal position, yref = "y" for the data value) -- see
# benchmark_annotations below. That combination renders correctly in the
# interactive widget. geom_text()/annotate() equivalents are added
# separately, after the fact, only to the static PNG used for the Word
# export (see export_static_charts.R), since plain data-coordinate text
# annotations do not justify reliably through ggplotly() in this chart.
scale_x_discrete(expand = expansion(add = c(0.75, 0.75))) +
geom_text(aes(label = paste0("$", number(annual_earnings, big.mark = ",", accuracy = 1))),
position = position_dodge(width = 0.65), vjust = -0.6, size = 3.0, colour = "grey20") +
scale_fill_manual(values = pal4, name = NULL) +
scale_y_continuous(labels = label_dollar(big.mark = ","), expand = expansion(mult = c(0, 0.16))) +
labs(
title = "Capped hours still clear the government's own benchmark",
subtitle = paste0("Estimated annual earnings at the current ", current_cap_hours,
" hrs/fortnight work cap, versus the student visa financial capacity requirement"),
caption = "Sources: Fair Work Ombudsman (2026a, 2026b); Federal Register of Legislation (2023, 2024)",
x = NULL,
y = "Estimated annual earnings (AUD)"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 14),
plot.subtitle = element_text(colour = "grey30", size = 9.5, margin = margin(b = 10)),
plot.caption = element_text(colour = "grey55", size = 8, hjust = 0, margin = margin(t = 10)),
panel.grid.minor = element_blank(),
panel.grid.major.x = element_blank(),
legend.position = "top",
legend.text = element_text(size = 8.5)
)
p4_ly <- ggplotly(p4, tooltip = "text")
# Add the two benchmark lines as native plotly layout shapes (not ggplot
# layers -- see the comment above p4). They must be appended directly to
# p4_ly$x$layout$shapes rather than via layout(shapes = ...): passing shapes
# through layout() gets silently discarded in favour of ggplotly's own
# panel-background shape when the widget is built, dropping the benchmark
# lines entirely (confirmed by inspecting plotly_build() output). Direct
# assignment onto the existing shapes list is the only path that survives.
benchmark_shapes <- list(
list(type = "line", xref = "paper", x0 = 0, x1 = 1,
yref = "y", y0 = current_benchmark$annual_amount_aud, y1 = current_benchmark$annual_amount_aud,
line = list(color = "grey20", width = 2)),
list(type = "line", xref = "paper", x0 = 0, x1 = 1,
yref = "y", y0 = previous_benchmark$annual_amount_aud, y1 = previous_benchmark$annual_amount_aud,
line = list(color = "grey55", width = 1.5, dash = "dash"))
)
p4_ly$x$layout$shapes <- c(p4_ly$x$layout$shapes, benchmark_shapes)
benchmark_annotations <- list(
list(xref = "paper", x = 1, xanchor = "right",
yref = "y", y = current_benchmark$annual_amount_aud,
yanchor = "bottom",
text = paste0("Current financial capacity benchmark: $",
number(current_benchmark$annual_amount_aud, big.mark = ",")),
showarrow = FALSE, font = list(size = 11, color = "grey20")),
list(xref = "paper", x = 1, xanchor = "right",
yref = "y", y = previous_benchmark$annual_amount_aud,
yanchor = "top",
text = paste0("2023 benchmark: $", number(previous_benchmark$annual_amount_aud, big.mark = ",")),
showarrow = FALSE, font = list(size = 10.5, color = "grey45"))
)
caption4 <- caption_annotation(
"Sources: Fair Work Ombudsman (2026a, 2026b); Federal Register of Legislation (2023, 2024)",
yshift = -120
)
# As with Charts 1 and 3, a paper-referenced subtitle annotation here sat too
# close to the panel (overlapping the tallest bar's value label); the native
# plotly multi-line `title` clears the panel reliably because the renderer
# positions it relative to the actual margin, so it replaces subtitle4.
p4_ly %>%
layout(
legend = list(orientation = "h", y = -0.3, x = 0.5, xanchor = "center"),
hoverlabel = list(bgcolor = "white", font = list(size = 12)),
margin = list(t = 110, b = 150, r = 90),
title = list(
text = paste0(
"<b>Capped hours still clear the government's own benchmark</b><br>",
"<span style='font-size:12px;color:rgb(89,89,89)'>",
"Estimated annual earnings at the current ", current_cap_hours,
" hrs/fortnight work cap, versus the student visa financial capacity requirement",
"</span>"
),
x = 0, xanchor = "left", y = 0.97, yanchor = "top",
font = list(size = 16)
),
annotations = c(benchmark_annotations, list(caption4))
)
Every student visa applicant has to clear a financial capacity benchmark, currently $29,710 a year, up from $24,505 in 2023, and now pegged to 75% of the national minimum wage rather than adjusted by inflation (Federal Register of Legislation, 2023, 2024; Martin, 2024). That $29,710 figure remains the current benchmark at the time of writing. Annualising the floor and ceiling from the previous chart produces a striking result: even the floor scenario (every one of the 48 capped hours worked at the lowest weekday rate, every fortnight, for a full year) clears that benchmark comfortably, in both awards, by a wide margin. That complicates the simplest version of the “the cap doesn’t let students survive” argument. On paper, 48 hours a fortnight at almost any casual rate in these two awards is worth more than the amount the government itself treats as evidence a student can support themselves. That floor figure is itself conservative in one respect: condition 8105 only applies while a student’s course is actually in session, and work hours are unrestricted during scheduled course breaks, so a student’s real annual earning potential, if they can get the hours, is higher again than the year-round-48-hours floor used here (TEQSA, n.d.).
The catch is the word “every.” That floor figure assumes a student is rostered the full 48 hours, every single fortnight, all year, with no slow weeks, no exam-period cutbacks, and no gap between jobs, an assumption casual employment, by its nature as employment with no guaranteed hours, does not provide. The cap is a ceiling on what a student is allowed to earn from paid work; it is not a guarantee of what they are actually rostered. That gap between the two is exactly what the next chart tries to make visible.
schedule <- read_csv("data/illustrative_week_schedule.csv", show_col_types = FALSE)
rates_join <- read_csv("data/award_pay_rates.csv", show_col_types = FALSE) %>%
mutate(award_match = if_else(grepl("Retail", award_name), "Retail (MA000004)", "Hospitality (MA000009)"))
sched <- schedule %>%
left_join(rates_join %>% select(award_match, day_type, hourly_rate_aud), by = c("award_match", "day_type")) %>%
mutate(
hours = end_hour - start_hour,
earnings = hourly_rate_aud * hours,
day = factor(day, levels = c("Mon","Tue","Wed","Thu","Fri","Sat","Sun")),
week_label = paste("Week", week_number, "of the fortnight"),
is_class = activity == "Class / study commitment",
tooltip = if_else(
is_class,
paste0(day, ", ", week_label, "<br>", activity, "<br>",
sprintf("%02d:00", start_hour), "-", sprintf("%02d:00", end_hour),
" (", hours, " hrs, unpaid study time)"),
paste0(day, ", ", week_label, "<br>", activity, " (", award_match, ")<br>",
sprintf("%02d:00", start_hour), "-", sprintf("%02d:00", end_hour),
" (", hours, " hrs) - ", day_type, "<br>",
"$", number(hourly_rate_aud, accuracy = 0.01), "/hr = $", number(earnings, accuracy = 1))
)
)
total_hours <- sum(sched$hours[!sched$is_class])
total_earnings <- sum(sched$earnings[!sched$is_class], na.rm = TRUE)
pal5 <- c(
"Convenience retail shift" = "#0072B2",
"Hospitality shift" = "#E69F00",
"Casual catering shift" = "#009E73",
"Class / study commitment" = "#B0B0B0"
)
p5 <- ggplot(sched) +
geom_rect(aes(xmin = day_order - 0.42, xmax = day_order + 0.42,
ymin = start_hour, ymax = end_hour, fill = activity, text = tooltip),
colour = "white", linewidth = 0.6) +
scale_fill_manual(values = pal5, name = NULL) +
scale_x_continuous(breaks = 1:7, labels = c("Mon","Tue","Wed","Thu","Fri","Sat","Sun"), expand = expansion(add = 0.5)) +
scale_y_reverse(breaks = seq(8, 22, 2), labels = paste0(seq(8, 22, 2), ":00"), expand = expansion(add = 0.6)) +
facet_wrap(~week_label, ncol = 1) +
labs(
title = "What 48 hours a fortnight actually looks like, rostered",
subtitle = paste0(
"An illustrative composite fortnight, ", total_hours, " paid hours total - worth an estimated $",
number(total_earnings, big.mark = ",", accuracy = 1), " before tax"
),
caption = "Composite schedule; pay rates from Fair Work Ombudsman (2026a, 2026b). Not a literal real roster - see note above.",
x = NULL, y = NULL
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 14),
plot.subtitle = element_text(colour = "grey30", size = 9.3, margin = margin(b = 10)),
plot.caption = element_text(colour = "grey55", size = 8, hjust = 0, margin = margin(t = 10)),
panel.grid.minor = element_blank(),
panel.grid.major.x = element_blank(),
strip.text = element_text(face = "bold", size = 10.5),
legend.position = "top"
)
p5_ly <- ggplotly(p5, tooltip = "text")
# facet_wrap's strip labels ("Week 1 of the fortnight" / "Week 2 of the
# fortnight") already arrive as annotations on p5_ly - keep them and add
# the subtitle rather than replacing the whole list.
subtitle5 <- subtitle_annotation(paste0(
"An illustrative composite fortnight, ", total_hours, " paid hours total - worth an estimated $",
number(total_earnings, big.mark = ",", accuracy = 1), " before tax"
), yanchor = "bottom", yshift = 35)
caption5 <- caption_annotation(
"Composite schedule; pay rates from Fair Work Ombudsman (2026a, 2026b). Not a literal real roster - see note above.",
yshift = -90
)
p5_ly %>%
layout(
legend = list(orientation = "h", y = -0.1, x = 0.5, xanchor = "center"),
hoverlabel = list(bgcolor = "white", font = list(size = 12)),
margin = list(t = 125, b = 125),
annotations = c(p5_ly$x$layout$annotations, list(subtitle5), list(caption5))
)
To be clear about what this last chart is: it is not a literal copy of a real timetable. It is a composite fortnight I built to be realistic. It uses the real award day-types and hourly rates from Chart 2, and it is deliberately constructed to land on exactly 48 paid hours, the real current cap, but the specific days and times are illustrative, not a disclosed personal record. What it is meant to show honestly is the shape of the problem: hitting the cap rarely means 48 hours in one job. More often it means 48 hours spread across three separate employers, each with their own rostering, squeezed around a class timetable that does not move for any of them. In this composite fortnight that comes to roughly $1,837 before tax, a number that sits far below the naive “48 hours at the public holiday rate” ceiling from Chart 3, because most real weeks, mine included, are built from ordinary weekday and Saturday shifts, not holidays. Multiple employers also means multiple rosters to negotiate around a single, inflexible class timetable, and a public holiday shift at double pay is only worth something if a manager actually offers it to you, something the cap itself has no say over.
None of the five charts above say the cap is simply too low or simply generous enough. Both claims are too simple for what the data actually shows. The cap has been set three different ways in a decade (40 hours, then no limit, then 48 hours), landing 8 hours higher than its pre-pandemic standard, and unrestricted again during every course break along the way (TEQSA, n.d.). The award system it sits on top of prices an hour very differently depending on when it falls, with public holiday work worth close to double the weekday rate in both the retail and hospitality awards used here (Fair Work Ombudsman, 2026a, 2026b). Stretched across a full year, even a worst-case, all-weekday roster at the current cap clears the government’s own student financial capacity benchmark (Federal Register of Legislation, 2023, 2024). What the cap does not do, and what no fortnightly hour limit can do, is guarantee that those hours are actually offered, on the days that pay the most, by employers who are themselves balancing a dozen casual students’ timetables against their own rosters. The real constraint on a student’s income was never purely the number on the visa condition. It is how many of the allowed hours a juggling act across three jobs can actually turn into a rostered, paid shift.
This report draws on three independent, primary open-data
sources, each loaded directly into R with
read_csv() from its own CSV file rather than typed into the
chart code, and each cited both here and on the chart(s) it feeds: (1)
Australian Government visa policy records for the student visa (subclass
500) work-hour condition, drawn from TEQSA, the Department of Home
Affairs and the Minister for Immigration’s portfolio
(visa_work_hour_cap_history.csv); (2) Fair Work Ombudsman
casual pay rates for the General Retail Industry Award and the
Hospitality Industry (General) Award (award_pay_rates.csv);
and (3) Federal Register of Legislation instruments setting the student
visa financial capacity benchmark
(financial_capacity_benchmark.csv). A fourth file, the
illustrative composite fortnight used in Chart 5
(illustrative_week_schedule.csv), is not a separate
independent source. It is a constructed schedule whose dollar figures
are derived entirely from source (2) via a table join in the Chart 5
code, not from any additional data source or separately typed
numbers.
Charts 1-4 are built entirely from these three primary sources; Chart
5 combines source (2) with a composite, clearly-disclosed illustrative
schedule rather than a literal personal record, for the privacy reasons
explained above. Every chart was built in R (R Core Team, 2025) with
ggplot2 (Wickham, 2016) and made interactive with
plotly::ggplotly() (Sievert, 2020). The cross-chart
filtering on Charts 2-4 uses crosstalk (Cheng &
Sievert, 2025), with the award filter above Chart 2 narrowing Charts 2-4
together so a reader can isolate one award at a time. Chart 1 adds a
second interactive layer of its own: a button toggle between absolute
hours and percentage change since 2016, because a single static number
can only answer one of “how big” or “how much did it grow,” and the
toggle lets the reader pick which question they are asking.
Two design choices made throughout are worth setting out explicitly.
First, every chart in this report uses position along a common scale
(bar length, dot position, line height) as its primary encoding, rather
than area, angle or colour saturation, because position judgements are
the most accurately read of the elementary perceptual tasks available to
a chart designer (Cleveland & McGill, 1984); it is why Chart 3 is a
dumbbell plot rather than, say, a pair of pie charts, and why Chart 4’s
benchmark is a reference line at the correct y-value rather than a
separate annotated box. Second, colours are drawn from the Okabe-Ito
colour-blind-safe palette (#0072B2, #E69F00,
#009E73, #B0B0B0; Okabe & Ito, 2008), used
consistently across all five charts rather than a default, unvetted
qualitative palette. Each chart also carries its own source citation as
an on-chart caption, both in the static figure and in the published
interactive version, so the data source behind any individual chart is
never more than a glance away.
I acknowledge the use of Anthropic’s Claude large language model (Anthropic, 2026), accessed via the Claude app in October 2026, to paraphrase sentences in this report and improve their clarity and flow. A representative prompt was “rephrase this paragraph to make it clearer and more concise.” All ideas, data, analysis, code and editorial judgements are my own. I reviewed and edited every AI-suggested sentence before including it, and I take full responsibility for the final content.
Anthropic. (2026). Claude (Sonnet) [Large language model]. https://claude.ai
Cheng, J., & Sievert, C. (2025). crosstalk: Inter-widget interactivity for HTML widgets (R package version 1.2.2) [Computer software]. https://CRAN.R-project.org/package=crosstalk
Cleveland, W. S., & McGill, R. (1984). Graphical perception: Theory, experimentation, and application to the development of graphical methods. Journal of the American Statistical Association, 79(387), 531-554. https://doi.org/10.2307/2288400
Department of Education. (2026). International student monthly summary and data tables [Data set]. Australian Government. Retrieved October 28, 2026, from https://www.education.gov.au/international-education-data-and-research/resources/international-student-monthly-summary
Department of Home Affairs. (2020, April 24). COVID-19: Relaxation of student visa work [Notice]. Australian Government. https://www.ndiscommission.gov.au/sites/default/files/2022-07/department-home-affairs-changes-student-visa-work.pdf
Fair Work Ombudsman. (2026a). General Retail Industry Award (MA000004): Pay guide. Australian Government. Retrieved October 19, 2026, from https://calculate.fairwork.gov.au/Download/AwardSummary?awardCode=ma000004&fileType=pdf
Fair Work Ombudsman. (2026b). Hospitality Industry (General) Award (MA000009): Pay guide. Australian Government. Retrieved October 19, 2026, from https://calculate.fairwork.gov.au/Download/AwardSummary?awardCode=ma000009&fileType=pdf
Federal Register of Legislation. (2023). Migration (LIN 19/198: Evidence of financial capacity—Subclass 500 Visa and Subclass 590 Visa) Amendment (LIN 23/070) Specification 2023 (F2023L01349). Australian Government. Retrieved October 19, 2026, from https://www.legislation.gov.au/F2023L01349/asmade/2023-09-29/es/original/pdf
Federal Register of Legislation. (2024). Migration (LIN 19/198: Evidence of financial capacity—Subclass 500 Visa and Subclass 590 Visa) Amendment (LIN 24/042) Specification 2024 (F2024L00529). Australian Government. Retrieved October 19, 2026, from https://legislation.gov.au/F2024L00529/asmade/2024-05-09/es/original/pdf
Martin, K. (2024, May 8). Australia’s financial requirement capacity for int’l students rises again. The PIE News. https://thepienews.com/australias-financial-requirement-capacity-for-intl-students-rises-again/
Minister for Immigration, Citizenship, Migrant Services and Multicultural Affairs. (2022, January 19). Student and Working Holiday Maker visa holders [Joint media release]. Australian Government. https://minister.homeaffairs.gov.au/AlexHawke/Pages/student-and-working-holiday-maker-visa-holders.aspx
Okabe, M., & Ito, K. (2008). Color universal design (CUD): How to make figures and presentations that are friendly to colorblind people (2nd ed.). https://jfly.uni-koeln.de/color/
Parliamentary Budget Office. (2025). Student visa work hours – increase [Election commitment costing]. Australian Government. Retrieved October 28, 2026, from https://www.pbo.gov.au/elections/2025-general-election/2025-election-commitments-costings/Student-visa-work-hours-increase
R Core Team. (2025). R: A language and environment for statistical computing (Version 4.5.2) [Computer software]. R Foundation for Statistical Computing. https://www.R-project.org/
Sievert, C. (2020). Interactive web-based data visualization with R, plotly, and shiny. Chapman and Hall/CRC. https://plotly-r.com
Tertiary Education Quality and Standards Agency. (n.d.). Student visa holder work restrictions. Australian Government. Retrieved October 19, 2026, from https://www.teqsa.gov.au/about-us/news-and-events/latest-news/student-visa-holder-work-restrictions
Wickham, H. (2016). ggplot2: Elegant graphics for data analysis (2nd ed.). Springer. https://ggplot2.tidyverse.org