R Programming • Data Structures in R

which() & Logical Subsetting — Use which() and logical conditions to subset

Master index-based and Boolean filtering to extract, transform, and analyze subsets of R data structures efficiently.

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

SECTION 1

Historical Context & Motivation

Data subsetting is among the most fundamental operations in any statistical computing environment, and R's approach to this problem has deep roots in the S language developed at Bell Laboratories in the 1970s. The original designers of S—most notably John Chambers and Rick Becker—recognized that analysts spend a disproportionate amount of time selecting, filtering, and reshaping data before performing any statistical computation. Their solution was to embed powerful vectorized subsetting directly into the language's syntax, allowing users to express complex data selection operations in concise, declarative statements rather than writing explicit loops.

1976 • S Language at Bell Labs

John Chambers and colleagues develop the S language with built-in vector operations and logical indexing, establishing the paradigm that R would later inherit.

1993 • R Created by Ihaka & Gentleman

Ross Ihaka and Robert Gentleman create R at the University of Auckland. R adopts S's subsetting semantics—including logical vectors and which()—while adding its own memory management model.

2000 • R 1.0.0 Released

The first stable release of R formalizes the which() function in the base package, solidifying its role as the canonical way to convert logical masks into integer index vectors.

2014–Present • Tidyverse & Modern Subsetting

Hadley Wickham's dplyr introduces filter() as a high-level abstraction, but which() and logical subsetting remain the foundational engine beneath these abstractions, essential for writing efficient base-R code and understanding package internals.

The central question that logical subsetting and which() address is deceptively simple: given a data structure, how do we extract only the elements that satisfy a particular condition? While this can be accomplished with explicit iteration, R's vectorized approach offers both a more expressive syntax and significantly better performance. Understanding the distinction between a logical vector (a Boolean mask of TRUE/FALSE values) and an integer index vector (the positions where conditions hold) is essential for writing correct, efficient, and idiomatic R code.

SECTION 2

Core Principles & Definitions

Logical subsetting in R rests on a small number of powerful ideas that, once internalized, unlock fluent data manipulation across vectors, matrices, data frames, and lists. The following principles form the conceptual foundation upon which all subsetting operations are built.

1

Logical Vectors as Masks

A comparison operator applied to a vector produces a logical vector of the same length. This Boolean mask can be passed directly into the bracket operator x[mask] to select elements where the mask is TRUE.
2

which() Converts Masks to Indices

The which() function takes a logical vector and returns an integer vector of the positions where the value is TRUE. This is useful when you need the indices themselves—for example, to find the position of a maximum value.
3

Compound Conditions with & and |

Element-wise logical operators & (AND) and | (OR) combine multiple conditions into a single logical mask. Note the single-character operators operate element-wise, unlike the double-character && and || which evaluate only the first element.
4

NA Propagation in Logic

When a logical vector contains NA values, subsetting with it produces NA entries in the output. The which() function silently drops NAs, returning only positions of definite TRUE values—a critical distinction for data with missing values.
5

Vectorized Operations

Both logical subsetting and which() are vectorized operations implemented in compiled C code beneath R's interpreter. They operate on entire vectors at once, avoiding the overhead of R-level loops and typically running orders of magnitude faster than equivalent for-loop implementations.
✦

KEY TAKEAWAY

Think of logical subsetting like a database query's WHERE clause applied at the language level: the logical vector is the filter predicate evaluated row-by-row, and which() is like asking the database engine to return the row numbers instead of the rows themselves. Just as a SQL optimizer benefits from knowing whether you need row IDs or row data, choosing between logical masks and integer indices in R affects both correctness (especially around NAs) and performance (especially on sparse selections from large vectors).

SECTION 3

Visual Explanation

The following diagram illustrates the two primary subsetting pathways in R. Starting from a source vector and a logical condition, you can either apply the logical mask directly (the left path) or convert it to integer indices via which() (the right path). Both yield the matching elements, but their behavior diverges in the presence of NA values.

Logical Subsetting vs. which() PipelineSource Vector x1025NA4053015Condition: x > 20Logical MaskFTNATFTFDirect Subsettingwhich() Pathx[mask]passes logical vector to [ ]which(mask)returns integer indices: 2, 4, 6Result (3 values + NA slot)25NA4030Result (3 values, no NA)254030⚠ Critical Difference: NA HandlingLogical mask: NA in mask → NA in outputwhich(): NA in mask → silently dropped, never appears in output
The left path shows direct logical subsetting, where NA in the mask propagates an NA into the result. The right path shows the which() pathway, where NAs are silently excluded, yielding only definite matches.

As the diagram makes clear, the two approaches are functionally equivalent when the data contains no missing values. The divergence becomes critical in real-world datasets where NA values are pervasive. Direct logical subsetting with x[x > 20] will include NA entries wherever the condition could not be evaluated, which can introduce unexpected NA elements into downstream computations. The which()-based approach—x[which(x > 20)]—provides a stricter filter that only returns elements for which the condition is definitively TRUE. Choosing between these two strategies is a design decision that should be made deliberately based on how you want missing data to be handled.

SECTION 4

How It Works Under the Hood

Understanding the internal mechanics of subsetting helps you predict behavior in edge cases and write more performant code. At the interpreter level, R's bracket operator [ dispatches to different C-level routines depending on whether it receives a logical vector, a positive integer vector, a negative integer vector, or a character vector. Each pathway has distinct semantics and performance characteristics.

Logical Subsetting Semantics

LOGICAL SUBSETTING RULE

x[L]returnsx[i]foreachiwhereL[i]=TRUE;returnsNAwhereL[i]=NA;skipswhereL[i]=FALSEx[L] returns x[i] for each i where L[i] = TRUE; returns NA where L[i] = NA; skips where L[i] = FALSE

Here x is a vector of length n and L is a logical vector of length ≤ n. If L is shorter, it is recycled with a warning (when n is not a multiple of the length of L).

which() Semantics

WHICH() DEFINITION

which(L)=i∈1,…,n:L[i]=TRUEwhich(L) = { i ∈ {1, …, n} : L[i] = TRUE }

The function returns a named or unnamed integer vector containing only those indices where the logical vector is strictly TRUE. Both FALSE and NA positions are excluded from the result. The optional argument arr.ind = TRUE returns a matrix of row-column index pairs when the input is a matrix or array.

Negation and Complement

NEGATION PATTERNS

x[!L]≡x[−which(L)](onlywhenLcontainsnoNAs)x[!L] ≡ x[-which(L)] (only when L contains no NAs)

Negating a logical mask with ! flips TRUE to FALSE and vice versa but preserves NA. Using -which(L) produces negative indices that exclude matching positions. These are equivalent only in the absence of NAs, because !NA evaluates to NA, not TRUE.

💡

Performance Note

For sparse selections (where very few elements match the condition relative to the total length), which() followed by integer subsetting can be faster because R only needs to store and iterate over a small integer vector rather than a full-length logical vector. However, for dense selections (most elements match), direct logical subsetting avoids the overhead of an intermediate allocation. Profile before optimizing—R's internal C routines are highly efficient for both pathways.

SECTION 5

Common Subsetting Patterns & Idioms

Experienced R programmers rely on a repertoire of idiomatic subsetting patterns that compose logical conditions, which(), and bracket operators to accomplish common tasks. The following diagram catalogs the most frequently used patterns across vectors, matrices, and data frames, showing the relationship between the condition, the subsetting mechanism, and the resulting output.

Common Subsetting Patterns in R1. Simple Logical Filterx <- c(3, 7, 1, 9, 4)x[x > 5]→ Returns: 7, 9Mask: F T F T FUse: Quick element extraction2. which() for Positionsx <- c(3, 7, 1, 9, 4)which(x > 5)→ Returns: 2, 4Integer indices of matchesUse: Find location, not value3. Compound Conditionsx[x > 2 & x < 8]→ Returns: 3, 7, 4Mask: T T F F TUse & (AND), | (OR), ! (NOT)Use: Multi-criteria filtering4. Data Frame Rowsdf[df$age > 21, ]→ All columns, filtered rowsNote: comma is essentialto select rows (not columns)Use: Row-level data filtering5. which.min / which.maxwhich.min(x) # → 3which.max(x) # → 4Returns index of firstmin/max elementUse: Locate extrema6. Conditional Assignmentx[x < 0] <- 0Replaces matching elementsAlso works with which():x[which(x<0)] <- 0Use: In-place data cleaningDecision Guide: Logical Mask vs. which()Need the values themselves?→ x[x > k]Need the positions / indices?→ which(x > k)Data has NAs and you want to exclude them?→ x[which(x > k)]Want to replace matching values in place?→ x[condition] <- newvalNeed row & col indices in a matrix?→ which(M > k, arr.ind=TRUE)
Six common subsetting patterns organized by use case, plus a decision guide at the bottom for choosing between logical masks and which().

Pattern 4 deserves special attention for data frame operations. When you write df[df$age > 21, ], the logical vector produced by df$age > 21 is placed in the row position of the bracket operator (before the comma), while the empty column position (after the comma) tells R to keep all columns. Omitting the comma entirely would treat the data frame as a list and subset columns instead—a subtle but common source of bugs. When working with data frames that contain NA values in the filtering column, wrapping the condition in which() is strongly recommended: df[which(df$age > 21), ] avoids producing rows full of NA values.

SECTION 6

Worked Example

Consider a scenario where you have a vector of exam scores from a class, and you need to (1) identify all passing scores (≥ 60), (2) find the positions of failing scores, and (3) replace any NA entries with 0 before computing the class average. This worked example demonstrates how logical subsetting and which() work together on realistic data.

Filtering and Cleaning Exam Scores

  1. 1

    Step 1 — Create the Data Vector

    We define a numeric vector with 8 scores, including two NA values representing students who did not take the exam: scores <- c(85, 42, NA, 91, 67, NA, 55, 78) This vector has length 8, with NA at positions 3 and 6.
    scores = [85, 42, NA, 91, 67, NA, 55, 78]
  2. 2

    Step 2 — Build the Logical Mask for Passing Scores

    Apply the comparison operator to generate a logical vector: pass_mask <- scores >= 60 This produces: TRUE, FALSE, NA, TRUE, TRUE, NA, FALSE, TRUE. Observe that positions 3 and 6 produce NA because comparing NA >= 60 yields NA—the result is unknown.
    pass_mask = [TRUE, FALSE, NA, TRUE, TRUE, NA, FALSE, TRUE]
  3. 3

    Step 3 — Extract Passing Scores (Two Approaches)

    Direct logical subsetting: scores[pass_mask] returns [85, NA, 91, 67, NA, 78]. The two NA entries from the mask propagate into the result. Using which(): scores[which(pass_mask)] first computes which(pass_mask) = [1, 4, 5, 8], then subsets to get [85, 91, 67, 78]. The NA entries are excluded because which() only returns positions of definite TRUE.
    Direct: [85, NA, 91, 67, NA, 78] | via which(): [85, 91, 67, 78]
  4. 4

    Step 4 — Find Positions of Failing Scores

    To find which students failed (scored below 60), we use which() with the negated condition: fail_idx <- which(scores < 60) This returns integer indices [2, 7], corresponding to scores 42 and 55. Importantly, positions 3 and 6 (NA) are not included—which() treats unknown scores as neither passing nor failing.
    fail_idx = [2, 7] (scores 42 and 55)
  5. 5

    Step 5 — Replace NAs and Compute Average

    Use is.na() for a reliable NA check, then conditional assignment to replace: scores[is.na(scores)] <- 0 Now scores = [85, 42, 0, 91, 67, 0, 55, 78]. The class average is mean(scores) = (85 + 42 + 0 + 91 + 67 + 0 + 55 + 78) / 8 = 418 / 8 = 52.25.
    Class average = 52.25

SECTION 7

Logical Masks vs. which() — When to Use Each

Choosing between direct logical subsetting and the which() pathway is not merely a matter of style; each approach has concrete advantages and trade-offs that affect correctness, performance, and readability. The table below provides a systematic comparison across dimensions that matter in production code.

DimensionLogical Mask (x[L])which() Path (x[which(L)])
NA handlingNA in mask → NA in output. Downstream functions may break if they don't expect NA.NAs silently excluded. Output contains only confirmed matches.
Return typeValues from the original vector (same type as input).Integer vector of positions when using which() alone; values if chained as x[which(L)].
Memory (sparse)Logical vector same length as x must be allocated. Costly for very large vectors with few matches.Integer index vector proportional to number of matches—much smaller when selection is sparse.
Memory (dense)Logical vector is compact (1 byte per element). Efficient when most elements match.Integer vector nearly as long as x (4 bytes per element). Less efficient than logical mask for dense selections.
Assignmentx[L] <- val works naturally. Standard idiom for conditional replacement.x[which(L)] <- val also works, but marginally less common in style guides.
ReadabilityConcise and idiomatic for simple conditions. Reads like a declarative filter.Explicit about intent to find positions. Slightly more verbose but self-documenting.

Comprehensive comparison of the two subsetting strategies

🧭

PRACTICAL GUIDELINE

Use direct logical subsetting (x[condition]) as your default when you are confident the data contains no NAs in the filtering column, or when NAs in the output are acceptable. Switch to x[which(condition)] when working with messy real-world data where NA propagation could silently corrupt results—this pattern is analogous to using COALESCE or IS NOT NULL guards in SQL to prevent null propagation through joins.

SECTION 8

Connection to Advanced Subsetting Abstractions

While which() and logical subsetting form the bedrock of data selection in base R, the modern R ecosystem has built several higher-level abstractions on top of these primitives. Understanding the connection between the base-R mechanisms and their tidyverse counterparts will make you a more versatile programmer who can move fluidly between paradigms—and debug issues in either.

Base R PatternTidyverse Equivalentdata.table Equivalent
df[df$x > 5, ]filter(df, x > 5)dt[x > 5]
which(df$x > 5)No direct equivalent; filter() returns rows, not indicesdt[x > 5, which = TRUE]
df[df$x > 5 & df$y < 10, ]filter(df, x > 5, y < 10)dt[x > 5 & y < 10]
df$x[df$x < 0] <- 0mutate(df, x = if_else(x < 0, 0, x))dt[x < 0, x := 0]
subset(df, x > 5, select = c(y, z))select(filter(df, x > 5), y, z)dt[x > 5, .(y, z)]

Base R subsetting patterns and their equivalents in dplyr and data.table

It is worth noting that dplyr::filter() internally handles NA propagation by treating NA conditions as FALSE—effectively behaving like which() semantics rather than raw logical subsetting. This design choice reflects the practical reality that analysts almost never want NA rows in their filtered results. Similarly, data.table's i argument (the row-filter position in dt[i, j, by]) automatically wraps logical conditions in which()-like logic for performance. As you progress into package development, Rcpp programming, or large-scale data pipelines, the ability to reason precisely about logical masks, integer indices, and their NA-handling semantics will remain indispensable.

ℹ

Looking Ahead

For matrix and array subsetting, explore which(x, arr.ind = TRUE) which returns a two-column matrix of (row, col) index pairs. This is the base-R analog of NumPy's np.argwhere() and is essential for spatial data, image processing, and adjacency matrix operations.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

Explain why x[x > 3] and x[which(x > 3)] can produce different results. Under what specific condition will their outputs be identical? Provide a concrete example vector where they differ.
Show answer
The two expressions differ when x contains NA values. Direct logical subsetting (x[x > 3]) propagates NA: wherever x is NA, the comparison NA > 3 yields NA, and subsetting with NA produces an NA in the output. In contrast, which(x > 3) returns only the integer positions of TRUE values, silently dropping NAs, so the final result contains no NA entries. They are identical when x has no NA values. Example: x <- c(1, NA, 5); x[x > 3] returns [NA, 5] but x[which(x > 3)] returns [5].

Question 2: BASIC CALCULATION

Given temps <- c(72, 68, 85, 90, 61, 77, 95, 83) (daily temperatures in °F), write R expressions to: (a) extract all temperatures above 80, (b) find the indices of temperatures below 70, and (c) count how many days had temperatures between 75 and 90 inclusive.
Show answer
(a) temps[temps > 80] returns [85, 90, 95, 83]. (b) which(temps < 70) returns [2, 5], corresponding to the values 68 and 61. (c) sum(temps >= 75 & temps <= 90) returns 4, because temperatures 85, 90, 77, and 83 all fall in the range [75, 90]. The sum() function works here because it coerces TRUE to 1 and FALSE to 0.

Question 3: INTERMEDIATE

You have a data frame students with columns name (character), gpa (numeric, may contain NAs), and major (character). Write a single base-R expression that returns the names of all Computer Science majors with a GPA above 3.5, correctly handling any NA values in the gpa column.
Show answer
The expression students$name[which(students$major == "Computer Science" & students$gpa > 3.5)] solves this correctly. The compound condition uses element-wise & to require both conditions simultaneously. Wrapping in which() ensures that rows where gpa is NA are excluded rather than producing NA entries in the result. Without which(), a student with major = "Computer Science" but gpa = NA would yield NA in the logical mask, which would produce an NA entry in the name vector.

Question 4: APPLIED

A sensor array produces a numeric vector readings of 10,000 measurements. Values below −999 are known error codes. Write an R pipeline that: (1) replaces all error codes with NA, (2) identifies the indices of the top 5 valid readings, and (3) extracts a ±2-index neighborhood around each of those top-5 positions (clamped to valid bounds).
Show answer
(1) Replace error codes: readings[readings < -999] <- NA uses conditional assignment to convert sentinel values. (2) Find top-5 indices: top5 <- order(readings, decreasing = TRUE, na.last = NA)[1:5] sorts non-NA values in descending order and takes the first 5 positions. Alternatively, repeated use of which.max() with masking works but is less efficient. (3) Build neighborhoods: nbrs <- unique(unlist(lapply(top5, function(i) max(1, i-2):min(length(readings), i+2)))) generates a ±2 window around each index, clamps to [1, n], and deduplicates. The final result is readings[nbrs]. This pattern is common in signal processing for extracting local context around peaks.

Question 5: CRITICAL THINKING

Consider a logical vector L of length n where exactly k entries are TRUE and the rest are FALSE (no NAs). Analyze the memory consumption of x[L] versus x[which(L)] in terms of n and k. At what ratio of k/n does the which() approach start consuming more memory than the logical mask? Assume logical values use 4 bytes and integers use 4 bytes in R's internal representation.
Show answer
The logical mask L occupies 4n bytes (R stores logical vectors as 32-bit integers internally, with LOGICAL values taking 4 bytes each in the underlying SEXP structure). The integer vector produced by which(L) has length k, consuming 4k bytes. The which() approach also requires computing and temporarily storing L, so the total transient memory is 4n + 4k bytes for the which() path versus 4n bytes for the direct path. However, if we focus on the subsetting index alone (the object passed to []), the logical vector is 4n bytes and the integer vector is 4k bytes. The which() index uses less memory when 4k < 4n, i.e., when k < n, which is true for any proper subset. They break even at k = n (selecting everything). But accounting for the intermediate logical vector in the which() path, total memory is 4n + 4k. This exceeds the direct path's 4n when k > 0—meaning the which() path always uses weakly more total memory. The advantage of which() is that the logical vector can be garbage-collected before the integer index is used in subsetting, so peak memory depends on R's garbage collection timing. In practice, which() is preferred for sparse selections (k ≪ n) because the smaller integer vector is faster to iterate over during the actual element extraction.

SUMMARY

Summary

Summary

R provides two complementary mechanisms for extracting elements that satisfy a condition. Logical subsetting uses a Boolean mask of TRUE/FALSE/NA values passed directly to the bracket operator, returning matching elements but propagating NA values into the result. The which() function converts a logical vector into an integer index vector containing only the positions of definite TRUE values, silently excluding NAs—making it the safer choice for data with missing values.

Compound conditions are built with the element-wise operators & and | (not the short-circuit && and ||). For data frame subsetting, the logical condition goes before the comma in df[condition, ] to filter rows. Related functions which.min() and which.max() efficiently locate extrema. These base-R primitives underlie higher-level abstractions in dplyr and data.table, and mastering them ensures you can reason precisely about data selection regardless of which ecosystem you work in.

Varsity Tutors • R Programming • which() & Logical Subsetting