Since moving to Melbourne, I have been using public transport almost every day, especially the tram to get to work and university.

One thing I have always noticed was that the tram arrival times shown in the app were not always correct. Sometimes the app would say the tram was delayed, but it would arrive on time. Other times, it would say “Arriving now,” but the tram still hadn’t arrived.

This made me think about how accurate the app really was. At first, I wanted to explore this for my assignment, but I couldn’t find a suitable dataset with both the predicted and actual arrival times.

Then I thought about something else I had noticed while travelling. When I take the tram to work on weekdays, it usually feels crowded. But when I travel at a similar time on weekends, it usually not that crowded as weekdays.

This made me think, is it just something I have noticed, or do people actually use public transport differently on weekdays and weekends? I was also curious to know whether trains and buses follow the same pattern and if school holidays make any difference.

My main question is: How does public transport usage in Victoria change between weekdays and weekends, during school holidays, and in different types of transport throughout the year?

Who is this story for?

This story is for anyone who uses public transport in Melbourne or in regional Victoria. It may also be interesting for people who want to know how travel patterns change on different days.

What will we explore?

I have created four interactive charts to understand how people use public transport in Victoria. First, I look at how public transport usage has changed over time.Then, I look at which types of public transport have had the biggest increase in passenger numbers. Next, I explore whether people use public transport differently during school holidays. Finally, I go to the question that made me choose this topic: How different is public transport usage on weekdays and weekends?

The data show calculated passenger boardings, which means the number of times people get on public transport. They don’t tell us why people are travelling or how crowded a particular tram is, but they give us a better idea of the overall travel patterns.

How to explore the charts

All four charts are interactive. You can move your mouse over them to see more details or zoom in to take a closer look. In Chart 4, you can click Focus: tram vs bus to compare trams and buses, or Show all five to see all five types of transport together. You can also click the names in the legend to hide or show different lines.

If you want to see how I created the charts, you can click Code to view the R code I have used.

Data Preparation and Methods

For this project, I used two datasets from the Victorian Department of Transport and Planning (DTP, 2026a, 2026b).

The first dataset shows how many times people got on six different types of public transport each month. The second shows average daily passenger numbers for five types of transport, including weekdays, weekends and school holidays.

I downloaded both datasets as CSV files and used R to clean and study the data. I also used ggplot2 and plotly to make my charts interactive and R Markdown to put my report together.

I started by loading both CSV files into R. I checked for missing values, incorrect numbers, repeated rows and negative passenger numbers. I also made sure the data was in the right format for my charts.

monthly_path <- "monthly_public_transport_patronage_by_mode.csv"
daytype_path <- "monthly_average_patronage_by_day_type.csv"

if (!file.exists(monthly_path) || !file.exists(daytype_path)) {
  stop("Place both CSV files alongside this .Rmd in your RStudio project folder.")
}

# Read monthly data as text first to detect invalid passenger values.
monthly_raw <- read_csv(monthly_path, col_types = cols(.default = col_character()))
names(monthly_raw) <- names(monthly_raw) |>
  str_to_lower() |>
  str_replace_all("[^[:alnum:]]+", "_") |>
  str_replace_all("^_|_$", "")

mode_cols <- c("metropolitan_train", "metropolitan_tram", "metropolitan_bus",
               "v_line_train", "v_line_coach", "regional_bus")
stopifnot(all(c("year", "month", mode_cols) %in% names(monthly_raw)))

bad_cells <- monthly_raw |>
  pivot_longer(all_of(mode_cols), names_to = "mode", values_to = "original_value") |>
  filter(is.na(original_value) | !str_detect(original_value, "^[0-9]+$")) |>
  select(year, month, mode, original_value)

monthly <- monthly_raw |>
  mutate(year = as.integer(year), month = as.integer(month),
         date = as.Date(sprintf("%04d-%02d-01", year, month))) |>
  pivot_longer(all_of(mode_cols), names_to = "mode", values_to = "original_value") |>
  mutate(
    boardings = as.numeric(if_else(str_detect(original_value, "^[0-9]+$"),
                                   original_value, NA_character_)),
    mode = recode(mode,
      metropolitan_train = "Metropolitan train",
      metropolitan_tram = "Metropolitan tram",
      metropolitan_bus = "Metropolitan bus",
      v_line_train = "V/Line train",
      v_line_coach = "V/Line coach",
      regional_bus = "Regional bus")
  ) |>
  select(date, year, month, mode, boardings) |>
  arrange(date, mode)

travel <- read_csv(daytype_path, show_col_types = FALSE) |>
  mutate(Year = as.integer(Year), Month = as.integer(Month),
         Pax_daily = as.numeric(Pax_daily))
stopifnot(all(c("Year", "Month", "Day_of_week", "Day_type", "Mode", "Pax_daily") %in% names(travel)))
Summary of data-quality checks
dataset rows columns original_missing_cells duplicate_rows negative_passenger_values
Monthly CSV 102 9 0 0 0
Daily CSV 4450 8 0 0 0

I found one incorrect value (940930Jan-25) for regional buses in November 2024. Since I wasn’t sure what the correct number should be, I marked it as missing (NA) instead of guessing.

This did not affect my comparison between January–June 2025 and January–June 2026 because the incorrect value was from November 2024.

1.Six Different Travel Patterns

First, I wanted to see how public transport usage in Victoria has changed over the past few years. This chart shows the monthly passenger numbers for metropolitan trains, trams, buses, V/Line trains, V/Line coaches and regional buses from January 2022 to June 2026.

I used different colours and separate graphs for each type of transport to make the changes easier to understand.

One thing to keep in mind: Each graph uses a different scale, so we cannot directly compare the height of the lines between the graphs.

trend <- monthly |>
  filter(date >= as.Date("2022-01-01"), !is.na(boardings)) |>
  mutate(hover_label = paste0(
    mode, "<br>", format(date, "%b %Y"),
    "<br>Estimated boardings: ", comma(boardings)
  ))

p1 <- ggplot(
  trend,
  aes(x = date, y = boardings, colour = mode,
      group = mode, text = hover_label)
) +
  geom_line(linewidth = 0.95, show.legend = FALSE) +
  facet_wrap(~mode, ncol = 2, scales = "free_y") +
  scale_colour_manual(values = mode_palette, guide = "none") +
  scale_y_continuous(labels = label_number(scale = 1e-6, suffix = "m")) +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  labs(
    title = "Six services, six patterns of use",
    subtitle = "Monthly estimated boardings, Jan 2022–Jun 2026 | Different scale for each mode",
    x = NULL, y = "Estimated boardings (millions)",
    caption = "Source: Victorian Department of Transport and Planning. One boarding is not one unique person."
  ) +
  theme_minimal(base_size = 12) +
  theme(
    legend.position = "none",  # Panel labels already identify each type.
    panel.grid.minor = element_blank(),
    strip.text = element_text(face = "bold", size = 11),
    plot.title = element_text(face = "bold", size = 15),
    plot.caption = element_text(hjust = 0, size = 9),
    axis.text.x = element_text(size = 9),
    axis.text.y = element_text(size = 9)
  )

ggplotly(p1, tooltip = "text", width = 1060, height = 920) |>
  layout(
    showlegend = FALSE,       # Prevents cut-off legend on the right.
    margin = list(l = 115, r = 40, t = 120, b = 95)
  )

What can we see?

Looking at the chart, I noticed that each type of transport has a different pattern. Some have increased more than others over the years.

One thing that i also noticed that V/Line train usage had increased a lot. In 2022, there were around 14.8 million boardings, but by 2025, this had increased to 25.7 million.

This shows how public transport usage has changed over time, but it doesn’t really answer my question about weekdays and weekends. So, before looking at that, I wanted to find out which types of transport have grown the most recently.

2. Are All Types of Transport Growing at the Same Rate?

For my second chart, I looked at the number of passenger boardings from January to June 2025 and compared them with the same months in 2026.

I used the same months in both years to get a better comparison. I also used different colours to make it easier to see which types of transport had grown the most.

# Compare the same six months in 2025 and 2026.
yoy <- monthly |>
  filter(year %in% c(2025, 2026), month <= 6, !is.na(boardings)) |>
  group_by(mode, year) |>
  summarise(total = sum(boardings), n_months = n_distinct(month),
            .groups = "drop") |>
  pivot_wider(names_from = year, values_from = c(total, n_months),
              names_glue = "{.value}_{year}") |>
  filter(n_months_2025 == 6, n_months_2026 == 6) |>
  mutate(
    change_pct = 100 * (total_2026 / total_2025 - 1),
    hover_label = paste0(
      mode, "<br>Jan–Jun 2025: ", comma(total_2025), " boardings",
      "<br>Jan–Jun 2026: ", comma(total_2026), " boardings",
      "<br>Change: ", sprintf("%+.1f%%", change_pct)
    )
  ) |>
  arrange(change_pct)

# Each bar uses the same colour as that transport type in Chart 1.
plot_ly(
  data = yoy, x = ~change_pct, y = ~mode,
  type = "bar", orientation = "h",
  marker = list(color = unname(mode_palette[yoy$mode])),
  text = ~sprintf("%+.1f%%", change_pct),
  textposition = "outside", cliponaxis = FALSE,
  hovertext = ~hover_label, hoverinfo = "text", showlegend = FALSE
) |>
  layout(
    title = list(text = "Which types of transport changed the most?"),
    xaxis = list(
      title = "Change in estimated boardings (%)", ticksuffix = "%",
      zeroline = TRUE, zerolinecolor = "#64748B",
      range = c(min(-3, min(yoy$change_pct) * 1.3),
                max(5, max(yoy$change_pct) * 1.35))
    ),
    yaxis = list(title = "", categoryorder = "array", categoryarray = yoy$mode),
    margin = list(l = 185, r = 105, t = 100, b = 85),
    hovermode = "closest"
  )

What did I find?

Looking at the chart, I noticed that metropolitan trains and trams had different growth rates. Metropolitan train usage increased by +18.3%, while tram usage increased by +2.0%

These numbers show how passenger usage has changed. One thing to keep in mind is that the Department of Transport and Planning changed how they counted tram passengers in January 2026. They also used different methods when public transport was free. This means the numbers from 2025 and 2026 might not be fully comparable (DTP, 2026b).

3. Do School Holiday Weekdays Look Different?

Next, I wanted to see if school holidays make any difference to public transport usage.

For this chart, I compared school holiday weekdays with normal weekdays from the same month. For example, I compared a Monday during the school holidays with normal Mondays in that same month.

I used data from January to November 2025 and calculated the percentage differences. I then used the median, to show the usual change for each type of transport.

I did not include December because the travel arrangements towards the end of 2025 made it harder to compare fairly.

# Compare school holiday and normal weekdays in the same month and weekday.
holiday_pairs <- travel |>
  filter(Year == 2025, Month <= 11,
         Day_type %in% c("Normal Weekday", "School Holiday Weekday")) |>
  select(Year, Month, Day_of_week, Mode, Day_type, Pax_daily) |>
  pivot_wider(names_from = Day_type, values_from = Pax_daily) |>
  filter(!is.na(`Normal Weekday`), !is.na(`School Holiday Weekday`),
         `Normal Weekday` > 0) |>
  mutate(change_pct = 100 * (`School Holiday Weekday` / `Normal Weekday` - 1))

holiday_summary <- holiday_pairs |>
  group_by(Mode) |>
  summarise(median_change = median(change_pct), n_pairs = n(),
            .groups = "drop") |>
  mutate(mode = recode(Mode,
    MetroTrain = "Metropolitan train", Tram = "Metropolitan tram",
    MetroBus = "Metropolitan bus", RegionalTrain = "Regional train",
    RegionalBus = "Regional bus")) |>
  arrange(median_change) |>
  mutate(
    # Place labels away from the dots, to the left for negative values
    # and to the right for nonnegative values.
    label_x = median_change + if_else(median_change < 0, -0.85, 0.85),
    label_hjust = if_else(median_change < 0, 1, 0),
    hover_label = paste0(
      mode, "<br>School holiday vs normal weekday: ",
      sprintf("%+.1f%%", median_change),
      "<br>Based on ", n_pairs, " matched month/weekday pairs"
    )
  )

p3 <- ggplot(
  holiday_summary,
  aes(y = reorder(mode, median_change), colour = mode, text = hover_label)
) +
  geom_vline(xintercept = 0, colour = "#64748B", linewidth = 0.7) +
  geom_segment(
    aes(x = 0, xend = median_change,
        yend = reorder(mode, median_change)),
    linewidth = 1.15, show.legend = FALSE
  ) +
  geom_point(aes(x = median_change), size = 3.8, show.legend = FALSE) +
  geom_text(
    aes(
  x = label_x,
  label = ifelse(
    abs(median_change) < 0.05,
    "≈0%",
    sprintf("%+.1f%%", median_change)
  ),
  hjust = label_hjust
),
    colour = "#263746", size = 3.5, show.legend = FALSE
  ) +
  scale_colour_manual(values = mode_palette, guide = "none") +
  scale_x_continuous(
    labels = label_number(suffix = "%", accuracy = 1),
    limits = c(min(holiday_summary$median_change) - 5,
               max(holiday_summary$median_change) + 5)
  ) +
  labs(
    title = "School holidays affect different services differently",
    subtitle = "Median change for matched weekdays, Jan–Nov 2025",
    x = "Change compared with a normal weekday", y = NULL,
    caption = "Source: Victorian Department of Transport and Planning. These comparisons do not establish cause."
  ) +
  theme_minimal(base_size = 12) +
  theme(
    legend.position = "none",
    panel.grid.major.y = element_blank(), panel.grid.minor = element_blank(),
    plot.title = element_text(face = "bold", size = 15),
    plot.caption = element_text(hjust = 0, size = 9)
  )

ggplotly(p3, tooltip = "text", width = 1060, height = 580) |>
  layout(showlegend = FALSE, margin = list(l = 185, r = 95, t = 110, b = 100))

What did I find?

Looking at the chart, I noticed a clear difference between regional train and regional bus usage during school holidays.

Regional train usage increased by 6.2%, while regional bus usage decreased by 11.7% compared with normal weekdays.

I found this interesting because trains and buses showed different travel patterns during school holidays. However, I cannot say that school holidays were the only reason for these changes.

4. What Changes When the Weekend Arrives?

This is the part I was most interested in because it brings me back to what I noticed while travelling to work.

I wanted to see how public transport usage changes from weekdays to weekends and if the same pattern can be seen in trains and buses.

For this chart, I used data from January to November 2025 and looked at passenger usage from Monday to Sunday for five types of transport.

To make the comparison easier, I compared each type of transport with its own normal weekday usage in the same month. I used 100% to show normal weekday usage, so I could see how much it changed on other days.

The dashed line at 100% shows the normal weekday level.

The chart first shows all five types of transport in different colours. You can click Focus: tram vs bus to look at just trams and buses or Show all five to see everything again. You can also click the names in the legend to hide or show different lines.

# Compare each day with the normal-weekday average of that mode/month.
weekday_order <- c("Monday", "Tuesday", "Wednesday", "Thursday",
                   "Friday", "Saturday", "Sunday")

weekly_profile <- travel |>
  filter(Year == 2025, Month <= 11,
         Day_type %in% c("Normal Weekday", "Weekend")) |>
  group_by(Month, Mode) |>
  mutate(normal_weekday_avg = mean(
    Pax_daily[Day_type == "Normal Weekday"], na.rm = TRUE
  )) |>
  ungroup() |>
  filter(is.finite(normal_weekday_avg), normal_weekday_avg > 0) |>
  mutate(percentage = 100 * Pax_daily / normal_weekday_avg) |>
  group_by(Mode, Day_of_week) |>
  summarise(typical_pct = median(percentage, na.rm = TRUE),
            months = n_distinct(Month), .groups = "drop") |>
  mutate(
    day = factor(Day_of_week, levels = weekday_order),
    mode = recode(Mode,
      Tram = "Metropolitan tram", MetroTrain = "Metropolitan train",
      MetroBus = "Metropolitan bus", RegionalTrain = "Regional train",
      RegionalBus = "Regional bus")
  )

mode_order <- c("Metropolitan tram", "Metropolitan bus",
                "Metropolitan train", "Regional train", "Regional bus")

fig4 <- plot_ly()
for (m in mode_order) {
  df <- weekly_profile |>
    filter(mode == m) |>
    arrange(day) |>
    mutate(hover_label = paste0(
      mode, "<br>", Day_of_week, ": ",
      sprintf("%.1f%%", typical_pct), " of normal-weekday usage",
      "<br>Based on ", months, " available months"
    ))

  # All five lines visible; trams/buses are thicker to highlight the story.
  main_mode <- m %in% c("Metropolitan tram", "Metropolitan bus")
  fig4 <- fig4 |>
    add_trace(
      data = df, x = ~day, y = ~typical_pct,
      type = "scatter", mode = "lines+markers", name = m,
      visible = TRUE,
      line = list(color = unname(mode_palette[[m]]),
                  width = if (main_mode) 4 else 2.5),
      marker = list(color = unname(mode_palette[[m]]),
                    size = if (main_mode) 9 else 7),
      opacity = if (main_mode) 1 else 0.8,
      hovertext = ~hover_label, hoverinfo = "text"
    )
}

fig4 |>
  layout(
    title = list(text = "How does usage change throughout the week?"),
    xaxis = list(title = "Day of the week", type = "category",
                 categoryorder = "array", categoryarray = weekday_order),
    yaxis = list(title = "Usage compared with a normal weekday (%)",
                 ticksuffix = "%", range = c(0, 125)),
    shapes = list(list(
      type = "line", x0 = 0, x1 = 1, xref = "paper",
      y0 = 100, y1 = 100, yref = "y",
      line = list(color = "#64748B", width = 1.5, dash = "dash")
    )),
    annotations = list(list(
      x = 0.98, y = 100, xref = "paper", yref = "y",
      text = "Normal weekday = 100%", showarrow = FALSE,
      xanchor = "right", yanchor = "bottom",
      font = list(size = 11, color = "#48576A")
    )),
    updatemenus = list(list(
      type = "buttons",
      direction = "right",
      x = 0, y = 1.18,
      xanchor = "left", yanchor = "bottom",
      showactive = TRUE,
      buttons = list(
        list(
          label = "Focus: tram vs bus",
          method = "update",
          args = list(list(visible = c(TRUE, TRUE, "legendonly", "legendonly", "legendonly")))
        ),
        list(
          label = "Show all five",
          method = "update",
          args = list(list(visible = rep(TRUE, 5)))
        )
      )
    )),
    legend = list(orientation = "h", x = 0.5, xanchor = "center",
                  y = -0.35, itemclick = "toggle",
                  itemdoubleclick = "toggleothers"),
    margin = list(l = 135, r = 55, t = 155, b = 170),
    hovermode = "closest"
  )

What did I find?

Looking at the chart, I noticed a clear difference between tram and bus usage on Saturdays.

Tram usage was around 90.8% of its normal weekday level, while metropolitan bus usage was only around 51.2%.

I found this interesting because tram usage stayed much closer to its normal weekday level than bus usage did. This helped me understand how public transport usage changes on weekends.

Conclusion — Different Days, Different Travel Patterns

When I first started this project, I wanted to find out why the tram arrival times shown in the app were sometimes different from what actually happened. However, I couldn’t find the right data to answer that question.

So, I decided to explore something else I had noticed while travelling to work. Trams often felt busy on weekdays but quieter when I travelled at a similar time on weekends.

After exploring the data, I found that public transport usage changes depending on the day and the type of transport. I noticed that metropolitan train usage increased by 18.3% between January–June 2025 and the same period in 2026. During school holidays, regional train usage increased by 6.2%, while regional bus usage decreased by 11.7% compared with normal weekdays.

The fourth chart was the most interesting to me because it connected directly to my own experience. On Saturdays, tram usage was around 90.8% of its normal weekday level, while metropolitan bus usage was only 51.2%. This showed me that people still used trams quite a lot on Saturdays, while bus usage dropped much more.

I also realised that fewer buses or trams might be running on certain days, which could affect passenger numbers. Since my datasets don’t show how many services were running, I cannot say for sure what caused these differences.

The main thing I learned is that different types of public transport have different travel patterns. Although the data couldn’t tell me how crowded a particular tram was or whether the app’s arrival times were correct, it helped me better understand how public transport is used in Victoria.

What the Data Cannot Tell Us

While working on this project, I noticed a few limitations.

The passenger numbers count boardings, not individual people. My datasets also cannot tell me why people are travelling, how crowded a particular tram or bus is at a certain time, or whether the arrival times shown in the tram app are correct.

The datasets also don’t show how many services were running each day, so I cannot say for sure what caused the differences in passenger numbers.

References

Baglin, J. (2026). Data visualisation: From theory to practice. https://data-visualisation.stem.melbourne/

Department of Transport and Planning. (2026a). Monthly average patronage by day type and by mode [Data set]. Transport Victoria Open Data. https://opendata.transport.vic.gov.au/dataset/monthly-average-patronage-by-day-type-and-by-mode

Department of Transport and Planning. (2026b). Monthly public transport patronage by mode [Data set]. Transport Victoria Open Data. https://opendata.transport.vic.gov.au/dataset/monthly-public-transport-patronage-by-mode

R Core Team. (2025). R: A language and environment for statistical computing [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/

Acknowledgement

I used ChatGPT (OpenAI, 2026) to help troubleshoot problems with my R code and understand some of the results. I reviewed the suggestions, checked my results and took responsibility for the final submission.

I used R and RStudio to import, clean, analyse and prepare the data. I used the ggplot2 and plotly packages to create interactive charts.