How to Implement One Way ANOVA in R

R Published Updated 5 mins read By Leon Wei
How to Implement One Way ANOVA in R cover image

Quick summary

Summarize this blog with AI

Introduction

One-way ANOVA tests whether all group population means are equal. A small p-value indicates evidence against that joint hypothesis; it does not identify every differing pair or measure practical importance. This complete example uses three fictional independent groups and keeps one fitted model throughout.

Tested with R 4.5.1. Run the code blocks in order. Install the named packages before starting; inputs are supplied below.

Supply the data and define the analysis rows

Each row is one independent unit, with a numeric response and a categorical group. Repeated measurements from one person are not independent units. Missing rows are counted before removal; the decision to analyze complete cases must be justified in a real study.

raw <- data.frame(group = factor(rep(c("A", "B", "C"), each = 5)),
                   score = c(8, 9, 10, 11, 12, 10, 11, 12, 13, 14,
                             14, 15, 16, 17, 18))
 keep <- complete.cases(raw[c("score", "group")])
 cat("Excluded rows:", sum(!keep), "
")
 dat <- droplevels(raw[keep, ])
 stopifnot(is.numeric(dat$score), all(is.finite(dat$score)),
           nlevels(dat$group) >= 2, all(table(dat$group) >= 2))
 print(aggregate(score ~ group, dat, mean))

No rows are excluded; the group means are A = 10, B = 12 and C = 16. The response’s units should accompany these estimates in a report.

Fit the model and verify the F statistic

In aov(score ~ group), group is a factor, so the model estimates a separate group mean. The classical F statistic divides the between-group mean square by the within-group mean square. It uses 2 and 12 degrees of freedom here.

fit <- aov(score ~ group, data = dat, na.action = na.fail)
 tab <- summary(fit)[[1]]
 print(tab)
 group_mean <- ave(dat$score, dat$group, FUN = mean)
 ss_between <- sum((group_mean - mean(dat$score))^2)
 ss_within <- sum((dat$score - group_mean)^2)
 f_manual <- (ss_between / 2) / (ss_within / 12)
 stopifnot(abs(f_manual - tab[1, "F value"]) < 1e-12,
           abs(tab[1, "F value"] - 18.6666666667) < 1e-8)
 cat("F:", round(f_manual, 3), "p:", signif(tab[1, "Pr(>F)"], 4), "
")

F = 18.667 and p is approximately 0.000207. Under the model this is evidence that at least one population mean differs. It does not mean the null hypothesis has that probability.

Inspect assumptions without turning checks into certificates

The classical model assumes independent errors with a common variance and normally distributed errors within groups for exact small-sample inference. Normality does not protect against outliers. Independence comes from design; a nonsignificant diagnostic test does not prove the assumptions. Inspect group distributions and residual patterns, including data collection order when relevant.

draw_anova_checks <- function() {
   old <- par(mfrow = c(1, 2), mar = c(4, 4, 3, 1))
   on.exit(par(old))
   boxplot(score ~ group, data = dat, xlab = "Group", ylab = "Score",
           main = "Within-group spread", col = "#b6d9e6")
   qqnorm(residuals(fit), main = "Residual Q-Q plot", pch = 19, cex = 0.7)
   qqline(residuals(fit), col = "#b33d24")
 }
 draw_anova_checks()
 print(tapply(dat$score, dat$group, var))
ANOVA group spread and residual quantilesThree groups have means 10,12,16 and equal sample variances; residual quantiles repeat because the constructed group patterns match. A B C 8 10 12 14 16 18 Within-group spread Group Score -1 0 1 -2 -1 0 1 2 Residual Q-Q plot Theoretical Quantiles Sample Quantiles
Diagnostics for the supplied 15-row fixture. Independence must be justified by study design.

All three sample variances are 2.5 in this deliberately simple fixture. The plot illustrates the checks; fifteen constructed observations do not demonstrate that a real study meets its assumptions.

Use Tukey comparisons for the classical model

TukeyHSD() reports simultaneous intervals and adjusted p-values for pairwise differences. Its familywise interpretation belongs to the model assumptions; it is not a guarantee about arbitrary searches across analyses.

pairs <- TukeyHSD(fit, "group")$group
 print(round(pairs, 4))
 stopifnot(isTRUE(all.equal(unname(pairs[, "diff"]), c(2, 6, 4))),
           pairs["B-A", "lwr"] < 0, pairs["B-A", "upr"] > 0,
           pairs["C-A", "lwr"] > 0, pairs["C-B", "lwr"] > 0)

The observed B − A, C − A and C − B differences are 2, 6 and 4 points. The B − A interval includes zero; the other two do not. Report the intervals, not just a significant/nonsignificant label.

Unequal variances and invalid groups

Welch’s one-way test in oneway.test() allows unequal variances. It does not solve dependence or every outlier problem. Do not append classical Tukey intervals to a Welch analysis as if they share its variance model. Kruskal–Wallis is a rank-based alternative with its own assumptions and is not generally a test of equal means.

welch <- oneway.test(score ~ group, data = dat, var.equal = FALSE)
 cat("Welch p:", signif(welch$p.value, 4), "
")
 check_groups <- function(d) {
   if (!is.numeric(d$score) || any(!is.finite(d$score)) ||
       anyNA(d$group) || length(unique(d$group)) < 2 ||
       any(table(droplevels(factor(d$group))) < 2))
     stop("Need finite scores and at least two observations in each of two groups")
   invisible(TRUE)
 }
 fails <- function(expr) inherits(tryCatch(expr, error = identity), "error")
 stopifnot(check_groups(dat), fails(check_groups(dat[FALSE, ])),
           fails(check_groups(dat[1:5, ])),
           fails(check_groups(transform(dat, score = NA_real_))))

Plan sample size from a meaningful difference

For a balanced future study, power.anova.test() uses the variance of anticipated group means and the within-group variance. Those inputs should come from substantive planning or external evidence. This illustrative plan targets means 10, 12 and 14 with within-group SD 3, 80% power and a 5% test level.

planned <- power.anova.test(groups = 3, between.var = var(c(10, 12, 14)),
                             within.var = 3^2, sig.level = 0.05, power = 0.8)
 n_each <- ceiling(planned$n)
 cat("Units per group:", n_each, "total:", 3 * n_each, "
")
 achieved <- power.anova.test(groups = 3, n = n_each,
                             between.var = 4, within.var = 9, sig.level = 0.05)$power
 stopifnot(achieved >= 0.8)

The result rounds up to 12 units per group, or 36 total. Allow separately for expected attrition. This calculation assumes balanced independent groups and the classical variance model; observed post-hoc power is not extra evidence for a fitted result.

Primary references

Interview Prep

Begin Your SQL, Python, and R Journey

Master 230 interview-style coding questions and build the data skills needed for analyst, scientist, and engineering roles.

Related Articles

All Articles
How to Remove Outliers in R cover image
r Oct 17, 2026

How to Remove Outliers in R

Learn how to identify and remove outliers in R with this step-by-step guide, featuring detailed code samples for beginners.

How to Use 'countif' in R cover image
r Oct 17, 2026

How to Use 'countif' in R

Unlock the power of 'countif' in R with our comprehensive guide. Perfect for beginners looking to enhance their R programming skills.

How to Use 'abline' in R cover image
r Apr 30, 2024

How to Use 'abline' in R

Unlock the power of 'abline' function in R for data visualization; this guide covers everything from basics to advanced applications with exampl…