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
$ormutate(), remove withNULL - Filter rows with logical conditions, sort with
order()orarrange() as_tibble()andas.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(), andarrange() - 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