rguides

dplyr::bind_rows

bind_rows(..., .id = NULL)

bind_rows() stacks two or more data frames on top of each other. It lines up columns by name, fills missing ones with NA, and offers an optional .id column to record each row’s source. The function is the dplyr replacement for base::rbind() and the row-binder counterpart to bind_cols().

Syntax

bind_rows(..., .id = NULL)

Parameters

ParameterTypeDefaultDescription
...data frames, listsrequiredData frames to combine. Each argument can be a data frame, a list that could be a data frame, or a list of data frames. Mixing all three forms is fine.
.idstringNULLOptional column name. When set, bind_rows() adds a column with the source label for each row. If the inputs are named, the labels are those names; if unnamed, the labels are the positional strings "1", "2", etc.

Return value

A data frame whose class follows the first input. If the first argument is a tibble, the output is a tibble; if it is a base data.frame, the output stays a base data.frame even when later arguments are tibbles. This is the easiest detail to miss when chaining across package boundaries, because the rest of the inputs are coerced into whatever shape the first one had.

Basic usage

The canonical example: two tibbles with overlapping columns.

library(dplyr)

df1 <- tibble(x = 1:2, y = letters[1:2])
df2 <- tibble(x = 4:5, z = 1:2)

bind_rows(df1, df2)
# # A tibble: 4 × 3
#       x y         z
#   <int> <chr> <int>
# 1     1 a        NA
# 2     2 b        NA
# 3     4 NA        1
# 4     5 NA        2

Columns are matched by name, not position. y exists only in df1, so it is NA in the rows from df2; z is the reverse. The column order in the output follows the order each column first appears across the inputs (x, then y, then z).

The .id argument

bind_rows() accepts either individual data frames or a list of data frames. When the inputs are named and .id is set, the names become the values of the new column.

bind_rows(list(a = df1, b = df2), .id = "id")
# # A tibble: 4 × 4
#   id        x y         z
#   <chr> <int> <chr> <int>
# 1 a         1 a        NA
# 2 a         2 b        NA
# 3 b         4 NA        1
# 4 b         5 NA        2

When the inputs arrive without names, bind_rows() falls back to the positional index of each input, so the new column fills with strings like "1", "2", "3", and so on. This is the result you usually get when a list is built programmatically and never tagged with names, which is also why I prefer to name the inputs up front whenever I am iterating over them. Reaching for purrr::set_names() before the call costs nothing and makes the output self-describing.

bind_rows(list(df1, df2), .id = "id")
# # A tibble: 4 × 4
#   id        x y         z
#   <chr> <int> <chr> <int>
# 1 1         1 a        NA
# 2 1         2 b        NA
# 3 2         4 NA        1
# 4 2         5 NA        2

This is the cleanest way to label per-group summaries when you compute them one group at a time and want a single combined table at the end.

Mismatched columns

bind_rows() is permissive about column sets. Any column present in one input but not another becomes NA in the rows from the missing side. This is the main reason to prefer it over base::rbind(), which errors on column mismatch.

bind_rows(
  list(
    tibble(id = 1,   a = "x"),
    tibble(id = 2,   b = 10),
    tibble(id = 3,   a = "y", b = 20)
  )
)
# # A tibble: 3 × 3
#      id a         b
#   <int> <chr> <int>
# 1     1 x        NA
# 2     2 NA       10
# 3     3 y        20

If you actually have a key to align on, use a join instead. bind_rows() does not match rows by any column, it only stacks them. The right tool is dplyr::left_join() (or a sibling) when a shared id is what should align your rows. See dplyr::left_join() for the key-based alternative.

Type coercion

bind_rows() is backed by vctrs::vec_rbind() and follows vctrs’ type-coercion rules. Compatible types are promoted to their common type; incompatible types raise an error.

Compatible cases worth knowing:

# integer + double → double
bind_rows(tibble(x = 1L), tibble(x = 1.5))
# # A tibble: 2 × 1
#       x
#   <dbl>
# 1   1.0
# 2   1.5

# factors with different levels → union of levels
bind_rows(tibble(f = factor("a")), tibble(f = factor("b")))
# # A tibble: 2 × 1
#   f
#   <fct>
# 1 a
# 2 b
# Levels: a b

# factor + character → character (silent since dplyr 1.0.0)
bind_rows(tibble(f = factor("a")), tibble(f = "b"))
# # A tibble: 2 × 1
#   f
#   <chr>
# 1 a
# 2 b

vctrs draws a hard line at types it cannot reconcile. The error message names both source columns and their types, which usually points at the offending input without further detective work. That strictness is a feature, not a wart. It stops bad data from silently turning into a column of NAs that you only notice three joins later, after the damage has already been folded into a downstream summary.

bind_rows(tibble(x = 1), tibble(x = "a"))
# Error in dplyr::bind_rows():
# ! Can't combine ..1$x <double> and ..2$x <character>.

There is no coerce_to argument to relax this. The fix is to coerce explicitly before binding: mutate(x = as.character(x)), as.Date(...), and so on. The dplyr::across() verb is a clean way to normalise types across many columns in one go.

Common patterns

Reading and binding many CSVs

The classic use case: a directory of files, all with the same schema, that you want stacked into one tibble.

library(dplyr)
library(purrr)
library(readr)

files <- list.files("data", pattern = "\\.csv$", full.names = TRUE)
all   <- map(files, read_csv) |> bind_rows()
# equivalent shortcut:
all   <- map_dfr(files, read_csv)

purrr::map_dfr() is just map() followed by bind_rows(). Use whichever reads more clearly to you.

Appending new observations

When a fresh batch of rows lands, bind it onto the existing data rather than re-reading everything.

existing <- tibble(name = c("Alice", "Bob"), score = c(85, 92))
new_obs  <- tibble(name = "Charlie", score = 78)

bind_rows(existing, new_obs)
# # A tibble: 3 × 2
#   name    score
#   <chr>   <dbl>
# 1 Alice      85
# 2 Bob        92
# 3 Charlie    78

If new_obs has columns that existing does not, those columns become NA in the old rows, and vice versa. This is the same name-matching behaviour as above.

Stacking per-group summaries

When you compute the same summary for several groups and want one tidy table at the end, .id does the labelling for you.

df <- tibble(
  group = c("A", "A", "B", "B"),
  value = c(10, 15, 20, 25)
)

bind_rows(
  a = df |> filter(group == "A") |> summarise(mean = mean(value)),
  b = df |> filter(group == "B") |> summarise(mean = mean(value)),
  .id = "group"
)
# # A tibble: 2 × 2
#   group  mean
#   <chr> <dbl>
# 1 a      12.5
# 2 b      22.5

For more than a handful of groups, compute the summaries in a list and bind once: bind_rows(summary_list, .id = "group"). That keeps the call short and gives you the same labelled result.

Alternatives

bind_rows() is the right call when you have a handful of frames (or a list of many) with compatible schemas and no shared key. A few related verbs solve related-looking problems, and reaching for the wrong one usually costs you an afternoon of cleanup.

  • base::rbind() — works on base data frames only, errors on column mismatch, and offers no .id argument. bind_rows() exists precisely because of those three limitations. The base function also coerces everything to a base data.frame, which can quietly undo a tibble pipeline.
  • dplyr::add_row() — adds one or a few rows to a single data frame, taking name-value pairs. Reach for it when you are filling in a constant row by hand, not when you have another frame to attach. Its tibble argument is for a single tibble, so it is not a substitute for bind_rows() in a list-of-frames workflow.
  • dplyr::bind_cols() — the column-wise twin. It matches on row position, not on a key, and is the right verb when the columns are independent variables and the rows already align. See bind_cols() for the column-side counterpart.
  • dplyr::left_join() and friends — when you actually have a column to align on, a join is what you want. bind_rows() only stacks rows. It does not reconcile any values, and it cannot produce the wide-on-the-left shape that joins are designed for.

dplyr::bind() was the original combined function for rows and columns and is no longer the recommended spelling in current dplyr. Use bind_rows() and bind_cols() in new code; the old name survives in some older codebases but is on its way out.

Gotchas

A few details that are easy to miss in practice:

  • .id collisions are silent. If one of the input data frames already has a column named the same as .id, bind_rows() overwrites it with the source labels. Rename the existing column first if you need to keep it.
  • Output class follows the first argument. A base data.frame first means a base data.frame result, even when the other inputs are tibbles. Convert the first input to a tibble (as_tibble()) if you want a tibble back.
  • Row names are dropped. Input row names are not preserved; the result gets integer sequence row names. Carry any row identifier you care about as a column.
  • Empty inputs are fine. bind_rows(list()) returns a zero-row, zero-column tibble. bind_rows() on data frames with zero rows works, and the column types come from whichever non-empty input provides them.
  • No coerce_to argument. A long-standing feature request, intentionally not implemented. Coerce columns explicitly before binding.

See also