rguides

dplyr::bind_cols

bind_cols(..., .name_repair = "unique")

bind_cols() adds columns from multiple data frames side by side. It matches rows by their position in each data frame, not by any key column. This makes it fast and simple for data that is already aligned, but dangerous for data that might be in different orders.

Where possible, prefer a join by key over bind_cols(). Joins match rows explicitly, so you catch misalignments instead of silently producing garbage.

Syntax

bind_cols(..., .name_repair = "unique")

Parameters

ParameterTypeDefaultDescription
...data framesrequiredData frames to combine. Each can be a data frame, a list of data frames, or a list that could be a data frame. Shorter inputs are recycled to match the longest.
.name_repairstring"unique"How to handle duplicate column names. One of "unique" (make unique), "universal" (R-compatible unique), "check_unique" (error on conflict), or "minimal" (no repair).

Basic usage

Join two data frames column-wise:

library(dplyr)

df1 <- tibble(name = c("Alice", "Bob", "Charlie"))
df2 <- tibble(score = c(85, 92, 78), grade = c("B", "A", "C"))

bind_cols(df1, df2)

# # A tibble: 3 × 3
#   name    score grade
#   <chr>   <dbl> <chr>
# 1 Alice      85 B
# 2 Bob        92 A
# 3 Charlie    78 C

Rows are matched purely by position. The first row of df1 combines with the first row of df2, and so on.

The positional matching gotcha

This is the most important thing to understand about bind_cols(). It does not check that your rows align by any key. If df1 and df2 are in different orders, you’ll get wrong results with no warning:

df1 <- tibble(name = c("Alice", "Bob", "Charlie"))
df2 <- tibble(score = c(92, 85, 78))  # Bob and Alice swapped!

bind_cols(df1, df2)
# Alice gets Bob's score. No error. No warning.

When position-based binding produces silent errors, the fix is either to guarantee alignment by sorting both data frames on the same key before binding, or better yet, to abandon position-based binding entirely. A join by a shared identifier column matches rows explicitly, so misaligned data cannot go undetected.

# Safe: join by name instead
df1 <- tibble(name = c("Alice", "Bob"), score = c(85, 92))
df2 <- tibble(name = c("Bob", "Alice"), grade = c("A", "B"))

df1 |> left_join(df2, by = "name")

Supplying multiple data frames

bind_cols() accepts any number of data frames, not just two. Each additional argument appends its columns to the right of the result. The positional matching rule still applies — every row position across all data frames must refer to the same entity. This is most useful when you have built up a data set from several independent sources that share a common row ordering guaranteed by construction.

df1 <- tibble(x = 1:3)
df2 <- tibble(y = 4:6)
df3 <- tibble(z = 7:9)

bind_cols(df1, df2, df3)

# # A tibble: 3 × 3
#       x     y     z
#   <int> <int> <int>
# 1     1     4     7
# 2     2     5     8
# 3     3     6     9

Combining a list of data frames

When data frames are collected in a list — for instance, from lapply() or purrr::map() — individual calls to bind_cols() would be tedious. The splice operator !!! unpacks the list elements as separate arguments, allowing a single bind_cols() call to handle the entire collection. This pattern is common when building features for a model where each feature is computed and returned as a one-column data frame.

df_list <- list(
  tibble(a = 1:3),
  tibble(b = 4:6),
  tibble(c = 7:9)
)

bind_cols(!!!df_list)

# # A tibble: 3 × 3
#       a     b     c
#   <int> <int> <int>
# 1     1     4     7
# 2     2     5     8
# 3     3     6     9

Row size mismatch

When data frames differ in length, bind_cols() must reconcile the mismatch. If the shorter frame divides evenly into the longer one, it is recycled; otherwise, the function raises an error. Vector recycling in R is a general mechanism, but relying on it with bind_cols() is rarely intentional and often masks a data preparation bug.

df1 <- tibble(x = 1:3)
df2 <- tibble(y = 1:2)

bind_cols(df1, df2)
# Error: Can't recycle `..1` (size 3) to match `..2` (size 2).

Recycling repeats the shorter data frame’s rows to fill the longer frame. A single-row data frame repeats its one row for every row, which is the safe case — it behaves like adding a constant column. When the shorter frame has multiple rows, the recycling pattern is harder to predict and can produce nonsensical pairings. Always verify that recycling produces the intended alignment.

df1 <- tibble(x = 1:3)
df2 <- tibble(y = c(1, 2))  # recycled to c(1, 2, 1)

bind_cols(df1, df2)
# # A tibble: 3 × 2
#       x     y
#   <int> <dbl>
# 1     1     1
# 2     2     2
# 3     3     1

Recycling is convenient for one-row data frames acting as constants, but for multi-row frames the repeating pattern can silently produce incorrect pairings. The safe use case is adding an unchanging value like a timestamp or source label as a single-row data frame. For anything longer, verify the intended alignment explicitly.

Column name repair

When two or more input data frames share column names, bind_cols() must decide how to handle the collision.

df1 <- tibble(x = 1:3)
df2 <- tibble(x = 4:6)

bind_cols(df1, df2, .name_repair = "unique")
# # A tibble: 3 × 2
#       x x...1 x...2
#   <int> <int> <int>

bind_cols(df1, df2, .name_repair = "check_unique")
# Error: Names must be unique.

Common patterns

The patterns below apply bind_cols() to practical data-wrangling tasks. While the function is simple in concept, these examples show its role in routine workflows like appending source metadata to a query result or assembling wide-format tables from separately computed components. The key assumption — consistent row ordering — holds when you control the generation of all inputs.

Adding metadata columns

df <- tibble(name = c("Alice", "Bob"), score = c(85, 92))

bind_cols(df, tibble(
  date = Sys.Date(),
  source = "exam"
))

# # A tibble: 2 × 4
#   name    score date       source
#   <chr>   <dbl> <date>     <chr>
# 1 Alice      85 2026-04-18 exam
# 2 Bob        92 2026-04-18 exam

Building a wide dataset incrementally

Incremental column binding works when all parts share a guaranteed alignment — for example, when you decompose a pipeline into parallel branches that each produce one column, then recombine them. The bind_cols(part1, part2, part3) pattern is a shortcut for sequential binding but carries the same positional risk: if any branch reorders its rows, the final table is silently corrupted.

part1 <- tibble(id = 1:3, x = c("a", "b", "c"))
part2 <- tibble(y = c(1, 2, 3))
part3 <- tibble(z = c(TRUE, FALSE, TRUE))

bind_cols(part1, part2, part3)

bind_cols() matches rows by position, not by any key column. If the data frames have rows in different orders, the values will be silently misaligned. When a shared identifier exists, use a join function (left_join(), inner_join()) instead of bind_cols() to ensure rows match by value. bind_cols() is safest when you split a data frame into parts and then reassemble, the split guarantees position alignment.

Column names must be unique across all inputs. If there are duplicates, bind_cols() repairs them using the .name_repair strategy (default: "unique"), appending ...1, ...2, etc. to disambiguate. You can control this with .name_repair = "minimal" to allow duplicates or .name_repair = "check_unique" to raise an error. For adding a single computed vector as a column, dplyr::mutate() is more idiomatic than bind_cols().

See also