How to put letters at the top and center of the error bars

Hi everyone please I can someone help me with this; I need the letters to be at the top of the error bars in this bar plot. Thank you very much.

data

yield <- structure(
  list(
    N_rate = structure(
      c(
        4L,
        5L,
        3L,
        1L,
        2L,
        5L,
        3L,
        4L,
        1L,
        2L,
        1L,
        4L,
        5L,
        2L,
        3L,
        1L,
        2L,
        4L,
        3L,
        5L,
        1L,
        3L,
        2L,
        4L,
        5L,
        3L,
        5L,
        4L,
        2L,
        1L,
        5L,
        1L,
        2L,
        4L,
        3L,
        4L,
        3L,
        1L,
        2L,
        5L,
        2L,
        5L,
        3L,
        1L,
        4L,
        3L,
        5L,
        1L,
        2L,
        4L,
        3L,
        5L,
        1L,
        4L,
        2L,
        2L,
        5L,
        1L,
        4L,
        3L
      ),
      levels = c("0", "45", "90", "179", "269"),
      class = "factor"
    ),
    History = structure(
      c(
        2L,
        2L,
        2L,
        2L,
        2L,
        3L,
        3L,
        3L,
        3L,
        3L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        2L,
        2L,
        2L,
        2L,
        2L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        1L,
        1L,
        1L,
        1L,
        1L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        3L,
        3L,
        3L,
        3L,
        3L,
        1L,
        1L,
        1L,
        1L,
        1L
      ),
      levels = c("fallow", "corn", "soybean"),
      class = "factor"
    ),
    Rep = structure(
      c(
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        1L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        2L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        3L,
        4L,
        4L,
        4L,
        4L,
        4L,
        4L,
        4L,
        4L,
        4L,
        4L,
        4L,
        4L,
        4L,
        4L,
        4L
      ),
      levels = c("1", "2", "3", "4"),
      class = "factor"
    ),
    `Yield at std moisture (kg/ha)` = c(
      12346.9249590533,
      11860.0460239527,
      6286.35275360947,
      2793.90516411834,
      5127.27805576331,
      12312.7660459172,
      12086.4480249941,
      12772.9435023905,
      7781.93368615385,
      10711.1602139645,
      7813.41363351479,
      12249.8439422485,
      13803.4656340828,
      7778.30574504142,
      11177.9322092308,
      4926.47739379882,
      7942.4952744142,
      11233.1764305799,
      10579.7313287574,
      12547.0432825562,
      3660.44141822485,
      10565.0201115266,
      7381.04619323077,
      10584.2536581302,
      12692.5976236686,
      11582.7898622485,
      12617.2233679527,
      13225.3150068639,
      11339.0543647811,
      9553.99606381065,
      12957.7648473373,
      8697.03144369231,
      9722.50007176331,
      12775.756836355,
      12011.991252071,
      13313.2421907692,
      11158.0877072663,
      7208.26549775148,
      8189.39052383432,
      14138.8465351953,
      8795.41415233136,
      13314.8147184852,
      10684.0892228166,
      6489.22772449704,
      12286.5138609231,
      10587.1551712189,
      12207.7824999763,
      4808.05912842604,
      8768.34316118343,
      11741.9321865089,
      11842.0952736568,
      12872.0694350769,
      9509.94219323077,
      13085.1710848757,
      11423.857488284,
      8773.69689372781,
      12733.0655431953,
      7236.61298669823,
      12536.2749318817,
      11056.5389481657
    )
  ),
  row.names = c(NA, -60L),
  class = c("tbl_df", "tbl", "data.frame")
)

Convert character to factor

yield$History <- factor(yield$History,levels = c('fallow','corn','soybean'))
yield$N_rate <- factor(yield$N_rate)
yield$Rep <- factor(yield$Rep)

Fit a linear model

mod_plot <- lm(Yield at std moisture (kg/ha)~Rep+History*N_rate,data=yield)

Letters <- agricolae::LSD.test(y = mod_plot,trt = c('N_rate','History'),console = T,group = T)

LSD_table <- Letters$groups  # Extracts means and groups

Add proper column names for interaction terms

LSD_table <- LSD_table %>%
  tibble::rownames_to_column(var = "Interaction_Term") %>%  # Convert row names to a column
  tidyr::separate(Interaction_Term, into = c("N_rate", "History"), sep = ":", fill = "right")

Print formatted table

print(LSD_table)

First, calculate summary stats (mean and SE)

summary_data <- yield %>%
  group_by(N_rate, History) %>%
  summarise(
    mean = mean(`Yield at std moisture (kg/ha)`, na.rm = TRUE),
    se = sd(`Yield at std moisture (kg/ha)`, na.rm = TRUE) / sqrt(n()),
    .groups = "drop"
  )

Merge with LSD table for labels

label_data <- summary_data %>%
  left_join(LSD_table, by = c("N_rate", "History")) %>%
  mutate(label_y = mean + se)  # place label at center of error bar top

Final plot

p1 <- ggplot(data = yield, aes(x = N_rate, y = `Yield at std moisture (kg/ha)`, fill = History)) +
  # Error bars
  stat_summary(
    geom = 'errorbar',
    position = position_dodge(.9),
    width = .3,
    fun.data = 'mean_se', col = 'black'
  ) +
  # Bars
  stat_summary(
    geom = 'col',
    position = position_dodge(.9),
    fun = mean
  ) +
  scale_fill_brewer(palette = 'Dark2') +
  scale_y_continuous(expand = expansion(mult = c(0, 0.2), add = c(0, 0.4))) +
  ylab(expression(bold(Yield ~ (kg ~ ha ^ -1)))) +
  xlab(expression(bold(Nitrogen ~rate ~ (kg ~ ha ^ -1)))) +
  scale_fill_manual(name = '', values = c('#7570B3','#D95F02','#1B9E77')) +
  # Text labels exactly at the top of error bars
  geom_text(
    data = label_data,
    aes(
      x = N_rate,
      y = label_y,
      label = groups,
      fill = History
    ),
    position = position_dodge(.9),
    show.legend = FALSE,
    vjust = -0.9,
    fontface='bold'
  ) +
  theme_test() +
  theme(
    legend.position = c(0.1, 0.9),
    axis.title  = element_text(
      family = 'serif',
      face = 'bold',
      colour = 'black',
      size = 16
    ),
    axis.text = element_text(
      family = 'serif',
      face = 'bold',
      colour = 'black',
      size = 16
    ),
    legend.text = element_text(
      family = 'serif',
      face = 'bold',
      colour = 'black',
      size = 16
    )
  )

p1

You’re almost there—the misalignment comes from using different dodge/group settings across layers (bars, error bars, and text). The fix is to (1) compute the summary yourself, (2) use one shared position_dodge() object everywhere, and (3) map the same grouping (group = History) for error bars and text so they dodge identically to the bars.

Below is a clean, reproducible solution that places the letters exactly at the top-center of each error bar.

Key fixes

  • Wrap your response variable in backticks in the model:
    lm(Yield at std moisture (kg/ha) ~ Rep + History * N_rate, data = yield)

Use one position object for all layers:
pos <- position_dodge(width = 0.9)

Compute summaries once; then use geom_col(), geom_errorbar(), and geom_text() all with the same pos and grouping.

library(dplyr)
library(ggplot2)
library(tidyr)
library(agricolae)

--- Model (fix backticks) ---

mod_plot <- lm(Yield at std moisture (kg/ha) ~ Rep + History * N_rate, data = yield)

--- Letters from LSD.test (interaction groups) ---

Letters <- agricolae::LSD.test(y = mod_plot, trt = c("N_rate","History"),
console = TRUE, group = TRUE)

LSD_table <- Letters$groups %>%
tibble::rownames_to_column(var = "Interaction_Term") %>%
tidyr::separate(Interaction_Term, into = c("N_rate","History"), sep = ":", fill = "right")

--- Summary stats (mean and SE) ---

summary_data <- yield %>%
group_by(N_rate, History) %>%
summarise(
mean = mean(Yield at std moisture (kg/ha), na.rm = TRUE),
se = sd(Yield at std moisture (kg/ha), na.rm = TRUE) / sqrt(n()),
.groups = "drop"
)

--- Merge letters and compute label y-position (top of error bar) ---

label_data <- summary_data %>%
left_join(LSD_table, by = c("N_rate","History")) %>%
mutate(y_label = mean + se)

--- One shared dodge for all layers ---

pos <- position_dodge(width = 0.9)

--- Plot: bars, error bars, centered letters at the top of error bars ---

p1 <- ggplot(summary_data,
aes(x = N_rate, y = mean, fill = History, group = History)) +

Bars from precomputed means (width slightly < dodge width looks nice)

geom_col(position = pos, width = 0.8) +

Error bars using the same dodge & grouping

geom_errorbar(aes(ymin = mean - se, ymax = mean + se),
position = pos, width = 0.3, color = "black") +

Letters exactly on top of the error bars (same dodge + group)

geom_text(data = label_data,
aes(y = y_label, label = groups, group = History),
position = pos,
vjust = -0.25, # small lift above the cap; use vjust = 0.5 to sit on the cap
fontface = "bold",
show.legend = FALSE) +

Scales & labels (pick either brewer OR manual, not both)

scale_fill_brewer(palette = "Dark2", name = "") +
scale_y_continuous(
expand = expansion(mult = c(0, 0.08)) # leave room for letters
) +
ylab(expression(bold(Yield ~ (kg ~ ha^-1)))) +
xlab(expression(bold(Nitrogen ~ rate ~ (kg ~ ha^-1)))) +
theme_test(base_size = 14) +
theme(
legend.position = c(0.1, 0.9),
axis.title = element_text(family = "serif", face = "bold", colour = "black"),
axis.text = element_text(family = "serif", face = "bold", colour = "black"),
legend.text = element_text(family = "serif", face = "bold", colour = "black")
)

p1

Notes

  • Exact centering on the top cap: The text is horizontally centered by the dodge; if you want the text to sit right on the cap, set vjust = 0.5. If you want it slightly above, use a small negative value like -0.2 to -0.4.
  • Remove either scale_fill_brewer() or scale_fill_manual()—using both can cause conflicts. I kept scale_fill_brewer() only.
  • Because we precomputed mean and se, we use geom_col()/geom_errorbar() instead of stat_summary()—this avoids any stat/dodge inconsistencies.

If you prefer to keep stat_summary(), the same principle applies: create pos <- position_dodge(0.9), use it in every layer, and set group = History on layers that don’t use fill directly.


More R plotting patterns and tidyverse recipes at rprogrammingbooks.com — quick guides, reproducible snippets, and end-to-end tutorials.

@rprogrammingbooks thank you very much for the help, I am looking for a way to put all the letters at the top of the error bars.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.

If you have a query related to it or one of the replies, start a new topic and refer back with a link.