Data manipulation with base R
Select, filter, arrange, mutate, group and join without leaving base R — and the exact dplyr verb each base expression replaces.
Selecting, filtering and ordering
df <- data.frame(
city = c("oslo", "oslo", "bergen", "bergen", "oslo"),
month = c(1, 2, 1, 2, 3),
sales = c(120, 90, 200, 150, 60),
stringsAsFactors = FALSE
)
df[c("city", "sales")] # select columns, always a data frame
df[["sales"]] # one column as a vector
df[df$sales > 100, ] # the comma is required, and so is the space
subset(df, sales > 100 & city == "oslo") # reads better but silently drops NA rows
df[order(df$city, -df$sales), ] # arrange: order() returns a permutation
df$per_day <- df$sales / 30 # mutate in place
copy <- transform(df, band = ifelse(sales > 100, "big", "small"))
renamed <- df
names(renamed)[names(renamed) == "city"] <- "town"
rm(df) # base R has no verb for this; names are vectors you edit directlysubset()is convenient but drops rows whose condition evaluates toNA, and it is documented as unsuitable inside functions — use[for library code.order()returns positions, not sorted values. You must re-index the data frame with it.- Prefix a numeric column with a minus inside
order()to sort descending. transform()returns a new data frame and can see the columns you just defined in the same call.
Grouping, summarising and joining
aggregate(sales ~ city, data = df, FUN = mean)
aggregate(cbind(sales, month) ~ city, data = df, FUN = sum) # several columns at once
# one FUN that returns a vector gives a matrix column; flatten it
agg <- aggregate(sales ~ city, data = df,
FUN = function(v) c(n = length(v), mean = mean(v), max = max(v)))
do.call(data.frame, agg)
sapply(split(df$sales, df$city), mean) # split, apply, combine
tapply(df$sales, df$city, max) # named vector result
ave(df$sales, df$city, FUN = mean) # vector the same length as df
lookup <- data.frame(city = c("oslo", "bergen"),
country = c("NO", "NO"), stringsAsFactors = FALSE)
merge(df, lookup, by = "city", all.x = TRUE) # left join
merge(df, lookup, by = "city", all = TRUE) # full outer join
merge(df, lookup, by = "city", all.y = TRUE) # right join| dplyr verb | Base R equivalent | Note |
|---|---|---|
| filter(df, x > 1) | df[df$x > 1, ] or subset() | subset() drops NA rows |
| select(df, a, b) | df[c("a", "b")] | No tidy-select helpers in base R |
| arrange(df, x) | df[order(df$x), ] | order() is a permutation; you re-index |
| mutate(df, y = x * 2) | df$y <- x * 2, transform() | transform copies, $<- modifies in place |
| group_by() then summarise() | aggregate, tapply, ave | aggregate takes one FUN, which may return a vector |
| left_join(a, b) | merge(a, b, all.x = TRUE) | merge sorts by the key by default |
| rename(df, y = x) | names(df)[names(df) == "x"] <- "y" | No rename helper; edit the names vector |
| slice(df, 1:5) | head(df, 5) | head is the idiomatic base answer |
| distinct(df) | unique(df) | unique works on the rows of a data frame |
| count(df, g) | table(df$g) or aggregate with length | table returns a contingency table |
⚠️
merge sorts its result by the key, so a join quietly reorders your rows — carry an explicit index column or re-sort afterwards if order matters. It also expands anything ambiguous: duplicate keys on both sides produce the full cross product, which is how a join turns 500 rows into 50 000.FAQ
Should I learn base R or dplyr?
Learn base R first: it is always available, it is what packages return, and the verbs map one-to-one onto it. Then adopt dplyr for readability on wide pipelines. Knowing the mapping in the table above means you can read either style.
Why does sapply return a character matrix?
Because
sapply simplifies its result, and a data frame with mixed column types becomes a matrix — which can hold only one type, so everything is coerced to character. Use lapply to keep a list, or vapply with an explicit type to fail loudly instead.Related
Vectors, factors and data frames Grouping, joining and reshaping
Last refreshed 2026-09-18.