R Programming • R-Specific Topics (Data Workflows)

filter()

Subset rows from data frames using logical predicates with dplyr's most essential verb.

A statistical scatter plot with code braces and a variable symbol for R programming.
Inside this lesson 10 sections 5 questions 6 figures

SECTION 1

Historical Context & Motivation

Before the tidyverse ecosystem reshaped data analysis in R, practitioners relied on base R's bracket-based subsetting syntax — expressions like df[df$age > 30, ] — to extract rows matching certain conditions. While correct, this approach suffered from verbosity, repeated references to the data frame name, and poor readability when conditions grew complex. The desire for a more expressive, composable grammar of data manipulation drove the development of packages that would eventually coalesce into the tidyverse, with filter() becoming the foundational verb for row subsetting.

2008 • plyr Package Released

Hadley Wickham releases plyr, introducing the split-apply-combine paradigm to R and establishing the philosophy of small, composable functions for data manipulation.

2014 • dplyr 0.1 on CRAN

Wickham releases dplyr as a focused successor to plyr for data frames, introducing the five core verbs: filter(), select(), mutate(), arrange(), and summarise().

2016 • Tidyverse Umbrella

The tidyverse meta-package is introduced, bundling dplyr with ggplot2, tidyr, readr, and others into a unified ecosystem with a shared design philosophy.

2020 • dplyr 1.0.0 and across()

A major API overhaul introduces across() and row-wise operations. filter() gains improved support for programmatic conditions via data masking and tidy evaluation.

2023+ • Modern dplyr & Backends

dplyr's verb interface, including filter(), extends to database backends (dbplyr), Apache Arrow (arrow), and Spark (sparklyr), enabling the same syntax to operate on data of any scale.

The central question filter() addresses is deceptively simple: how can we express the predicate "keep only the rows where some condition holds" in a way that is simultaneously readable, composable with other transformations via the pipe operator, and portable across different data backends? Understanding filter() deeply means understanding non-standard evaluation, data masking, and the design philosophy that separates the tidyverse from base R.

SECTION 2

Core Principles & Definitions

At its core, filter() accepts a data frame (or tibble) and one or more logical expressions, returning a new data frame containing only the rows for which all expressions evaluate to TRUE. Its signature is straightforward: filter(.data, ...), where .data is the input data frame and ... captures an arbitrary number of predicate expressions. Several foundational principles govern its behavior and make it a distinctive construct in R's data manipulation landscape.

1

Data Masking

Inside filter(), column names are resolved as if they were variables in the local environment. You write age > 30 rather than df$age > 30. This data masking is powered by rlang's tidy evaluation framework.
2

Immutability

filter() never modifies the original data frame; it returns a new tibble. This functional, side-effect-free design aligns with best practices in reproducible analysis and simplifies debugging in pipeline-heavy workflows.
3

Logical Conjunction

When multiple expressions are passed as separate arguments (comma-separated), they are combined with logical AND. Writing filter(df, x > 1, y < 5) is equivalent to filter(df, x > 1 & y < 5). Disjunction requires an explicit | operator.
4

NA Handling

Rows where the predicate evaluates to NA are silently dropped. Unlike base R's [ subsetting, which preserves NA-indexed rows, filter() only keeps rows where the condition is unambiguously TRUE.
5

Pipe Composability

filter() is designed to slot into pipe chains using |> or %>%. The first argument is the data frame, which the pipe provides, enabling expressive left-to-right workflows like df |> filter(...) |> select(...).
✦

KEY TAKEAWAY

Think of filter() as a SQL WHERE clause expressed in R syntax. Just as a database query planner evaluates a WHERE predicate against each row and returns the qualifying subset, filter() evaluates one or more logical expressions row-wise and retains only the rows that yield TRUE. The analogy extends further: when you use dbplyr, filter() literally translates your R predicate into a SQL WHERE clause behind the scenes.

SECTION 3

Visual Explanation

The following diagram illustrates how filter() operates on a tibble. The input data frame is shown on the left with all rows visible. Each row passes through the logical predicate (shown in the center), and only rows evaluating to TRUE survive into the output tibble on the right. Rows yielding FALSE or NA are discarded.

Input TibblePredicate: age > 25Output TibblenameageAlice30Bob22Carol28DanNAEve3530 > 25 → TRUE ✓22 > 25 → FALSE ✗28 > 25 → TRUE ✓NA > 25 → NA (drop)35 > 25 → TRUE ✓nameageAlice30Carol28Eve35LegendTRUE — row keptFALSE — row droppedNA — row dropped (silent)Row passes to output
Each row is independently evaluated against the predicate age > 25. Green borders indicate TRUE (kept), red indicates FALSE (dropped), and amber indicates NA (silently dropped). Only Alice, Carol, and Eve survive into the output tibble.

Notice the critical detail in the diagram: Dan's age is NA, so the comparison NA > 25 yields NA, not FALSE. In base R subsetting with [, this would produce a row of NAs in the result — a common source of bugs. By contrast, filter() treats NA as "insufficiently true" and drops the row, which is almost always the desired behavior. If you explicitly want to retain NA rows, you must include is.na(age) as part of a disjunction.

SECTION 4

How filter() Works Under the Hood

While filter() appears to accept bare column names as if they were variables, this behavior is powered by R's non-standard evaluation (NSE) mechanism, specifically the tidy evaluation framework provided by the rlang package. Understanding this mechanism is essential for writing correct programmatic code — for instance, when the column name to filter on is stored in a variable.

Step 1: Expression Capture (Quoting)

When you call filter(df, age > 25), the expression age > 25 is not immediately evaluated. Instead, dplyr captures it as an unevaluated quosure — a data structure that bundles the expression with its lexical environment. This is analogous to how Lisp macros capture code as data before transforming it. The quosure ensures that if you reference a variable from an enclosing scope (e.g., a threshold stored as min_age <- 25), it will be resolved in the correct environment.

Step 2: Data Masking (Environment Construction)

dplyr constructs a special evaluation environment — the data mask — where each column of .data is bound as a variable. The quosure is then evaluated inside this mask, so age resolves to the age column vector. If a name is not found in the data mask, R falls through to the quosure's original environment — this is the mechanism that lets you mix column references with external variables.

Step 3: Logical Vector & Subsetting

The evaluated expression produces a logical vector of length equal to nrow(.data). dplyr then applies which()-like logic internally (actually optimized C++ via the vctrs package) to identify integer positions where the vector is TRUE, constructs the output tibble by slicing those rows, and preserves all original column types, names, and grouping metadata.

FILTER SEMANTICS

output=rowi∈.data:predicate(rowi)≡TRUEoutput = { row_i ∈ .data : predicate(row_i) ≡ TRUE }

Where row_i is the i-th row, predicate is the conjunction of all filter expressions, and the identity operator ≡ excludes both FALSE and NA values.

💡

Programmatic Filtering with .data and .env

When writing functions that wrap filter(), use the .data pronoun to disambiguate column references (e.g., .data[[col_name]]) and .env for environment variables. Alternatively, use the embrace operator {{ }} to tunnel user-supplied column names through tidy evaluation.

SECTION 5

Common Predicate Patterns & Classification

The expressiveness of filter() comes from the diversity of logical predicates R supports. Below is a classification of the most commonly used patterns, ranging from simple comparisons to advanced helper functions provided by dplyr itself. Mastering these patterns will cover the vast majority of real-world subsetting scenarios you encounter in data analysis workflows.

filter() Predicate Taxonomyfilter(.data, ...)Comparison OperatorsLogical CombinatorsHelper Functions== Equal to!= Not equal to> Greater than>= Greater or equal< Less than<= Less or equal& Element-wise AND| Element-wise OR! Negation (NOT)xor() Exclusive ORComma-separated argsare implicitly &%in% Set membershipbetween() Range checknear() Float equalityis.na() Missing checkstr_detect() Regex matchif_any() Column-wise ORExample Expressionsfilter(df, age > 25)Simple comparisonfilter(df, age > 25 & score >= 80)Conjunction of two conditionsfilter(df, name %in% c("Alice", "Eve"))Set membership testfilter(df, between(age, 20, 30))Range inclusion (closed interval)filter(df, if_any(starts_with("x"), ~ . > 0))Keep row if ANY x-column is positive
A taxonomy of predicate patterns supported by filter(). The three main families — comparison operators, logical combinators, and helper functions — can be freely composed within a single filter() call. The bottom section shows concrete example expressions with descriptions.
PatternSyntax ExampleUse Case
Equalityfilter(df, status == "active")Exact match on a categorical variable.
Numeric rangefilter(df, between(price, 10, 50))Closed-interval check; cleaner than chaining two inequalities.
Set membershipfilter(df, state %in% c("CA", "NY", "TX"))Preferred over chaining multiple == with |.
Floating-point comparisonfilter(df, near(weight, 3.14, tol = 0.01))Avoids floating-point equality pitfalls (machine epsilon).
String patternfilter(df, str_detect(name, "^Dr\\."))Regex-based subsetting via stringr integration.
Column-wise logicfilter(df, if_all(x1:x5, ~ !is.na(.)))Keep rows with no NAs across a range of columns.

Common filter() predicate patterns

SECTION 6

Worked Example: Analyzing Flight Delays

We will use the well-known nycflights13::flights dataset, which contains 336,776 rows of on-time data for all flights departing New York City airports in 2013. Our goal is to identify all United Airlines flights departing from JFK in December that arrived more than 60 minutes late, and then count how many such flights existed.

Filtering Delayed UA Flights from JFK in December

  1. 1

    Step 1 — Load Libraries and Data

    We load dplyr and the flights dataset. The tibble has columns including carrier (airline code), origin (departure airport), month (integer 1–12), and arr_delay (arrival delay in minutes, negative means early).
    library(dplyr); library(nycflights13)
  2. 2

    Step 2 — Construct the Filter Pipeline

    We chain four conditions into a single filter() call. Comma separation means all conditions are joined with logical AND: the carrier must be "UA", the origin must be "JFK", the month must be 12, and arr_delay must exceed 60.
    delayed_ua <- flights |> filter(carrier == "UA", origin == "JFK", month == 12, arr_delay > 60)
  3. 3

    Step 3 — Evaluate NA Implications

    Some flights have NA for arr_delay (cancelled flights). Since NA > 60 evaluates to NA, those rows are automatically excluded — exactly the behavior we want, since cancelled flights are not meaningfully "delayed."
  4. 4

    Step 4 — Inspect and Count Results

    We inspect the result with glimpse() and count the rows with nrow(). We can also pipe into count() to verify.
    nrow(delayed_ua) # Returns 37 — There were 37 United Airlines flights from JFK in December 2013 that arrived more than 60 minutes late.
  5. 5

    Step 5 — Extend: Adding select() and arrange()

    To produce a clean report, we extend the pipeline with select() and arrange(), demonstrating filter's composability within a larger data workflow.
    flights |> filter(carrier == "UA", origin == "JFK", month == 12, arr_delay > 60) |> select(month, day, flight, arr_delay) |> arrange(desc(arr_delay))

SECTION 7

filter() vs. Alternatives: Strengths & Limitations

While filter() is the idiomatic choice for row subsetting in modern R, it is not the only tool available. Understanding how it compares to base R subsetting and data.table's approach helps you choose the right tool for a given context and recognize situations where filter() may not be the optimal choice.

Criteriondplyr::filter()Base R [ subsettingdata.table [i, ]
ReadabilityExcellent — verb name states intent, column names unquoted.Fair — requires repeating data frame name; bracket syntax is cryptic for newcomers.Good — concise, but the implicit i argument requires familiarity.
NA HandlingNA rows silently dropped — safe default.NA-indexed rows produce NA rows — common bug source.NA rows silently dropped (same as dplyr).
Performance (large n)Good — C++ backend, but overhead from tidy eval layer.Good — minimal overhead for simple expressions.Excellent — highly optimized; automatic indexing with keys.
ComposabilityFirst-class pipe support; chains naturally with other dplyr verbs.No native pipe support; nesting calls is awkward.Chainable via [][] but not pipe-idiomatic.
Database backendsYes — translates to SQL via dbplyr.No — operates only on in-memory data frames.No — in-memory only (though DuckDB integration exists).
Programmatic useRequires tidy eval ({{ }}, .data pronoun).Standard evaluation — straightforward programmatic use.Standard evaluation — use column names as strings easily.

Comparison of row-subsetting approaches in R

⚖️

WHEN TO USE WHAT

Use dplyr::filter() for interactive analysis, teaching, and production code where readability and backend portability matter. Reach for data.table when processing hundreds of millions of rows where nanoseconds per operation matter. Resort to base R [ subsetting inside performance-critical loops, packages with zero dependencies, or when writing functions that need standard evaluation without the rlang dependency.

SECTION 8

Connection to Advanced Data Workflows

The filter() verb serves as a gateway to several advanced topics that deepen your mastery of R's data workflow ecosystem. Each of these areas builds directly on the same conceptual foundation — applying logical predicates to subsets of data — but extends it in important directions.

ConceptBasic filter()Advanced Extension
Grouped filteringfilter(df, score > 80) applies a global threshold.df |> group_by(class) |> filter(score > mean(score)) applies the predicate within each group, keeping students above their class mean.
Lazy evaluation (dbplyr)Evaluates immediately on in-memory data.With a database-backed tibble, filter() generates a SQL WHERE clause; execution is deferred until collect().
Tidy selection + filterPredicates reference specific column names.if_any() and if_all() accept tidy selection helpers like starts_with(), where(is.numeric) to apply predicates across many columns dynamically.
Slice variantsKeeps rows matching a logical condition.slice_max(), slice_min(), slice_sample() offer rank-based and random subsetting — complementing filter's predicate-based approach.
Arrow / Spark backendsOperates on data frames in RAM.The same filter() syntax works on Arrow datasets (out-of-memory Parquet files) via the arrow package, and on Spark DataFrames via sparklyr.

Basic vs. advanced applications of filter()

As your data workflows grow more complex, you will find that filter() remains the stable foundation upon which grouped aggregations, joins, window functions, and cross-backend portability are built. Mastering its semantics now — especially around NA handling, data masking, and predicate composition — will pay dividends when you encounter these advanced scenarios in production data engineering and research computing contexts.

SECTION 9

Practice Problems

The following problems escalate in difficulty from conceptual recall to critical analysis. All problems assume the nycflights13::flights dataset is loaded, along with dplyr and stringr.

Question 1: CONCEPTUAL

Explain why filter(flights, arr_delay == NA) returns zero rows, even though the dataset contains thousands of rows with NA arrival delays. What is the correct way to filter for those rows?
Show answer
In R, any comparison with NA — including NA == NA — evaluates to NA, not TRUE. Since filter() only retains rows where the predicate is strictly TRUE, every row's predicate evaluates to NA and is dropped. The correct approach is filter(flights, is.na(arr_delay)), which uses the dedicated is.na() function that returns TRUE for missing values.

Question 2: BASIC CALCULATION

Write a filter() call that selects all flights from the flights dataset that departed in July or August, originated from LaGuardia ("LGA"), and had a departure delay of at least 120 minutes.
Show answer
The solution is flights |> filter(month %in% c(7, 8), origin == "LGA", dep_delay >= 120). Using %in% c(7, 8) for the month condition is more idiomatic and readable than month == 7 | month == 8. The three comma-separated conditions are implicitly joined with logical AND. Note that dep_delay uses >= (not >) because the problem says "at least" 120 minutes.

Question 3: INTERMEDIATE

Using filter() with grouped data, find all flights where the departure delay exceeded twice the mean departure delay for that flight's carrier. Your output should be a tibble with the grouping preserved.
Show answer
The solution uses grouped filtering: flights |> group_by(carrier) |> filter(dep_delay > 2 * mean(dep_delay, na.rm = TRUE)). When filter() is applied to a grouped tibble, the predicate is evaluated within each group. The mean(dep_delay, na.rm = TRUE) computes the carrier-specific mean, and only flights exceeding twice that value are retained. The na.rm = TRUE is critical because some departure delays are NA; without it, the mean would be NA for every group, and the entire predicate would evaluate to NA, dropping all rows.

Question 4: APPLIED

You are writing a Shiny dashboard where users select an airline from a dropdown. The selected airline code is stored in input$carrier. Write a reactive expression that filters flights by the user-selected carrier. Explain why simply writing filter(flights, carrier == input$carrier) might fail and how to fix it.
Show answer
In this case, filter(flights, carrier == input$carrier) actually works because data masking resolves carrier to the column and input$carrier falls through to the environment. However, if the input variable were named carrier (shadowing the column name), data masking would bind both sides to the column, producing carrier == carrier (always TRUE). The robust approach is filter(flights, .data$carrier == .env$selected) where selected <- input$carrier. The .data pronoun forces column resolution, and .env forces environment resolution, eliminating any ambiguity.

Question 5: CRITICAL THINKING

Consider the expression flights |> filter(dep_delay > 0 | arr_delay > 0) versus flights |> filter(dep_delay > 0) |> filter(arr_delay > 0). Are these equivalent? Prove your answer by reasoning about the logical semantics, and construct a specific scenario involving NA values where the two expressions produce different row counts.
Show answer
These are not equivalent even ignoring NAs. The first uses OR (disjunction): a row is kept if either delay is positive. The second uses AND (conjunction via sequential filtering): a row must have both delays positive. But NAs introduce a subtler discrepancy even within the AND interpretation. Consider filter(df, x > 0 & y > 0) vs filter(df, x > 0) |> filter(y > 0). For a row where x = 5 and y = NA: in the single-filter AND case, TRUE & NA yields NA (row dropped). In the chained case, the first filter passes the row (x > 0 is TRUE), and then the second filter evaluates y > 0 as NA and drops it. The result is the same here. However, consider x = NA, y = 5: the single AND gives NA & TRUE = NA (dropped), and the chained version drops at the first filter. Both drop it. The key insight is that the original question's OR vs implicit AND is the fundamental difference — a flight with dep_delay = 10 and arr_delay = −5 would be kept by the first expression (OR) but dropped by the second (AND).

SUMMARY

Summary

Summary

The filter() function from dplyr is the primary verb for row subsetting in the tidyverse. It accepts a data frame and one or more logical predicates, returning only the rows where all conditions evaluate to TRUE. Its key behaviors include data masking (column names used directly without quoting), implicit AND for comma-separated conditions, and silent NA dropping — a crucial safety feature that distinguishes it from base R's bracket subsetting.

Predicate expressions can leverage comparison operators, logical combinators (&, |, !), and helper functions such as %in%, between(), and near(). For programmatic use, the .data and .env pronouns disambiguate column names from environment variables. The function composes seamlessly with the pipe operator and extends transparently to database, Arrow, and Spark backends, making it a foundational skill for data workflows at any scale.

Varsity Tutors • R Programming • filter()