Kruskal-Wallis Test in R: Non-Parametric One-Way ANOVA

Compare 3+ groups when normality fails — rstatix the modern way, base R for the classic — with Dunn post-hoc

Biostatistics

Learn to run the Kruskal-Wallis test in R, the non-parametric alternative to one-way ANOVA for comparing three or more groups when normality fails. Use rstatix kruskal_test() for a tidy result and base R kruskal.test() for the classic output, measure the effect size with kruskal_effsize(), then run Dunn’s post-hoc test (dunn_test()) to find which groups differ — with a ggpubr box plot that prints the p-values on the panel.

Published

June 23, 2026

Modified

July 8, 2026

TipKey takeaways
  • The Kruskal-Wallis test is the non-parametric alternative to one-way ANOVA — it compares three or more independent groups using ranks, not means.
  • Reach for it when one-way ANOVA’s normality assumption fails (or with ordinal data) — it makes no normality assumption.
  • Run it with kruskal_test() from rstatix for a tidy result, or base R kruskal.test() for the classic printout. Both wrap the same statistics.
  • A significant omnibus result tells you some group differs, not which — follow up with a post-hoc test: rstatix dunn_test() (rank-aware, recommended) or pairwise Wilcoxon.
  • Report the effect size (kruskal_effsize(), eta-squared) and plot it with ggpubr::ggboxplot() + stat_pvalue_manual() — the box plot with the p-values on the panel.

Introduction

You have three or more groups and want to know whether they differ — the classic job for a one-way ANOVA. But ANOVA assumes each group is roughly normally distributed. When that assumption fails — skewed data, outliers, small samples, or ordinal scores — the Kruskal-Wallis test is the answer.

The Kruskal-Wallis test is the non-parametric alternative to one-way ANOVA. It extends the two-group Wilcoxon test to more than two groups by ranking all the observations together and asking whether the rank sums differ between groups — so it makes no assumption about normality. It’s the test to reach for when the assumptions of one-way ANOVA are not met.

This lesson runs it two ways — the modern, pipe-friendly rstatix kruskal_test() and the classic base-R kruskal.test() — measures the effect size based on the Kruskal-Wallis H-statistic, and runs the post-hoc comparison that tells you which groups actually differ, all on the built-in PlantGrowth data.

When to use the Kruskal-Wallis test

Use it to compare a numeric outcome across 3+ independent groups when a parametric one-way ANOVA isn’t safe:

Situation Parametric (means) Non-parametric (ranks)
2 groups t-test Wilcoxon / Mann-Whitney
3+ groups one-way ANOVA Kruskal-Wallis

Switch from ANOVA to Kruskal-Wallis when the normality assumption fails — check it with a Shapiro-Wilk test and a Q-Q plot (see the normality test lesson). Kruskal-Wallis still assumes the groups are independent and have similar-shaped distributions; under those conditions a significant result is read as a difference in medians.

You never compute it by hand — kruskal_test() does — but the idea is simple. Pool all \(N\) observations, rank them from smallest to largest, then compare the average rank in each of the \(k\) groups. The test statistic is

\[ H = \frac{12}{N(N+1)} \sum_{i=1}^{k} \frac{R_i^2}{n_i} - 3(N+1) \]

where \(R_i\) and \(n_i\) are the rank sum and size of group \(i\). Under the null (all groups drawn from the same distribution), \(H\) follows a chi-square distribution with \(k-1\) degrees of freedom — which is the df and p you’ll see in the output. Because it uses ranks, outliers and non-normality barely move it.

The data: three treatment groups

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 median plant weight differs across the three conditions. Inspect one random row per group with sample_n_by():

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 

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:

library(rstatix)

data("PlantGrowth")
PlantGrowth <- PlantGrowth %>%
  reorder_levels(group, order = c("ctrl", "trt1", "trt2"))

levels(PlantGrowth$group)
[1] "ctrl" "trt1" "trt2"

Summary statistics by group

Before testing, summarise each group. Because Kruskal-Wallis is rank-based, the median and IQR are the natural companions — rstatix get_summary_stats() gives them in one pipe:

library(rstatix)

data("PlantGrowth")
PlantGrowth <- PlantGrowth %>%
  reorder_levels(group, order = c("ctrl", "trt1", "trt2"))

PlantGrowth %>%
  group_by(group) %>%
  get_summary_stats(weight, type = "median_iqr")
# A tibble: 3 × 5
  group variable     n median   iqr
  <fct> <fct>    <dbl>  <dbl> <dbl>
1 ctrl  weight      10   5.16 0.743
2 trt1  weight      10   4.55 0.662
3 trt2  weight      10   5.44 0.467

The median weight dips slightly from the control to trt1, then rises for trt2 — Kruskal-Wallis will tell us whether that pattern is statistically significant.

Look at the data first

Always plot the groups before testing them. A box plot of weight by group shows the three distributions side by side:

library(ggpubr)
library(rstatix)

data("PlantGrowth")
PlantGrowth <- PlantGrowth %>%
  reorder_levels(group, order = c("ctrl", "trt1", "trt2"))

ggboxplot(
  PlantGrowth, x = "group", y = "weight",
  color = "group", palette = "jco",
  xlab = "Treatment", ylab = "Plant weight"
)

A box plot of PlantGrowth plant weight by treatment group (ctrl, trt1, trt2); the trt2 group sits highest, trt1 lowest, with overlapping but visibly shifted distributions.

trt2 sits clearly above trt1, with the control in between — the picture suggests a real treatment effect. Rather than assume normality, we’ll confirm it with the rank-based Kruskal-Wallis test.

Run the Kruskal-Wallis test

The question: is there any significant difference between the average weights of plants in the 3 experimental conditions?

With base R

kruskal.test() computes the same test and prints the classic report:

library(rstatix)

data("PlantGrowth")
PlantGrowth <- PlantGrowth %>%
  reorder_levels(group, order = c("ctrl", "trt1", "trt2"))

kruskal.test(weight ~ group, data = PlantGrowth)

    Kruskal-Wallis rank sum test

data:  weight by group
Kruskal-Wallis chi-squared = 7.9882, df = 2, p-value = 0.01842

Read the output: Kruskal-Wallis chi-squared is the H statistic, df is groups − 1, and p-value is the significance. It matches the rstatix result exactly — the tidy tibble and the classic printout are the same test in two skins.

Effect size

A p-value tells you whether groups differ, not how much. Report the effect size too. For Kruskal-Wallis the standard measure is eta-squared based on the H-statistic, eta2[H] = (H − k + 1) / (n − k) — where H is the Kruskal-Wallis statistic, k the number of groups and n the total number of observations [@tomczak2014]. Multiplied by 100, it is the percentage of variance in the ranks explained by the grouping. kruskal_effsize() computes it and labels the magnitude:

library(rstatix)

data("PlantGrowth")
PlantGrowth <- PlantGrowth %>%
  reorder_levels(group, order = c("ctrl", "trt1", "trt2"))

PlantGrowth %>% kruskal_effsize(weight ~ group)
# A tibble: 1 × 5
  .y.        n effsize method  magnitude
* <chr>  <int>   <dbl> <chr>   <ord>    
1 weight    30   0.222 eta2[H] large    

The common interpretation thresholds are 0.01–0.06 small, 0.06–0.14 moderate, ≥ 0.14 large. Here a large effect size is detected, eta2[H] = 0.22 — treatment explains a substantial share of the variation in plant weight.

Post-hoc: which groups differ?

A significant Kruskal-Wallis test is an omnibus result — something differs, but we don’t yet know which pairs of groups are different. Follow it with a post-hoc pairwise test, corrected for multiple comparisons.

Pairwise Wilcoxon (the base alternative)

You can also follow up with pairwise Wilcoxon tests, with corrections for multiple testing. Base R’s pairwise.wilcox.test() does this directly; the rstatix equivalent keeps the tidy output:

library(rstatix)

data("PlantGrowth")
PlantGrowth <- PlantGrowth %>%
  reorder_levels(group, order = c("ctrl", "trt1", "trt2"))

# Base R: pairwise.wilcox.test(PlantGrowth$weight, PlantGrowth$group, p.adjust.method = "bonferroni")
PlantGrowth %>% wilcox_test(weight ~ group, p.adjust.method = "bonferroni")
# A tibble: 3 × 9
  .y.    group1 group2    n1    n2 statistic     p p.adj p.adj.signif
* <chr>  <chr>  <chr>  <int> <int>     <dbl> <dbl> <dbl> <chr>       
1 weight ctrl   trt1      10    10      67.5 0.199 0.597 ns          
2 weight ctrl   trt2      10    10      25   0.063 0.189 ns          
3 weight trt1   trt2      10    10      16   0.009 0.027 *           

The pairwise comparison confirms that only trt1 and trt2 are significantly different (Wilcoxon’s test, p = 0.027).

Note

Prefer Dunn’s test after Kruskal-Wallis: it is built on the same ranks as the omnibus test and adjusts for ties, so it’s internally consistent. Pairwise Wilcoxon re-ranks each pair separately — fine, but a slightly different procedure.

Plot it with the test result on the panel

The publication-ready figure puts the test result on the plot — the omnibus Kruskal-Wallis result 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 shows only the significant brackets, 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.kruskal <- PlantGrowth %>% kruskal_test(weight ~ group)
pwc <- PlantGrowth %>%
  dunn_test(weight ~ group, p.adjust.method = "bonferroni") %>%
  add_xy_position(x = "group")

ggboxplot(PlantGrowth, x = "group", y = "weight",
          color = "group", palette = "jco",
          xlab = "Treatment", ylab = "Plant weight") +
  stat_pvalue_manual(pwc, hide.ns = TRUE) +
  labs(
    subtitle = get_test_label(res.kruskal, detailed = TRUE),
    caption  = get_pwc_label(pwc)
  )

A box plot of PlantGrowth plant weight by treatment group (ctrl, trt1, trt2) with the Kruskal-Wallis H statistic, df and p-value printed as the subtitle, the post-hoc method named in the caption, and a significance bracket drawn between trt1 and trt2 above the boxes.

This is the figure to report: the H statistic, df and p-value up top, the post-hoc method named in the caption, and the one significant pair (trt1 vs trt2) marked right on the boxes.

Tip

One quick stat layer. If you only need the omnibus p-value on a plot (no pairwise brackets), add stat_compare_means(method = "kruskal.test") to any ggpubr/ggplot box plot — it prints the Kruskal-Wallis p-value directly.

Report the result

A publication-style write-up pulls the omnibus result and the significant pairwise comparison into a sentence — for example:

There was a statistically significant difference between treatment groups as assessed using the Kruskal-Wallis test, H(2) = 7.99, p = 0.018, with a large effect size (η²[H] = 0.22). Pairwise Wilcoxon tests showed that only the difference between the trt1 and trt2 groups was significant (p = 0.027); the control did not differ significantly from either treatment.

Report the H-statistic with its degrees of freedom, the p-value, the effect size, and the significant pairwise differences — not just “p < 0.05”.

Try it live

Run the full workflow on the iris data — does sepal length differ across the three species? Edit the columns or the post-hoc method and re-run; the sandbox boots on first Run.

🟢 With an AI agent

Ask Prova “my groups aren’t normal — should I use Kruskal-Wallis instead of ANOVA, and which post-hoc test do I run?” — it answers with rstatix code you can run on your own data, then helps you read the omnibus p-value, the effect size and the pairwise comparisons. The runtime is the judge. Ask Prova →

Common issues

kruskal_test() / dunn_test() could not find function. Load rstatix first — library(rstatix) — which also provides the %>% pipe used in these examples.

The omnibus test is significant but no pairwise comparison is. The Kruskal-Wallis test can detect an overall difference that the multiplicity-corrected post-hoc tests can’t pin to a single pair, especially with small groups. In PlantGrowth the omnibus is significant yet only trt1-vs-trt2 survives the correction — that’s normal. Report the omnibus result and the adjusted pairwise p-values honestly, and consider whether you have the power to localise the difference.

The grouping column is treated as a number, not groups. If your grouping column is numeric, the test/plot may treat it as continuous. Convert it to a factor first — mydata$group <- factor(mydata$group) — so R reads it as discrete groups. (In PlantGrowth, group is already a factor.)

Frequently asked questions

The Kruskal-Wallis test is a non-parametric statistical test that compares three or more independent groups to see whether they come from the same distribution. Instead of comparing means, it ranks all the observations together and compares the average rank in each group, so it makes no assumption that the data are normally distributed — making it the rank-based alternative to one-way ANOVA, recommended when ANOVA’s assumptions are not met.

“Non-parametric ANOVA” is the common shorthand for the Kruskal-Wallis test — the rank-based substitute for one-way ANOVA that you reach for when normality can’t be assumed. It compares the average ranks of the groups rather than their means, so it makes no normality assumption, which makes it a good choice for skewed, ordinal, or outlier-heavy data with three or more groups.

One-way ANOVA compares group means and assumes the data in each group are approximately normally distributed with similar variances. The Kruskal-Wallis test compares ranks (effectively medians) and makes no normality assumption, so it’s the test to use when ANOVA’s assumptions fail, when you have outliers, or when the outcome is ordinal. When normality holds, ANOVA has slightly more power; otherwise Kruskal-Wallis is the safer choice.

Use Dunn’s test (rstatix dunn_test()). It’s the recommended follow-up because it reuses the same rankings as the Kruskal-Wallis test and adjusts for tied values, so it’s internally consistent with the omnibus test. Always apply a multiple-comparison correction (e.g. p.adjust.method = "bonferroni"). Pairwise Wilcoxon tests (pairwise.wilcox.test()) are an acceptable alternative but re-rank each pair separately.

A p-value below your significance level (commonly 0.05) means it’s unlikely the groups come from the same distribution by chance — so you conclude that at least one group differs. In the PlantGrowth example, p = 0.018, so the groups differ. It does not tell you which groups differ (run a post-hoc test for that) nor how large the difference is (report the effect size from kruskal_effsize() for that).

Roughly, yes — but only under an extra condition. The test formally compares the distributions of the groups. When the groups have similarly shaped distributions (differing only in location), a significant result is interpreted as a difference in medians. If the shapes or spreads differ markedly, interpret it more cautiously as a difference in the overall distributions (stochastic dominance).

Test your understanding

  1. Run it. In the live cell below, test whether mtcars miles-per-gallon (mpg) differs across the number of cylinders (cyl). Remember to make cyl a factor. Is the result significant?
  2. Conceptual. Your one-way ANOVA’s Shapiro-Wilk test on the residuals returns p = 0.001. Which test should you switch to, and why?

Fill the two blanks with the outcome and grouping columns in outcome ~ group order: mpg is the numeric outcome, cyl is the (now-factor) group. Look at the p column for significance. For question 2, recall which assumption a p = 0.001 normality test rejects, and which test drops that assumption.

library(rstatix)
mt <- mtcars
mt$cyl <- factor(mt$cyl)
mt %>% kruskal_test(mpg ~ cyl)
#> statistic ≈ 25.7, df = 2, p ≈ 2.6e-06  → mpg differs significantly across cylinder counts.

For question 2: a Shapiro-Wilk p = 0.001 rejects normality, so the parametric one-way ANOVA’s core assumption fails. Switch to the Kruskal-Wallis test, which ranks the data and needs no normality assumption — then use dunn_test() to find which groups differ.

Conclusion

You can now run and interpret the Kruskal-Wallis test in R two ways: the tidy rstatix kruskal_test() and the classic base-R kruskal.test(). Use it as the non-parametric one-way ANOVA when normality fails; read the p-value for the omnibus result (here p = 0.018), report the effect size from kruskal_effsize() (η²[H] = 0.22, large), and run dunn_test() to find which groups differ (only trt1 vs trt2) — then put it all on a ggpubr box plot with the p-values on the panel.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {Kruskal-Wallis {Test} in {R:} {Non-Parametric} {One-Way}
    {ANOVA}},
  date = {2026-06-23},
  url = {https://www.datanovia.com/learn/biostatistics/anova/kruskal-wallis-test-in-r},
  langid = {en}
}
For attribution, please cite this work as:
“Kruskal-Wallis Test in R: Non-Parametric One-Way ANOVA.” 2026. June 23. https://www.datanovia.com/learn/biostatistics/anova/kruskal-wallis-test-in-r.