broom tidy() for aov in R: Convert ANOVA Tables to Tibbles
The broom::tidy() function turns a fitted aov object into a one-row-per-term tibble with sumsq, meansq, statistic, and p.value columns. It replaces the printed ANOVA table from summary(fit) with a data frame you can pipe into dplyr, ggplot2, or a Word report.
tidy(aov_fit) # one row per ANOVA term tidy(aov_fit) |> filter(term != "Residuals") # drop residual row tidy(aov_fit) |> arrange(p.value) # rank effects by p-value tidy(TukeyHSD(aov_fit)) # tidy post-hoc pairwise table tidy(aov(y ~ A * B, data = df)) # two-way with interaction tidy(aov(y ~ A + Error(subject/A), data = df)) # repeated measures glance(aov_fit) # one-row model summary
Need explanation? Read on for examples and pitfalls.
What tidy() does for aov in one sentence
tidy() converts the printed ANOVA table into a tibble. A fitted aov object holds the same information summary(fit) prints, but it lives in a list of class c("aov", "lm") that is awkward to subset programmatically. broom::tidy() pulls the per-term sum of squares, mean square, F statistic, and p-value, returning one row per source of variation including Residuals.
The content matches summary(fit)[[1]], but it is a true tibble with stable column names. That unlocks every tidyverse verb: filter residuals, arrange by p-value, plot effect sizes, or bind_rows() multiple ANOVA tables. The shape never changes across one-way, two-way, or factorial designs.
Syntax
tidy.aov() is the S3 method that broom dispatches to when you pass an aov fit. You never call it directly; calling tidy() on an aov object routes to the right method automatically.
The function signature is short:
x: the fittedaovobject (oraovlistfor designs withError()strata)conf.int: ignored foraov(no coefficient-level intervals are returned); passtidy()to the underlyinglmif you need themconf.level: same, ignored at the aov level...: forwarded to internal helpers; rarely used
The returned tibble always has these six columns:
| Column | Meaning |
|---|---|
term |
Source of variation (predictor name or Residuals) |
df |
Degrees of freedom for the term |
sumsq |
Sum of squares |
meansq |
Mean square (sumsq / df) |
statistic |
F statistic (or NA for the residual row) |
p.value |
Two-sided p-value (or NA for the residual row) |
Residuals row when reporting variance explained, drop it for effect plots. The residual row carries the within-group variability you need to compute eta-squared or omega-squared, so filter it out only after the calculation, not before.Common patterns
1. One-way ANOVA as a tidy table
Two rows: the treatment factor group and the within-group Residuals. The F statistic 4.85 is meansq(group) / meansq(Residuals); the p-value 0.0159 says the three treatment means differ at the 5% level. Unlike the fixed-width summary(aov_fit) printout, the tibble lets you compute follow-ups (like effect size) in one extra line.
2. Effect size from the tidy table
Eta-squared is the proportion of total variance explained by each factor (factor sumsq divided by total sumsq, residuals included). The tidy table makes it a one-liner. Here, 26.4% of variation in plant weight is attributable to treatment group, a large effect by Cohen's conventions (>= 0.14).
3. Two-way ANOVA with an interaction
Four rows: a main effect per factor, the supp:dose interaction, and residuals. The interaction p-value 0.0246 is significant, so the effect of supp depends on dose. With more factors the table grows but the columns stay identical, which is the point of tidy data.
4. Tidy a Tukey HSD post-hoc test
After a significant omnibus F, TukeyHSD() runs all pairwise mean differences with a family-wise alpha correction. tidy() on its result returns one row per contrast with the point estimate, 95% CI, and adjusted p-value. Only trt2-trt1 is significant (adjusted p = 0.012), which is the report-ready summary rather than the matrix-of-matrices that print(TukeyHSD(fit)) produces.
aovlist. When the formula contains an Error() term, the fit class becomes c("aovlist", "listof") and tidy() returns one tibble per stratum stacked by row, with an extra stratum column. The shape rule still holds, just with one more identifier.tidy() vs base summary() and other reporting paths
Three tools cover the same job from different angles. Pick by what you do next with the output.
| Tool | Output type | Best for |
|---|---|---|
summary(fit) |
printed text plus nested list | Quick console check |
broom::tidy(fit) |
tibble (data frame) | dplyr piping, ggplot, effect size math |
gtsummary::tbl_regression() |
rendered HTML or Word table | Final report without manual formatting |
Use tidy() whenever the next step is code: computing eta-squared, faceting F-statistic plots across designs, or saving ANOVA tables to CSV. Use gtsummary for the final document; it accepts a tidied table and adds publication formatting. The summary() printout is the fastest interactive eyeball but a dead end for anything programmatic.
tidy() returns a six-column tibble, every dplyr verb, every ggplot geom, and every gt or flextable layout works without a custom shim. This is why broom ships inside the tidymodels meta-package even if you only fit a single ANOVA.Common pitfalls
Pitfall 1: confusing tidy(aov_fit) with tidy(lm_fit). An aov object inherits from lm, so tidy(lm(weight ~ group, data = PlantGrowth)) also runs but returns a different shape: one row per coefficient (intercept, grouptrt1, grouptrt2). The aov tidy is the F-test ANOVA table; the lm tidy is the coefficient table for contrast-coded dummies. They are not interchangeable in reports.
Pitfall 2: Type I sums of squares by default. Base R aov() uses sequential (Type I) sumsq, so term order in the formula changes per-term sumsq for unbalanced designs. For Type II or III, pass car::Anova(aov_fit, type = 3) to tidy(). The returned tibble keeps the same shape with corrected sumsq.
tidy(aov_fit) returns NA for statistic and p.value on the Residuals row. This is intentional; there is no F-test for residuals. But it breaks naive aggregations like summarise(across(everything(), mean)) unless you filter the residual row first or pass na.rm = TRUE. Always handle the NA row explicitly when piping the tidy table into a calculation.Pitfall 3: forgetting to install broom. The package is installed by default if you have tidymodels, but a bare R installation does not include it. Run install.packages("broom") once, then library(broom) per session. If you see could not find function "tidy", that is the cause.
Try it yourself
Try it: Fit a two-way ANOVA on ToothGrowth predicting len from supp and dose (as a factor) without the interaction. Use tidy() to produce the ANOVA table and arrange the rows by ascending p-value. Save the result to ex_anova_table.
Click to reveal solution
Explanation: Wrapping dose in factor() makes the model fit a two-degree-of-freedom term instead of a single linear slope, which is the correct ANOVA framing for a categorical predictor. The NA p-value on the residual row sorts to the bottom by default with arrange().
Related broom functions for aov
After mastering tidy(), the next two broom verbs round out the workflow:
glance(aov_fit): one-row model summary withr.squared,adj.r.squared,sigma,statistic,p.value,df,logLik,AIC,BIC,deviance,df.residual,nobsaugment(aov_fit): per-observation tibble with.fitted,.resid,.std.resid,.hat,.sigma,.cooksdtidy(TukeyHSD(aov_fit)): post-hoc pairwise comparison table after a significant omnibus F
To combine multiple ANOVA fits across groups, use purrr::map_dfr(fits, tidy, .id = "model"). The .id column lets you facet an F-statistic comparison plot by model.
See the official broom reference for aov methods for the full argument list.
FAQ
How do I extract the F statistic and p-value from an aov in R?
Call tidy(aov_fit) and read the statistic and p.value columns. Treatment-effect rows hold the F values; the Residuals row has NA in both. For a one-liner, pipe tidy(fit) |> filter(term != "Residuals") |> pull(p.value). The column names never change across one-way, two-way, or factorial designs.
What is the difference between tidy(aov) and tidy(lm) on the same data?
tidy(aov_fit) returns the ANOVA table: one row per source of variation with df, sumsq, meansq, statistic (F), p.value. tidy(lm_fit) returns the coefficient table: one row per dummy contrast with estimate, std.error, statistic (t), p.value. The first answers "does this factor matter overall"; the second, "how does each level differ from the reference".
Does broom tidy work with repeated-measures ANOVA?
Yes. When the formula contains an Error() stratum, aov() returns an aovlist object. tidy() handles it by returning a stacked tibble with one row per term per stratum, plus a stratum column to identify which Error level the row belongs to. The other six columns are unchanged.
How do I tidy a Type II or Type III ANOVA?
Base aov() uses Type I (sequential) sumsq. For Type II or III on unbalanced designs, pass the fit to car::Anova(fit, type = 2) or type = 3, then call tidy() on the result. The tibble shape matches tidy(aov_fit), so downstream code stays the same.
Can I plot effect sizes directly from the tidy output?
Yes. After tidy(fit) |> mutate(eta_sq = sumsq / sum(sumsq)) |> filter(term != "Residuals"), pipe into ggplot(aes(x = eta_sq, y = reorder(term, eta_sq))) + geom_col() for a horizontal bar chart of variance explained. The Residuals row must be dropped before plotting or the bar for unexplained variance will dwarf the others.