Skip to main content

CFB conference table

The brief: the season-review newsletter needs the final Big Ten standings as an image: 1600 px wide for the email, plus a square cut for social. Indiana went 16-0 and won the national title, so the table should make that obvious. The records are built from the cfbfastR schedule through sportsdataverse.cfb, and the table is great_tables with sdvplot's logo, theme and export helpers.

import tempfile
from pathlib import Path

import polars as pl
import sportsdataverse.cfb as cfb
from great_tables import GT, html, loc, nanoplot_options, style
from IPython.display import Image
from PIL import Image as PILImage

import sdvplot
from sdvplot.great_tables import gt_save_crop, gt_sdv_logos, gt_social_crop, gt_theme_sdv

SEASON = 2025
CONFERENCE = "Big Ten"
OUT = Path(tempfile.mkdtemp(prefix="sdvplot-recipe-")) # where the exports go; use your own folder

1. Get the data​

The schedule has one row per game. Stacking the home and away sides gives one row per team per game, which makes every record a group_by. The ESPN team ids arrive as integers; they become strings once, at the boundary, because sdvplot's team_id is always a string. The margins are kept in date order as a list, one value per game, for a small chart later.

schedule = cfb.load_cfb_schedule([SEASON]).filter(pl.col("completed"))


def side(me, opp):
return schedule.select(
"start_date",
"season_type",
"conference_game",
"notes",
team_id=pl.col(f"{me}_id").cast(pl.Utf8),
team=f"{me}_team",
conference=f"{me}_conference",
opponent=f"{opp}_team",
pf=f"{me}_points",
pa=f"{opp}_points",
)


games = (
pl.concat([side("home", "away"), side("away", "home")])
.filter(pl.col("conference") == CONFERENCE)
.sort("start_date")
.with_columns(won=pl.col("pf") > pl.col("pa"))
)
in_conf = pl.col("conference_game")
standings = (
games.group_by("team_id", "team")
.agg(
conf_w=(pl.col("won") & in_conf).sum(),
conf_l=(~pl.col("won") & in_conf).sum(),
w=pl.col("won").sum(),
l=(~pl.col("won")).sum(),
pf=pl.col("pf").mean(),
pa=pl.col("pa").mean(),
margins=pl.col("pf") - pl.col("pa"),
# the last game: where a bowl or the playoff shows up
last_type=pl.col("season_type").last(),
last_won=pl.col("won").last(),
last_score=pl.format("{}-{}", pl.max_horizontal("pf", "pa"), pl.min_horizontal("pf", "pa")).last(),
last_opponent=pl.col("opponent").last(),
last_event=pl.col("notes").last(),
)
.sort(["conf_w", "w", "team"], descending=[True, True, False])
)
standings.head()
team_idteamconf_wconf_lwlpfpamarginslast_typelast_wonlast_scorelast_opponentlast_event
84Indiana9016041.62511.6875[13, 47, … 6]postseasontrue27-21MiamiCollege Football Playoff National Championship Presented by AT&T
194Ohio State9012233.4285719.285714[7, 70, … -10]postseasonfalse24-14MiamiCollege Football Playoff Quarterfinal at the Goodyear Cotton Bowl Classic
2483Oregon8113236.93333317.866667[46, 66, … -34]postseasonfalse56-22IndianaCollege Football Playoff Semifinal at the Chick-fil-A Peach Bowl
130Michigan729427.53846220.384615[17, -11, … -14]postseasonfalse41-27TexasCheez-It Citrus Bowl
30USC729435.76923123.0[60, 39, … -3]postseasonfalse30-27TCUValero Alamo Bowl

2. The first draft​

Hand the frame to great_tables as it is.

GT(standings)

Every number is there, and none of it is readable: ids, a list printed as text, a dozen decimals and column names only the analyst knows.

3. Shape it for a reader​

Records read as "9-0", not two columns. Each team's last game becomes one short line ("W 27-21 vs Miami, CFP National Championship"), which is where the national title shows up. Columns get real labels, conference and overall records sit under spanners, and the averages get one decimal.

event = (
pl.col("last_event")
.str.replace(" Presented by.*", "")
.str.replace(" at the .*", "")
.str.replace("College Football Playoff", "CFP")
)
postseason = (
pl.when(pl.col("last_type") == "postseason")
.then(
pl.format(
"{} {} vs {}, {}",
pl.when("last_won").then(pl.lit("W")).otherwise(pl.lit("L")),
"last_score",
"last_opponent",
event,
)
)
.otherwise(pl.lit(""))
)

table = standings.with_columns(
conf=pl.format("{}-{}", "conf_w", "conf_l"),
overall=pl.format("{}-{}", "w", "l"),
postseason=postseason,
).select("team_id", "team", "conf", "overall", "pf", "pa", "margins", "postseason")

draft = (
GT(table)
.cols_hide(["team_id", "margins"])
.cols_label(team="Team", conf="W-L", overall="W-L", pf="Pts/G", pa="Opp/G", postseason="Postseason")
.tab_spanner("Conference", ["conf"])
.tab_spanner("Overall", ["overall", "pf", "pa"])
.fmt_number(["pf", "pa"], decimals=1)
.cols_align("center", ["conf", "overall", "pf", "pa"])
.cols_align("left", ["team", "postseason"])
)
draft

4. Logos and the season at a glance​

gt_sdv_logos turns the team_id column into logos; the ESPN ids resolve as they are. The margins list becomes a nanoplot, great_tables' in-cell bar chart: one bar per game, green for a win and red for a loss, so a perfect season is a solid green row.

margin_bars = nanoplot_options(
data_bar_fill_color="#2e8540",
data_bar_negative_fill_color="#c0392b",
data_bar_stroke_color="transparent",
data_bar_negative_stroke_color="transparent",
show_data_points=False,
show_reference_line=False,
show_vertical_guides=False,
show_y_axis_guide=False,
interactive_data_values=True, # values on hover only, so the saved image stays clean
)
with_marks = (
draft.cols_unhide(["team_id", "margins"])
.pipe(gt_sdv_logos, "team_id", league="cfb", season=SEASON, height=26)
.fmt_nanoplot("margins", plot_type="bar", autoscale=True, options=margin_bars)
.cols_label(team_id="", margins="Game by game")
.tab_spanner("Margin", ["margins"])
)
with_marks

5. Theme it and say what it means​

A theme does the typography and rules in one call (gt_theme_sdv, the SportsDataverse house style, here). The title says the news, the subtitle how to read the table, and the source note credits the data. Indiana's row gets a soft fill in its own red, and a footnote owns up to the ordering: teams tied on conference record are listed by overall record, which is not the conference's tiebreaker.

indiana = standings.filter(pl.col("team") == "Indiana")
fill = sdvplot.team_colors(indiana["team_id"][0], "cfb") + "1f" # the primary color at 12% opacity

final = (
with_marks.tab_header(
title=f"Indiana ran the table: 9-0 in the {CONFERENCE}, 16-0 overall and national champion",
subtitle=html(
f"Final {SEASON} {CONFERENCE} standings. Bars are each game's margin, in date order: "
"<span style='color:#2e8540'><b>wins</b></span> and "
"<span style='color:#c0392b'><b>losses</b></span>."
),
)
.tab_source_note("Data: cfbfastR via sportsdataverse-py | Table: sdvplot + great_tables")
.tab_footnote(
"Teams tied on conference record are listed by overall record, then by name.",
locations=loc.column_labels(columns="conf"),
)
.tab_style(style.fill(fill), loc.body(rows=pl.col("team") == "Indiana"))
.tab_style(style.text(weight="bold"), loc.body(columns="team", rows=pl.col("team") == "Indiana"))
.pipe(gt_theme_sdv)
)
final

6. Export for the newsletter and for social​

gt_save_crop renders the table in headless Chrome, trims it with an even border and, with width=, scales it to the email's 1600 px. gt_social_crop centers the same table on a square canvas for Instagram, never cropping it: a tall table just gets side padding.

newsletter = gt_save_crop(final, OUT / "big_ten_1600.png", width=1600)
square = gt_social_crop(final, OUT / "big_ten_1080x1080.png", aspect_ratio="1:1", width=1080)
for f in (newsletter, square):
print(Path(f).name, PILImage.open(f).size)
Image(newsletter, width=800)
big_ten_1600.png (1600, 1628)
big_ten_1080x1080.png (1080, 1080)

png

The square cut keeps the whole table and pads the sides:

Image(square, width=540)

png

Run it yourself​

Download the notebook (outputs cleared) or open it on GitHub.