You run the analysis, produce the numbers, and paste df.head() into the report. The stakeholder replies: “Which column is which? Why are the numbers so long? Can you highlight the failing programs?” A plain DataFrame output served you during exploration. It doesn’t serve a reader in a report.
Great Tables (great_tables) bridges that gap: it wraps a DataFrame in a fluent API and produces publication-ready HTML tables: precise formatting, readable labels, conditional highlighting, and summary rows: with no CSS knowledge required. Chapters 8-11 built the data; this notebook builds the presentation.
Callout markers used throughout this notebook are explained on the book cover page.
from great_tables import GT, loc, md as gt_md, nanoplot_options, styleimport numpy as npimport pandas as pdimport polars as plfrom ark.plot.gt_style import metrics_report, themed_gtfrom ark.plot.tokens import PRIMARY, SUCCESS, SURFACE_MUTEDdf = pd.read_csv("data/university_analytics.csv")df["average_marks"] = (df["midterm_score"] + df["final_score"] + df["project_score"]) /3df.head(3)
student_id
cohort
program
gender
region
guardian
has_internet
course_id
course
semester
enrollment_date
study_hours
attendance_pct
midterm_score
final_score
project_score
final_grade
passed
average_marks
0
S0001
2023
Information Technology
F
South
Father
True
C01
Python Programming
Fall 2023
2023-09-04
10.8
88.6
49.9
58.2
61.6
C
True
56.566667
1
S0001
2023
Information Technology
F
South
Father
True
C02
Statistics
Spring 2024
2024-01-15
16.1
71.5
48.7
63.7
65.8
C
True
59.400000
2
S0001
2023
Information Technology
F
South
Father
True
C03
Data Structures
Fall 2023
2023-09-04
23.6
71.5
54.1
60.9
79.8
C
True
64.933333
The Last Mile of a Data Story
You have run the analysis. You have the numbers: pass rates by school, score distributions by program, trend lines across semesters. Now your manager asks for a report : something to put in front of a stakeholder, not a developer. You open a notebook, call df.head(), and stare at a grey monospace grid with no hierarchy, no colour, no units, and no sense of which numbers matter.
df.head() serves you in a notebook. It doesn’t serve anyone else.
The gap between “I have the result” and “I can communicate the result” is called the last mile of a data story. It is where a lot of analysis work quietly disappears: correct findings, buried in formatting nobody wanted to read. Great Tables (posit-dev.github.io/great-tables) is the Python library that closes that gap. It wraps a pandas DataFrame in a fluent API : one that mirrors R’s {gt} package : and produces publication-ready HTML tables with column spanners, colour scales, embedded sparklines, and controlled footnotes.
Explain when a table is the right choice over a chart
Sec. 1
2
Wrap a DataFrame with GT() and apply the project brand with themed_gt()
Sec. 2
3
Format numbers, percentages, and missing values with fmt_* methods
Sec. 3
4
Add readable column labels with cols_label and group related columns with tab_spanner
Sec. 4
5
Target cells with loc and apply styling with tab_style
Sec. 5
6
Add grand summary rows with grand_summary_rows
Sec. 6
7
Build a model comparison table with metrics_report()
Sec. 7
1. When Tables Beat Charts
A chart compresses a distribution into shape: it shows a trend, a cluster, or an outlier at a glance. A table preserves exact values so a reader can answer a precise question: which course has the highest midterm average? or by how many points does one program outperform another?
Use a table when: - Readers will look up a specific row or compare two exact values - The differences between groups are small and a chart would compress them into noise - A report or stakeholder document needs a citable number, not an impression
Use a chart when: - You want to show a trend, a distribution, or a relationship across many data points - The pattern matters more than the individual values
Neither replaces the other. A data storytelling section (Chapter 7) shows a trend with a chart. A summary report shows the underlying numbers in a table. The combination answers both the what happened and the by exactly how much.
2. Your First Styled Table
Every Great Tables workflow starts with GT(df): wrapping a pandas DataFrame in the Great Tables object. From there you chain methods to add structure and styling. On its own, GT(df) renders a minimal unstyled table. themed_gt() applies the project’s brand: column header background, font, border colours, and alternating row stripes: one call at the end of the chain.
The first example is a summary of mean scores by gender:
GT(df) alone already renders a table, but column names are raw and values have no formatting. Wrapping it in themed_gt() applies the brand while .tab_header() adds a title and subtitle:
table = ( GT(summary) .tab_header( title=gt_md("**Mean Exam Scores by Gender**"), subtitle="Students with complete score records across all three components", ) .tab_source_note("Source: University analytics dataset · 2,400 rows"))themed_gt(table, n_rows=len(summary))
Mean Exam Scores by Gender
Students with complete score records across all three components
gender
n_students
midterm
final
project
fail_rate
F
1170
60.55
59.44
65.35
0.04
M
1128
60.51
59.29
64.65
0.05
Other
102
60.22
62.39
63.94
0.06
Source: University analytics dataset · 2,400 rows
Key Concept: The chain always ends with themed_gt()
themed_gt() applies brand-wide options (tab_options) and text styling. Call it last, after all structural methods (tab_header, cols_label, tab_spanner, etc.) so it can apply consistently across everything you have added.
Activity 1 - First Styled Table
Group df by program instead of gender, compute the same five aggregates, then wrap with GT and themed_gt. Add a title that identifies the program.
# TODO: build program_summary and display with GT + themed_gt...
3. Formatting Values and Labelling Columns
Key Concept: Format numbers to signal meaningful precision, not raw float noise
A pass rate of 0.87654 tells the reader nothing that 87.7% doesn’t: and the extra digits imply a precision the data doesn’t have. Great Tables formats numbers through chainable fmt_*() methods: fmt_number(decimals=1), fmt_percent(decimals=1), fmt_integer(). These are display-only transforms: the underlying DataFrame is unchanged.
Raw floats in a table communicate false precision: a pass rate of 0.87654 signals noise, not information. Great Tables fmt_* methods format each column’s values to the right precision for its type, and cols_label replaces machine-readable column names with reader-facing ones.
The four formatting methods used most in DS tables: - fmt_number(columns, decimals): round to decimals places - fmt_integer(columns): strip decimal point, add thousands separator - fmt_percent(columns, decimals): multiply by 100 and append % - fmt_missing(columns, missing_text): replace NaN with a readable label
Example: fmt_percent turns 0.913 into 91.3%
Without formatting, fail_rate=0.04 reads as a raw proportion. With fmt_percent(columns=‘fail_rate’, decimals=1), the same cell displays as 4.0%: the reader doesn’t need to mentally multiply by 100.
Pro Tip: fmt_missing catches the NaN before the reader sees it
Any column that can contain NaN: a score column with ~3% missing, an optional field: should have fmt_missing(columns=…, missing_text=“:”) added to the chain. A blank cell in a published table is ambiguous: did the student not sit the exam, or did the pipeline drop the value?
Activity 2 - Format the Program Table
Take the program_summary from Activity 1 and add cols_label, fmt_integer, fmt_number, and fmt_percent to match the formatted table above.
# TODO: add cols_label and fmt_* to your program_summary table...
4. Column Spanners
When a table has several columns that belong to a natural group, for example three score columns or multiple model metrics, a column spanner adds a shared header label above the group. This reduces cognitive load: the reader understands the table structure before reading the individual values.
tab_spanner(label, columns) draws the label above the specified columns. It doesn’t move or reorder columns; it only adds a visual grouping above them.
Conditional styling directs the reader’s eye to the cells that matter: the highest pass rate, the lowest score, an outlier. tab_style applies a visual property and loc specifies exactly where it applies. style is the what, loc is the where.
The most common locations: - loc.body(columns, rows): specific cells in the data area - loc.column_labels(): the column header row - loc.title() / loc.subtitle(): the table header text
rows inside loc.body() accepts an integer index, a list of indices, or a lambda that receives the DataFrame and returns a boolean Series.
Key Concept: loc is a targeting system, not a filter
loc.body(rows=lambda df: df[‘pass_rate’] == df[‘pass_rate’].max()) doesn’t subset the table: it identifies which rows receive the styling. The underlying data is unchanged. You can chain multiple tab_style calls; later ones add to earlier ones without overwriting.
Common Mistake: Passing a boolean mask directly to rows
loc.body(rows=course_detail[‘pass_rate’] == course_detail[‘pass_rate’].max()) fails because rows inside loc.body() needs a callable that receives the rendered DataFrame, not the original one. Always use a lambda: rows=lambda df: df[‘pass_rate’] == df[‘pass_rate’].max().
Activity 4 - Highlight the Best Midterm Score
Take the highlighted table and add a third tab_style call that highlights the midterm cell with the highest value in a light blue (#EAF3FA). Use a lambda for the row selection.
# TODO: add a third tab_style call for the highest midterm value...
6. Summary Rows
A summary row aggregates the entire table into one footer row: a grand mean, a column total, or a count. The reader no longer needs to mentally compute the aggregate, and the table and its summary stay in the same visual unit.
grand_summary_rows(fns) adds these rows. fns is a dict mapping a display label to an aggregation function. In version 0.20, it aggregates all numeric columns in the table, so the DataFrame passed to GT should contain only the columns you want summarised:
from great_tables import vals as gt_vals # noqa: F401# Use only the score + pass_rate columns so the summary row is meaningfulcourse_scores = course_detail.drop(columns=["students"])with_summary = ( GT(course_scores) .tab_header(title=gt_md("**Course Summary with Grand Mean**")) .cols_label( course="Course", midterm="Midterm", final="Final", project="Project", pass_rate="Pass Rate", # noqa: S106 ) .tab_spanner(label="Score (0-100)", columns=["midterm", "final", "project"]) .fmt_number(columns=["midterm", "final", "project"], decimals=1) .fmt_percent(columns="pass_rate", decimals=1) # noqa: S106 .grand_summary_rows( fns={"Mean": lambda x: x.mean(numeric_only=True)}, ))themed_gt(with_summary, n_rows=len(course_scores))
Course Summary with Grand Mean
Course
Score (0-100)
Pass Rate
Midterm
Final
Project
Data Structures
59.6
57.5
64.1
94.0%
Databases
63.8
62.5
67.8
98.0%
Linear Algebra
57.6
56.5
61.6
93.0%
Machine Learning
56.8
56.6
60.7
91.0%
Python Programming
65.4
64.1
70.1
99.0%
Statistics
60.0
59.7
65.4
96.0%
Mean
---
60.53333333333333
59.49666666666667
64.96333333333332
0.9516666666666667
Pro Tip: Shape the DataFrame before passing it to GT
grand_summary_rows aggregates every numeric column in the table. If a count column like students would produce a meaningless mean, drop it before calling GT(): df.drop(columns=[“students”]). If the table still includes a string column like the row label, pass numeric_only=True to the aggregation: lambda x: x.mean(numeric_only=True).
Activity 5 - Add a Min and Max Row
Extend with_summary to show three summary rows: Min, Max, and Mean across all score columns. Pass a dict with three keys to fns.
A table that shows a pass rate of 72.4% answers “how many passed?” It doesn’t show whether that rate improved over five semesters or collapsed. A sparkline: a mini line chart inside a single cell: answers both questions at once without opening a separate figure.
fmt_nanoplot transforms a column of Python lists into inline micro-charts. The pattern is two steps:
Add a list-valued column: each cell holds a sequence of numbers (one per period, per category, or per experiment)
Chain .fmt_nanoplot(columns='col_name', plot_type='line') into your GT chain
plot_type accepts 'line' (trends over time) or 'bar' (magnitude across categories). nanoplot_options controls colours, point visibility, and hover labels.
rng: np.random.Generator = np.random.default_rng(42)N_SEMESTERS: int=5# Simulate a rising score trajectory with noise for each coursetrajectories: list[list[float]] = []for _, row in course_detail.iterrows(): start: float= row["midterm"] - rng.uniform(4, 8) end: float= row["midterm"] + rng.uniform(2, 10) noise: np.ndarray = rng.normal(0, 1.5, N_SEMESTERS) traj: list[float] = [round(float(v), 1) for v in np.linspace(start, end, N_SEMESTERS) + noise] trajectories.append(traj)course_trend: pd.DataFrame = course_detail[["course", "midterm", "pass_rate"]].copy()course_trend["score_trend"] = trajectoriescourse_trend.head(3)# fmt_nanoplot requires a Polars DataFrame for list columns# (pd.isna on Python lists in pandas 3.x returns an array, not a scalar)course_trend_pl: pl.DataFrame = pl.from_pandas(course_trend)course_trend_pl.head(3)
shape: (3, 4)
course
midterm
pass_rate
score_trend
str
f64
f64
list[f64]
"Data Structures"
59.6
0.94
[53.6, 57.1, … 65.3]
"Databases"
63.77
0.98
[55.3, 60.5, … 68.5]
"Linear Algebra"
57.59
0.93
[52.4, 52.8, … 61.1]
Apply the same styling to the complete table:
spark_table: GT = ( GT(course_trend_pl) .tab_header( title=gt_md("**Midterm Score Trend by Course**"), subtitle="Sparkline shows the estimated 5-semester score trajectory", ) .cols_label( course="Course", midterm="Mean Midterm", pass_rate="Pass Rate", # noqa: S106 score_trend="Score Trend", ) .fmt_number(columns="midterm", decimals=1) .fmt_percent(columns="pass_rate", decimals=1) # noqa: S106 .fmt_nanoplot( columns="score_trend", plot_type="line", options=nanoplot_options( data_point_fill_color=PRIMARY, data_line_stroke_color=PRIMARY, show_data_area=False, interactive_data_values=True, ), ) .tab_source_note("Trajectories are simulated; real data would use per-semester aggregations"))themed_gt(spark_table, n_rows=len(course_trend))
Midterm Score Trend by Course
Sparkline shows the estimated 5-semester score trajectory
Course
Mean Midterm
Pass Rate
Score Trend
Data Structures
59.6
94.0%
Databases
63.8
98.0%
Linear Algebra
57.6
93.0%
Machine Learning
56.8
91.0%
Python Programming
65.4
99.0%
Statistics
60.0
96.0%
Trajectories are simulated; real data would use per-semester aggregations
Key Concept: fmt_nanoplot requires a list-valued column
fmt_nanoplot doesn’t aggregate data for you. Pass a Polars DataFrame to GT() when your column holds Python lists: Polars types them as List(Float64), which GT renders without ambiguity. Pandas 3.x pd.isna(list) returns an array that causes a ValueError inside the nanoplot renderer. If your data is in wide format (one column per semester), convert it first with .values.tolist() row-wise or with a list comprehension, then assign the result as a new column before calling GT().
Activity 7 - Switch to a Bar Sparkline
Copy spark_table and change two things: set plot_type=‘bar’ and set show_data_area=True inside nanoplot_options. Compare the two chart types side by side. Which makes it easier to spot the course with the largest single-semester score peak? Which better communicates overall trend direction?
Hint: Bar nanoplots colour negative bars differently by default. Try setting show_data_points=False to clean up the bar variant.
# TODO: build a bar nanoplot version of spark_table...
8. Model Comparison with metrics_report()
The ark.plot.gt_style module ships metrics_report(): a one-call wrapper that produces a publication-ready model comparison table. It handles formatting, brand styling, and conditional highlighting in a single call.
metrics_report(df, metrics, minimize_cols, maximize_cols) highlights the best value in each metric column: green for minimize metrics (lower is better: MAE, RMSE), green for maximize metrics (higher is better: R², accuracy). The caller decides which direction is better for each metric; the function doesn’t guess.
Predicting average_marks from study_hours, attendance_pct, and program
Model
MAE
RMSE
R2
Linear Regression
8.210
10.420
0.781
Ridge (α=0.1)
8.090
10.310
0.784
Ridge (α=1.0)
7.980
10.190
0.788
Random Forest
7.430
9.610
0.810
university_analytics.csv · 5-fold CV · held-out 20% test set
Key Concept: metrics_report highlights by direction, not by rank
minimize_cols highlights the row with the lowest value: better for error metrics. maximize_cols highlights the row with the highest value: better for performance metrics. A column can appear in at most one list. If a column appears in neither, it’s formatted but not highlighted.
Activity 6 - Add a Gradient Boosting Row
Add a fifth row to comparison: “Gradient Boosting” with MAE=6.91, RMSE=8.84, R2=0.843: and re-run metrics_report(). Confirm the highlighted row updates automatically.
# TODO: add Gradient Boosting row and re-run metrics_report...
Capstone: Course Performance Report
Combine every technique from this notebook into one complete report table. The report should give a department head a single table they can paste into a slide deck.
Capstone Exercise - Course Performance Report
Build a report DataFrame grouped by course with columns: students, midterm mean, final mean, project mean, pass rate, and average_marks mean
Wrap with GT. Add a descriptive title and source note
Apply cols_label and the appropriate fmt_* for each column
Add a tab_spanner over the three score columns
Highlight the course with the highest pass rate (green) and lowest pass rate (light red)
Add a grand Mean summary row across all numeric columns
Call themed_gt() last
# Build report DataFrame first, then chain all GT methods in one expression
# TODO: build the complete course performance report...