Compute a test with rstatix, position the brackets, draw them on any ggpubr plot — basic, grouped, faceted & horizontal
Data Visualization
Add p-values from real statistical tests to a ggplot in R with rstatix and ggpubr. The workflow is always the same: compute a test with t_test() / wilcox_test() / anova_test(), adjust for multiplicity with adjust_pvalue(), auto-place the brackets with add_xy_position(), then draw them with stat_pvalue_manual() — on basic, grouped, faceted, free-scale and horizontal plots. Every example is rendered for you and runs live.
Published
June 22, 2026
Modified
July 12, 2026
TipKey takeaways
The workflow is three steps, always the same: compute the test with rstatix (t_test(), wilcox_test(), anova_test()), position the brackets with add_xy_position(), then draw them with stat_pvalue_manual().
Get adjusted p-values with adjust_pvalue() + add_significance() — then label with "p.adj" or the stars "p.adj.signif".
Grouped plots: group_by() the test, then add_xy_position(dodge = 0.8) so brackets line up with the dodged boxes.
Faceted plots: group_by() the facet variable; for panels with different y-axes add scales = "free" to bothadd_xy_position() and the facet.
Horizontal plots: pass coord.flip = TRUE to stat_pvalue_manual()and add coord_flip().
Every plot below is rendered for you — then tweak any example live in the sandbox.
The companion lesson Add p-values to ggplots uses one call, stat_compare_means(), that computes and draws the test together — perfect for a quick annotation. But the moment you need adjusted p-values on the brackets, a specific test (anova_test(), tukey_hsd()), or p-values on a grouped / faceted / horizontal plot, you want the test as a data frame you control, then drawn separately.
That is the rstatix → ggpubr workflow, and it never changes:
Compute the test with rstatix — t_test(), wilcox_test(), anova_test() (see the biostatistics guides for when to use each) — and optionally adjust_pvalue() + add_significance() for multiplicity-corrected p-values and stars.
Position the brackets automatically with add_xy_position() — it reads the plot type (box, bar, dodged, faceted) and computes a sensible y.position for every comparison.
Draw them on the plot with stat_pvalue_manual() [ggpubr] — point its label at any column of your test ("p", "p.adj", "p.adj.signif").
This lesson walks that workflow on the built-in ToothGrowth data, through every layout people actually search for: basic comparisons, grouped (dodged) plots, faceted panels, facets with free / different scales, and horizontal plots. Each figure is rendered right here, and you can re-run or edit any of them live.
If you only need a quick global or pairwise p-value with no adjustment, start with stat_compare_means() — it’s the simpler entry point. For the modern one-layer shortcut that computes and positions in a single call, see Auto p-values with geom_pwc().
The canonical workflow: compute → position → draw
Here is the image most people come for: a box plot of tooth length by dose with a bracket and an adjusted significance star over each pair of doses. Read it top to bottom — the three steps are labelled in the code.
library(ggpubr)library(rstatix)df <- ToothGrowthdf$dose <-factor(df$dose)# 1. COMPUTE: pairwise t-tests, p-values adjusted for multiplicitystat.test <- df %>%t_test(len ~ dose) %>%adjust_pvalue(method ="holm") %>%add_significance("p.adj")# 2. POSITION: auto-place the brackets for this box plotstat.test <- stat.test %>%add_xy_position(x ="dose")# 3. DRAW: put the adjusted stars on the plotggboxplot(df, x ="dose", y ="len", fill ="dose", palette ="jco") +stat_pvalue_manual(stat.test, label ="p.adj.signif", tip.length =0.01)
Everything else in this lesson is a variation on those three steps. Let’s build them up.
Basic comparisons: two groups and a clean label
Start with the simplest case — do the two supplements (OJ vs VC) differ? Compute a t_test() (use wilcox_test() for the non-parametric version — same call), add the position, and draw the raw p-value. add_significance() appends the p.signif stars column for later.
library(ggpubr)library(rstatix)df <- ToothGrowth# Compute: t-test of len by supplementstat.test <- df %>%t_test(len ~ supp) %>%add_significance()stat.test
# Position + drawstat.test <- stat.test %>%add_xy_position(x ="supp")ggboxplot(df, x ="supp", y ="len", fill ="#3a86d4") +stat_pvalue_manual(stat.test, label ="p") +scale_y_continuous(expand =expansion(mult =c(0.05, 0.1)))
The columns you’ll label with are worth knowing: p (raw p-value), p.adj (adjusted), p.signif / p.adj.signif (the stars), and y.position (added by add_xy_position()).
Labels are partially hidden by the top border? Add headroom with scale_y_continuous(expand = expansion(mult = c(0.05, 0.1))) — 5% padding at the bottom, 10% at the top, so the brackets aren’t clipped.
Choosing and formatting the label
Point label at whichever column you want — or build a custom one. A glue template like "T-test, p = {p}" interpolates any column into the label; you can also format the numbers yourself.
library(ggpubr)library(rstatix)df <- ToothGrowthstat.test <- df %>%t_test(len ~ supp)stat.test <- stat.test %>%add_xy_position(x ="supp")ggboxplot(df, x ="supp", y ="len", fill ="#3a86d4") +stat_pvalue_manual( stat.test, label ="T-test, p = {p}",vjust =-1, bracket.nudge.y =1 ) +scale_y_continuous(expand =expansion(mult =c(0.05, 0.15)))
A few label recipes you’ll reuse:
Formatted p-value with controlled accuracy — p_format(stat.test$p, accuracy = 0.01, leading.zero = FALSE) into a new column, then label = "p.format" (renders <.01).
Scientific notation — format(stat.test$p, scientific = TRUE) into a column, then label that.
Show p if significant else “ns” — build a custom column with ifelse(stat.test$p <= 0.05, stat.test$p, "ns") and label it.
Paired samples
If the same subjects were measured under both conditions, pass paired = TRUE to the test so it accounts for the pairing. ggpaired() draws the matched observations with connecting lines.
library(ggpubr)library(rstatix)df <- ToothGrowthstat.test <- df %>%t_test(len ~ supp, paired =TRUE) %>%add_significance()stat.test <- stat.test %>%add_xy_position(x ="supp")ggpaired(df, x ="supp", y ="len", fill ="#E7B800",line.color ="gray", line.size =0.4) +stat_pvalue_manual(stat.test, label ="{p}{p.signif}")
Pairwise across three groups + a reference group
For three or more groups, t_test(len ~ dose) runs all pairwise comparisons and adjusts the p-values automatically. Often you instead want each group against one reference (a control) — set ref.group. The tip.length argument shortens the little vertical bracket tips.
library(ggpubr)library(rstatix)df <- ToothGrowthdf$dose <-factor(df$dose)bxp <-ggboxplot(df, x ="dose", y ="len", fill ="dose", palette ="jco")# All pairwise comparisonsall.pairs <- df %>%t_test(len ~ dose) %>%add_xy_position(x ="dose")p1 <- bxp +stat_pvalue_manual(all.pairs, label ="p.adj.signif", tip.length =0.01)# Each dose vs the reference 0.5ref <- df %>%t_test(len ~ dose, ref.group ="0.5") %>%add_xy_position(x ="dose")p2 <- bxp +stat_pvalue_manual(ref, label ="p.adj.signif", tip.length =0.01)ggarrange(p1, p2, ncol =2)
Two reference shortcuts worth knowing:
ref.group = "all" — compares each group to all the data pooled (the grand mean / base-mean), a clean way to flag which groups stand out.
One-sample test — t_test(len ~ 1) (optionally group_by()’d) compares each group’s mean to a hypothetical mu (default 0).
You can also override the auto-placement: pass y.position = c(35, 40, 35) and bracket.shorten = 0.05 straight to stat_pvalue_manual() to hand-tune crowded brackets.
The omnibus test: ANOVA / Kruskal-Wallis
Before the pairwise brackets, you often want the global (“is anything different?”) test. rstatix gives it to you as a tidy frame too: anova_test() for parametric, kruskal_test() for non-parametric. Annotate it as a subtitle with get_test_label() while the brackets carry the pairwise results.
library(ggpubr)library(rstatix)df <- ToothGrowthdf$dose <-factor(df$dose)# Global one-way ANOVAaov.test <- df %>%anova_test(len ~ dose)# Pairwise comparisons for the bracketspwc <- df %>%t_test(len ~ dose) %>%add_xy_position(x ="dose")ggboxplot(df, x ="dose", y ="len", fill ="dose", palette ="jco") +stat_pvalue_manual(pwc, label ="p.adj.signif", tip.length =0.01) +labs(subtitle =get_test_label(aov.test, detailed =TRUE))
Swap anova_test() for kruskal_test() (and t_test() for wilcox_test()) to get the non-parametric pair — the rest of the call is identical.
Grouped (dodged) plots
When colour splits each x-position into several dodged boxes, the test must be grouped and the brackets must follow the dodge. Group the data by dose, compare the supp levels within each, then tell add_xy_position() the same dodge you used on the plot.
library(ggpubr)library(rstatix)df <- ToothGrowthdf$dose <-factor(df$dose)# Compute within each dose: OJ vs VCstat.test <- df %>%group_by(dose) %>%t_test(len ~ supp) %>%adjust_pvalue(method ="bonferroni") %>%add_significance("p.adj")# Position with the SAME dodge as the plotstat.test <- stat.test %>%add_xy_position(x ="dose", dodge =0.8)ggboxplot(df, x ="dose", y ="len",color ="supp", palette =c("#00AFBB", "#E7B800")) +stat_pvalue_manual(stat.test, label ="p.adj", tip.length =0) +scale_y_continuous(expand =expansion(mult =c(0.05, 0.1)))
Useful options on the grouped layout:
remove.bracket = TRUE — drop the bracket and print just the label (handy when boxes are narrow).
hide.ns = TRUE — hide non-significant annotations; combine with a combined label "{p.adj}{p.adj.signif}".
Bar / line plots — for ggbarplot(..., add = "mean_sd") or ggline(..., add = "mean_sd"), pass the matching fun = "mean_sd" to add_xy_position() so the bracket starts above the error bars.
Pairwise brackets grouped by colour
To draw multiple pairwise brackets per group, group the test by the x variable, then stack the brackets within each group with step.group.by + step.increase.
library(ggpubr)library(rstatix)df <- ToothGrowthdf$dose <-factor(df$dose)# Within each supplement, compare the dosespwc <- df %>%group_by(supp) %>%t_test(len ~ dose, p.adjust.method ="bonferroni")pwc <- pwc %>%add_xy_position(x ="supp", group ="dose", dodge =0.8)ggboxplot(df, x ="supp", y ="len", color ="dose", palette ="npg") +scale_y_continuous(expand =expansion(mult =c(0.05, 0.25))) +stat_pvalue_manual( pwc, step.group.by ="supp",tip.length =0, step.increase =0.1 )
Faceted plots
For a multipanel plot, group the test by the facet variable so each panel gets its own comparison, then position and draw exactly as before — stat_pvalue_manual() matches each test row to its panel.
library(ggpubr)library(rstatix)df <- ToothGrowthdf$dose <-factor(df$dose)# One test per facet (dose), comparing OJ vs VCstat.test <- df %>%group_by(dose) %>%t_test(len ~ supp) %>%adjust_pvalue(method ="bonferroni") %>%add_significance()stat.test <- stat.test %>%add_xy_position(x ="supp")ggboxplot(df, x ="supp", y ="len", fill ="#00AFBB", facet.by ="dose") +stat_pvalue_manual(stat.test, hide.ns =TRUE, label ="{p.adj}{p.adj.signif}") +scale_y_continuous(expand =expansion(mult =c(0.05, 0.1)))
The same recipe scales up:
Pairwise within a facet — when each panel holds 3+ groups, group_by(facet_var) then add_y_position() (the y-only helper) instead of add_xy_position().
facet_grid by two variables — group_by(var1, var2) the test and facet = c("var1", "var2") the plot; everything else is unchanged.
Bar plots in facets — ggbarplot(..., add = "mean_sd") with add_xy_position(fun = "mean_sd") (use fun = "max" if you also add jitter, so brackets clear the points).
Faceted plots with free / different scales
When panels have very different y-ranges, fixed scales squash some panels. Setting scales = "free" fixes the plot — but the brackets must be repositioned for it too: pass scales = "free" to add_xy_position() as well, and use facet_wrap() so each panel frees both axes. Here we use tukey_hsd() (Tukey post-hoc) for the pairwise tests.
library(ggpubr)library(rstatix)df <- ToothGrowthdf$dose <-factor(df$dose)df$group <-factor(rep(c("grp1", "grp2"), 30))# Make the panels genuinely different in scaledf[c(1, 3, 5), "len"] <-c(500, 495, 505)# Tukey post-hoc within each supp x group panelstat.test <- df %>%group_by(supp, group) %>%tukey_hsd(len ~ dose)# Position for FREE scales: pass scales = "free" here toostat.test <- stat.test %>%add_xy_position(x ="dose", fun ="mean_se", scales ="free")ggbarplot(df, x ="dose", y ="len", fill ="#00AFBB",add ="mean_se", facet.by =c("supp", "group")) +facet_wrap(vars(supp, group), scales ="free") +stat_pvalue_manual(stat.test, hide.ns =TRUE, tip.length =0) +scale_y_continuous(expand =expansion(mult =c(0.05, 0.15)))
facet_wrap vs facet_grid with free scales.facet_wrap() frees both axes per panel, so each plot is independent — best when panels are unrelated. facet_grid(scales = "free") only frees within a row/column, so brackets may still need nudging: set step.increase = 0 in add_xy_position() and supply it (per-panel) in stat_pvalue_manual() instead, or hand-tune with a vector bracket.nudge.y.
Multiple response variables
Sometimes you measure several outcomes on the same subjects — four flower measurements, a panel of biomarkers — and you want the same group comparison on each, with all the p-values on one figure. The move is to test them together: reshape the data so every outcome stacks into one value column keyed by a variables column, then group_by(variables) and run one test per outcome.
Reshape wide → long with base R’s stack() (no extra packages), group by the outcome, and compute a t_test() of value ~ Species inside each group. adjust_pvalue() corrects across all the tests at once, and add_xy_position(scales = "free") gives each panel its own bracket height — essential here, since petal and sepal measurements live on very different scales.
library(ggpubr)library(rstatix)# Reshape WIDE -> LONG: stack the four numeric columns into value + variablesiris.long <-stack(iris[, 1:4]) # columns: values, indiris.long$Species <-rep(iris$Species, times =4)names(iris.long) <-c("value", "variables", "Species")# One test per outcome: compare the three species within each variablestat.test <- iris.long %>%group_by(variables) %>%t_test(value ~ Species) %>%adjust_pvalue(method ="holm") %>%add_significance("p.adj")# Position for FREE scales, then facet by the outcomestat.test <- stat.test %>%add_xy_position(x ="Species", scales ="free")ggboxplot(iris.long, x ="Species", y ="value", fill ="Species", palette ="npg") +facet_wrap(~variables, scales ="free") +stat_pvalue_manual(stat.test, label ="p.adj.signif", tip.length =0.01) +scale_y_continuous(expand =expansion(mult =c(0.05, 0.1)))
Each panel is one outcome, tested on its own: the species differ on every measurement (mostly ****, with the closer versicolor-vs-virginica sepal width landing at **), and every bracket sits at the right height because scales = "free" was passed to bothadd_xy_position() and facet_wrap() — the same rule as the free-scale facets above. One stat.test frame, one figure, every outcome covered.
Name the key column variables, not variable. rstatix uses variable internally, so a column literally called variable collides with add_xy_position() (names are duplicated). Any other name — variables, outcome — works.
Horizontal plots
To flip a plot horizontal you add coord_flip() — but the brackets are drawn in the original coordinate system, so they must be told about the flip. Pass coord.flip = TRUE to stat_pvalue_manual()and add coord_flip().
library(ggpubr)library(rstatix)df <- ToothGrowthdf$dose <-factor(df$dose)stat.test <- df %>%t_test(len ~ dose) %>%add_xy_position(x ="dose")ggboxplot(df, x ="dose", y ="len", fill ="dose", palette ="jco") +stat_pvalue_manual( stat.test, label ="p.adj.signif", tip.length =0.01,coord.flip =TRUE ) +coord_flip()
For a reference-group comparison, or to rotate the labels upright, the same arguments you’d use upright still apply — label = "p = {p.adj}", angle = 90, and hjust / vjust to nudge the text — alongside coord.flip = TRUE and coord_flip().
P-values computed entirely elsewhere
stat_pvalue_manual() doesn’t care how the p-values were made — if you have a data frame with group1, group2, a label column and a y.position, it will draw it. This is the escape hatch for results from any test, even outside R.
library(ggpubr)df <- ToothGrowthdf$dose <-factor(df$dose)# A hand-built results frame (could come from anywhere)stat.test <-data.frame(group1 =c("0.5", "0.5", "1"),group2 =c("1", "2", "2"),p.adj =c(2.5e-07, 1.3e-13, 1.9e-05))ggboxplot(df, x ="dose", y ="len") +stat_pvalue_manual( stat.test, y.position =35, step.increase =0.1,label ="p.adj" )
For full control over a single annotation — custom text, plotmath expressions, exactly placed brackets — reach for geom_bracket():
The recipe never changes: compute the test with rstatix, position with add_xy_position(), draw with stat_pvalue_manual() — and geom_bracket() is there for the one-off custom annotation.
Try it live
The plots above were rendered at build time. Want to experiment? Edit the code and press Run — it executes in your browser via webR (no server, no install). Try swapping t_test for wilcox_test, changing ref.group, or pointing label at "p.adj" instead of "p.adj.signif".
🟢 With an AI agent
Ask Prova“my stat_pvalue_manual() brackets are floating in the wrong place on my grouped plot — how do I fix add_xy_position()?” — it answers with code you can run on your own data frame and checks the dodge/fun/scales arguments against your plot, so the fix is reproducible. The runtime is the judge.Ask Prova →
Common issues
Brackets float in the wrong place on a grouped plot.add_xy_position() must know the dodge — pass the samedodge you gave the plot (e.g. add_xy_position(x = "dose", dodge = 0.8)). For bar/line plots add the matching fun (e.g. fun = "mean_sd") so the bracket clears the error bars.
add_xy_position() errors with “object ‘p.adj’ not found”. Compute the column before you label it: adjust_pvalue() creates p.adj, and add_significance("p.adj") creates p.adj.signif. Run those steps before stat_pvalue_manual(label = "p.adj.signif").
Brackets on a free-scale facet are mispositioned.scales = "free" must be set in both places — add_xy_position(..., scales = "free")and the facet (facet_wrap(..., scales = "free")). Setting it only on the plot leaves the y-positions computed for a fixed scale.
Horizontal brackets are drawn vertically (or off the plot).coord_flip() alone isn’t enough — also pass coord.flip = TRUE to stat_pvalue_manual() so it flips the bracket geometry.
Labels are clipped by the top border. Add headroom: scale_y_continuous(expand = expansion(mult = c(0.05, 0.1))).
Frequently asked questions
NoteHow do I add a p-value from a t-test (or ANOVA) to a ggplot in R?
Use the rstatix → ggpubr workflow: compute the test as a data frame with df %>% t_test(len ~ dose) (or anova_test(), wilcox_test()), position the brackets with add_xy_position(x = "dose"), then draw them with ggboxplot(...) + stat_pvalue_manual(stat.test, label = "p.adj.signif"). The three steps never change.
NoteWhat’s the difference between stat_compare_means() and stat_pvalue_manual()?
stat_compare_means() computes and draws the test in one layer — fast for a quick global or pairwise p-value. stat_pvalue_manual() draws p-values you computed separately (with rstatix), which is what you need for adjusted p-values, specific tests, or grouped / faceted / horizontal layouts. See the companion lesson Add p-values to ggplots for stat_compare_means().
NoteHow do I display adjusted p-values instead of raw ones?
Add adjust_pvalue(method = "holm") (or "bonferroni", "BH", …) after the test, then add_significance("p.adj") for the stars. Label the plot with label = "p.adj" for the number or label = "p.adj.signif" for the significance symbol.
NoteWhy are my brackets in the wrong position on a grouped or faceted plot?
add_xy_position() needs to match the plot’s geometry. For grouped/dodged plots pass the same dodge (e.g. dodge = 0.8); for bar/line plots pass the matching fun (fun = "mean_sd"); for free-scale facets pass scales = "free" in bothadd_xy_position() and the facet. Mismatched arguments are the usual cause of floating brackets.
NoteHow do I add p-values to a horizontal (flipped) ggplot?
Add coord_flip() to make the plot horizontal and pass coord.flip = TRUE to stat_pvalue_manual() so the brackets flip with it. You can rotate the labels upright with angle = 90 and nudge them with hjust / vjust.
NoteHow do I add p-values for several outcome variables at once?
Reshape the data from wide to long so the outcomes stack into one value column keyed by a variables column (base R stack() works), then group_by(variables) %>% t_test(value ~ group), adjust_pvalue() and add_xy_position(scales = "free"). Draw a facet_wrap(~variables, scales = "free") plot with stat_pvalue_manual() — each panel shows one outcome’s comparison. See the Multiple response variables section above.
Test your understanding
ImportantExercise: adjusted stars on a grouped plot
Complete the code so the test is computed within each dose (OJ vs VC), the p-values are Bonferroni-adjusted, the brackets are positioned for a dodged plot, and the adjusted stars are drawn.
# 1. Adjust the p-values: adjust_pvalue(method = "bonferroni")# 2. Match the plot's dodge: dodge = 0.8# 3. Label with the adj. stars: p.adj.signif
library(ggpubr)library(rstatix)df <- ToothGrowthdf$dose <-factor(df$dose)stat.test <- df %>%group_by(dose) %>%t_test(len ~ supp) %>%adjust_pvalue(method ="bonferroni") %>%add_significance("p.adj")stat.test <- stat.test %>%add_xy_position(x ="dose", dodge =0.8)ggboxplot(df, x ="dose", y ="len", color ="supp", palette ="jco") +stat_pvalue_manual(stat.test, label ="p.adj.signif")
Quick check. You add scales = "free" to facet_wrap() but the brackets are still mispositioned. What did you forget? (Answer: pass scales = "free" to add_xy_position() too — the y-positions must be recomputed for free scales, not just the plot.)
Conclusion
You learned the rstatix → ggpubr workflow for putting p-values from real statistical tests onto any ggplot: compute with t_test() / wilcox_test() / anova_test() (+ adjust_pvalue() for multiplicity), position with add_xy_position(), and draw with stat_pvalue_manual() — across basic, grouped, faceted, free-scale and horizontal layouts, plus geom_bracket() for one-off custom annotations. The three steps never change; only the add_xy_position() arguments adapt to the plot.
For a quicker, single-layer annotation with no adjustment, start with stat_compare_means(). For the modern shortcut that computes and positions in one call, see Auto p-values with geom_pwc(). To choose and interpret the tests themselves (assumptions, effect size), see the upcoming statistics series.
This lesson is reproducible: the figures are executed at build time (if they render, the code works), and the sandbox + quiz re-run live in your browser. The runtime is the judge.
@online{2026,
author = {},
title = {P-Values from {Tests} on Ggplots in {R} (Rstatix)},
date = {2026-06-22},
url = {https://www.datanovia.com/learn/data-visualization/ggpubr/p-values-from-tests},
langid = {en}
}