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.
  • dplyr filter() — 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:

head(iris)
  Sepal.Length Sepal.Width Petal.Length Petal.Width Species
1          5.1         3.5          1.4         0.2  setosa
2          4.9         3.0          1.4         0.2  setosa
3          4.7         3.2          1.3         0.2  setosa
4          4.6         3.1          1.5         0.2  setosa
5          5.0         3.6          1.4         0.2  setosa
6          5.4         3.9          1.7         0.4  setosa

Subset by one condition

base R — df[condition, ]

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:

big <- iris[iris$Sepal.Length > 7, ]
head(big)
    Sepal.Length Sepal.Width Petal.Length Petal.Width   Species
103          7.1         3.0          5.9         2.1 virginica
106          7.6         3.0          6.6         2.1 virginica
108          7.3         2.9          6.3         1.8 virginica
110          7.2         3.6          6.1         2.5 virginica
118          7.7         3.8          6.7         2.2 virginica
119          7.7         2.6          6.9         2.3 virginica

Note the trailing comma — iris[condition, ] keeps all columns. The condition iris$Sepal.Length > 7 is a vector of TRUE/FALSE, one per row.

subset() says the same thing without repeating the data frame name — you write the column bare:

big <- subset(iris, Sepal.Length > 7)
head(big)
    Sepal.Length Sepal.Width Petal.Length Petal.Width   Species
103          7.1         3.0          5.9         2.1 virginica
106          7.6         3.0          6.6         2.1 virginica
108          7.3         2.9          6.3         1.8 virginica
110          7.2         3.6          6.1         2.5 virginica
118          7.7         3.8          6.7         2.2 virginica
119          7.7         2.6          6.9         2.3 virginica

subset(df, condition) is the readable base-R form: no df$, no trailing comma to forget.

dplyr — filter(df, condition)

filter() reads the same way as subset():

library(dplyr)
big <- filter(iris, Sepal.Length > 7)
head(big)
  Sepal.Length Sepal.Width Petal.Length Petal.Width   Species
1          7.1         3.0          5.9         2.1 virginica
2          7.6         3.0          6.6         2.1 virginica
3          7.3         2.9          6.3         1.8 virginica
4          7.2         3.6          6.1         2.5 virginica
5          7.7         3.8          6.7         2.2 virginica
6          7.7         2.6          6.9         2.3 virginica

All three return the same rowsfilter() and subset() are the clean forms; the raw [ shows what they do under the hood.

Combine several conditions

Use & for AND (all must hold) and | for OR (any may hold). Here: long sepals and the virginica species.

base R

sel <- subset(iris, Sepal.Length > 6.5 & Species == "virginica")
head(sel)
    Sepal.Length Sepal.Width Petal.Length Petal.Width   Species
103          7.1         3.0          5.9         2.1 virginica
106          7.6         3.0          6.6         2.1 virginica
108          7.3         2.9          6.3         1.8 virginica
109          6.7         2.5          5.8         1.8 virginica
110          7.2         3.6          6.1         2.5 virginica
113          6.8         3.0          5.5         2.1 virginica

dplyr

In filter(), separating conditions with a comma is the same as & — a common shorthand:

library(dplyr)
sel <- filter(iris, Sepal.Length > 6.5, Species == "virginica")
head(sel)
  Sepal.Length Sepal.Width Petal.Length Petal.Width   Species
1          7.1         3.0          5.9         2.1 virginica
2          7.6         3.0          6.6         2.1 virginica
3          7.3         2.9          6.3         1.8 virginica
4          6.7         2.5          5.8         1.8 virginica
5          7.2         3.6          6.1         2.5 virginica
6          6.8         3.0          5.5         2.1 virginica

For OR, use | in either engine: subset(iris, Species == "setosa" | Sepal.Length > 7).

Match a set of values: %in%

To keep rows where a column is one of several values, don’t chain == with |. Use %in%:

two <- subset(iris, Species %in% c("setosa", "virginica"))
table(two$Species)

    setosa versicolor  virginica 
        50          0         50 

It reads the same in dplyr:

library(dplyr)
two <- filter(iris, Species %in% c("setosa", "virginica"))
table(two$Species)

    setosa versicolor  virginica 
        50          0         50 

%in% is base R, so it works identically in both engines — the cleanest way to filter against a list.

Match a numeric range: between()

For “between two numbers”, dplyr’s between() is clearer than two comparisons:

library(dplyr)
mid <- filter(iris, between(Sepal.Length, 5, 6))
nrow(mid)
[1] 67

The base-R equivalent is two comparisons joined with &:

mid <- subset(iris, Sepal.Length >= 5 & Sepal.Length <= 6)
nrow(mid)
[1] 67

Same count — between() is the readable shorthand. Note it is inclusive of both ends.

Keep the first n rows

When you want rows by position, not by condition — the first few, a specific slice — use position indexing.

# first 5 rows, base R
head(iris, 5)
  Sepal.Length Sepal.Width Petal.Length Petal.Width Species
1          5.1         3.5          1.4         0.2  setosa
2          4.9         3.0          1.4         0.2  setosa
3          4.7         3.2          1.3         0.2  setosa
4          4.6         3.1          1.5         0.2  setosa
5          5.0         3.6          1.4         0.2  setosa

iris[1:5, ] does the same by explicit index. dplyr’s slice() family is the tidy equivalent:

library(dplyr)
slice(iris, 1:5)            # rows 1 through 5
  Sepal.Length Sepal.Width Petal.Length Petal.Width Species
1          5.1         3.5          1.4         0.2  setosa
2          4.9         3.0          1.4         0.2  setosa
3          4.7         3.2          1.3         0.2  setosa
4          4.6         3.1          1.5         0.2  setosa
5          5.0         3.6          1.4         0.2  setosa

slice_head(iris, n = 5) keeps the first 5 and slice_max(iris, Sepal.Length, n = 3) keeps the 3 largest — handy when “first” means “top by a value”:

library(dplyr)
slice_max(iris, Sepal.Length, n = 3)
  Sepal.Length Sepal.Width Petal.Length Petal.Width   Species
1          7.9         3.8          6.4         2.0 virginica
2          7.7         3.8          6.7         2.2 virginica
3          7.7         2.6          6.9         2.3 virginica
4          7.7         2.8          6.7         2.0 virginica
5          7.7         3.0          6.1         2.3 virginica

Pick random rows

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 index
iris[sample(nrow(iris), 5), ]
    Sepal.Length Sepal.Width Petal.Length Petal.Width   Species
14           4.3         3.0          1.1         0.1    setosa
50           5.0         3.3          1.4         0.2    setosa
118          7.7         3.8          6.7         2.2 virginica
43           4.4         3.2          1.3         0.2    setosa
150          5.9         3.0          5.1         1.8 virginica

dplyr’s slice_sample() says it directly — n = for a fixed count, prop = for a fraction:

library(dplyr)
set.seed(123)
slice_sample(iris, n = 5)            # 5 random rows
  Sepal.Length Sepal.Width Petal.Length Petal.Width   Species
1          4.3         3.0          1.1         0.1    setosa
2          5.0         3.3          1.4         0.2    setosa
3          7.7         3.8          6.7         2.2 virginica
4          4.4         3.2          1.3         0.2    setosa
5          5.9         3.0          5.1         1.8 virginica

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 value
df <- data.frame(id = 1:4, x = c(10, NA, 30, 5))

# base [: the NA condition leaks a phantom all-NA row
df[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

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.

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.

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")).

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.

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

From iris, keep the rows where Species is setosa or versicolor and Sepal.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
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

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.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {Subset {Rows} in {R:} Base {R} Subset() \& Dplyr Filter()},
  date = {2026-06-26},
  url = {https://www.datanovia.com/learn/programming/data-wrangling/subset-rows-in-r},
  langid = {en}
}
For attribution, please cite this work as:
“Subset Rows in R: Base R Subset() & Dplyr Filter().” 2026. June 26. https://www.datanovia.com/learn/programming/data-wrangling/subset-rows-in-r.