Year in Review: The 2025-26 MLV Season

Volleyball
Pro
Featured
Season Recap
A full season of Major League Volleyball box scores, scraped from PDF scoresheets and parsed into BigQuery: the league’s most productive hitter, and how the No. 2 seed knocked out an upset-minded No. 4 for the title.
Published

July 9, 2026

The MLV pipeline is the odd one out on this site: every other pipeline here reads a first-party JSON API directly, but MLV (provolleyball.com’s own Major League Volleyball) only publishes per-player box scores as PDF scoresheets attached to its schedule API. This pipeline downloads each match’s PDF, extracts the text, and parses it back into structured rows, working around two different scoresheet templates the league used across the season (see the pipeline’s DEPLOY.md for the full story). This post is the first analysis built on the result: every completed match from January through the May championship, backfilled into BigQuery.

One exclusion worth flagging up front: schedule_event 636 (Team Launiere vs. Team Meske, March 28) is an All-Star exhibition with draft-style team names, not a real matchup between league teams. It’s filtered out of every query below, the same way the hockey year in review filters out Olympic break games.

Querying the season

from google.cloud import bigquery

client = bigquery.Client()

query = """
SELECT
  player_name,
  team,
  SUM(points) AS points,
  SUM(kills) AS kills,
  SUM(attack_attempts) AS attempts,
  SAFE_DIVIDE(SUM(kills), SUM(attack_attempts)) AS kill_pct,
  COUNT(DISTINCT schedule_event_id) AS matches
FROM `maydaystats.mlv_volleyball.boxscores`
WHERE team NOT IN ('Team Launiere', 'Team Meske')
GROUP BY player_name, team
ORDER BY points DESC
LIMIT 10
"""

leaders = client.query(query).to_dataframe()
leaders
player_name team points kills attempts kill_pct matches
0 Mimi Colyer Dallas Pulse 516.0 455.0 1180.0 0.385593 28
1 Diaz Maldonado Dallas Pulse 509.0 445.0 1113.0 0.399820 28
2 Raina Terry Columbus Fury 398.0 344.0 1048.0 0.328244 24
3 Leah Edmond Atlanta Vibe 387.0 338.0 984.0 0.343496 24
4 Grace Loberg San Diego Mojo 361.0 301.0 998.0 0.301603 26
5 Paige Briggs-Romine Grand Rapids Rise 351.0 315.0 931.0 0.338346 25
6 Carli Snyder Grand Rapids Rise 334.0 293.0 956.0 0.306485 26
7 Brooke Nuneviller Omaha Supernovas 325.0 306.0 950.0 0.322105 28
8 Azhani Tealer Indy Ignite 319.0 287.0 634.0 0.452681 26
9 Aiko Jones Atlanta Vibe 316.0 273.0 789.0 0.346008 24

Mimi Colyer of Dallas Pulse led the league with 516 points on 455 kills across 28 matches. Her teammate Diaz Maldonado finished second, meaning the league’s two most productive hitters played for the same team all season, a preview of who’d end up cutting down the net in May.

The top ten, by points

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(9, 5))
labels = leaders["player_name"] + " (" + leaders["team"] + ")"
ax.barh(labels[::-1], leaders["points"][::-1], color="#2c3e50")
ax.set_xlabel("Points")
ax.set_title("Top 10 Scorers, 2025-26 MLV Season")
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout()
plt.show()
Figure 1: Top 10 point scorers, 2025-26 MLV regular season and playoffs combined

Digs, aces, and assists

digs_query = """
SELECT player_name, team, SUM(digs) AS total
FROM `maydaystats.mlv_volleyball.boxscores`
WHERE team NOT IN ('Team Launiere', 'Team Meske')
GROUP BY player_name, team
ORDER BY total DESC
LIMIT 1
"""
digs_leader = client.query(digs_query).to_dataframe()

assists_query = """
SELECT player_name, team, SUM(assists) AS total
FROM `maydaystats.mlv_volleyball.boxscores`
WHERE team NOT IN ('Team Launiere', 'Team Meske')
GROUP BY player_name, team
ORDER BY total DESC
LIMIT 1
"""
assists_leader = client.query(assists_query).to_dataframe()

aces_query = """
SELECT player_name, team, SUM(service_aces) AS total
FROM `maydaystats.mlv_volleyball.boxscores`
WHERE team NOT IN ('Team Launiere', 'Team Meske')
GROUP BY player_name, team
ORDER BY total DESC
LIMIT 1
"""
aces_leader = client.query(aces_query).to_dataframe()

Shara Venegas (San Diego Mojo) led all players in digs with 320. Marlie Monserez (San Diego Mojo) ran the offense from the back row, topping the league with 1067 assists. And Natalie Foster (Orlando Valkyries) led in aces.

The best record didn’t win it either

standings_query = """
WITH regular_season AS (
  SELECT * FROM `maydaystats.mlv_volleyball.matches`
  WHERE schedule_event_id NOT IN (637, 638, 639)
),
team_results AS (
  SELECT first_team_name AS team,
    IF(first_team_score > second_team_score, 1, 0) AS win
  FROM regular_season
  UNION ALL
  SELECT second_team_name AS team,
    IF(second_team_score > first_team_score, 1, 0) AS win
  FROM regular_season
)
SELECT team, SUM(win) AS wins, COUNT(*) - SUM(win) AS losses
FROM team_results
GROUP BY team
ORDER BY wins DESC
LIMIT 5
"""

standings = client.query(standings_query).to_dataframe()
standings
team wins losses
0 Indy Ignite 20 5
1 Dallas Pulse 18 8
2 San Diego Mojo 13 12
3 Omaha Supernovas 12 14
4 Grand Rapids Rise 11 15

Indy Ignite had the best regular-season record in the league at 20-5, good enough for the No. 1 seed in a four-team playoff. It didn’t matter: the No. 4 seed knocked them out in the semifinal, the same shape as this site’s NHL year in review, where Colorado had the league’s best regular season and still lost in the Western Conference Final. The best regular season and the tournament that actually decides the title are two different contests.

The playoffs

import re

playoff_query = """
SELECT schedule_event_id, game_date, first_team_name, first_team_score,
  second_team_name, second_team_score
FROM `maydaystats.mlv_volleyball.matches`
WHERE schedule_event_id IN (637, 638, 639)
ORDER BY game_date
"""
playoffs = client.query(playoff_query).to_dataframe()

# Playoff schedule-events carry a seed prefix in the raw team name
# ("No. 4 Omaha Supernovas") that the regular-season rows don't - strip
# it for display, same cleanup the pipeline itself does before matching
# team names against the PDF text (see fetch.py's _strip_seed).
seed_re = re.compile(r"^No\.\s*\d+\s+")
for col in ("first_team_name", "second_team_name"):
    playoffs[col] = playoffs[col].str.replace(seed_re, "", regex=True)

playoffs
schedule_event_id game_date first_team_name first_team_score second_team_name second_team_score
0 637 2026-05-07T23:00:00.000000Z Indy Ignite 2.0 Omaha Supernovas 3.0
1 638 2026-05-08T00:00:00.000000Z Dallas Pulse 3.0 San Diego Mojo 1.0
2 639 2026-05-09T19:00:00.000000Z Dallas Pulse 3.0 Omaha Supernovas 2.0

Four teams made the playoffs, seeded 1 through 4 on regular-season record, in a single-elimination bracket: 1-seed vs. 4-seed, 2-seed vs. 3-seed, winners meet in the final. The No. 4 seed Omaha Supernovas beat the No. 1 seed Indy Ignite 3-2 in the semifinal, while the No. 2 seed Dallas Pulse handled the No. 3 seed San Diego Mojo in four sets to reach the final.

The semifinal upset

semifinal_query = """
SELECT team, SUM(kills) AS kills, SUM(attack_attempts) AS attempts,
  SUM(service_errors) AS service_errors,
  SUM(reception_errors) AS reception_errors, SUM(total_blocks) AS blocks
FROM `maydaystats.mlv_volleyball.boxscores`
WHERE schedule_event_id = 637
GROUP BY team
"""
semifinal_team_totals = client.query(semifinal_query).to_dataframe()
semifinal_team_totals
team kills attempts service_errors reception_errors blocks
0 Indy Ignite 70.0 187.0 17.0 4.0 12.0
1 Omaha Supernovas 63.0 178.0 13.0 1.0 13.0

The team totals are the surprise: Indy Ignite actually out-hit Omaha in this match, 70 kills on 187 attempts to Omaha’s 63 kills on 178 attempts, and blocking was close, 12 to 13. Indy lost the match anyway, and the reason shows up in two other columns rather than hitting: Indy committed 17 service errors and 4 reception errors, against Omaha’s 13 and 1. Four extra free points from bad passing on top of four extra serves into the net or out of bounds accounts for most of the margin in a match that went the full five sets.

semifinal_players_query = """
SELECT player_name, team, kills, attack_attempts, total_blocks
FROM `maydaystats.mlv_volleyball.boxscores`
WHERE schedule_event_id = 637
  AND player_name IN ('Leketor Member-Meneh', 'Janice Leao')
"""
semifinal_players = client.query(semifinal_players_query).to_dataframe()
semifinal_players
player_name team kills attack_attempts total_blocks
0 Janice Leao Omaha Supernovas 10.0 17.0 4.0
1 Leketor Member-Meneh Indy Ignite 6.0 22.0 NaN

The individual swing goes the other way. Leketor Member-Meneh came in averaging 9.7 kills a match on a .367 hitting percentage all season, Indy’s second-most productive hitter behind Azhani Tealer. Against Omaha she went 6-for-22, a .273 clip on well below her usual number of swings. Omaha, meanwhile, got a night nobody could have projected from its bench: Janice Leao entered the match averaging 1.6 kills and 1.3 blocks per match on a .344 hitting percentage for the season, and went 10-for-17 (.588) with 4 blocks, by a wide margin her best match of the year, in the one match her team needed it most.

final_query = """
SELECT player_name, team, kills, points, digs, total_blocks
FROM `maydaystats.mlv_volleyball.boxscores`
WHERE schedule_event_id = 639
ORDER BY points DESC
LIMIT 5
"""
final_box = client.query(final_query).to_dataframe()
final_box
player_name team kills points digs total_blocks
0 Diaz Maldonado Dallas Pulse 26.0 27.0 11.0 1.0
1 Merritt Beason Omaha Supernovas 14.0 20.0 6.0 5.0
2 Brooke Nuneviller Omaha Supernovas 15.0 16.0 22.0 1.0
3 Parsons Wilhite Omaha Supernovas 12.0 16.0 19.0 1.0
4 Kaylee Cox Dallas Pulse 9.0 13.0 11.0 4.0

Dallas Pulse beat Omaha Supernovas 3-2 in the final to win the title, powered by Diaz Maldonado’s 26 kills and 27 points, a clean encore of the two-hitter attack that carried the team all season. The runner-up’s semifinal upset ran out of steam one match short of the trophy.

What’s next

This covers the season at a high level: the league leaderboards, the regular-season standings, and the playoff bracket itself. The same table supports much narrower questions too, like a single hitter’s efficiency swings across the two scoresheet eras, or how a team’s block numbers held up on the road. Those are posts for another day, now that a full season of validated data is sitting in BigQuery.

The pipeline code behind this post lives here.

Note

Every match row in this dataset carries a checksum_ok flag: the pipeline sums each parsed player’s stats and cross-checks the total against the scoresheet’s own printed team total, and flags anything that doesn’t match exactly rather than trusting it silently. 88 of the 99 matches in the dataset check out clean; the other 11 are flagged for a small, isolated discrepancy in one column (attack attempts) that traces back to the scoresheet’s own printed total rather than a parsing error - see the pipeline’s DEPLOY.md for the full investigation. Nothing in this post depends on that column, but a query that does should filter WHERE checksum_ok.

Like the other posts on this site, this one uses Quarto’s frozen execution (freeze: auto): the queries above ran once, locally, against BigQuery, and the deployed site reuses that committed output rather than re-querying on every build.