library(rstatix)
data("PlantGrowth")
set.seed(1234)
PlantGrowth %>% sample_n_by(group, size = 1)# A tibble: 3 × 2
weight group
<dbl> <fct>
1 5.14 ctrl
2 3.83 trt1
3 5.37 trt2
Test whether three or more group means differ — rstatix the modern way, base R for the classics
Learn to run a one-way ANOVA in R to compare the means of three or more groups. Use rstatix anova_test() for a tidy result with an effect size, and base R aov() + summary() for the classic table — with the normality and equal-variance assumption checks, Tukey HSD post-hoc comparisons (and Games-Howell when variances differ), and a ggpubr boxplot that prints the p-values on the panel.
June 23, 2026
July 17, 2026
anova_test() from rstatix for a tidy result (F, p, and the ges effect size) or base R aov() + summary() for the classic table. Both compute the same F-test.tukey_hsd() (equal variances) or games_howell_test() (unequal).shapiro_test() + Q-Q plot) and homogeneity of variance (levene_test()). If normality fails, use the non-parametric Kruskal-Wallis test instead.ggpubr::ggboxplot() + stat_pvalue_manual() — the boxplot with the comparison p-values printed on the panel.A one-way ANOVA (analysis of variance) answers a question that comes up constantly in experiments: do these several groups have different means, or is the spread just noise? It is the natural extension of the independent two-samples t-test to more than two groups, where the data are split by a single grouping variable (the factor).
The test reports an F-statistic and a p-value for the omnibus hypothesis — that all group means are equal. A significant p-value tells you at least one group differs, but not which one; for that you run a post-hoc pairwise comparison. This lesson runs the whole workflow two ways — the modern, pipe-friendly rstatix anova_test() and the classic base-R aov() — using the built-in PlantGrowth data, and shows the assumption checks that tell you whether to trust the F-test.
ANOVA hypotheses. Null: the means of all groups are equal. Alternative: at least one group mean differs from the others. With exactly two groups, the F-test and the t-test are equivalent.
The idea is simple: split the total variation in the outcome into two pieces and compare them.
between / within.A small F (≈ 1) means the groups barely differ relative to their internal spread; a large F means the group means are spread far apart compared with the noise — evidence that at least one mean is genuinely different.
ANOVA partitions the total variation in the outcome into the part between the groups and the part within them. Writing \(y_{ij}\) for observation \(i\) in group \(j\), \(\bar{y}_j\) for that group’s mean and \(\bar{y}\) for the overall (grand) mean, the sums of squares — total (T), between-groups (B) and within-groups (W) — add up exactly:
\[ SS_T = SS_B + SS_W \]
where \(SS_B = \sum_j n_j (\bar{y}_j - \bar{y})^2\) measures how far the group means sit from the grand mean, and \(SS_W = \sum_j \sum_i (y_{ij} - \bar{y}_j)^2\) measures the spread of observations inside their own group (the residual noise).
Each sum of squares becomes a mean square when you divide by its degrees of freedom, \(MS = SS / df\): with \(k\) groups and \(N\) observations, \(df_B = k - 1\) and \(df_W = N - k\). The F-statistic is then the ratio of the two mean squares:
\[ F = \frac{MS_B}{MS_W} \]
If the groups truly share one mean, both mean squares estimate the same noise variance and \(F\) sits near 1; a real between-group difference inflates the numerator and pushes \(F\) above 1. (For PlantGrowth: \(k = 3\), \(N = 30\), so \(df = 2\) and \(27\) — the \(F(2, 27)\) you see in the output.)
We use the built-in PlantGrowth data — the dried weight of plants grown under a control and two different treatment conditions (ctrl, trt1, trt2), 10 plants per group. The research question is whether the mean plant weight differs across the three conditions. Inspect one random row per group with sample_n_by():
# A tibble: 3 × 2
weight group
<dbl> <fct>
1 5.14 ctrl
2 3.83 trt1
3 5.37 trt2
R orders factor levels alphabetically by default. Here that already gives ctrl, trt1, trt2 — but it’s good practice to set the order explicitly with reorder_levels() so the control group comes first in every table and plot:
[1] "ctrl" "trt1" "trt2"
Put numbers behind the plot with rstatix get_summary_stats() — the per-group counts, means and standard deviations you’ll report with the test:
# A tibble: 3 × 5
group variable n mean sd
<fct> <fct> <dbl> <dbl> <dbl>
1 ctrl weight 10 5.03 0.583
2 trt1 weight 10 4.66 0.794
3 trt2 weight 10 5.53 0.443
The control group averages about 5.03, trt1 is a touch lower (4.66) and trt2 is the highest (5.53) — the numbers behind the boxplot that follows.
Always plot the groups before testing them. A boxplot of weight by group shows the three distributions side by side:

A mean plot with error bars makes the pattern explicit — ggline() plots the group means joined by a line, with the standard error as the band and the raw points jittered on top:

trt2 sits clearly above the control while trt1 dips slightly below it — the picture suggests a real treatment effect. Let’s confirm it with the test, then verify the assumptions.
A one-way ANOVA trusts a few assumptions about the data (beyond independent, random observations): no extreme outliers, the residuals are normally distributed, and the groups have equal variance. Check them before believing the p-value.
identify_outliers() flags extreme values in each group with the boxplot rule — an empty result means none would distort the group means:
# A tibble: 2 × 4
group weight is.outlier is.extreme
<fct> <dbl> <lgl> <lgl>
1 trt1 5.87 TRUE FALSE
2 trt1 6.03 TRUE FALSE
There were no extreme outliers.
Where you do have extreme outliers, they can come from data-entry or measurement errors, or be genuinely unusual values. You can keep an outlier if you don’t believe it will change the conclusion — compare the ANOVA with and without it — or run a robust ANOVA (e.g. the WRS2 package) that down-weights it.
The normality assumption can be checked two ways: by analysing the model residuals (one check for all groups together — handy with many groups or few points per group), or per group (useful with few groups and many points each). We show both.
On the residuals. Fit the model, extract the residuals, and test them with Shapiro-Wilk (p > 0.05 → no departure from normal), backed by a Q-Q plot — the points should hug the reference line:
# A tibble: 1 × 3
variable statistic p.value
<chr> <dbl> <dbl>
1 residuals(model) 0.966 0.438

The Shapiro-Wilk p-value is non-significant (p ≈ 0.13) and the points fall along the line — normality holds.
Per group. With only three groups you can also test each one and draw a faceted Q-Q plot:
# A tibble: 3 × 4
group variable statistic p
<fct> <chr> <dbl> <dbl>
1 ctrl weight 0.957 0.747
2 trt1 weight 0.930 0.452
3 trt2 weight 0.941 0.564

The weights are normally distributed (p > 0.05) in every group.
If your sample size is large (n > 50), prefer the Q-Q plot: Shapiro-Wilk becomes very sensitive to even minor departures from normality at large n. And if you have real doubt about normality, use the non-parametric Kruskal-Wallis test instead.
ANOVA also assumes the groups share a common variance. The residuals-vs-fitted plot is the visual check — a roughly flat, even band of points (no funnel shape) means the spread is constant across the groups:

Back it with Levene’s test, which asks formally whether the group variances are equal (p > 0.05 → no evidence they differ, which is what we want). With rstatix it’s one pipe:
# A tibble: 1 × 4
df1 df2 statistic p
<int> <int> <dbl> <dbl>
1 2 27 1.12 0.341
A non-significant Levene’s test (p > 0.05) means equal-variance holds, so the classic ANOVA (and Tukey post-hoc) is appropriate. If it were significant, switch to the Welch ANOVA (welch_anova_test()) and the Games-Howell post-hoc test, which don’t assume equal variances — see the relaxing the assumption section below.
anova_test() takes the data and an outcome ~ factor formula and returns a tidy tibble — one row, every number you need:
ANOVA Table (type II tests)
Effect DFn DFd F p p<.05 ges
1 group 2 27 4.846 0.016 * 0.264
The columns are F (the statistic), DFn/DFd (degrees of freedom), p (the p-value), p<.05 (a significance flag), and ges — the generalized eta-squared effect size, the proportion of variation in weight explained by group. There are significant differences between groups, F(2, 27) = 4.85, p = 0.016, and ges = 0.26 — treatment explains about 26% of the variation in plant weight. As a rule of thumb, generalized eta-squared (η²) benchmarks are small ≈ 0.01, medium ≈ 0.06 and large ≥ 0.14 — so 0.26 is a large effect.
Run it on your own data. Load your file first — mydata <- read.csv("my-file.csv") — make the grouping column a factor with mydata$group <- factor(mydata$group), then pipe it in: mydata %>% anova_test(outcome ~ group).
aov() fits the model and summary() prints the classic ANOVA table:
Df Sum Sq Mean Sq F value Pr(>F)
group 2 3.766 1.8832 4.846 0.0159 *
Residuals 27 10.492 0.3886
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Read the table: Df are the degrees of freedom, F value is the statistic, and Pr(>F) is the p-value (the stars flag significance). It’s the same F and p as the rstatix call — aov() simply prints the textbook layout and keeps the fitted model for diagnostics and post-hoc tests.
A significant ANOVA says some mean differs — to find which pairs, run a post-hoc test that corrects for the multiple comparisons.
tukey_hsd() compares every pair and adjusts the p-values (Tukey Honest Significant Differences):
# A tibble: 3 × 9
term group1 group2 null.value estimate conf.low conf.high p.adj p.adj.signif
* <chr> <chr> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <chr>
1 group ctrl trt1 0 -0.371 -1.06 0.320 0.391 ns
2 group ctrl trt2 0 0.494 -0.197 1.19 0.198 ns
3 group trt1 trt2 0 0.865 0.174 1.56 0.0120 *
The output gives estimate (the difference between the two group means), conf.low/conf.high (the 95% confidence interval) and p.adj (the adjusted p-value). Only the difference between trt2 and trt1 is significant (adjusted p = 0.012); the control-vs-treatment differences are not.
The base-R equivalent takes the fitted aov() object:
Tukey multiple comparisons of means
95% family-wise confidence level
Fit: aov(formula = weight ~ group, data = PlantGrowth)
$group
diff lwr upr p adj
trt1-ctrl -0.371 -1.0622161 0.3202161 0.3908711
trt2-ctrl 0.494 -0.1972161 1.1852161 0.1979960
trt2-trt1 0.865 0.1737839 1.5562161 0.0120064
Tukey HSD isn’t the only post-hoc route. Base R’s pairwise.t.test() runs every pairwise comparison and lets you pick the multiple-comparison correction directly. Use the Benjamini-Hochberg ("BH") method to control the false-discovery rate — the expected proportion of false positives among the significant results — a less conservative alternative to Tukey when you have several pairs:
Pairwise comparisons using t tests with pooled SD
data: PlantGrowth$weight and PlantGrowth$group
ctrl trt1
trt1 0.194 -
trt2 0.132 0.013
P value adjustment method: BH
The result is a triangular matrix of adjusted p-values, one per pair. Only trt2 vs trt1 comes out significant (p ≈ 0.013) — the same conclusion as Tukey: the two treatments differ from each other, but neither differs significantly from the control.
If you already use multcomp for other contrasts, its glht() function gives the same Tukey comparisons as TukeyHSD() from the fitted aov() model — summary(multcomp::glht(res.aov.base, linfct = multcomp::mcp(group = "Tukey"))). It’s worth knowing when you need general linear hypotheses beyond the standard all-pairs Tukey set.
The publication-ready figure puts the test result on the plot — the overall ANOVA in the subtitle (get_test_label()) and the significant pairwise bracket via stat_pvalue_manual(), after add_xy_position() computes where the bracket sits. hide.ns = TRUE hides the non-significant pairs so only the real difference (trt1 vs trt2) is drawn:
library(rstatix)
library(ggpubr)
data("PlantGrowth")
PlantGrowth <- PlantGrowth %>%
reorder_levels(group, order = c("ctrl", "trt1", "trt2"))
res.aov <- PlantGrowth %>% anova_test(weight ~ group)
pwc <- PlantGrowth %>%
tukey_hsd(weight ~ group) %>%
add_xy_position(x = "group")
ggboxplot(PlantGrowth, x = "group", y = "weight",
xlab = "Treatment", ylab = "Plant weight") +
stat_pvalue_manual(pwc, hide.ns = TRUE) +
labs(
subtitle = get_test_label(res.aov, detailed = TRUE),
caption = get_pwc_label(pwc)
)
This is the figure to report: the omnibus F, degrees of freedom, p-value and effect size up top, and the one significant pair (trt1 vs trt2) marked right on the boxes.
A publication-style write-up pulls the omnibus result, the effect size and the significant pairwise comparisons into one sentence — for example:
A one-way ANOVA was performed to compare plant weight across the three treatment groups — control (n = 10), trt1 (n = 10) and trt2 (n = 10). Plant weight differed significantly between groups, F(2, 27) = 4.85, p = 0.016, generalized η² = 0.26. Tukey post-hoc comparisons showed that the increase from trt1 (4.66 ± 0.79) to trt2 (5.53 ± 0.44) was significant (difference 0.87, 95% CI [0.17, 1.56], p.adj = 0.012), with no other pair significantly different.
Report the F-statistic with its degrees of freedom, the p-value, the effect size (ges), and the significant pairwise differences — not just “p < 0.05”.
The classic one-way ANOVA assumes equal variances across groups. Here Levene’s test was non-significant, so it’s fine — but when equal variance fails, the Welch one-way ANOVA (welch_anova_test()) is the alternative that does not require it, paired with the Games-Howell post-hoc test (built on Welch’s correction, no equal-variance or equal-size assumption). The figure below swaps in both — note the same get_test_label() / stat_pvalue_manual() recipe:
library(rstatix)
library(ggpubr)
data("PlantGrowth")
PlantGrowth <- PlantGrowth %>%
reorder_levels(group, order = c("ctrl", "trt1", "trt2"))
# Welch one-way ANOVA + Games-Howell post-hoc (no equal-variance assumption)
res.aov2 <- PlantGrowth %>% welch_anova_test(weight ~ group)
pwc2 <- PlantGrowth %>%
games_howell_test(weight ~ group) %>%
add_xy_position(x = "group", step.increase = 1)
ggboxplot(PlantGrowth, x = "group", y = "weight",
xlab = "Treatment", ylab = "Plant weight") +
stat_pvalue_manual(pwc2, hide.ns = TRUE) +
labs(
subtitle = get_test_label(res.aov2, detailed = TRUE),
caption = get_pwc_label(pwc2)
)
Which post-hoc? Run Levene’s test first. Equal variances → Tukey HSD. Unequal variances → Games-Howell (often the safer default, since it degrades gracefully when variances are equal). You can also use pairwise t-tests with pool.sd = FALSE when variances differ.
If the residuals stray far from the line, or Levene’s test is significant, the data may not fit the classic ANOVA. Use the Welch ANOVA for unequal variances, or the non-parametric Kruskal-Wallis test when normality fails. See the dedicated normality test lesson for the full assumption workflow.
Let the assumptions pick the test:
anova_test()), with Tukey HSD (tukey_hsd()) post-hoc.welch_anova_test()), with Games-Howell (games_howell_test()) post-hoc.kruskal_test()), with Dunn’s test post-hoc.Run the full workflow on a different dataset — InsectSprays (insect count by spray type, six groups A–F). Edit the columns, the dataset, or the post-hoc test and re-run; the sandbox boots on first Run.
Ask Prova “I have a numeric outcome and one grouping column — run the right ANOVA on my data, check the assumptions, and tell me which groups differ” — it answers with rstatix code you can run on your own data, then helps you read the F-test, the effect size and the post-hoc table. The runtime is the judge. Ask Prova →
anova_test() reports the factor as numeric / gives 1 degree of freedom. Your grouping variable is still numeric — ANOVA is treating it as a continuous predictor (a regression slope), not several groups. Make it a factor first: mydata$group <- factor(mydata$group) (in PlantGrowth, group is already a factor).
The ANOVA is significant but every post-hoc pair is non-significant (or vice-versa). The omnibus F-test and the multiplicity-corrected pairwise tests answer different questions; borderline cases can disagree. In PlantGrowth the omnibus is significant yet only trt1-vs-trt2 survives the Tukey correction — that’s normal. Trust the post-hoc comparisons for which groups differ, and always report the effect size, not just the p-value.
Levene’s test is significant, so my Tukey HSD may be wrong. Equal variance has failed. Re-run the comparisons with games_howell_test() (and report a welch_anova_test() omnibus), which don’t assume equal variances.
The tidy way is rstatix: make your grouping column a factor, then mydata %>% anova_test(outcome ~ group) — it returns the F-statistic, p-value and a ges effect size in one row. The base-R way is summary(aov(outcome ~ group, data = mydata)), which prints the classic ANOVA table. Both compute the same F-test.
A t-test compares the means of exactly two groups; a one-way ANOVA compares three or more. Running many t-tests instead inflates the false-positive rate, which ANOVA avoids with a single omnibus F-test. With exactly two groups, the ANOVA and the independent t-test give identical results (F = t²).
If Levene’s test shows equal variances, use Tukey HSD (tukey_hsd()) — it compares all pairs and corrects for multiplicity. If variances are unequal, use Games-Howell (games_howell_test()), which is built on Welch’s correction and doesn’t assume equal variances or equal group sizes. Run a post-hoc test only after a significant omnibus ANOVA.
Four, really: the observations are independent and random, there are no extreme outliers (identify_outliers()), the residuals are normally distributed (check with shapiro_test() on the model residuals and a Q-Q plot), and the groups have equal variance (check with levene_test()). If equal-variance fails, use the Welch ANOVA; if normality fails, use the non-parametric Kruskal-Wallis test.
The F-statistic is the ratio of the between-group variance to the within-group variance — how far the group means spread apart relative to the noise inside each group. A large F means the group means differ by more than chance noise would explain, which is evidence that at least one mean is genuinely different.
For unequal variances, use welch_anova_test() plus the Games-Howell post-hoc test — neither assumes equal variances. For non-normal data, use the Kruskal-Wallis rank-sum test (kruskal_test()), the non-parametric alternative to one-way ANOVA, followed by Dunn’s test for pairwise comparisons.
weight by group in PlantGrowth. Fill the blanks. Is the omnibus test significant? What is the ges effect size?Fill the two blanks with the outcome weight and the factor group. Look at the p column for significance and the ges column for the effect size. For question 2, remember that the omnibus F-test answers a different question from the pairwise comparisons.
The omnibus ANOVA is significant (p = 0.016) and ges = 0.26 — group explains about 26% of the variation in plant weight.
For question 2: a significant omnibus ANOVA only tells you that at least one of the three group means differs — not which ones. You must run a post-hoc pairwise test (tukey_hsd(), or games_howell_test() if variances are unequal) that corrects for the multiple comparisons to identify the specific pairs that differ (here, only trt1 vs trt2).
You can now run and interpret a one-way ANOVA in R two ways: the tidy rstatix anova_test() (with its ges effect size) and the classic base-R aov() + summary(). Check the assumptions — no outliers, normality of residuals (shapiro_test) and equal variance (levene_test) — read the omnibus F-test, then follow a significant result with a post-hoc comparison (tukey_hsd(), or games_howell_test() when variances differ). When the assumptions fail, the Kruskal-Wallis test is the non-parametric fallback.
Prove you can do it. Master the whole ANOVA in R series — track your path, build projects, and earn a certificate.
Go Pro — unlimited Prova on your own data and a verifiable certificate that proves the skill.
from $15/mo billed yearly
✓ You're Pro — keep going. The runtime is the judge.
Ready to level up?
Get new R & Python lessons by email
Practical, reproducible, no spam. Unsubscribe anytime.
Double opt-in. We never share your email.
Every result on this page was produced by the code shown, run at build time against a pinned R environment — edit any block and Run to reproduce it yourself.
@online{2026,
author = {},
title = {One-Way {ANOVA} in {R:} {Compare} {Several} {Group} {Means}},
date = {2026-06-23},
url = {https://www.datanovia.com/learn/biostatistics/anova/anova-in-r},
langid = {en}
}