Forest Plot in R: Hazard-Ratio and Subgroup Figures for a Clinical Study Report

Build the two forest plots a Clinical Study Report ships — the Cox-model hazard-ratio forest with survminer’s ggforest(), and the treatment-effect-by-subgroup forest with ggplot2 — from a CDISC ADTTE, in R

Pharma & Clinical

A complete, runnable tutorial for the clinical forest plot — the hazard-ratio figure that summarizes a Cox proportional-hazards model and shows whether a treatment effect holds across subgroups in a Clinical Study Report. Learn what a forest plot shows (a point estimate and confidence interval per row, the HR = 1 no-effect reference line, “the CI crosses 1 = not significant”), then build both figures in R from a CDISC ADTTE time-to-event dataset on public pharmaverse data: the per-covariate Cox-model forest with survminer’s ggforest(), and the subgroup forest (one hazard ratio and 95% CI per subgroup, a dashed reference line, and a right-side HR (CI) text column) with ggplot2. Includes how to read consistency across subgroups and the caution about underpowered subgroups.

Published

July 1, 2026

Modified

July 7, 2026

TipKey takeaways
  • A forest plot is how a Clinical Study Report (CSR) shows an effect and whether it holds everywhere. Each row carries a point estimate and its confidence interval (CI); a vertical reference line at a hazard ratio (HR) of 1 marks “no effect.” If a row’s CI crosses 1, that subgroup’s effect is not statistically significant.
  • There are two forest plots, from two models. The Cox-model forest shows one HR per covariate from a single Cox proportional-hazards model; the subgroup forest shows the treatment HR estimated separately within each subgroup (age, sex, region).
  • The Cox-model forest is one call. survminer’s ggforest() turns a coxph fit into the per-covariate HR forest — estimates, CIs, p-values, and a global test — from the model object directly.
  • The subgroup forest you assemble yourself. ggforest() reads one model’s terms, not many per-subgroup models, so for the subgroup figure you fit a Cox model within each subgroup, pull the HR and CI, and draw the forest with ggplot2 — a point, a whisker, the HR = 1 line, and an HR (95% CI) text column.
  • Read it for direction, magnitude, and consistency. All estimates on one side of 1 tell a consistent story; a subgroup whose CI is wide and crosses 1 is usually underpowered, not evidence of a real difference — subgroup findings are hypothesis-generating, not confirmatory.

Introduction

Every survival read-out ends with the same question from a reviewer: the drug worked overall — but did it work for women? for the elderly? in every region? The forest plot answers it on one page. It is the figure beside the Kaplan-Meier curve in a Clinical Study Report — a column of hazard ratios, each with its confidence interval, lined up against a single vertical reference line at 1, so a reader sees the overall effect and its consistency in one glance.

This lesson builds the two forest plots a CSR ships, in R, from a CDISC (Clinical Data Interchange Standards Consortium) ADTTE — the analysis-ready time-to-event dataset. The first summarizes a Cox proportional-hazards model (the model behind every hazard ratio) with survminer’s ggforest(); the second shows the treatment effect within each subgroup, built with ggplot2. We work on public pharmaverse data, so every line runs as written.

Here is the figure we are building toward — the treatment effect (active dose vs placebo) overall and within each subgroup, each row a hazard ratio with its 95% confidence interval against the HR = 1 line:

A clinical subgroup forest plot of the hazard of a first adverse event, active dose versus placebo, built from a CDISC ADTTE. Five rows — Overall, Female, Male, under-65, and 65-or-over — each show a blue point at the hazard ratio with a horizontal 95% confidence-interval whisker, plotted on a log x-axis against a dashed vertical reference line at a hazard ratio of 1. A right-hand column prints each hazard ratio with its confidence interval. Every point sits to the right of 1 (hazard raised on the active arm), and the estimates are consistent across subgroups; the small under-65 subgroup has a wide interval that crosses 1.

By the end you will produce both forest plots and know how to read every row. If the words “ADTTE”, “CNSR”, or “Cox model” are new, build the dataset in Build the time-to-event ADaM dataset (ADTTE) and meet the survival model in Cox proportional hazards first; this lesson reuses both and focuses on the figure.

What a forest plot shows

A forest plot is a stack of confidence intervals read against one vertical line. Each row is an estimate — here a hazard ratio (HR), the ratio of event rates between two groups — with a horizontal whisker for its 95% confidence interval (CI). The reference line sits at the value that means “no effect”: for a ratio like an HR, that value is 1.

Element What it is How to read it
Point The estimated effect for that row (the HR) Its position left or right of 1 gives the direction and size
Whisker The 95% confidence interval around the point Its width is the precision — wide means the row has few events
Reference line at HR = 1 The “no effect” value for a ratio An HR of 1 means the event rate is the same in both groups
CI crosses 1 The interval spans the reference line The effect for that row is not statistically significant
HR (95% CI) text column The numbers printed beside each row The exact estimate a table would report, on the figure
Log x-axis Hazard ratios plotted on a log scale A halving (HR 0.5) and a doubling (HR 2) sit the same distance from 1

Two readings matter. Vertically, does the CI cross 1? If it does, that row is compatible with no effect. Across rows, do the estimates point the same way? Consistent effects across subgroups make the overall result trustworthy; a lone subgroup that flips direction is a flag to investigate (usually noise, occasionally a real effect modifier).

The data and the Cox model

Both forests read a CDISC ADTTE — the analysis-ready time-to-event dataset in the CDISC ADaM (Analysis Data Model) standard. It carries AVAL (the time to the event) and CNSR (the censor flag, where the CDISC convention is CNSR = 0 for an event and a positive integer for censored). We reuse the time-to-first-adverse-event ADTTE derived in the Kaplan-Meier figure lesson; the derivation is unchanged, so here it is condensed to one block. The public CDISC pilot in pharmaverseadam is a low-mortality safety study, so we use its adverse-event endpoint, which has the events an interpretable figure needs.

library(admiral)
library(dplyr, warn.conflicts = FALSE)
library(pharmaversesdtm)
library(pharmaverseadam)
library(lubridate)

adsl <- pharmaverseadam::adsl
ae   <- pharmaversesdtm::ae %>% convert_blanks_to_na()

adae <- ae %>%
  derive_vars_merged(dataset_add = adsl, new_vars = exprs(TRTSDT, TRTEDT),
                     by_vars = exprs(STUDYID, USUBJID)) %>%
  derive_vars_dt(new_vars_prefix = "AST", dtc = AESTDTC, highest_imputation = "M") %>%
  mutate(TRTEMFL = if_else(ASTDT >= TRTSDT & ASTDT <= TRTEDT + days(30), "Y", NA_character_))

ttae <- event_source(dataset_name = "ae", date = ASTDT, order = exprs(AESEQ),
  set_values_to = exprs(EVNTDESC = "ADVERSE EVENT", SRCDOM = "ADAE", SRCVAR = "ASTDT"))
eot <- censor_source(dataset_name = "adsl", date = TRTEDT + days(30),
  set_values_to = exprs(EVNTDESC = "END OF TREATMENT", SRCDOM = "ADSL", SRCVAR = "TRTEDT"))

adtte <- derive_param_tte(
  dataset_adsl = adsl, start_date = TRTSDT,
  event_conditions = list(ttae), censor_conditions = list(eot),
  source_datasets = list(adsl = adsl, ae = filter(adae, TRTEMFL == "Y")),
  set_values_to = exprs(PARAMCD = "TTAE", PARAM = "Time to First Adverse Event")) %>%
  derive_vars_duration(new_var = AVAL, start_date = STARTDT, end_date = ADT, out_unit = "days") %>%
  derive_vars_merged(dataset_add = adsl, new_vars = exprs(TRT01A, AGE, SEX),
                     by_vars = exprs(STUDYID, USUBJID)) %>%
  filter(TRT01A != "Screen Failure") %>%
  mutate(TRT01A = factor(TRT01A,
    levels = c("Placebo", "Xanomeline Low Dose", "Xanomeline High Dose")))

Now assemble the modeling data frame. This is the one non-obvious step: dplyr and the pharmaverse return a tibble, and survminer’s ggforest() chokes on a tibble (more on that below). So coerce it to a clean base data.frame for the model with as.data.frame(), and add an age group we will need for the subgroup forest.

cox_dat <- adtte %>%
  transmute(
    AVAL  = as.numeric(AVAL),
    CNSR  = as.integer(CNSR),
    TRT01A,                                        # already a clean factor
    AGE   = as.numeric(AGE),
    SEX   = factor(SEX, labels = c("Female", "Male")),
    AGEGR = factor(if_else(AGE < 65, "< 65 years", ">= 65 years"),
                   levels = c("< 65 years", ">= 65 years"))
  ) %>%
  as.data.frame()                                  # ggforest needs a base data.frame, not a tibble

head(cox_dat, 4)
  AVAL CNSR               TRT01A AGE    SEX       AGEGR
1    2    0              Placebo  63 Female  < 65 years
2    3    0              Placebo  64   Male  < 65 years
3    3    0 Xanomeline High Dose  71   Male >= 65 years
4   44    1  Xanomeline Low Dose  74   Male >= 65 years

Fit the Cox proportional-hazards model — the survival package’s coxph() — for time to first adverse event on treatment arm, adjusted for age and sex. Flip the censor flag with 1 - CNSR, as every survival call on an ADTTE does:

library(survival)

cox <- coxph(Surv(AVAL, 1 - CNSR) ~ TRT01A + AGE + SEX, data = cox_dat)
summary(cox)$conf.int
                           exp(coef) exp(-coef) lower .95 upper .95
TRT01AXanomeline Low Dose   1.910915  0.5233095 1.3700822  2.665239
TRT01AXanomeline High Dose  2.211721  0.4521367 1.5588830  3.137958
AGE                         1.004550  0.9954707 0.9881041  1.021269
SEXMale                     1.297196  0.7708936 0.9913165  1.697457

Read the conf.int block as the forest’s rows. Relative to placebo (the reference arm), the hazard of a first adverse event is HR 1.91 (95% CI 1.37–2.67) on Xanomeline Low Dose and HR 2.21 (95% CI 1.56–3.14) on High Dose — both CIs sit entirely above 1, so both are significant. Age (HR 1.00 per year, CI 0.99–1.02) and sex (Male HR 1.30, CI 0.99–1.70) have intervals that touch or cross 1 — no clear independent effect. That is exactly what the Cox-model forest draws next.

The Cox-model forest: ggforest()

survminer’s ggforest() turns the coxph object into the standard model forest in one call. It reads the fit, lays out one row per model term (each factor level against its reference), prints the HR with its CI and p-value, and adds the number of events and the global test at the foot:

library(survminer)

ggforest(
  cox,
  data = cox_dat,
  main = "Hazard ratio: time to first adverse event"
)

A survminer ggforest hazard-ratio forest plot of the Cox model for time to first adverse event. One row per model term: the two Xanomeline arms against the Placebo reference (hazard ratios 1.9 and 2.2, both confidence intervals above 1 and marked highly significant), age (hazard ratio 1.0, not significant), and Male against the Female reference (hazard ratio 1.3, borderline). Each row shows a black box at the point estimate with a horizontal confidence-interval line, against a vertical reference at a hazard ratio of 1, with the HR, CI, and p-value printed. The number of events and the global log-rank p-value are noted at the foot.

That is the model on one page. The two Xanomeline arms sit well right of 1 and are flagged highly significant; age contributes nothing (its box sits on the line); Male is borderline (its CI just touches 1). The foot reports the number of events and the model’s global test — the same proportional-hazards fit, read as a picture instead of a table. ggforest() is the fastest route from a coxph object to a submission-shaped covariate forest.

ImportantThe ggforest() gotcha: it needs a base data.frame

If you pass ggforest() a tibble (what dplyr and the pharmaverse return), it fails with a cryptic match.names(clabs, names(xi)) : names do not match previous names. The trigger is the tibble class itself, not the data — a plain base data.frame works even when its columns still carry SAS-style label attributes. The fix is the as.data.frame() in the cox_dat step above: coerce the tibble to a base data frame, fit the model on it, and ggforest() works. (ggforest() reading a tibble directly is a genuine survminer rough edge — noted for a future fix.)

The subgroup forest: build it with ggplot2

The Cox-model forest shows every covariate’s effect from one model. A subgroup forest answers a different question: does the treatment effect hold within each subgroup? That means a separate Cox model fit inside each subgroup, estimating the treatment HR there — which is not what ggforest() does (it reads one model’s terms, not many per-subgroup models). So we compute the per-subgroup HRs ourselves and draw the forest with ggplot2. This is the standard approach, and it gives you full control over the rows, the reference line, and the HR text column.

First, collapse treatment to a binary contrast — active dose vs placebo — so each subgroup yields a single HR (a three-level arm would give two HRs per subgroup and clutter the figure). Then fit coxph(Surv(AVAL, 1 - CNSR) ~ ARM) within each subgroup and pull the HR and CI:

sub <- cox_dat %>%
  mutate(ARM = factor(if_else(TRT01A == "Placebo", "Placebo", "Active"),
                      levels = c("Placebo", "Active")))

# fit the treatment Cox model on a subset and return one row: HR + 95% CI + counts
subgroup_hr <- function(data, label) {
  s <- summary(coxph(Surv(AVAL, 1 - CNSR) ~ ARM, data = data))
  data.frame(
    subgroup = label,
    n        = nrow(data),
    events   = s$nevent,
    hr       = s$conf.int[1, "exp(coef)"],
    lower    = s$conf.int[1, "lower .95"],
    upper    = s$conf.int[1, "upper .95"]
  )
}

forest_df <- rbind(
  subgroup_hr(sub,                                "Overall"),
  subgroup_hr(filter(sub, SEX == "Female"),       "Female"),
  subgroup_hr(filter(sub, SEX == "Male"),         "Male"),
  subgroup_hr(filter(sub, AGEGR == "< 65 years"),  "< 65 years"),
  subgroup_hr(filter(sub, AGEGR == ">= 65 years"), ">= 65 years")
)
forest_df
     subgroup   n events       hr     lower    upper
1     Overall 252    217 2.030788 1.5043809 2.741394
2      Female 142    120 1.778575 1.2083352 2.617924
3        Male 110     97 2.342898 1.4457207 3.796840
4  < 65 years  33     28 1.635197 0.7424657 3.601338
5 >= 65 years 219    189 2.091737 1.5136035 2.890695

One row per subgroup, each with the treatment HR, its 95% CI, and — crucially — the event count the estimate rests on. The < 65 years subgroup has only 28 events; watch what that does to its interval.

Now draw the forest. The recipe is fixed: a dashed reference line at HR = 1, a point per subgroup, a horizontal whisker for the CI, a log x-axis (so the scale is symmetric around 1), and the HR (CI) text column on the right.

library(ggplot2)

# order rows top-to-bottom as listed, and pre-format the HR (CI) labels
forest_df$row <- factor(forest_df$subgroup, levels = rev(forest_df$subgroup))
forest_df$lab <- sprintf("%.2f (%.2f, %.2f)", forest_df$hr, forest_df$lower, forest_df$upper)

ggplot(forest_df, aes(x = hr, y = row)) +
  geom_vline(xintercept = 1, linetype = "dashed", color = "grey55") +
  geom_errorbar(aes(xmin = lower, xmax = upper),
                width = 0.22, orientation = "y", color = "#3a86d4") +
  geom_point(size = 2.9, color = "#3a86d4") +
  geom_text(aes(x = 6, label = lab), hjust = 0, size = 3.4) +
  scale_x_log10(breaks = c(0.5, 1, 2, 4)) +
  coord_cartesian(xlim = c(0.5, 6), clip = "off") +
  labs(x = "Hazard ratio (Active vs Placebo, log scale)", y = NULL,
       title = "Time to first adverse event by subgroup") +
  theme_minimal(base_size = 13) +
  theme(plot.margin = margin(6, 130, 6, 6),
        panel.grid.minor = element_blank())

A subgroup forest plot of the hazard of a first adverse event, active dose versus placebo. Rows for Overall, Female, Male, under-65, and 65-or-over each show a blue point at the hazard ratio with a 95% confidence-interval whisker, on a log x-axis, against a dashed vertical reference line at a hazard ratio of 1. A right-hand column prints the hazard ratio and its confidence interval. Overall the hazard ratio is about 2.0 with the interval well above 1; the estimates are consistent across sex and age, and only the small under-65 subgroup (28 events) has a wide interval that crosses 1.

Two ggplot details make this a forest plot and not a dot plot: scale_x_log10() puts the reference at 1 with a halving and a doubling equidistant from it (the honest scale for a ratio), and coord_cartesian(clip = "off") with a wide right margin lets the HR (CI) text sit outside the panel. Everything else is a point, a whisker, and a dashed line.

Read the figure

Read the subgroup forest in three moves.

Direction and magnitude. Every point sits to the right of 1: the hazard of a first adverse event is raised on the active arm in every subgroup — the safety signal is real and everywhere. The overall HR is 2.0 (95% CI 1.50–2.74): active-arm subjects reach their first adverse event about twice as fast as placebo subjects.

Consistency. The subgroup estimates line up — Female 1.78 (1.21–2.62), Male 2.34 (1.45–3.80), >= 65 years 2.09 (1.51–2.89) — all close to the overall 2.0 and all pointing the same way. That consistency is what makes the overall result trustworthy: the effect is not driven by one subgroup.

The underpowered subgroup. The < 65 years row is the exception — HR 1.64 but a wide CI (0.74–3.60) that crosses 1. Do not read that as “the drug is safe in the under-65s.” That subgroup has only 28 events; the wide interval is lack of precision, not evidence of no effect. This is the central caution of subgroup analysis: a CI that crosses 1 in a small subgroup almost always means too few events, not a real difference. Regulators treat subgroup findings as hypothesis-generating, not confirmatory — the EMA guideline on subgroup analysis and ICH E9 both stress that subgroups must be pre-specified and that a formal treatment-by-subgroup interaction test, not eyeballing overlapping CIs, is the way to claim an effect genuinely differs across groups.

Note

This worked example is a safety read-out (time to first adverse event), so an HR above 1 means the active arm does worse — more, faster adverse events. For an efficacy endpoint (overall survival, progression-free survival) the identical code produces the forest, but you hope for a protective HR below 1, with the points and their CIs sitting left of the reference line. The mechanics never change; only the clinical direction does.

Report

In the intention-to-treat (ITT) population, the hazard of a first treatment-emergent adverse event was higher on the active arm than on placebo (HR 2.03, 95% CI 1.50–2.74). The effect was consistent across subgroups defined by sex (female HR 1.78, 95% CI 1.21–2.62; male HR 2.34, 95% CI 1.45–3.80) and age (≥ 65 years HR 2.09, 95% CI 1.51–2.89); the < 65-year subgroup (28 events) was too small for a reliable estimate (HR 1.64, 95% CI 0.74–3.60). Subgroup analyses were exploratory.

That paragraph — overall effect, subgroup consistency, and an honest note on the underpowered subgroup — is what accompanies the forest plot in the CSR.

🟢 With an AI agent

Ask Prova “how do I add a treatment-by-subgroup interaction p-value column to my ggplot2 subgroup forest plot for an overall-survival endpoint?” — it answers grounded in this pillar’s lessons, with runnable survminer and ggplot2 code you can try on the example pharmaverse ADTTE. The runtime is the judge. Ask Prova →

Common issues

Your forest plot uses a linear x-axis, so the reference sits off-center. A hazard ratio is a ratio: HR 0.5 (halved hazard) and HR 2 (doubled hazard) are equal-and-opposite effects, but on a linear axis 2 looks twice as far from 1 as 0.5. Always plot HRs on a log scale (scale_x_log10()), which places 1 in the middle and makes protective and harmful effects symmetric. Every published forest plot does this.

You read a CI that crosses 1 as “no effect.” A confidence interval crossing the reference line means the effect is not statistically significant in that row — it is compatible with no effect. It does not mean the effect is zero, especially in a small subgroup where the interval is wide because there are few events. Check the event count before concluding: a crossing CI on 28 events (like the < 65 subgroup here) is usually underpowered, not reassuring.

ggforest() errors with names do not match previous names. You passed it a tibble (common straight out of the pharmaverse) — the tibble class is the trigger, not the column attributes. Build the model’s data as a plain base data.frame (finish the pipe with as.data.frame()), fit coxph() on that, and pass the same frame to ggforest(data = ...).

You eyeballed overlapping subgroup CIs and claimed the effect “differs.” Overlapping or non-overlapping confidence intervals are not a test of whether a treatment effect differs across subgroups. To claim genuine effect modification, fit a treatment-by-subgroup interaction term (coxph(Surv(AVAL, 1 - CNSR) ~ ARM * SEX)) and read its p-value — and only for pre-specified subgroups, per ICH E9.

Frequently asked questions

For a Cox model, fit it with coxph() and pass the fit to survminer’s ggforest() — one call draws the per-covariate hazard-ratio forest with CIs and p-values. For a subgroup forest, fit a Cox model within each subgroup, pull the HR and 95% CI (from summary(fit)$conf.int), assemble a data frame, and draw it with ggplot2: geom_point() + geom_errorbar(orientation = "y") + a dashed geom_vline(xintercept = 1) on a scale_x_log10() axis. Both are worked through above on public pharmaverse data.

ggforest() takes a fitted coxph object and draws the Cox-model forest plot: one row per model term (each factor level against its reference), showing the hazard ratio, its 95% confidence interval, and the p-value, with the number of events and the model’s global test at the foot. It visualizes a single model’s covariates — it does not fit per-subgroup models, so for a subgroup forest you compute the HRs yourself and plot with ggplot2.

Because you passed it a tibble — common straight from the pharmaverse. It is the tibble class that trips ggforest(), not the column attributes: a plain base data.frame works even when its columns carry SAS-style labels. ggforest() needs a base data.frame, so finish the modeling pipe with as.data.frame(), fit coxph() on it, and pass that same frame to ggforest(data = ...).

Because a hazard ratio is a ratio, and ratios are symmetric on a log scale but not a linear one. A halving (HR 0.5) and a doubling (HR 2) are equal-and-opposite effects; on scale_x_log10() they sit the same distance either side of the reference line at 1, so the figure does not visually exaggerate one direction. Every published forest plot plots hazard (or odds, or risk) ratios on a log axis.

That the treatment effect in that subgroup is not statistically significant — the data are compatible with no effect (HR = 1). It does not prove the effect is absent. In a small subgroup the interval is wide mainly because there are few events, so a crossing CI usually signals an underpowered subgroup, not a real difference. Always check the event count, and use a formal interaction test — not overlapping CIs — to judge whether an effect genuinely differs across groups.

Yes — the code is identical. Point the Cox model at the overall-survival rows of your ADTTE (PARAMCD == "OS") and rebuild; only the endpoint changes. We use time to first adverse event here because the public CDISC pilot is a low-mortality study whose overall-survival curve is nearly flat. For an efficacy endpoint you expect a protective HR below 1, so the points and their CIs sit to the left of the reference line.

Test your understanding

Using the sub data frame built in this lesson, add a third subgroup factor and rebuild the forest. Create a crude age-tertile grouping (< 70, 70–79, >= 80) with cut(), compute the treatment HR in each tertile with the subgroup_hr() helper, add those rows to forest_df, and redraw. Then say which tertile is least reliable and why.

Build the grouping with sub$AGETERT <- cut(sub$AGE, breaks = c(0, 70, 80, Inf), right = FALSE, labels = c("< 70", "70-79", ">= 80")). Call subgroup_hr(filter(sub, AGETERT == "< 70"), "Age < 70") for each level, rbind() the rows, and reuse the exact ggplot() block. “Least reliable” = the row with the fewest events and the widest CI (look at the events column and the whisker length).

library(dplyr)
library(survival)
library(ggplot2)

sub <- sub %>%
  mutate(AGETERT = cut(AGE, breaks = c(0, 70, 80, Inf), right = FALSE,
                       labels = c("< 70", "70-79", ">= 80")))

tert_df <- rbind(
  subgroup_hr(sub,                            "Overall"),
  subgroup_hr(filter(sub, AGETERT == "< 70"),  "Age < 70"),
  subgroup_hr(filter(sub, AGETERT == "70-79"), "Age 70-79"),
  subgroup_hr(filter(sub, AGETERT == ">= 80"), "Age >= 80")
)

tert_df$row <- factor(tert_df$subgroup, levels = rev(tert_df$subgroup))
tert_df$lab <- sprintf("%.2f (%.2f, %.2f)", tert_df$hr, tert_df$lower, tert_df$upper)

ggplot(tert_df, aes(x = hr, y = row)) +
  geom_vline(xintercept = 1, linetype = "dashed", color = "grey55") +
  geom_errorbar(aes(xmin = lower, xmax = upper), width = 0.22, orientation = "y", color = "#3a86d4") +
  geom_point(size = 2.9, color = "#3a86d4") +
  geom_text(aes(x = 6, label = lab), hjust = 0, size = 3.4) +
  scale_x_log10(breaks = c(0.5, 1, 2, 4)) +
  coord_cartesian(xlim = c(0.5, 6), clip = "off") +
  labs(x = "Hazard ratio (Active vs Placebo, log scale)", y = NULL) +
  theme_minimal(base_size = 13) +
  theme(plot.margin = margin(6, 130, 6, 6), panel.grid.minor = element_blank())

The < 70 tertile is least reliable — it has the fewest subjects and events, so its CI is the widest and most likely to cross 1. The lesson: a subgroup forest is only as trustworthy as the event count behind each row, which is why you print n/events and never over-read a wide interval.

A. The treatment clearly raises the hazard in this subgroup. B. The treatment has no effect in this subgroup. C. The estimate is not significant in this subgroup — likely underpowered — and is not evidence of a real difference.

C. The CI (0.7–2.8) crosses 1, so the effect is not statistically significant in this subgroup — the data are compatible with no effect. But a wide interval like this usually means too few events (underpowered), not that the effect is truly absent. A over-reads a non-significant point estimate; B treats “not significant” as “proven no effect,” which a wide CI does not support. Check the event count and use a formal interaction test before claiming the effect differs here.

Conclusion

The forest plot is how a Clinical Study Report shows an effect and its consistency, and it comes in two forms. The Cox-model forest — one row per covariate from a single coxph fit — is a single ggforest() call from survminer, once you give it a clean base data.frame. The subgroup forest — the treatment effect estimated separately within each subgroup — you assemble yourself: fit a Cox model per subgroup, pull the HR and CI, and draw the forest with ggplot2 (a point, a whisker, the dashed HR = 1 line, and an HR (CI) text column, on a log axis). Read both for direction, magnitude, and consistency — and treat a wide CI that crosses 1 in a small subgroup as underpowered, not as proof of no effect. Get those readings right and your forest plot is submission-ready.

Note

This lesson is reproducible: every result on this page was produced by the code shown — copy any block and run it to reproduce them. The runtime is the judge.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {Forest {Plot} in {R:} {Hazard-Ratio} and {Subgroup} {Figures}
    for a {Clinical} {Study} {Report}},
  date = {2026-07-01},
  url = {https://www.datanovia.com/learn/pharma-clinical/04-tlf-generation/forest-plot-survminer},
  langid = {en}
}
For attribution, please cite this work as:
“Forest Plot in R: Hazard-Ratio and Subgroup Figures for a Clinical Study Report.” 2026. July 1. https://www.datanovia.com/learn/pharma-clinical/04-tlf-generation/forest-plot-survminer.