`forrest` creates publication-ready forest plots from any data frame that contains point estimates and confidence intervals. A single function, `forrest()`, handles the full range of use cases — regression model results, subgroup analyses, meta-analyses, dose-response patterns, and more.

The only hard dependency is tinyplot. `forrest` works with base R data frames, tibbles, and data.tables.

## Basic forest plot

The simplest call requires only three column names: `estimate`, `lower`, and `upper`. Here we display adjusted regression coefficients from a linear model predicting systolic blood pressure (SBP).

    dat <- data.frame(
      predictor = c("Age (per 10 y)", "Female sex", "BMI (per 5 kg/m\u00b2)",
                    "Current smoker", "Physically active"),
      estimate  = c( 0.18, -0.42,  0.11, -0.31,  0.24),
      lower     = c( 0.05, -0.61, -0.04, -0.52,  0.08),
      upper     = c( 0.31, -0.23,  0.26, -0.10,  0.40)
    )

    forrest(
      dat,
      estimate = "estimate",
      lower    = "lower",
      upper    = "upper",
      label    = "predictor",
      xlab     = "Regression coefficient (95% CI)"
    )

## Section headers from a grouping column

Pass a column name to `section` to automatically group rows under bold section headers. `forrest()` inserts a header row wherever the section value changes, indents the row labels within each section, and adds a blank spacer row after each section. No manual data manipulation is required.

    sub_dat <- data.frame(
      subgroup = c("Sex",    "Sex",
                   "Age group", "Age group", "Age group"),
      label    = c("Female", "Male",
                   "30\u201349 years", "50\u201369 years", "70+ years"),
      estimate = c(-0.38,  0.12,  0.22, -0.15, -0.41),
      lower    = c(-0.58, -0.08,  0.02, -0.38, -0.66),
      upper    = c(-0.18,  0.32,  0.42,  0.08, -0.16)
    )

    forrest(
      sub_dat,
      estimate = "estimate",
      lower    = "lower",
      upper    = "upper",
      label    = "label",
      section  = "subgroup",
      xlab     = "Regression coefficient (95% CI)",
      header   = "Subgroup"
    )

Use `section_indent = FALSE` to suppress automatic indentation, and `section_spacer = FALSE` to suppress the blank row after each section.

### Two-level hierarchy with subsection

For analyses with a nested grouping structure, combine `section` and `subsection`. `forrest()` inserts top-level bold headers for `section` changes and indented sub-headers for `subsection` changes within each section.

    nested_dat <- data.frame(
      domain    = c(
        "Physical environment", "Physical environment", "Physical environment",
        "Physical environment", "Physical environment",
        "Social environment",   "Social environment",
        "Social environment",   "Social environment"
      ),
      type      = c(
        "Air quality", "Air quality",
        "Urban form",  "Urban form",  "Urban form",
        "Support",     "Support",
        "Deprivation", "Deprivation"
      ),
      predictor = c(
        "PM2.5 (per 10 \u03bcg/m\u00b3)", "NO2 (per 10 ppb)",
        "Green space (%)", "Walkability", "Noise (per 10 dB)",
        "Social cohesion", "Social isolation",
        "Area deprivation", "Employment rate"
      ),
      estimate  = c(-0.18,  0.12, -0.22,  0.15, -0.08,
                    -0.11,  0.09,  0.05, -0.03),
      lower     = c(-0.38, -0.08, -0.42, -0.05, -0.28,
                    -0.31, -0.09, -0.12, -0.20),
      upper     = c( 0.02,  0.32, -0.02,  0.35,  0.12,
                     0.09,  0.27,  0.22,  0.14)
    )

    forrest(
      nested_dat,
      estimate   = "estimate",
      lower      = "lower",
      upper      = "upper",
      label      = "predictor",
      section    = "domain",
      subsection = "type",
      xlab       = "Mean difference in SBP (mmHg, 95% CI)"
    )

## Adding a summary (pooled) estimate

Mark one or more rows with `is_summary = TRUE` to draw them as filled diamonds instead of squares. This is useful for pooled estimates in meta-analyses or for overall effects after subgroup rows.

    sex_dat <- data.frame(
      label    = c("Female", "Male", "Overall"),
      estimate = c(-0.42, -0.29, -0.36),
      lower    = c(-0.61, -0.48, -0.50),
      upper    = c(-0.23, -0.10, -0.22),
      is_sum   = c(FALSE, FALSE, TRUE)
    )

    forrest(
      sex_dat,
      estimate   = "estimate",
      lower      = "lower",
      upper      = "upper",
      label      = "label",
      is_summary = "is_sum",
      xlab       = "Regression coefficient (95% CI)",
      title      = "Association of female sex with SBP by subgroup"
    )

## Group colouring

Pass a `group` column to colour estimates by a categorical variable. A legend is added automatically using the Okabe-Ito colorblind-safe palette.

    grp_dat <- data.frame(
      predictor = rep(
        c("Air pollution (PM2.5)", "Noise exposure",
          "Green space access", "Walkability index",
          "Food environment"), 2
      ),
      domain    = rep(c("Physical environment", "Social environment"),
                      each = 5),
      estimate  = c(-0.18,  0.12, -0.22,  0.15, -0.08,
                     0.05, -0.03,  0.09, -0.11,  0.14),
      lower     = c(-0.38, -0.08, -0.42, -0.05, -0.28,
                    -0.12, -0.20, -0.09, -0.31, -0.04),
      upper     = c( 0.02,  0.32, -0.02,  0.35,  0.12,
                     0.22,  0.14,  0.27,  0.09,  0.32)
    )

    forrest(
      grp_dat,
      estimate = "estimate",
      lower    = "lower",
      upper    = "upper",
      label    = "predictor",
      group    = "domain",
      xlab     = "Mean difference in SBP (mmHg, 95% CI)"
    )

## Multiple estimates per row (dodge)

Set `dodge = TRUE` (or a positive number) when consecutive rows share the same `label` value. The CIs are vertically offset within each label band, and the label is displayed once at the group centre. Combine with `group` to colour the series.

    dodge_dat <- data.frame(
      exposure = rep(
        c("PM2.5 (per 10 \u03bcg/m\u00b3)", "NO2 (per 10 ppb)",
          "Noise (per 10 dB)", "Green space (%)", "Walkability"),
        each = 2
      ),
      period   = rep(c("Childhood", "Adulthood"), 5),
      estimate = c(
         0.14, -0.05,  0.08,  0.12, -0.19, -0.06,  0.11, -0.03,  0.07,  0.10
      ),
      lower    = c(
        -0.10, -0.26, -0.09, -0.08, -0.40, -0.25, -0.05, -0.12, -0.14, -0.09
      ),
      upper    = c(
         0.38,  0.16,  0.25,  0.32,  0.02,  0.13,  0.27,  0.06,  0.28,  0.29
      )
    )

    forrest(
      dodge_dat,
      estimate = "estimate",
      lower    = "lower",
      upper    = "upper",
      label    = "exposure",
      group    = "period",
      dodge    = TRUE,
      header   = "Environmental exposure",
      ref_line = 0,
      xlab     = "Mean difference in SBP (mmHg, 95% CI)"
    )

A numeric value for `dodge` sets the vertical spacing between rows within a group directly (in y-axis units). `dodge = TRUE` uses the default of `0.25`. Structural rows (section and subsection headers, spacers) are always treated as singleton groups and are not affected by dodging.

### Wide-format text columns alongside dodged CIs

By default, `cols` text values appear at each row’s dodged y position, keeping them aligned with their CI whiskers. Set `cols_by_group = TRUE` to collapse each text column to one value per label group — this produces a wide table with one row per label and one column per condition, matching the layout commonly seen in multi-period epidemiology papers.

    # Add per-condition formatted text columns to the long-format data
    dodge_dat$est_ci     <- sprintf("%.2f (%.2f, %.2f)",
                                    dodge_dat$estimate,
                                    dodge_dat$lower,
                                    dodge_dat$upper)
    dodge_dat$text_child <- ifelse(
      dodge_dat$period == "Childhood", dodge_dat$est_ci, ""
    )
    dodge_dat$text_adult <- ifelse(
      dodge_dat$period == "Adulthood", dodge_dat$est_ci, ""
    )

    forrest(
      dodge_dat,
      estimate      = "estimate",
      lower         = "lower",
      upper         = "upper",
      label         = "exposure",
      group         = "period",
      dodge         = TRUE,
      cols_by_group = TRUE,
      cols          = c("Childhood (95% CI)" = "text_child",
                        "Adulthood (95% CI)" = "text_adult"),
      widths        = c(2.8, 3.5, 2.2, 2.2),
      header        = "Environmental exposure",
      ref_line      = 0,
      xlab          = "Mean difference in SBP (mmHg, 95% CI)"
    )

## Point shapes

Pass a `shape` column to assign different point characters per category. Use together with `group` and `dodge` to encode two categorical dimensions at once — for example, colour = time period and shape = sex.

    shape_dat <- data.frame(
      exposure = rep(
        c("PM2.5 (per 10 \u03bcg/m\u00b3)", "NO2 (per 10 ppb)",
          "Noise (per 10 dB)", "Green space (%)", "Walkability"),
        each = 4
      ),
      period   = rep(rep(c("Childhood", "Adulthood"), each = 2), 5),
      sex      = rep(c("Female", "Male"), 10),
      estimate = c(
         0.22, -0.08, -0.10,  0.18,
         0.11,  0.05,  0.00,  0.14,
        -0.31,  0.06, -0.09, -0.02,
         0.08,  0.12, -0.04,  0.03,
         0.17, -0.06,  0.22,  0.01
      ),
      lower    = c(
        -0.20, -0.38, -0.28, -0.14,
        -0.12, -0.22, -0.22, -0.14,
        -0.56, -0.24, -0.38, -0.30,
        -0.18, -0.14, -0.22, -0.15,
        -0.09, -0.38, -0.04, -0.27
      ),
      upper    = c(
         0.64,  0.22,  0.08,  0.50,
         0.34,  0.32,  0.22,  0.42,
        -0.06,  0.36,  0.20,  0.26,
         0.34,  0.38,  0.14,  0.21,
         0.43,  0.26,  0.48,  0.29
      )
    )

    forrest(
      shape_dat,
      estimate = "estimate",
      lower    = "lower",
      upper    = "upper",
      label    = "exposure",
      group    = "period",
      shape    = "sex",
      dodge    = TRUE,
      ref_line = 0,
      xlab     = "Mean difference in SBP (mmHg, 95% CI)"
    )

The shape legend appears at `legend_shape_pos` (`"bottomright"` by default). Set `legend_shape_pos = NULL` to suppress it.

## Adding text columns

Use the `cols` argument — a named character vector mapping display headers to column names in `data` — to show formatted statistics alongside the plot.

    tc_dat <- data.frame(
      predictor = c("Age (per 10 y)", "Female sex", "BMI (per 5 kg/m\u00b2)",
                    "Current smoker", "Physically active"),
      estimate  = c( 0.18, -0.42,  0.11, -0.31,  0.24),
      lower     = c( 0.05, -0.61, -0.04, -0.52,  0.08),
      upper     = c( 0.31, -0.23,  0.26, -0.10,  0.40),
      coef_ci   = c(
        " 0.18 ( 0.05,  0.31)",
        "-0.42 (-0.61, -0.23)",
        " 0.11 (-0.04,  0.26)",
        "-0.31 (-0.52, -0.10)",
        " 0.24 ( 0.08,  0.40)"
      ),
      pval = c("0.006", "<0.001", "0.148", "0.003", "0.009")
    )

    forrest(
      tc_dat,
      estimate = "estimate",
      lower    = "lower",
      upper    = "upper",
      label    = "predictor",
      header   = "Predictor",
      cols     = c("Coef (95% CI)" = "coef_ci", "P-value" = "pval"),
      widths   = c(2.8, 4, 2.5, 1.2),
      xlab     = "Regression coefficient (95% CI)"
    )

### Section-level annotations in text columns

When `section` is active, text columns show `""` for section header rows by default. Use `section_cols` — a named character vector with the same syntax as `cols` — to populate specific columns in section header rows with a section-level value (e.g. number of studies, total N). The name must match a name in `cols`; the value is a column in `data` whose first non-NA entry in each section is used.

    sc_dat <- data.frame(
      subgroup  = c("Sex",    "Sex",   "Age group", "Age group", "Age group"),
      label     = c("Female", "Male",  "30\u201349 y", "50\u201369 y", "70+ y"),
      estimate  = c(-0.38,  0.12,  0.22, -0.15, -0.41),
      lower     = c(-0.58, -0.08,  0.02, -0.38, -0.66),
      upper     = c(-0.18,  0.32,  0.42,  0.08, -0.16),
      coef_ci   = c(
        "-0.38 (-0.58, -0.18)",
        " 0.12 (-0.08,  0.32)",
        " 0.22 ( 0.02,  0.42)",
        "-0.15 (-0.38,  0.08)",
        "-0.41 (-0.66, -0.16)"
      ),
      # Constant within each section — section header will show this value
      k_text    = c("k = 2", "k = 2", "k = 3", "k = 3", "k = 3")
    )

    forrest(
      sc_dat,
      estimate     = "estimate",
      lower        = "lower",
      upper        = "upper",
      label        = "label",
      section      = "subgroup",
      section_cols = c("k" = "k_text"),
      header       = "Subgroup",
      cols         = c("Coef (95% CI)" = "coef_ci", "k" = "k_text"),
      widths       = c(2.5, 4, 2.5, 1.2),
      xlab         = "Regression coefficient (95% CI)"
    )

## Alternating row stripes

Set `stripe = TRUE` for a subtle alternating row background that aids readability with many rows.

    stripe_dat <- data.frame(
      label    = c(
        "Age (per 10 y)", "Female sex", "BMI (per 5 kg/m\u00b2)",
        "Current smoker", "Physically active",
        "Alcohol intake", "Sleep duration", "Depressive symptoms"
      ),
      estimate = c( 0.42, -0.18,  0.31, -0.07,  0.25, -0.12,  0.19, -0.34),
      lower    = c( 0.22, -0.38,  0.12, -0.24,  0.06, -0.30,  0.01, -0.52),
      upper    = c( 0.62,  0.02,  0.50,  0.10,  0.44,  0.06,  0.37, -0.16)
    )

    forrest(
      stripe_dat,
      estimate = "estimate",
      lower    = "lower",
      upper    = "upper",
      label    = "label",
      stripe   = TRUE,
      xlab     = "Regression coefficient (95% CI)"
    )

## Themes

`forrest()` ships three built-in themes. Pass the theme name as a character string, or supply a named list of style overrides for full control.

    theme_dat <- data.frame(
      predictor = c("Age (per 10 y)", "Female sex", "BMI (per 5 kg/m\u00b2)"),
      estimate  = c( 0.18, -0.42,  0.11),
      lower     = c( 0.05, -0.61, -0.04),
      upper     = c( 0.31, -0.23,  0.26)
    )

    # "minimal" theme — lighter gridlines and softer reference line
    forrest(
      theme_dat,
      estimate = "estimate",
      lower    = "lower",
      upper    = "upper",
      label    = "predictor",
      theme    = "minimal",
      title    = 'theme = "minimal"',
      xlab     = "Coefficient (95% CI)"
    )

    # "classic" theme — dotted gridlines and solid black reference line
    forrest(
      theme_dat,
      estimate = "estimate",
      lower    = "lower",
      upper    = "upper",
      label    = "predictor",
      theme    = "classic",
      title    = 'theme = "classic"',
      xlab     = "Coefficient (95% CI)"
    )

    # Custom theme — override individual style keys
    forrest(
      theme_dat,
      estimate = "estimate",
      lower    = "lower",
      upper    = "upper",
      label    = "predictor",
      theme    = list(ref_col = "#e63946", ref_lty = 1L, grid_col = "#eeeeee"),
      title    = "Custom theme (red reference line)",
      xlab     = "Coefficient (95% CI)"
    )

## Saving plots

Use `save_forrest()` to export a plot to PDF, PNG, SVG, or TIFF. Pass a zero-argument function that calls `forrest()`.

    save_forrest(
      file   = "my_forest_plot.pdf",
      plot   = function() {
        forrest(
          dat,
          estimate = "estimate",
          lower    = "lower",
          upper    = "upper",
          label    = "predictor",
          xlab     = "Regression coefficient (95% CI)"
        )
      },
      width  = 8,
      height = 5
    )