How to Clean Data in R with dplyr: A Guide (2026)

Cleaning data in R with dplyr means running a fixed, repeatable sequence of checks on a data frame and fixing what you find: first inspect the columns, then fix names and types, then clean the values inside each column, then handle missing data, duplicates and outliers, and finally reshape and validate the result. There is no single cleaning function. It is a pipeline of small dplyr verbs chained with the pipe, and you always know the order because you wrote it.

If you are new to R, that ordering is the whole trick. dplyr is part of the tidyverse, the collection of packages Hadley Wickham built around a consistent grammar: select() columns, mutate() values, filter() rows, group_by() then summarise() to collapse. Read that list as plain English and most of dplyr’s documentation makes sense before you have ever written a line of code.

One thing to fix in your head before you start: raw data is read-only. You load it, you copy it, and you never edit the copy you loaded from disk. Every cleaning step goes into a script, so you can rerun it next month on the new file. If you find yourself right-clicking a spreadsheet to fix a typo, stop and write the fix in R instead.

The rest of this guide walks the workflow end to end, with runnable code for each step.

Table of Contents
  1. 1What You Need
  2. 2Step-by-Step: How to Clean Data in R with dplyr
  3. 31. Install and Load dplyr
  4. 42. Inspect the Dataset Before Cleaning
  5. 53. Select Only the Columns You Need
  6. 64. Rename Columns Consistently
  7. 75. Clean Text and Categorical Values
  8. 86. Find and Handle Missing Values
  9. 97. Remove Duplicate Rows
  10. 108. Fix Variable Types and Check Categories
  11. 119. Sort, Filter, and Validate the Final Data
  12. 1210. Save the Cleaned Dataset
  13. 13Common Mistakes
  14. 14Frequently Asked Questions
  15. 15Should I remove all missing values from my dataset?
  16. 16What is the difference between NA and an empty string in R?
  17. 17How do I find duplicate rows with dplyr?
  18. 18How do I fix the ‘NAs introduced by coercion’ warning in R?
  19. 19Can I use dplyr to clean a very large dataset?
  20. 20Should I use the native pipe or %% in new R code?
  21. 21Conclusion

What You Need

You need four things, and only one of them is a decision you have to think about.

  • An R installation and a way to run code. RStudio Desktop is the usual choice for beginners because it has a script editor, a console and a data viewer in one window. Positron, VS Code with the R extension, or plain R in a terminal all work equally well once you get past the first hour.
  • dplyr, plus a few companions. dplyr does most of the work. tidyr reshapes wide data into long form, lubridate parses dates, stringr handles text patterns, janitor converts column names to snake_case, forcats tidies factor levels, and readr reads CSV files without turning everything into text.
  • A dataset to practise on. Any CSV works. Messy real-world data teaches more than tidy tutorial data, because the problems only show up when the values are inconsistent.
  • Four concepts. A data frame is a table with rows and columns. A column has a type: numeric, character, logical, factor, or date. A missing value is written NA. And a vector is just a column. That is genuinely enough R knowledge to follow every step below.

While the packages install, it is worth knowing what cleaning is not. It is not summarising. Computing a mean or fitting a model is analysis, not cleaning, and mixing the two makes it impossible to tell later which numbers came from the data and which came from your decisions.

Step-by-Step: How to Clean Data in R with dplyr

The workflow below runs in a deliberate order: types, then values, then units, then outliers, then missing data, then duplicates, then reshape, then validate. That order is not arbitrary. Fixing types before values means your trimws() and str_to_lower() calls work on real numbers, and running outlier checks before unit conversion is the single most common way to invent outliers that are not there.

Here is the short version as a reference table. Every row is a problem you will meet, the verb that solves it, and the one line you need.

Problemdplyr verbOne line of code
Keep only the variables you needselect()select(df, id, age, score)
Drop variables you do not needselect() minusselect(df, -notes, -internal_id)
Rename a columnrename()rename(df, age_years = participant_age)
Convert all names to snake_casejanitor::clean_names()clean_names(df)
Change a column’s typemutate()mutate(df, age = as.numeric(age))
Apply a fix to many columns at onceacross()mutate(df, across(where(is.character), trimws))
Recode messy category labelscase_when()mutate(df, region = case_when(region == "North" ~ "N", TRUE ~ region))
Remove whitespace and fix capitalisationtrimws(), stringrmutate(df, city = str_to_title(trimws(city)))
Count missing values per columnsummarise(across())summarise(df, across(everything(), ~ sum(is.na(.x))))
Drop rows with missing valuesfilter()filter(df, !is.na(score))
Find and remove duplicate rowsdistinct()distinct(df, id, .keep_all = TRUE)
Reshape wide to longtidyr::pivot_longer()pivot_longer(df, cols = starts_with("wk"), names_to = "week", values_to = "score")
Sort rowsarrange()arrange(df, desc(score))
Grouped summarygroup_by() + summarise()df |> group_by(region) |> summarise(n = n(), mean_score = mean(score, na.rm = TRUE))
Find rows with no match in a lookup tableanti_join()anti_join(df, lookup, by = "region_code")
Attach a lookup tableleft_join()left_join(df, lookup, by = "region_code")

1. Install and Load dplyr

Install packages once, load them every session. This trips up almost everyone at least once, because loading is not remembered between R sessions.

# once per machine
install.packages(c("dplyr", "tidyr", "stringr", "lubridate", "janitor", "forcats", "readr"))

# every session
library(dplyr)

You can tell it worked because the console stays quiet. If dplyr is not installed, R stops with there is no package called 'dplyr'. A second RStudio session, a different project, or a fresh server all need the install line again.

Load dplyr with library() rather than calling dplyr:: before every verb. The :: form is slower to read and tells you nothing extra once the package is attached.

2. Inspect the Dataset Before Cleaning

Do not transform anything until you have looked. Every fix you make without looking first is a guess, and guesses about missing data are expensive.

library(readr)
raw <- read_csv("survey_raw.csv", na = c("", "NA", "n/a", "N/A"))

dim(raw)                 # rows and columns
names(raw)               # the column names as they arrived
glimpse(raw)             # first rows, all columns, compact
str(raw)                 # the type of every column
summary(raw)             # per-column summaries

Reading the output: glimpse() shows the actual values, str() shows the storage type, and summary() shows the spread. A column of ages that prints chr instead of num in str() is the single most common finding in this whole guide, because one stray text value in a column turns the entire column into text.

Look specifically for character columns that should be numbers, dates stored as text like "03/14/2026", category labels that differ only by capitalisation or trailing spaces, and columns where summary() reports a wildly implausible minimum or maximum.

Also count missing values per column while you are here, so you know the scale of the problem before you start fixing things.

summarise(raw, across(everything(), ~ sum(is.na(.x))))

Make a copy now, before any transformation.

df <- raw   # everything from here happens to df, never to raw

3. Select Only the Columns You Need

Dropping unused columns first makes every later step faster and every output easier to read. It is also the cheapest way to reduce your memory footprint on a large file.

df <- df |>
  select(id, age, region, score, enrolled)

To remove columns instead, prefix with a minus sign: select(df, -notes, -internal_id). Selection helpers are useful when a survey export adds numbered columns: starts_with("q"), ends_with("_raw"), contains("score"), and everything().

Use relocate() when you want to move a column without renaming or removing anything, which matters more than it sounds when the first column is your join key.

4. Rename Columns Consistently

Pick one naming convention and apply it everywhere. Lowercase snake_case is the tidyverse default because it survives R’s case-sensitivity rules without surprise.

# automatic, for a whole messy header
df <- df |> janitor::clean_names()

# manual, when you want a meaningful name
df <- df |>
  rename(
    participant_id = id,
    score_years    = score
  )

clean_names() turns "Participant ID (n=210)" into participant_id_n_210, which is consistent but not beautiful. Use it first to get everything uniform, then rename the handful of columns whose names carry meaning.

The order matters. Clean names before you write any code that references a column, so you are not chasing a typo through a pipeline.

5. Clean Text and Categorical Values

This is where real datasets break. The same region gets typed as North, north, NORTH and North , and every one of those is a different category to R.

library(stringr)

df <- df |>
  mutate(across(where(is.character), ~ str_to_title(trimws(.x))))

# see what you are left with
df |> count(region, sort = TRUE)

Read that as: apply trimws() to every character column, then convert to title case. across() is the modern replacement for looping over column names by hand, and it is the idiom you will use constantly.

When labels vary in ways trimming cannot fix, recode them explicitly with case_when() and always end with TRUE ~ . so anything unrecognised passes through untouched instead of becoming NA.

df <- df |>
  mutate(
    region = case_when(
      region %in% c("North", "N", "Nth") ~ "North",
      region %in% c("South", "S", "Sth") ~ "South",
      TRUE ~ region
    )
  )

If a category appears only once or twice, consider lumping rare levels together with forcats::fct_lump() so your summaries do not report a mean for a group of one.

If one cell holds several values, such as "120 mg/dL" or "5.2 (fasting)", pull the number out with str_extract() and convert afterwards.

df <- df |>
  mutate(result_value = as.numeric(str_extract(result, "[0-9.]+")))

For a column assembled from two halves, tidyr::separate() splits on a delimiter and tidyr::unite() puts halves back together.

6. Find and Handle Missing Values

First count, then decide, then act. Skipping the deciding step is how people accidentally throw away a third of their sample.

df |>
  summarise(
    across(everything(), ~ sum(is.na(.x))),
    across(everything(), ~ round(100 * mean(is.na(.x)), 1))
  )

That prints the missing count and the missing percentage for every column. A column that is 2 percent missing and one that is 60 percent missing call for completely different decisions.

You have four reasonable options:

  • Keep the row, blank the value. mutate(df, score = ifelse(failed_qc, NA, score)). This is the right pattern when a participant failed a quality check at one timepoint but you still want their other rows.
  • Drop incomplete rows for one analysis only. filter(df, !is.na(score)). Make it a temporary object so the full dataset survives.
  • Impute. Replace with the column median using mutate(df, score = ifelse(is.na(score), median(score, na.rm = TRUE), score)). Fine for exploratory work; document it, and never impute your outcome variable in a study where the outcome is the point.
  • Keep and flag. mutate(df, score_was_missing = is.na(score)) so the model or the reader can see which values were filled in.

Note na.rm = TRUE inside mean() and median(). Without it you get NA back, and NA in R is contagious: any function meeting an NA usually returns NA unless you tell it to skip them.

One trap worth knowing early: an empty string "" is not NA. It is a character value containing nothing, so is.na() will not catch it. Pass na = c("", "NA", "n/a") to read_csv() at import time and the problem disappears before it starts.

7. Remove Duplicate Rows

Duplicate detection is easy to get wrong in both directions: leaving genuine duplicates in distorts your counts, and deleting rows that look identical can erase real repeated measurements.

# exact duplicate rows
sum(duplicated(df))

# a participant appearing more than once
df |> count(participant_id, sort = TRUE) |> head(10)

# keep one row per participant, drop the rest
df_clean <- df |> distinct(participant_id, .keep_all = TRUE)

distinct(participant_id, .keep_all = TRUE) keeps the first row for each participant and discards the others. That is the right call for a one-row-per-person table. For longitudinal data where each person has many legitimate timepoints, use distinct(participant_id, visit_date) instead, so only true repeat visits collapse.

To see duplicates without deleting them, filter on the count.

df |>
  group_by(participant_id) |>
  filter(n() > 1) |>
  ungroup()

That is split-apply-combine: split the rows into groups by key, apply a function to each group, then recombine. group_by() splits, summarise() or mutate() applies, and the result comes back as one table.

Write down the row count before and after. If a step removes 300 rows, you want to be able to say why.

8. Fix Variable Types and Check Categories

Now that the values are clean, convert the columns to the types your analysis expects. This is the step that produces the famous NAs introduced by coercion warning, and it is worth understanding rather than ignoring.

df <- df |>
  mutate(
    age        = as.integer(age),
    score      = as.numeric(score),
    visit_date = lubridate::ymd(visit_date),
    enrolled   = as.logical(enrolled)
  )

When as.numeric() meets something it cannot read, it returns NA and R warns you. Find the culprit rows instead of guessing.

bad <- which(is.na(df$score) & !is.na(raw$score))
raw$score[bad]

On a dataset exported from a PDF you will often find a capital letter I where a digit 1 belongs, or a stray comma. Nine times out of ten that single value is why the whole column was text in the first place.

For dates, choose the lubridate function by reading the letter order: ymd() for year-month-day, dmy() for day-month-year, mdy() for month-day-year. If a column mixes formats, parse each piece separately and combine.

df |>
  mutate(
    visit_date = lubridate::ymd(paste(year, month, day, sep = "-"))
  )

Finally, check the categories you believe you are working with. If group_by(region) |> summarise(n = n()) shows a group with a single row, you missed a label somewhere.

9. Sort, Filter, and Validate the Final Data

Validation is the step beginners skip and professionals do not. It is also the fastest way to catch a mistake from any earlier step.

# how many rows did I start with, and how many do I have now?
nrow(raw)
nrow(df)

# did any column lose everything?
df |> summarise(across(everything(), ~ sum(!is.na(.x))))

# are the numbers plausible now?
df |> summarise(
  min_age = min(age, na.rm = TRUE),
  max_age = max(age, na.rm = TRUE),
  n_below_0 = sum(score < 0, na.rm = TRUE)
)

# are the groups balanced the way you expect?
df |> group_by(region) |> summarise(n = n(), mean_score = mean(score, na.rm = TRUE))

Rule of thumb: if the row count changed, you should be able to name the step that changed it and the number of rows it removed. If you cannot, go back and add a count after each transformation.

Outliers belong here too, after units are consistent. Flag them with the IQR rule rather than deleting them outright.

q1 <- quantile(df$score, 0.25, na.rm = TRUE)
q3 <- quantile(df$score, 0.75, na.rm = TRUE)
iqr <- q3 - q1

df <- df |>
  mutate(
    outlier = score < (q1 - 1.5 * iqr) | score > (q3 + 1.5 * iqr)
  )

Create the outlier flag, then decide. An extreme value in a lab result may be the most informative observation you have. If the cause is a unit mismatch, fix the unit and rerun the check rather than removing rows.

Sorting is cosmetic but cheap, and it helps you eyeball the extremes.

df <- df |> arrange(desc(score))

If you are merging a reference table at this point, use left_join() to keep every row you started with, then anti_join() to list the keys that did not match. A join that returns more rows than you started with means the lookup table has repeated keys, and that is a bug worth finding rather than a feature.

10. Save the Cleaned Dataset

Build the whole workflow as one script and save the script. Then save the result in two formats for two different purposes.

# R's own format: fast, lossless, keeps column types
saveRDS(df, "survey_clean.rds")
df <- readRDS("survey_clean.rds")

# CSV: for anything outside R
write_csv(df, "survey_clean.csv")

Use RDS for your own work. Reading a CSV back with read_csv() re-infers types, and a column that was an integer can come back as a double or a date can come back as text. Use CSV when the file goes to a supervisor, a co-author or a web app.

A complete script for this guide, start to finish, is roughly this shape.

library(dplyr)
library(tidyr)
library(stringr)
library(lubridate)
library(janitor)
library(readr)

raw <- read_csv("survey_raw.csv", na = c("", "NA", "n/a", "N/A"))

df <- raw |>
  clean_names() |>
  select(-notes, -internal_id) |>
  mutate(across(where(is.character), ~ str_to_title(trimws(.x)))) |>
  mutate(
    age        = as.integer(age),
    score      = as.numeric(score),
    visit_date = lubridate::ymd(visit_date)
  ) |>
  distinct(participant_id, .keep_all = TRUE)

stopifnot(nrow(df) <= nrow(raw))
saveRDS(df, "survey_clean.rds")

The |> is the native pipe, built into R since 4.1. It is faster and reads more cleanly than the older %>, and it is what every current dplyr tutorial uses. The older pipe still works if you inherit a script that uses it.

Common Mistakes

Almost every painful cleaning session traces back to one of these.

MistakeSymptomFix
Overwriting the raw dataYou cannot reproduce last month’s result and the original values are goneNever assign to the object you read from disk. Work on df <- raw and keep raw untouched
Converting everything to charactermean() returns NA and comparisons fail silentlyCoerce only the columns you inspected, one at a time, and check for new NAs after each one
Deleting all missing values automaticallyYour sample drops from 500 to 210 and you did not notice until the results looked thinCount missing values per column, report the percentages, and choose a strategy per column
Deleting rows that look duplicatedReal repeat visits disappear and the longitudinal analysis collapsesUse distinct(key, timepoint) and inspect the flagged rows before removing anything
Running outlier detection before fixing unitsValues in mmol/L all look impossibly large next to mg/dL valuesConvert units first, then recompute the fences
Not checking after each stepAn error surfaces at the end and you cannot tell which of 30 lines caused itRun glimpse() or count() after each verb while you are still in that part of the script
Creating a new column inside filter()object 'x' not found or a filter that silently does nothingCompute derived columns in mutate() first, then filter on them
Joining on mismatched key typesThe join returns zero rows, or far more rows than you expectedCheck both keys with class() and use anti_join() to see which keys fail to match

A short quality-control habit covers most of the rest. Count rows at the start and at every stage where rows can change. Keep the NAs introduced by coercion warning visible instead of suppressing it. Write what you did in comments as you do it, because in six weeks you will not remember why that column was recoded. And when something looks wrong, print the actual rows rather than reasoning about them.

One last habit: run the whole script from a fresh R session once, top to bottom, before you trust it. If it only works when you run the lines one at a time in the order you happened to click them, it is not a script yet.

Frequently Asked Questions

Should I remove all missing values from my dataset?

No. Start by counting missing values per column and deciding per column why they are missing. A value that was never recorded and a value that was skipped because it was extreme are not the same problem, and removing both changes what your results mean. For most analyses, keep the full dataset, filter out incomplete rows into a separate object when you need complete cases, and add a flag column recording which values were imputed.

What is the difference between NA and an empty string in R?

NA is R’s marker for a missing value of any type, and functions such as mean() propagate it unless you pass na.rm = TRUE. An empty string is a character value that happens to contain zero characters, so is.na() does not detect it, and it will quietly become its own group when you summarise. You can find them by counting rows where the column equals ”, or prevent them at import time with read_csv(na = c(”, ‘NA’, ‘n/a’)).

How do I find duplicate rows with dplyr?

Use count() to see which values repeat within the columns that define an observation, and duplicated() to count exact duplicate rows. To remove them, use distinct(participant_id, .keep_all = TRUE), which keeps the first match. Always inspect the duplicated rows before deleting, because in panel or timepoint data several identical-looking rows can be genuine. anti_join(df, df, by = c(‘id’, ‘date’)) also shows you only the repeated keys.

How do I fix the ‘NAs introduced by coercion’ warning in R?

That warning means a conversion function such as as.numeric() met a value it could not read, so it returned NA for that cell. Find the offenders by comparing before and after: which(is.na(df$score) and !is.na(raw$score)), then print raw$score at those positions. Common causes are a stray unit, a comma, a currency symbol, or a capital I where a digit 1 belongs. Clean or correct the source values, then convert again.

Can I use dplyr to clean a very large dataset?

Yes, dplyr is built for large tabular data, but memory and file format still decide how far you get. Select only the columns you need early, set column types explicitly with readr’s col_types argument so nothing is guessed twice, and consider a lazy-reading approach such as vroom or Arrow for CSVs that will not fit in memory. Never write a for-loop over rows; use across() or rowwise() so the work stays vectorised.

Should I use the native pipe or %% in new R code?

Use the native pipe, |, for anything you write now. It is part of base R since version 4.1, it needs no package, and it runs faster than the magrittr pipe. The older %% still works fine and you will meet it in existing scripts and tutorials, so it is worth recognising. There is no need to convert working code for its own sake, but new code reads better without it.

Conclusion

Start your next cleaning session by printing the column names, the column types, the missing count per column and the number of duplicate rows. That takes one line of code and it tells you which of the remaining steps you actually need.

From there, work in order: select the columns you need, fix the names, fix the types, clean the values, handle missing data, remove duplicates, check outliers once units are consistent, reshape to tidy form, and validate. Knowing how to clean data in R with dplyr is mostly the discipline of keeping that order and checking your row count at every step that can change it.

Leave a Comment

Practical guides to statistics, surveys and research data

Read the latest guides