An interesting first sports analysis does not need a model, a large feature set, or a complicated dashboard. It needs a question, enough data to answer it, and a result you can explain.
In this walkthrough, we will make three requests with the official CFBD Python client and turn them into a profile of Michigan’s 2023 national championship season. The finished chart compares Michigan with the full FBS across record, scoring, efficiency, explosiveness, and pace.
Plan on about 20-30 minutes the first time through. Once it works, changing two variables will let you reuse the same analysis for another team or completed season.
One important note before we start: this is a descriptive profile, not a causal model. It summarizes what happened, but does not prove why it happened.
Set up a safe Python environment
This example pins version 5.18.0 of the official CFBD Python client as the minimum version required. This is the version used to produce the results in this post. You will also need pandas for the tables and Matplotlib for the chart.
pip install “cfbd>=5.18.0” “pandas>=2.2,<4” “matplotlib>=3.8,<4”
If you do not already have an API key, request a CFBD key here. Store it in an environment variable named CFBD_API_KEY. Do not paste a real key into a notebook, script, screenshot, or public repository.
On macOS or Linux:
export CFBD_API_KEY=”your-key-here”
In Windows PowerShell:
$env:CFBD_API_KEY=”your-key-here”
Or you can paste the key into a .env file at the root of your project (my recommendation):
CFBD_API_KEY=your-key-here
Now import the packages, choose a team and season, and throw a failure message if the key is missing or misconfigured:
import os
from pprint import pprint
import cfbd
import matplotlib.pyplot as plt
import pandas as pd
# Feel free to change this to any team and year
TEAM = “Michigan”
YEAR = 2023
EXCLUDE_GARBAGE_TIME = True
api_key = os.getenv(“CFBD_API_KEY”)
if not api_key:
raise RuntimeError(
“CFBD_API_KEY is not set. Add it to your environment, restart the ”
“kernel, and run the notebook again. Do not paste the key into this notebook.”
)
configuration = cfbd.Configuration(access_token=api_key)
print(f”Ready to build a {YEAR} profile for {TEAM}. API key loaded safely.”)
The key is read-only at runtime, and the code never prints it.
Choose a small set of questions
The CFBD API exposes far more data than we need for a first profile. Importing every available field would make the cleanup harder without necessarily making the result more useful.
Instead, we will answer four focused questions:
How successful was the team overall?How productive, efficient, and explosive were the offense and defense?Did the team play fast or slow?What was one clear relative strength and one clear relative weakness?
Eight metrics are enough for this version. This keeps the result readable and makes each choice easier to explain.
Retrieve the smallest useful dataset
We will make three requests:
TeamsApi.get_fbs_teams grabs the list of FBS teams from the 2023 season for comparison.GamesApi.get_games fetches completed regular-season and postseason scores.StatsApi.get_advanced_season_stats returns efficiency, explosiveness, and play counts.
You can verify the available arguments in the official client docs for TeamsApi, GamesApi, and StatsApi.
with cfbd.ApiClient(configuration) as api_client:
teams_api = cfbd.TeamsApi(api_client)
games_api = cfbd.GamesApi(api_client)
stats_api = cfbd.StatsApi(api_client)
fbs_teams = teams_api.get_fbs_teams(year=YEAR)
games = games_api.get_games(
year=YEAR,
season_type=cfbd.SeasonType.BOTH,
classification=cfbd.DivisionClassification.FBS,
)
advanced_stats = stats_api.get_advanced_season_stats(
year=YEAR,
exclude_garbage_time=EXCLUDE_GARBAGE_TIME,
)
print(
f”Retrieved {len(fbs_teams)} FBS teams, {len(games)} games, ”
f”and {len(advanced_stats)} advanced team rows.”
)
For this run, the three requests returned 133 FBS teams, 910 games, and 133 advanced team rows.
Before transforming anything, inspect one returned object. The client returns typed Python models and calling .dict() will convert to a dictionary shape:
sample_game = next(
game for game in games
if game.home_team == TEAM or game.away_team == TEAM
)
pprint(sample_game.dict())
The first Michigan game in the response includes fields such as home_team, away_team, home_points, away_points, completed, season_type, and week. Looking at one real object before writing transformation code is a simple habit, but it prevents a lot of guessing about field names and response structure.
Reshape games into one row per team
A game response has one home team and one away team. Our analysis will be easier if each completed game becomes one row per FBS team, so points_for, points_against, and win_value mean the same thing regardless of location.
fbs_names = {team.school for team in fbs_teams}
def win_value(points_for, points_against):
“””Return 1 for a win, 0.5 for a tie, and 0 for a loss.”””
if points_for > points_against:
return 1.0
if points_for == points_against:
return 0.5
return 0.0
team_game_rows = []
for game in games:
if not game.completed:
continue
if game.home_points is None or game.away_points is None:
continue
if game.home_team in fbs_names:
team_game_rows.append(
{
“game_id”: game.id,
“team”: game.home_team,
“points_for”: game.home_points,
“points_against”: game.away_points,
“win_value”: win_value(game.home_points, game.away_points),
}
)
if game.away_team in fbs_names:
team_game_rows.append(
{
“game_id”: game.id,
“team”: game.away_team,
“points_for”: game.away_points,
“points_against”: game.home_points,
“win_value”: win_value(game.away_points, game.home_points),
}
)
team_games_df = pd.DataFrame(team_game_rows)
team_games_df.head()
Now summarize those rows into one season record per team:
season_results = team_games_df.groupby(“team”).agg(
games=(“game_id”, “nunique”),
wins=(“win_value”, “sum”),
points_for=(“points_for”, “sum”),
points_against=(“points_against”, “sum”),
).reset_index()
season_results[“win_pct”] = season_results[“wins”] / season_results[“games”]
season_results[“points_per_game”] = (
season_results[“points_for”] / season_results[“games”]
)
season_results[“points_allowed_per_game”] = (
season_results[“points_against”] / season_results[“games”]
)
season_results[season_results[“team”] == TEAM]
The result gives Michigan 15 games, 15 wins, 35.9 points per game, and 10.4 points allowed per game.
There is a season-definition detail worth calling out. SeasonType.BOTH includes regular-season and postseason games assigned to the 2023 season. The scoring averages also include completed games against non-FBS opponents when an FBS team participated. Those choices make sense for this profile, but another analysis could make different choices.
Select the advanced fields we actually need
Next, copy a small set of fields from the advanced response:
advanced_rows = []
for team_stats in advanced_stats:
if team_stats.team not in fbs_names:
continue
advanced_rows.append(
{
“team”: team_stats.team,
“conference”: team_stats.conference,
“offensive_success_rate”: team_stats.offense.success_rate,
“defensive_success_rate”: team_stats.defense.success_rate,
“offensive_explosiveness”: team_stats.offense.explosiveness,
“defensive_explosiveness”: team_stats.defense.explosiveness,
“offensive_plays”: team_stats.offense.plays,
}
)
advanced_df = pd.DataFrame(advanced_rows)
advanced_df.head()
The units and directions matter:
Success rate is a share of plays, so 0.45 means 45%. Higher is better for offense; lower is better for defense.Explosiveness is CFBD’s advanced explosiveness value. Higher is better for offense; lower is better for defense.Plays per game will be derived as non-garbage-time offensive plays divided by completed games. It describes pace. Faster is not automatically better.
Because the advanced request uses exclude_garbage_time=True, these advanced fields intentionally omit CFBD-defined garbage-time plays.
Join the tables and handle missing values
Join the scoring and advanced tables by team, derive plays per game, and explicitly remove any team that is missing a metric required by the chart:
team_seasons = season_results.merge(advanced_df, on=”team”, how=”inner”)
team_seasons[“plays_per_game”] = (
team_seasons[“offensive_plays”] / team_seasons[“games”]
)
metric_columns = [
“win_pct”,
“points_per_game”,
“points_allowed_per_game”,
“offensive_success_rate”,
“defensive_success_rate”,
“offensive_explosiveness”,
“defensive_explosiveness”,
“plays_per_game”,
]
rows_before = len(team_seasons)
team_seasons = team_seasons.dropna(subset=metric_columns).reset_index(drop=True)
rows_removed = rows_before – len(team_seasons)
if TEAM not in set(team_seasons[“team”]):
raise ValueError(f”{TEAM!r} is not present in the cleaned {YEAR} FBS data.”)
print(
f”Clean baseline: {len(team_seasons)} FBS teams ”
f”({rows_removed} incomplete rows removed).”
)
In this 2023 run, all 133 FBS teams had the fields required for the profile. The missing-value step is still important. If coverage changes for another season, incomplete rows will not silently be included in the comparison.
Convert raw values into FBS percentiles
Raw values answer what happened. Percentile ranks add context by answering how unusual a result was among FBS teams.
A 90th-percentile value means the team ranked better than roughly 90% of the teams in the comparison group. For points allowed, defensive success rate, and defensive explosiveness, lower raw values are better, so we reverse their ranking direction. Pace remains different: a higher percentile means faster, not better.
# Higher raw values rank higher for these metrics.
team_seasons[“win_pct_percentile”] = (
team_seasons[“win_pct”].rank(pct=True) * 100
)
team_seasons[“points_per_game_percentile”] = (
team_seasons[“points_per_game”].rank(pct=True) * 100
)
team_seasons[“offensive_success_rate_percentile”] = (
team_seasons[“offensive_success_rate”].rank(pct=True) * 100
)
team_seasons[“offensive_explosiveness_percentile”] = (
team_seasons[“offensive_explosiveness”].rank(pct=True) * 100
)
# Lower raw values rank higher for defensive metrics.
team_seasons[“points_allowed_per_game_percentile”] = (
team_seasons[“points_allowed_per_game”].rank(pct=True, ascending=False) * 100
)
team_seasons[“defensive_success_rate_percentile”] = (
team_seasons[“defensive_success_rate”].rank(pct=True, ascending=False) * 100
)
team_seasons[“defensive_explosiveness_percentile”] = (
team_seasons[“defensive_explosiveness”].rank(pct=True, ascending=False) * 100
)
# For pace, a higher percentile means faster—not better.
team_seasons[“plays_per_game_percentile”] = (
team_seasons[“plays_per_game”].rank(pct=True) * 100
)
Collect the selected team’s raw value, the FBS median, and its percentile into one compact table:
selected_team = team_seasons[team_seasons[“team”] == TEAM].iloc[0]
# Each tuple contains: label, DataFrame column, direction, display scale.
metric_specs = [
(“Win percentage (%)”, “win_pct”, “higher”, 100),
(“Points per game”, “points_per_game”, “higher”, 1),
(“Points allowed per game”, “points_allowed_per_game”, “lower”, 1),
(“Offensive success rate (%)”, “offensive_success_rate”, “higher”, 100),
(“Defensive success rate allowed (%)”, “defensive_success_rate”, “lower”, 100),
(“Offensive explosiveness”, “offensive_explosiveness”, “higher”, 1),
(“Defensive explosiveness allowed”, “defensive_explosiveness”, “lower”, 1),
(“Non-garbage-time plays per game”, “plays_per_game”, “faster”, 1),
]
profile_rows = []
for label, column, direction, scale in metric_specs:
profile_rows.append(
{
“metric”: label,
“team_value”: selected_team[column] * scale,
“fbs_median”: team_seasons[column].median() * scale,
“percentile”: selected_team[f”{column}_percentile”],
“direction”: direction,
}
)
profile = pd.DataFrame(profile_rows)
profile_table = profile[[“metric”, “team_value”, “fbs_median”, “percentile”]].copy()
profile_table = profile_table.rename(
columns={“team_value”: TEAM, “percentile”: “FBS percentile”}
)
profile_table.round(1)
Michigan’s profile includes a 100th-percentile win percentage, 90th-percentile scoring, 92nd-percentile offensive success rate, and 100th-percentile marks for both points allowed and defensive success rate allowed. Its offensive explosiveness ranks much lower, at the 38th percentile, while its pace is among the slowest in the comparison group.
These comparisons are unadjusted. Opponent quality, game count, postseason path, and play-sample size all affect the profile. Percentiles make the teams easier to compare, but they do not remove schedule strength or turn a descriptive metric into proof of cause.
Build the chart
The plotting code uses one horizontal bar per metric and a dashed reference line at the FBS median. Quality metrics at or above the median are green, those below it are red, and pace is gray because fast and slow are styles rather than grades.
plot_data = profile.iloc[::-1].reset_index(drop=True)
colors = []
for row in plot_data.itertuples():
if row.direction == “faster”:
colors.append(“#6B7280”)
elif row.percentile >= 50:
colors.append(“#009C27”)
else:
colors.append(“#BB0019”)
fig, ax = plt.subplots(figsize=(10, 6.5))
bars = ax.barh(
plot_data[“metric”],
plot_data[“percentile”],
color=colors,
height=0.64,
)
ax.axvline(50, color=”#9CA3AF”, linewidth=1.2, linestyle=”–“)
labels = [f”{value:.0f}” for value in plot_data[“percentile”]]
ax.bar_label(bars, labels=labels, padding=3, fontsize=10, fontweight=”bold”)
ax.set_xlim(0, 105)
ax.set_xticks([0, 20, 40, 60, 80, 100])
ax.set_xlabel(“FBS percentile (higher = stronger; pace = faster)”, fontsize=10)
ax.set_title(
f”{TEAM} {YEAR} Team Profile”,
loc=”left”,
fontsize=18,
fontweight=”bold”,
color=”#00274C”,
)
for side in [“top”, “right”, “left”]:
ax.spines[side].set_visible(False)
ax.tick_params(axis=”y”, length=0)
ax.grid(axis=”x”, color=”#E5E7EB”, linewidth=0.8)
ax.set_axisbelow(True)
plt.tight_layout()
output_path = f”{TEAM.lower().replace(‘ ‘, ‘_’)}_{YEAR}_team_profile.png”
fig.savefig(output_path, dpi=200, bbox_inches=”tight”, facecolor=”white”)
plt.show()
print(f”Saved chart to {output_path}”)
The saved PNG is ready to use in a dashboard, article, class project, or social post.
Interpret the result
The chart supports a few direct observations:
Michigan’s record, scoring defense, and defensive success rate allowed all sit at the top of this FBS comparison.Offensive explosiveness is the lowest-ranked quality metric in the profile at the 38th percentile. That makes it a relative weakness within this set of metrics, not evidence that the offense was poor.Michigan’s non-garbage-time pace is around the 2nd FBS percentile. That describes tempo and should not be read as a quality grade.
Those statements describe where Michigan landed. They do not establish that pace, explosiveness, or any other single metric caused the 15–0 season. Coaching, personnel, opponent quality, game state, and other factors overlap.
Run it for your team
Change the two parameters near the top and run the full script again:
TEAM = “Indiana”
YEAR = 2025
Use a completed season for the cleanest first comparison. Team names must match the names returned by TeamsApi.get_fbs_teams.
Once this version works, there are several different directions you can take it:
Add an opponent adjustment or schedule-strength control.Put two teams side by side.Add an end_week filter and refresh the profile during the season.Move the saved chart into a dashboard or a longer analysis.
Start with the simple version. Build the profile for your own team, then share the chart or the one result that surprised you most.




