Subset Rows in R: base R subset() & dplyr filter()
Keep the rows that meet a condition — base-R brackets and subset(), and dplyr filter()
Programming
Subset data frame rows in R with base-R df[df$x > 5, ] and subset(), or dplyr filter(). Filter by one or several conditions (&, |, %in%, between()), keep the first n rows with head()/slice(), and handle the NA difference between [ and filter() — both engines, same result.
Published
June 26, 2026
Modified
July 7, 2026
TipKey takeaways
base R:df[df$x > 5, ] keeps rows where the condition is TRUE; subset(df, x > 5) is the cleaner form.
dplyr:filter(df, x > 5) does the same; combine conditions with & / |, or just commas (= AND).
Membership & ranges:x %in% c("a", "b") and between(x, lo, hi) keep rows matching a set or a range.
First n rows:head(df, 5), df[1:5, ], or dplyr slice(df, 1:5) / slice_head(df, n = 5).
NA gotcha: base [ keeps a phantom all-NA row when the condition is NA; subset() and filter() drop it. Use filter() or which() to stay safe.
Introduction
Most analyses start by keeping the rows that matter — the patients over 60, the orders from last quarter, the species you care about. That is subsetting (or filtering) rows by a condition.
This lesson covers both routes you’ll meet in real code:
base R — index with [ using a logical condition, or use subset() for a cleaner read; no package needed.
dplyrfilter() — the modern default, with helpers like %in% and between().
They give the same rows, so you can read any code base and pick whichever you prefer. There’s one real difference — how each handles missing values — and we cover it directly. We use the built-in iris data set:
The bracket [ takes rows before the comma and columns after it. A logical condition in the row slot keeps the rows where it is TRUE. Here we keep flowers with a sepal longer than 7:
To draw a random sample of rows — a quick spot-check, a train/test split, or a reproducible subsample — index with sample() in base R, or use dplyr’s slice_sample(). Set a seed first so the draw is repeatable:
set.seed(123)# 5 random rows, base R: sample row positions, then indexiris[sample(nrow(iris), 5), ]
Add replace = TRUE to sample with replacement (the bootstrap), and weight_by = to make some rows likelier to be drawn.
The NA gotcha — base [ vs filter()
This is the one real difference, and it bites people. When a condition evaluates to NA (because the column value is missing), base [ keeps a phantom row of all NAs, while subset() and filter() quietly drop it.
# a small frame with a missing valuedf <-data.frame(id =1:4, x =c(10, NA, 30, 5))# base [: the NA condition leaks a phantom all-NA rowdf[df$x >8, ]
id x
1 1 10
NA NA NA
3 3 30
That NA NA row is almost never what you want. subset() and filter() handle it correctly:
library(dplyr)subset(df, x >8) # base, but NA-safe
id x
1 1 10
3 3 30
filter(df, x >8) # dplyr, NA-safe
id x
1 1 10
2 3 30
If you must use raw [, wrap the condition in which() — it returns only the TRUE positions and ignores NA:
df[which(df$x >8), ]
id x
1 1 10
3 3 30
Rule of thumb: prefer filter() or subset(); if you reach for raw [ on a column that might contain NA, use which().
Try it live
The blocks above ran at build time. Edit this one and press Run to subset rows on your own — it executes in your browser via webR (no install).
🟢 With an AI agent
Real filtering gets messy — a date range, a list of IDs, missing values that quietly drop rows you meant to keep. Describe the rows you want and ask Prova“filter to virginica with sepal length over 6.5, and tell me if any rows had NA in that column”; it writes the filter() call, and because the runtime is right here you run it to confirm the row count is what you expected on your data — the NA trap is exactly where a blind copy-paste goes wrong. Ask Prova →
Working in Python? A pandas row-filtering guide (boolean masks, .query(), .isin(), .between()) is coming to the Python series.
Common issues
subset()/filter() returns zero rows when you expected some. Usually a typo in a factor level or a case mismatch — Species == "Virginica" matches nothing because the level is lowercase "virginica". Check the exact values with unique(df$Species) or levels(df$Species).
Base [ returns extra all-NA rows. The condition produced NA on rows where the column is missing, and [ keeps those as blank rows (see the NA section above). Switch to filter() / subset(), or wrap the condition in which().
filter() says “object not found”. You quoted a column name — filter(iris, "Species" == "setosa") compares two strings, never the column. dplyr takes unquoted column names: filter(iris, Species == "setosa"). (Base subset() is the same — subset(iris, Species == "setosa"), unquoted.)
Frequently asked questions
NoteHow do I subset rows in R?
Keep the rows where a condition is TRUE. Base R: df[df$x > 5, ] (note the trailing comma) or the cleaner subset(df, x > 5). dplyr: filter(df, x > 5). All three return the same rows.
NoteWhat’s the difference between subset() and filter()?
They keep the same rows. subset() is base R (no package); filter() is dplyr. Both take unquoted column names and drop rows where the condition is NA. The raw df[cond, ] form is fastest to type but keeps phantom all-NA rows when the condition is missing, so subset()/filter() are safer.
NoteHow do I filter rows by multiple conditions in R?
Join conditions with & (AND) or | (OR): subset(df, x > 5 & y == "a"). In dplyr filter(), a comma between conditions also means AND: filter(df, x > 5, y == "a"). To match a set of values use %in%: filter(df, group %in% c("a", "b")).
NoteHow do I select specific rows by number in R?
Index by position: df[c(1, 3, 5), ] keeps rows 1, 3, and 5; df[1:10, ] keeps the first ten; head(df, 10) is the readable shorthand. In dplyr, slice(df, 1:10) or slice_head(df, n = 10) do the same.
NoteWhy does my filter keep rows with NA values?
Base df[df$x > 5, ] keeps a blank all-NA row whenever x is missing, because the comparison returns NA (not FALSE). Use subset() or dplyr filter(), which drop those rows automatically, or wrap the condition in which(): df[which(df$x > 5), ].
Test your understanding
ImportantExercise: subset with a compound condition, two ways
From iris, keep the rows where Species is setosa or versicolorandSepal.Width is greater than 3. First with base-R subset(), then with dplyr filter() — and confirm both return the same number of rows.
# base R: subset(iris, Species %in% c("setosa", "versicolor") & Sepal.Width > 3)# dplyr: filter(iris, Species %in% c("setosa", "versicolor"), Sepal.Width > 3)
library(dplyr)base_way <-subset(iris, Species %in%c("setosa", "versicolor") & Sepal.Width >3)dplyr_way <-filter(iris, Species %in%c("setosa", "versicolor"), Sepal.Width >3)nrow(base_way) ==nrow(dplyr_way) # TRUE
NoteQuick check: which keeps rows where x is 2, 4, or 6?
df has a numeric column x. Which call keeps the rows where x is one of 2, 4, or 6?
A. filter(df, x == 2 | 4 | 6) B. filter(df, x %in% c(2, 4, 6)) C. filter(df, x == c(2, 4, 6))
Show answer
B.%in% tests membership in a set — exactly what you want. A is a classic bug: x == 2 | 4 | 6 parses as (x == 2) | 4 | 6, and the bare 4/6 are always truthy, so it keeps every row. C recycles the vector and matches only every third row by position. Always use %in% for “one of these values”.
Conclusion
Subsetting rows comes down to two equivalent tools: base-R df[cond, ] / subset(df, cond), or dplyr filter(df, cond). Combine conditions with & / | (or a comma in filter()), match a set with %in%, match a range with between(), and take rows by position with head() / slice(). The one difference that matters is missing values — subset() and filter() drop NA conditions cleanly, raw [ does not, so reach for them (or which()) whenever a column might be missing.
This lesson is reproducible: every result on this page was produced by the code shown — edit any block and press Run to reproduce it in your browser. The runtime is the judge.