rguides

Data Frames and Tibbles in R: A Complete Tutorial

Data frames are the workhorse of data analysis in R. They store tabular data (think spreadsheet or SQL table) where each column can hold a different type. This tutorial covers creating data frames, understanding their structure, working with tibbles (the tidyverse modern alternative), and essential manipulation operations.

What you’ll learn

This tutorial covers the key concepts and practical techniques for working with data frames and tibbles. By the end, you will know how to apply the core functions in real data analysis workflows. If you are new to R’s basic data structures, start with Vectors and Types in R before diving into tabular data.

What is a data frame?

A data frame is a list of vectors of equal length. Each vector represents a column, and all vectors must have the same number of rows. This rectangular structure makes data frames perfect for statistical analysis and data science.

data.frame() creates rectangular data with named columns. Each column must have the same length, but columns can hold different types such as numeric, character, logical, factor, or Date. The stringsAsFactors = FALSE argument (the default since R 4.0) keeps character columns as characters instead of silently converting them to factors.

# Create a simple data frame
df <- data.frame(
  name = c("Alice", "Bob", "Charlie", "Diana"),
  age = c(25, 30, 35, 28),
  score = c(85.5, 92.0, 78.5, 88.0),
  passed = c(TRUE, TRUE, FALSE, TRUE)
)

df
#      name age score passed
# 1    Alice  25  85.5   TRUE
# 2      Bob  30  92.0   TRUE
# 3  Charlie  35  78.5  FALSE
# 4    Diana  28  88.0   TRUE

Once a data frame exists, inspecting its shape and column types helps you understand what you are working with before applying transformations. The functions nrow(), ncol(), and dim() report the number of rows, columns, and both together as a vector. The names() function returns column headers as a character vector, and str() displays a compact summary showing each column’s type alongside its first few values, which is essential for spotting type surprises early in an analysis.

# Dimensions
nrow(df)
# [1] 4

ncol(df)
# [1] 4

dim(df)
# [1] 4 4

# Column names
names(df)
# [1] "name"   "age"    "score"  "passed"

# Structure
str(df)
# 'data.frame': 4 obs. of  4 variables:
#  $ name  : chr  "Alice" "Bob" "Charlie" "Diana"
#  $ age   : num  25 30 35 28
#  $ score : num  85.5 92 78.5 88
#  $ passed: logi  TRUE TRUE FALSE TRUE

For a more detailed look at your data, summary(df) computes per-column statistics: min, max, mean, median, and quartiles for numeric columns, plus frequency counts for factors and characters. The glimpse(df) function from dplyr transposes the output so column types and sample values appear row-wise, which fits more information on screen with wide datasets. In RStudio, View(df) opens an interactive viewer; wrapping it as View(head(df, 1000)) limits the rows to keep the viewer responsive.

Accessing tabular data

Selecting columns, rows, and individual cells is the most common operation you perform on a data frame. R gives you multiple ways to reach into the structure, each with different return types that matter when writing functions.

Selecting columns

Use $ for direct column access by name or [[ for computed names:

# By name (returns a vector)
df$name
# [1] "Alice"   "Bob"     "Charlie" "Diana"

df$age
# [1] 25 30 35 28

# Using double brackets (also returns vector)
df[["score"]]
# [1] 85.5 92.0 78.5 88.0

Column access also works with bracket notation df[, "col"], but the return type differs between data frames and tibbles. A data frame drops the dimensions and returns a vector when selecting a single column, while a tibble preserves the one-column structure. Using df[["col"]] always returns a vector regardless of input type, making it the safer choice inside functions that need a consistent return value.

Selecting rows and cells

# Single cell: row 1, column 2
df[1, 2]
# [1] 25

# Entire first row
df[1, ]
#   name age score passed
# 1 Alice  25  85.5   TRUE

# Entire first column (as a vector)
df[, 1]
# [1] "Alice"   "Bob"     "Charlie" "Diana"

# Multiple rows and columns
df[c(1, 2), c("name", "score")]
#    name score
# 1  Alice  85.5
# 2    Bob  92.0

Bracket subsetting with df[rows, columns] offers precise control over which portions of the table to extract. When you supply two arguments, the first selects rows by index or logical condition and the second picks columns by index, name, or a logical vector. The drop = FALSE argument on a data frame prevents R from collapsing a single-column result into a vector. Tibbles never drop dimensions, so this argument is unnecessary with them.

Introducing tibbles

tibble() from the tibble package creates the tidyverse alternative to data frames. Tibbles never convert strings to factors, never support partial column name matching, and print compactly with column types shown below each name. For new analysis work, prefer tibbles; they surface problems early instead of silently changing your data.

# Load tidyverse
library(tibble)

# Create a tibble
tb <- tibble(
  name = c("Alice", "Bob", "Charlie", "Diana"),
  age = c(25, 30, 35, 28),
  score = c(85.5, 92.0, 78.5, 88.0),
  passed = c(TRUE, TRUE, FALSE, TRUE)
)

tb
# # A tibble: 4 × 4
#   name      age score passed
#   <chr>   <dbl> <dbl> <lgl>
# 1 Alice      25  85.5 TRUE 
# 2 Bob        30  92   TRUE 
# 3 Charlie    35  78.5 FALSE
# 4 Diana      28  88   TRUE

The behavioral differences between tibbles and base data frames show up in everyday use. Partial column matching (where df$nam silently returns df$name in a data frame) is disabled in tibbles; tbl$nam returns NULL with a warning instead. Tibbles display only the first ten rows along with column types and dimensions, making wide datasets easier to scan in the console. Subsetting a single column with [ returns a tibble rather than a vector, which keeps the structure consistent in pipelines.

# Tibble vs data.frame: key differences
# Base R data.frame lets you write:
df$score
# [1] 85.5 92.0 78.5 88.0

# Tibble: no partial matching (safer)
# tb$sc  # Would error: column "sc" not found

# Tibble shows dimensions in print output
tb
# # A tibble: 4 × 4

The function as_tibble(df) converts a standard data frame to a tibble, which is handy when a package returns a base data frame but you want consistent behavior in downstream dplyr code. The reverse conversion, as.data.frame(tbl), is useful for passing data to legacy functions that expect a traditional data frame.

Modifying table contents

Adding columns

# Add column with $
df$grade <- c("B", "A", "C", "B")
df
#      name age score passed grade
# 1    Alice  25  85.5   TRUE     B
# 2      Bob  30  92.0   TRUE     A
# 3  Charlie  35  78.5  FALSE     C
# 4    Diana  28  88.0   TRUE     B

# Using transform() (base R)
df <- transform(df, 
                bonus = ifelse(score > 85, 5, 0))

# Using mutate() (tidyverse)
library(dplyr)
df <- df %>% 
  mutate(letter = ifelse(score >= 90, "A",
                  ifelse(score >= 80, "B", "C")))

Adding new columns is a routine part of data preparation. The dollar-sign assignment df$new_col <- values modifies the data frame in place by appending a new column at the end. The transform() function from base R and mutate() from dplyr both return a new data frame with the added columns, making them suitable for use inside longer processing chains where you want to avoid modifying the original data.

Removing columns and appending rows

# Remove a column
df$grade <- NULL

# Remove multiple columns
df[, c("bonus", "letter")] <- NULL

# Add a row with rbind()
new_row <- data.frame(
  name = "Eve",
  age = 22,
  score = 95.0,
  passed = TRUE
)

df <- rbind(df, new_row)
df
#      name age score passed
# 1    Alice  25  85.5   TRUE
# 2      Bob  30  92.0   TRUE
# 3  Charlie  35  78.5  FALSE
# 4    Diana  28  88.0   TRUE
# 5      Eve  22  95.0   TRUE

Removing columns is as simple as setting the column to NULL with df$col <- NULL. The alternative df[, !names(df) %in% "col"] drops columns by name match and works well in programmatic contexts where the column name comes from a variable. For appending rows, rbind(df1, df2) stacks data frames vertically when column names match exactly, while dplyr::bind_rows(df1, df2) handles mismatched column names by filling missing values with NA.

Filtering and sorting rows

Logical subsetting

# Filter rows where condition is TRUE
df[df$score > 85, ]
#      name age score passed
# 1    Alice  25  85.5   TRUE
# 2      Bob  30  92.0   TRUE
# 4    Diana  28  88.0   TRUE

# With base R subset()
subset(df, score > 85)
# Same result

The bracket-based filter df[df$score > 85, ] selects every row where the logical condition inside the brackets evaluates to TRUE. This pattern works because the comparison df$score > 85 returns a logical vector the same length as the number of rows, and R uses it to pick matching positions. The subset() function offers a cleaner syntax for combined row-and-column selection because it evaluates column names without quotes, though it is less predictable in programmatic use.

Sorting with base R

# Sort by score (descending)
df[order(df$score, decreasing = TRUE), ]
#      name age score passed
# 5      Eve  22  95.0   TRUE
# 2      Bob  30  92.0   TRUE
# 4    Diana  28  88.0   TRUE
# 1    Alice  25  85.5   TRUE
# 3  Charlie  35  78.5  FALSE

# Sort by multiple columns
df[order(df$passed, df$score, decreasing = c(FALSE, TRUE)), ]

Sorting rearranges rows without changing the data itself. The base R order() function returns a permutation vector (the indices of rows in sorted order), which you feed into the row position of [. Setting decreasing = TRUE reverses the sort direction for all columns, and you can pass a logical vector to sort different columns in different directions simultaneously, as shown with passed and score above.

Sorting with dplyr

library(dplyr)

# Arrange by score
arrange(df, score)

# Arrange by multiple columns
arrange(df, desc(passed), score)

The dplyr equivalent of order() is arrange(), which reads more naturally left to right. Instead of a decreasing argument, you wrap the column with desc() to reverse the sort order for that specific column. When sorting by several columns, arrange() resolves ties in the first column by looking at the second, and so on, matching the behavior of order() with multiple arguments.

Column types and coercion

Columns in a data frame or tibble each carry a specific type, and mismatched types cause issues in joins and aggregations. Convert a column in place with df$col <- as.integer(df$col), or use dplyr::mutate(df, col = as.integer(col)) to return a new data frame. For multiple columns at once, mutate(across(c(a, b), as.numeric)) applies the same conversion to every selected column.

Date columns deserve special attention because R stores them as numeric day counts internally. as.Date("2024-01-15") creates a Date object, and format(date_col, "%B %d, %Y") produces a readable string for display. For datetime values, as.POSIXct("2024-01-15 09:30:00") captures both date and time. The lubridate package simplifies parsing with functions like ymd() and ymd_hms() that guess common date formats.

The dplyr::type_convert(df) function re-guesses types for every character column using the same algorithm as readr, which is useful after importing data as all-character and cleaning up messy values.

Managing row names

Data frames support row names, but tibbles do not. Row names are a source of subtle bugs because they behave like metadata rather than a proper column, yet they affect operations like subsetting and merging. The function rownames_to_column(df, var = "id") from tibble converts row names into a regular column, and column_to_rownames(df, var = "id") reverses the conversion.

In tidyverse workflows, avoid row names entirely. If you encounter a data frame with meaningful row names (a common situation with outputs from base R functions like cor() or prcomp()), convert them to a column first before passing the data into dplyr pipelines.

Best practices with tabular data

Keep column names lowercase and use snake_case for readability. The janitor::clean_names(df) function converts existing names to snake_case automatically, handling spaces, capitals, and special characters in one call. Use consistent types per column; mixing types causes unpredictable behavior in joins, aggregations, and model fitting. Document column meanings alongside the analysis; a data dictionary that records each column’s type, units, and valid range prevents confusion when revisiting the data months later.

For large datasets that exceed available screen space, glimpse(df) transposes the output to fit more columns, and skim(df) from the skimr package produces per-column summary statistics with inline histograms and missing-value counts. When subsetting millions of rows, converting to a data.table with setDT(df) speeds up filter and aggregate operations substantially. For memory efficiency with files too large to load, the arrow package can query Parquet files directly without reading everything into memory.

The assertr::verify(df, is.numeric(amount), amount > 0) function adds runtime assertions to data pipelines that fail explicitly when data quality degrades, catching problems at the point of ingestion rather than in downstream results.

Summary

  • Data frames are rectangular data structures—lists of equal-length vectors
  • Use data.frame() for base R data frames, tibble() for tidyverse tibbles
  • Access columns with $ or [[]], rows and cells with [row, col]
  • Tibbles are stricter and print more informatively than data frames
  • Add columns with $ or mutate(), remove with NULL
  • Filter rows with logical conditions, sort with order() or arrange()
  • as_tibble() and as.data.frame() convert between the two representations
  • Use consistent column types and document column meanings for long-term maintainability

Next steps

Continue your r-fundamentals journey with Functions and Control Flow in R, where you will learn to write reusable code with functions and control program flow with conditionals and loops.

Practice data frame and tibble operations:

# Create your own data frame
students <- data.frame(
  name = c("Alex", "Beth", "Carl", "Dina", "Eli"),
  math = c(78, 92, 85, 88, 95),
  english = c(82, 88, 79, 92, 90)
)

# Add an average column
students$average <- (students$math + students$english) / 2

# Filter students with average above 85
students[students$average > 85, ]

See also

  • dplyr Basics — the tidyverse approach to data frame manipulation with filter(), select(), mutate(), and arrange()
  • Importing and Exporting Data — reading tabular data into R from CSV, Excel, and other file formats
  • Data Table in R — high-performance data manipulation for datasets with millions of rows
  • data.frame Reference — complete reference for the data.frame() function and its methods