To run a two-way ANOVA in R, you fit aov(outcome ~ factorA * factorB, data = df) so the model estimates both main effects and their interaction, read the table with summary(), and interpret the interaction first because it changes how the main effects are read. The defensible version depends on using Type III sums of squares for unbalanced designs, checking the residuals, and following a significant interaction with simple effects rather than plain post hoc tests. This guide covers the full procedure with code.
What a two-way ANOVA tests
A two-way analysis of variance examines one continuous outcome against two categorical factors at once. It returns three results: the main effect of factor A, the main effect of factor B, and the interaction that asks whether the effect of one factor depends on the level of the other. With a single factor you would run a one-way ANOVA in R; the move to two factors is what lets you separate combined influences that a one-way model would confound.
Setting the factors and inspecting the design
Both predictors must be factors, and you should view the cell counts as a crosstab so you know whether the design is balanced:
df$factorA <- factor(df$factorA)
df$factorB <- factor(df$factorB)
table(df$factorA, df$factorB) # cell sizes for every combinationBalance matters more here than in a one-way model. When cell sizes differ, the default sequential sums of squares give different answers depending on the order you list the factors, which is the source of most confused two-way results.
Fitting the model with the interaction
Use the * operator so the formula expands to both main effects and the interaction term:
model <- aov(score ~ factorA * factorB, data = df)
summary(model)
# factorA * factorB is shorthand for factorA + factorB + factorA:factorBThe factorA:factorB row is the interaction. Read it before anything else, because a significant interaction means the main effects cannot be interpreted on their own.
Why balanced and unbalanced designs need different code
With unequal cell sizes the standard summary(aov()) uses Type I (sequential) sums of squares, which are order-dependent and usually not what you want. Switch to Type III sums of squares with the car package, after setting the contrasts so the Type III test is correct:
library(car)
options(contrasts = c("contr.sum", "contr.poly"))
model <- aov(score ~ factorA * factorB, data = df)
Anova(model, type = "III")Report which sums of squares you used. This is the single most common reason two reviewers get different F values from the same dataset.
Interpreting the interaction with a plot
A significant interaction is easiest to read as a plot of group means. Lines that are not parallel show the effect of one factor changing across the levels of the other:
interaction.plot(df$factorA, df$factorB, df$score)
# or with ggplot2 for a cleaner figure
library(ggplot2)
ggplot(df, aes(factorA, score, color = factorB, group = factorB)) +
stat_summary(fun = mean, geom = "line") +
stat_summary(fun = mean, geom = "point")Following up a significant interaction
When the interaction is significant, ordinary post hoc tests on a main effect are misleading. Test simple effects instead, the effect of one factor held at each level of the other, which the emmeans package makes straightforward:
library(emmeans)
emm <- emmeans(model, ~ factorA | factorB)
pairs(emm) # compares levels of A within each level of BWhen the interaction is not significant, you can interpret each main effect directly and use TukeyHSD() on it, exactly as in a one-way model in R.
Assumptions, effect size, and reporting
Check the assumptions as you would any ANOVA: Levene's test for homogeneity of variance across the cells and shapiro.test(residuals(model)) for residual normality. For an effect size, report partial eta squared per term with effectsize::eta_squared(model, partial = TRUE). In the write-up, give each main effect and the interaction as F(df1, df2) = value with its p and partial eta squared, then describe the simple effects. The formatting conventions are in APA-style statistical reporting, and the prose belongs in your write-up of the results. If a within-subjects factor is involved instead, you need a repeated measures ANOVA in R.