Missing Values in R with dplyr and tidyr: A Practical Guide to NA Handling

R Published Updated 4 mins read By Leon Wei
Missing Values in R with dplyr and tidyr: A Practical Guide to NA Handling cover image

Quick summary

Summarize this blog with AI

Introduction

Treat missing-value handling as a sequence of explicit decisions: inspect, determine the source, select the analysis population, then replace only values with a justified meaning. dplyr and tidyr make these decisions readable; they cannot determine whether a missing measurement is zero or why it went missing.

Tested with R 4.5.1, dplyr 1.1.4 and tidyr 1.3.2. Run the code blocks in order. Install the named packages before starting; inputs are supplied below.

Inspect raw values and keep the original columns

The fictional source contract below says empty or whitespace-only region labels mean unknown. Scores are numeric measurements and may legitimately be zero. na_if() converts only the documented blank sentinel; it does not erase every string that resembles a missing-value label.

library(dplyr)
 library(tidyr)
 raw <- tibble(id = 1:5, region = c("North", "", "  ", "South", "North"),
               score = c(10, NA, 0, 20, NA), note = c(NA, "review", "ok", NA, "ok"))
 clean <- raw |> mutate(region_raw = region, region = na_if(trimws(region), ""))
 audit <- clean |> summarise(across(c(region, score, note), ~ sum(is.na(.x))))
 print(audit)
 stopifnot(audit$region == 2, audit$score == 2, audit$note == 2,
           identical(clean$region_raw, raw$region), clean$score[3] == 0)

There are 2 unknown regions,2 missing scores and 2 missing notes. Preserve the raw strings so the transformation is auditable. Numeric parse failures, source blanks and join nonmatches may all become NA but require different investigations.

Filter only the variables required by the question

drop_na(score) keeps records with an observed score even when a note or region is missing. Requiring both score and region defines a smaller population. dplyr::filter() keeps only TRUE conditions and drops NA conditions, so make that exclusion deliberate.

scored <- clean |> drop_na(score)
 scored_by_region <- clean |> drop_na(score, region)
 positive <- clean |> filter(score > 0)
 print(scored[c("id", "region", "score")])
 stopifnot(identical(scored$id, c(1L, 3L, 4L)),
           identical(scored_by_region$id, c(1L, 4L)),
           identical(positive$id, c(1L, 4L)), mean(scored$score) == 10,
           mean(scored_by_region$score) == 15)

The overall observed-score mean is 10 across 3 records. Restricting to known regions gives mean 15 across 2 records. This change is a population-selection effect, not evidence that filtering improved the estimate. Row 3’s zero is observed and must not be confused with missingness.

Separate display labels from measured values

replace_na() can provide a display label in a new column while preserving the source measurement. “Unknown” is a reporting category here, not an inferred geographic region. Do not replace missing scores with zero merely to silence a summary warning.

display <- clean |> mutate(region_display = replace_na(region, "Unknown"))
 summary <- display |> group_by(region_display) |>
   summarise(rows = n(), observed_scores = sum(!is.na(score)),
             missing_scores = sum(is.na(score)),
             mean_observed = if (all(is.na(score))) NA_real_ else mean(score, na.rm = TRUE),
             .groups = "drop")
 print(summary)
 stopifnot(identical(display$score, raw$score), sum(summary$rows) == nrow(raw),
           sum(summary$observed_scores) == 3)

North has 2 rows with 1 observed score averaging 10; South has 1 observed score of 20; Unknown has 2 rows with 1 observed score of 0. An all-missing group would have meanNA, not 0.

Distinguish absent combinations from stored missing values

Suppose a complete, verified event extract records a row for any positive daily count and no row means zero events. A stored NA instead means an unresolved count. Under that specific contract, complete(..., explicit = FALSE) can fill newly created combinations without overwriting existing missing values. If absent rows could mean ingestion failure, zero-filling is not justified.

daily <- tibble(site = c("A", "A", "B"), day = c(1L, 2L, 1L), count = c(5L, NA_integer_, 2L))
 completed <- daily |> complete(site = c("A", "B"), day = 1:2,
                                fill = list(count = 0L), explicit = FALSE)
 print(completed)
 stopifnot(nrow(completed) == 4,
           is.na(completed$count[completed$site == "A" & completed$day == 2]),
           completed$count[completed$site == "B" & completed$day == 2] == 0L)

A/day 2 remains NA because the unknown count was explicitly recorded. B/day 2 becomes 0 because it is the newly created combination covered by the stated contract. Completion changes the row universe; document the requested site/day range.

Mark reference matches before joining

A missing right-side attribute can mean either “no matching reference row” or “a matching row with an unknown attribute.” Add a match marker on the reference table before the join and declare the expected relationship.

reference <- tibble(id = c(1L, 2L, 3L, 5L), plan = c("Basic", NA, "Plus", "Basic"),
                     reference_matched = TRUE)
 enriched <- left_join(clean, reference, by = "id", relationship = "many-to-one") |>
   mutate(reference_matched = coalesce(reference_matched, FALSE))
 print(enriched[c("id", "plan", "reference_matched")])
 stopifnot(is.na(enriched$plan[2]), enriched$reference_matched[2],
           is.na(enriched$plan[4]), !enriched$reference_matched[4],
           nrow(enriched) == nrow(clean))

ID 2 matched a reference row with an unknown plan. ID 4 did not match at all. Replacing both plan values with the same label before this check would hide the distinction.

Check boundaries and record the resulting population

Keep raw data, count exclusions and report observed denominators. For modeling, learn imputation parameters on training data and assess sensitivity; a fitted value is not an observed truth. Patterns alone cannot identify the missing-data mechanism. For the lower-level mechanics, see NA masks and safe filtering and join diagnostics.

empty <- clean[FALSE, ]
 all_missing <- tibble(score = c(NA_real_, NA_real_))
 stopifnot(nrow(drop_na(empty, score)) == 0,
           nrow(drop_na(all_missing, score)) == 0,
           identical(na_if(c("NA", ""), ""), c("NA", NA_character_)),
           identical(raw$score, c(10, NA, 0, 20, NA)))

Primary references

Interview Prep

Begin Your SQL, Python, and R Journey

Master 230 interview-style coding questions and build the data skills needed for analyst, scientist, and engineering roles.

Related Articles

All Articles
How to Check for NA Values in R cover image
r Oct 17, 2026

How to Check for NA Values in R

Learn how to efficiently check and handle NA values in R programming with this comprehensive guide, featuring step-by-step tutorials and code sa…

How to Omit NA Values in R cover image
r May 7, 2024

How to Omit NA Values in R

Learn how to effectively omit NA values in R with this comprehensive guide, featuring detailed examples and techniques for beginners.