27  Displaying Data: Tables

If a graph is for seeing a pattern, a table is for reading an exact value. Ask which one your reader needs. If they need to feel the shape of a trend, draw it. If they need to look up “what was the bust rate for the Jets, exactly?” or compare a handful of precise numbers side by side, give them a table. A table is not a failed graph; it is a different instrument for a different job, and, like a graph, it can be honest or it can bury the point under too many digits.

We continue with the same real data from The Sports Page we used for graphics, so you can feel the difference between the two instruments on identical numbers.

27.1 Learning Objectives

  1. Decide when a table serves the reader better than a graph.
  2. Round to meaningful precision - kill false precision.
  3. Order rows to carry information, and align numbers so they can be compared.
  4. Summarize to the question, rather than dumping the raw data.

27.2 When a Table Beats a Graph

A table wins when the numbers are few, the exact values matter, or the reader will look things up rather than scan for a shape. The strikeout trend was a job for a line. But “what did each decade actually look like, in numbers I can quote?” is a job for a small table. Summarize the 42 years down to five decade averages - few rows, exact values, quotable:

NoteWorking in SPSS, Julia, or Python?

This chapter uses data/mlb_league_by_year.csv and data/nfl_draft_by_team.csv directly - no setup needed. See Getting the Book’s Data if you want the setup files for your language.

library(tidyverse)    # dplyr, ggplot2, purrr, tibble, readr, stringr, forcats
source("_common.R")   # book-wide helpers: round2(), fmt_p(), tidy2()

mlb <- read_csv("data/mlb_league_by_year.csv", show_col_types = FALSE) |>
  mutate(decade = paste0(floor(year / 10) * 10, "s"))

dec <- mlb |>
  summarise(across(c(K_9, BB_9, HR_9, AVG), mean), .by = decade) |>
  arrange(decade)

# This chapter is about choosing digits on purpose, so it sets them by hand
# rather than using the book's usual round2(): batting average is quoted to
# three decimals, the way baseball has always quoted it.
knitr::kable(dec, digits = c(0, 2, 2, 2, 3),
             col.names = c("Decade", "K/9", "BB/9", "HR/9", "AVG"),
             caption = "MLB by the decade: the strikeout climb, in exact numbers.")
MLB by the decade: the strikeout climb, in exact numbers.
Decade K/9 BB/9 HR/9 AVG
1980s 5.70 3.30 0.87 0.257
1990s 6.18 3.45 0.96 0.264
2000s 6.62 3.38 1.08 0.265
2010s 7.87 3.12 1.08 0.254
2020s 8.69 3.31 1.19 0.244
* Summarise 42 years down to five decade averages.
GET DATA /TYPE=TXT /FILE='data/mlb_league_by_year.csv'
  /DELIMITERS=',' /QUALIFIER='"' /FIRSTCASE=2
  /VARIABLES=year F16.6 OPS F16.6 K_PA F16.6 BB_PA F16.6 HR_PA F16.6 AVG F16.6 ERA F16.6 K_9 F16.6 BB_9 F16.6 HR_9 F16.6.

COMPUTE decade = TRUNC(year / 10) * 10.
FORMATS decade (F4.0).
EXECUTE.

MEANS TABLES=K_9 BB_9 HR_9 AVG BY decade
  /CELLS=MEAN.
            Case Processing Summary
+-------------+-------------------------------+
|             |             Cases             |
|             +----------+---------+----------+
|             | Included | Excluded|   Total  |
|             +--+-------+-+-------+--+-------+
|             | N|Percent|N|Percent| N|Percent|
+-------------+--+-------+-+-------+--+-------+
|K_9 * decade |42| 100.0%|0|    .0%|42| 100.0%|
|BB_9 * decade|42| 100.0%|0|    .0%|42| 100.0%|
|HR_9 * decade|42| 100.0%|0|    .0%|42| 100.0%|
|AVG * decade |42| 100.0%|0|    .0%|42| 100.0%|
+-------------+--+-------+-+-------+--+-------+

        K_9 * BB_9 * HR_9 * AVG * decade
+-----------+--------+--------+--------+-------+
|decade     |   K_9  |  BB_9  |  HR_9  |  AVG  |
+-----------+--------+--------+--------+-------+
|1980   Mean|5.700800|3.296200| .867000|.257220|
+-----------+--------+--------+--------+-------+
|1990   Mean|6.179900|3.451300| .961200|.264500|
+-----------+--------+--------+--------+-------+
|2000   Mean|6.623900|3.376600|1.083800|.265410|
+-----------+--------+--------+--------+-------+
|2010   Mean|7.873100|3.123300|1.076200|.253660|
+-----------+--------+--------+--------+-------+
|2020   Mean|8.685000|3.306286|1.193571|.244329|
+-----------+--------+--------+--------+-------+
|Total  Mean|7.049238|3.312786|1.045286|.257907|
+-----------+--------+--------+--------+-------+
using CSV, DataFrames, Statistics
mlb = CSV.read("data/mlb_league_by_year.csv", DataFrame)
mlb.decade = string.(fld.(mlb.year, 10) .* 10, "s")

combine(groupby(mlb, :decade),
        [:K_9, :BB_9, :HR_9, :AVG] .=> mean .=> [:K_9, :BB_9, :HR_9, :AVG])
5×5 DataFrame
 Row │ decade  K_9      BB_9     HR_9     AVG
     │ String  Float64  Float64  Float64  Float64
─────┼─────────────────────────────────────────────
   1 │ 1980s    5.7008  3.2962   0.867    0.25722
   2 │ 1990s    6.1799  3.4513   0.9612   0.2645
   3 │ 2000s    6.6239  3.3766   1.0838   0.26541
   4 │ 2010s    7.8731  3.1233   1.0762   0.25366
   5 │ 2020s    8.685   3.30629  1.19357  0.244329
import pandas as pd

mlb = pd.read_csv("data/mlb_league_by_year.csv")
mlb["decade"] = (mlb.year // 10 * 10).astype(str) + "s"

(mlb.groupby("decade")[["K_9", "BB_9", "HR_9", "AVG"]]
    .mean()
    .round({"K_9": 2, "BB_9": 2, "HR_9": 2, "AVG": 3}))
         K_9  BB_9  HR_9    AVG
decade                         
1980s   5.70  3.30  0.87  0.257
1990s   6.18  3.45  0.96  0.264
2000s   6.62  3.38  1.08  0.265
2010s   7.87  3.12  1.08  0.254
2020s   8.68  3.31  1.19  0.244

Five rows, and you can quote any cell. The graph made you feel the strikeout climb; the table lets you say “K/9 went from about 5.7 in the 1980s to about 8.5 in the 2020s” without guessing off an axis. Same data, different instrument, different job.

27.3 Kill False Precision

The fastest way to make a table mislead, or simply to tire your reader, is to print every digit the computer produced. A bust rate stored as 0.5455 does not mean you know it to four decimals; with a few dozen draft picks, you barely know the second. Rounding is not sloppiness; it is honesty about how much you actually know. Watch the difference:

nfl <- read_csv("data/nfl_draft_by_team.csv", show_col_types = FALSE)

worst <- nfl |>
  arrange(desc(rate_bust_a)) |>
  select(team, n_picks, rate_bust_a, rate_clean_hit)

# false precision: every digit the machine produced
knitr::kable(head(worst, 4), row.names = FALSE,
             caption = "Too many digits — implies a precision the data don't have.")
Too many digits — implies a precision the data don’t have.
team n_picks rate_bust_a rate_clean_hit
LV 44 0.7273 0.2500
CIN 52 0.7174 0.2609
GB 49 0.6905 0.2381
NYG 49 0.6829 0.3171
* False precision: every digit the machine produced.
GET DATA /TYPE=TXT /FILE='data/nfl_draft_by_team.csv'
  /DELIMITERS=',' /QUALIFIER='"' /FIRSTCASE=2
  /VARIABLES=team A24 n_picks F16.6 n_judged F16.6 n_hof F16.6 n_bust_a F16.6 n_bust_b F16.6 n_bust_c F16.6 n_triple_bust F16.6 n_clean_hit F16.6 rate_bust_a F16.6 rate_bust_b F16.6 rate_bust_c F16.6 rate_triple_bust F16.6 rate_clean_hit F16.6 hof_per_pick F16.6.

SORT CASES BY rate_bust_a (D).
* SPSS shows whatever the variable's FORMAT allows - widen it and the
* spurious digits appear:
FORMATS rate_bust_a rate_clean_hit (F8.4).
LIST VARIABLES=team n_picks rate_bust_a rate_clean_hit /CASES=4.
                 Data List
+----+---------+-----------+--------------+
|team| n_picks |rate_bust_a|rate_clean_hit|
+----+---------+-----------+--------------+
|LV  |44.000000|      .7273|         .2500|
|CIN |52.000000|      .7174|         .2609|
|GB  |49.000000|      .6905|         .2381|
|NYG |49.000000|      .6829|         .3171|
+----+---------+-----------+--------------+
using CSV, DataFrames, Plots
nfl = CSV.read("data/nfl_draft_by_team.csv", DataFrame)
worst = sort(select(nfl, :team, :n_picks, :rate_bust_a, :rate_clean_hit),
             :rate_bust_a, rev = true)

first(worst, 4)          # too many digits for what the data can support
4×4 DataFrame
 Row │ team     n_picks  rate_bust_a  rate_clean_hit
     │ String3  Int64    Float64      Float64
─────┼───────────────────────────────────────────────
   1 │ LV            44       0.7273          0.25
   2 │ CIN           52       0.7174          0.2609
   3 │ GB            49       0.6905          0.2381
   4 │ NYG           49       0.6829          0.3171
import pandas as pd
import matplotlib.pyplot as plt
nfl = pd.read_csv("data/nfl_draft_by_team.csv")
worst = (nfl.sort_values("rate_bust_a", ascending=False)
            [["team", "n_picks", "rate_bust_a", "rate_clean_hit"]])

worst.head(4)            # too many digits for what the data can support
   team  n_picks  rate_bust_a  rate_clean_hit
18   LV       44       0.7273          0.2500
6   CIN       52       0.7174          0.2609
11   GB       49       0.6905          0.2381
23  NYG       49       0.6829          0.3171
# honest precision: percentages, rounded to what the sample can support
worst <- worst |>
  mutate(across(c(rate_bust_a, rate_clean_hit), \(v) round(100 * v)))

knitr::kable(head(worst, 4), row.names = FALSE,
             col.names = c("Team", "Picks", "Bust rate (%)", "Clean-hit rate (%)"),
             caption = "Rounded to whole percentages — easier to read, honest about precision.")
Rounded to whole percentages — easier to read, honest about precision.
Team Picks Bust rate (%) Clean-hit rate (%)
LV 44 73 25
CIN 52 72 26
GB 49 69 24
NYG 49 68 32
GET DATA /TYPE=TXT /FILE='data/nfl_draft_by_team.csv'
  /DELIMITERS=',' /QUALIFIER='"' /FIRSTCASE=2
  /VARIABLES=team A24 n_picks F16.6 n_judged F16.6 n_hof F16.6 n_bust_a F16.6 n_bust_b F16.6 n_bust_c F16.6 n_triple_bust F16.6 n_clean_hit F16.6 rate_bust_a F16.6 rate_bust_b F16.6 rate_bust_c F16.6 rate_triple_bust F16.6 rate_clean_hit F16.6 hof_per_pick F16.6.
* Honest precision: whole percentages, rounded to what the sample supports.
COMPUTE rate_bust_a    = RND(100 * rate_bust_a).
COMPUTE rate_clean_hit = RND(100 * rate_clean_hit).
FORMATS rate_bust_a rate_clean_hit (F3.0).
VARIABLE LABELS rate_bust_a 'Bust rate (%)'
                rate_clean_hit 'Clean-hit rate (%)'.
EXECUTE.

LIST VARIABLES=team n_picks rate_bust_a rate_clean_hit /CASES=4.
                 Data List
+----+---------+-----------+--------------+
|team| n_picks |rate_bust_a|rate_clean_hit|
+----+---------+-----------+--------------+
|ARI |39.000000|         55|            42|
|ATL |48.000000|         54|            41|
|BAL |33.000000|         40|            60|
|BUF |46.000000|         48|            48|
+----+---------+-----------+--------------+
using CSV, DataFrames, Plots
nfl = CSV.read("data/nfl_draft_by_team.csv", DataFrame)
worst = sort(select(nfl, :team, :n_picks, :rate_bust_a, :rate_clean_hit),
             :rate_bust_a, rev = true)

worst.rate_bust_a    = round.(100 .* worst.rate_bust_a)
worst.rate_clean_hit = round.(100 .* worst.rate_clean_hit)

first(worst, 4)          # easier to read AND more truthful
4×4 DataFrame
 Row │ team     n_picks  rate_bust_a  rate_clean_hit
     │ String3  Int64    Float64      Float64
─────┼───────────────────────────────────────────────
   1 │ LV            44         73.0            25.0
   2 │ CIN           52         72.0            26.0
   3 │ GB            49         69.0            24.0
   4 │ NYG           49         68.0            32.0
import pandas as pd
import matplotlib.pyplot as plt
nfl = pd.read_csv("data/nfl_draft_by_team.csv")
worst = (nfl.sort_values("rate_bust_a", ascending=False)
            [["team", "n_picks", "rate_bust_a", "rate_clean_hit"]])

worst = worst.assign(
    rate_bust_a=lambda d: (100 * d.rate_bust_a).round(),
    rate_clean_hit=lambda d: (100 * d.rate_clean_hit).round())

worst.head(4)            # easier to read AND more truthful
   team  n_picks  rate_bust_a  rate_clean_hit
18   LV       44         73.0            25.0
6   CIN       52         72.0            26.0
11   GB       49         69.0            24.0
23  NYG       49         68.0            32.0

The second table is easier to read and more truthful. Show the digits you can defend, and not one more.

27.4 Order Rows to Carry Information

A table’s row order is free information - spend it. An alphabetical list of teams makes the reader hunt; a list sorted by the thing you care about answers the question before they ask it. We already sorted by bust rate above (worst first), so the ranking is the message. Now show the two ends that a reader actually wants - the best and worst drafters - instead of dumping all 32:

ends <- bind_rows(
  worst |> slice_head(n = 5) |> mutate(group = "Worst drafters", .before = 1),
  worst |> slice_tail(n = 5) |> mutate(group = "Best drafters",  .before = 1)
) |>
  select(group, team, n_picks, rate_bust_a)

knitr::kable(ends, row.names = FALSE,
             col.names = c("", "Team", "Picks", "Bust rate (%)"),
             caption = "The two ends of the ranking — the comparison the reader wanted.")
The two ends of the ranking — the comparison the reader wanted.
Team Picks Bust rate (%)
Worst drafters LV 44 73
Worst drafters CIN 52 72
Worst drafters GB 49 69
Worst drafters NYG 49 68
Worst drafters LAR 51 68
Best drafters BUF 46 48
Best drafters MIN 48 48
Best drafters KC 42 47
Best drafters BAL 33 40
Best drafters WAS 35 38
GET DATA /TYPE=TXT /FILE='data/nfl_draft_by_team.csv'
  /DELIMITERS=',' /QUALIFIER='"' /FIRSTCASE=2
  /VARIABLES=team A24 n_picks F16.6 n_judged F16.6 n_hof F16.6 n_bust_a F16.6 n_bust_b F16.6 n_bust_c F16.6 n_triple_bust F16.6 n_clean_hit F16.6 rate_bust_a F16.6 rate_bust_b F16.6 rate_bust_c F16.6 rate_triple_bust F16.6 rate_clean_hit F16.6 hof_per_pick F16.6.
* Show the two ends of the ranking, not all 32 rows.
* Flag the top and bottom five, then list only those.
COMPUTE rank_bust = $CASENUM.
COMPUTE ends = 0.
IF (rank_bust <= 5) ends = 1.
IF (rank_bust > 27) ends = 2.
VALUE LABELS ends 1 'Worst drafters' 2 'Best drafters'.
EXECUTE.

TEMPORARY.
SELECT IF (ends > 0).
LIST VARIABLES=ends team n_picks rate_bust_a.
            Data List
+----+----+---------+-----------+
|ends|team| n_picks |rate_bust_a|
+----+----+---------+-----------+
|1.00|ARI |39.000000|    .545500|
|1.00|ATL |48.000000|    .536600|
|1.00|BAL |33.000000|    .400000|
|1.00|BUF |46.000000|    .476200|
|1.00|CAR |31.000000|    .520000|
|2.00|SEA |43.000000|    .621600|
|2.00|SF  |50.000000|    .622200|
|2.00|TB  |42.000000|    .594600|
|2.00|TEN |43.000000|    .486500|
|2.00|WAS |35.000000|    .379300|
+----+----+---------+-----------+
using CSV, DataFrames, Plots
nfl = CSV.read("data/nfl_draft_by_team.csv", DataFrame)
worst = sort(select(nfl, :team, :n_picks, :rate_bust_a, :rate_clean_hit),
             :rate_bust_a, rev = true)

worst.rate_bust_a = round.(100 .* worst.rate_bust_a)
ends = vcat(transform(first(worst, 5), :team => ByRow(_ -> "Worst drafters") => :group),
            transform(last(worst, 5),  :team => ByRow(_ -> "Best drafters")  => :group))

select(ends, :group, :team, :n_picks, :rate_bust_a)
10×4 DataFrame
 Row │ group           team     n_picks  rate_bust_a
     │ String          String3  Int64    Float64
─────┼───────────────────────────────────────────────
   1 │ Worst drafters  LV            44         73.0
   2 │ Worst drafters  CIN           52         72.0
   3 │ Worst drafters  GB            49         69.0
   4 │ Worst drafters  NYG           49         68.0
   5 │ Worst drafters  LAR           51         68.0
   6 │ Best drafters   BUF           46         48.0
   7 │ Best drafters   MIN           48         48.0
   8 │ Best drafters   KC            42         47.0
   9 │ Best drafters   BAL           33         40.0
  10 │ Best drafters   WAS           35         38.0
import pandas as pd
import matplotlib.pyplot as plt
nfl = pd.read_csv("data/nfl_draft_by_team.csv")
worst = (nfl.sort_values("rate_bust_a", ascending=False)
            [["team", "n_picks", "rate_bust_a", "rate_clean_hit"]])

worst = worst.assign(rate_bust_a=lambda d: (100 * d.rate_bust_a).round())
ends = pd.concat([worst.head(5).assign(group="Worst drafters"),
                  worst.tail(5).assign(group="Best drafters")])

ends[["group", "team", "n_picks", "rate_bust_a"]]
             group team  n_picks  rate_bust_a
18  Worst drafters   LV       44         73.0
6   Worst drafters  CIN       52         72.0
11  Worst drafters   GB       49         69.0
23  Worst drafters  NYG       49         68.0
17  Worst drafters  LAR       51         68.0
3    Best drafters  BUF       46         48.0
20   Best drafters  MIN       48         48.0
15   Best drafters   KC       42         47.0
2    Best drafters  BAL       33         40.0
31   Best drafters  WAS       35         38.0

Ten rows, sorted, grouped into the contrast that matters. A 32-row alphabetical dump would contain the same facts but communicate far less of them. Order and trim the table to the question; do not make the reader do your sorting.

ImportantThe honest-table checklist
  1. Right instrument - do they need exact values or a lookup? If they need a shape, draw it instead.
  2. Precision - round to what the data support; four decimals on a noisy rate is a lie of false confidence.
  3. Order - sort rows by the quantity of interest; the ranking is information.
  4. Trim - summarize to the comparison (decades, top/bottom); don’t dump raw rows.
  5. Labels - human column names and units, so no cell needs a footnote to be understood.

27.5 Tables and Graphics Are Partners

The two instruments are not rivals; a good report uses both. Show the graph for the pattern - “strikeouts have soared” - and put the table beside it for the reader who needs the exact decade numbers to quote in their own argument. The graph earns the reader’s belief; the table arms them with the specifics. Reach for whichever answers the question in front of you, and often, reach for both.

27.6 Challenge

TipDo One Yourself
  1. Rebuild the decade table but add a column for how much each stat changed from the previous decade. Which display - this table or the small-multiple graph from the last chapter - makes the change clearer, and for which reader?
  2. Take the full 32-team draft data and design the single table you would actually publish. What do you sort by, how many rows do you keep, and to how many digits do you round? Defend each choice in one sentence.
  3. Find a table in a real paper or report with obvious false precision (six decimals on a correlation, say). Round it honestly and note whether any conclusion actually depended on the digits you deleted.

27.7 Where We Go Next

You can now show data honestly - as a picture and as a table - and you have the full toolkit of this book behind you: describe, measure, model, and display. What remains is the hardest thing to teach and the most valuable to learn: judgment, which tool, when, and why. For that, we stop explaining and start showing, walking through real published analyses with every decision laid bare, beginning with the way The Sports Page thinks. Welcome to the apprenticeship.