R dplyr - bind_rows() and bind_cols()

library(dplyr)

Key Insights

  • bind_rows() stacks data frames vertically by matching column names, automatically filling missing columns with NA values and handling mismatched schemas gracefully
  • bind_cols() combines data frames horizontally without key matching, requiring identical row counts and offering no safeguards against misaligned data
  • Both functions outperform base R alternatives (rbind(), cbind()) with faster execution, better type handling, and more predictable behavior with tibbles

Understanding bind_rows() Fundamentals

bind_rows() combines multiple data frames by stacking them vertically. Unlike base R’s rbind(), it matches columns by name rather than position and handles missing columns intelligently.

library(dplyr)

df1 <- data.frame(
  id = 1:3,
  name = c("Alice", "Bob", "Charlie"),
  age = c(25, 30, 35)
)

df2 <- data.frame(
  id = 4:6,
  name = c("David", "Eve", "Frank"),
  age = c(28, 32, 29)
)

combined <- bind_rows(df1, df2)
print(combined)
  id    name age
1  1   Alice  25
2  2     Bob  30
3  3 Charlie  35
4  4   David  28
5  5     Eve  32
6  6   Frank  29

The function preserves column types and creates a single data frame with all rows from both inputs. This becomes particularly valuable when working with data splits or batch processing results.

Handling Mismatched Schemas

bind_rows() excels when combining data frames with different column structures. It automatically fills missing values with NA:

sales_q1 <- data.frame(
  product = c("Widget", "Gadget"),
  revenue = c(10000, 15000),
  units = c(100, 150)
)

sales_q2 <- data.frame(
  product = c("Widget", "Gadget"),
  revenue = c(12000, 16000),
  region = c("North", "South")  # New column
)

annual_sales <- bind_rows(sales_q1, sales_q2)
print(annual_sales)
  product revenue units region
1  Widget   10000   100   <NA>
2  Gadget   15000   150   <NA>
3  Widget   12000    NA  North
4  Gadget   16000    NA  South

This behavior prevents errors that would occur with rbind(), which requires identical column structures. The automatic NA insertion makes bind_rows() ideal for combining data from different sources or time periods where schema evolution is common.

Adding Source Identifiers

Track which data frame each row originated from using the .id parameter:

experiment_a <- data.frame(
  subject = 1:3,
  score = c(85, 92, 78)
)

experiment_b <- data.frame(
  subject = 1:3,
  score = c(88, 90, 82)
)

all_results <- bind_rows(
  A = experiment_a,
  B = experiment_b,
  .id = "experiment"
)

print(all_results)
  experiment subject score
1          A       1    85
2          A       2    92
3          A       3    78
4          B       1    88
5          B       2    90
6          B       3    82

This approach eliminates manual group labeling and maintains data provenance throughout your analysis pipeline. The .id column becomes particularly useful when combining results from multiple models or data processing branches.

Working with Lists of Data Frames

bind_rows() accepts lists, making it efficient for combining many data frames:

# Simulate reading multiple CSV files
data_files <- list(
  data.frame(month = "Jan", sales = c(100, 150, 200)),
  data.frame(month = "Feb", sales = c(110, 160, 210)),
  data.frame(month = "Mar", sales = c(120, 170, 220))
)

yearly_data <- bind_rows(data_files, .id = "file_number")
print(yearly_data)
  file_number month sales
1           1   Jan   100
2           1   Jan   150
3           1   Jan   200
4           2   Feb   110
5           2   Feb   160
6           2   Feb   210
7           3   Mar   120
8           3   Mar   170
9           3   Mar   220

This pattern works exceptionally well with purrr::map() for processing multiple files:

library(purrr)

# Simulated file processing
process_file <- function(month_num) {
  data.frame(
    month = month_num,
    value = rnorm(5, mean = 100, sd = 10)
  )
}

monthly_data <- map(1:12, process_file) %>%
  bind_rows()

dim(monthly_data)  # 60 rows (5 per month)

Understanding bind_cols() Mechanics

bind_cols() combines data frames horizontally, appending columns side-by-side. It requires all inputs to have identical row counts:

customers <- data.frame(
  customer_id = 1:4,
  name = c("Alice", "Bob", "Charlie", "David")
)

orders <- data.frame(
  order_count = c(5, 3, 8, 2),
  total_spent = c(500, 300, 800, 200)
)

customer_summary <- bind_cols(customers, orders)
print(customer_summary)
  customer_id    name order_count total_spent
1           1   Alice           5         500
2           2     Bob           3         300
3           3 Charlie           8         800
4           4   David           2         200

Unlike join operations, bind_cols() performs no key matching. It simply places columns next to each other based on row position, making it fast but potentially dangerous if data isn’t properly aligned.

When bind_cols() Fails

bind_cols() throws an error when row counts don’t match:

df_a <- data.frame(x = 1:3)
df_b <- data.frame(y = 1:4)

# This will error:
# bind_cols(df_a, df_b)
# Error: Can't recycle `..1` (size 3) to match `..2` (size 4)

This strict requirement prevents silent data misalignment errors. If you need to combine data frames with different row counts, use join operations (left_join(), inner_join(), etc.) instead.

Handling Duplicate Column Names

When column names conflict, bind_cols() automatically renames them:

data1 <- data.frame(id = 1:3, value = c(10, 20, 30))
data2 <- data.frame(id = 4:6, value = c(40, 50, 60))

result <- bind_cols(data1, data2)
print(result)
  id...1 value...2 id...3 value...4
1      1        10      4        40
2      2        20      5        50
3      3        30      6        60

The suffix pattern (...1, ...2, etc.) indicates column position. For cleaner code, rename columns before binding:

data2_renamed <- data2 %>%
  rename(id2 = id, value2 = value)

result_clean <- bind_cols(data1, data2_renamed)
print(result_clean)
  id value id2 value2
1  1    10   4     40
2  2    20   5     50
3  3    30   6     60

Performance Considerations

Both functions significantly outperform base R alternatives with large datasets:

# Generate test data
large_df_list <- replicate(100, data.frame(
  x = rnorm(1000),
  y = rnorm(1000)
), simplify = FALSE)

# bind_rows() approach
system.time({
  result_dplyr <- bind_rows(large_df_list)
})

# do.call(rbind) approach
system.time({
  result_base <- do.call(rbind, large_df_list)
})

In typical benchmarks, bind_rows() executes 2-5x faster than do.call(rbind) on large datasets. The performance gap widens with more complex data structures or when handling mismatched schemas.

Practical Use Cases

Combine results from parallel processing:

library(parallel)

# Simulate parallel computation
process_chunk <- function(chunk_id) {
  data.frame(
    chunk = chunk_id,
    result = runif(100)
  )
}

cl <- makeCluster(4)
results <- parLapply(cl, 1:10, process_chunk)
stopCluster(cl)

final_results <- bind_rows(results)

Merge feature engineering outputs:

base_features <- data.frame(
  id = 1:100,
  feature_a = rnorm(100)
)

derived_features <- data.frame(
  feature_b = rnorm(100),
  feature_c = rnorm(100)
)

model_data <- bind_cols(base_features, derived_features)

The key distinction: use bind_rows() when accumulating records over time or from multiple sources, and bind_cols() when adding computed features to existing observations. Always prefer join operations when relationships between data frames require key-based matching rather than positional alignment.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.