The dplyr Verbs
In Lesson 1 you got Maya's bakery sales off her till, into R, and into tidy shape. The data is ready. So what do you actually DO with it? Almost every analysis is a handful of small, sharply named verbs, each doing one job to a table: keep some rows, keep some columns, add a column, sort, tally. Learn these and you can answer most questions you will ever put to a data frame.
By the end of this lesson you will be able to:
- Pick rows with
filter()and columns withselect() - Derive new columns with
mutate(), and order, dedupe and tally witharrange(),distinct()andcount() - Chain them with the pipe into one line that reads like a sentence
Prerequisites: Lesson 1, so your data is imported and tidy. No dplyr experience needed; we define every verb as it appears.
One shape in, one shape out: filter()
Here is Maya's tidy sales table from Lesson 1, six sales over three days. We will use this one table for the whole lesson.
Every dplyr verb works the same way: it is a function whose first argument is a data frame, and whose result is a brand new data frame. Table in, table out. That single rule is why the verbs snap together later.
The first verb, filter(), keeps the rows that pass a test. "Keep the busy sales" means "keep the rows where units is above 20":
The test units > 20 is checked on every row; rows where it is TRUE stay, the rest are dropped. Use == (two equals signs, "is equal to") to test a value, never a single =. Give several conditions and a row must pass them all: filter(sales, units > 20, item == "Bagel") keeps only busy Bagel sales.
filter(sales, ...) returns a new table and leaves sales exactly as it was. To keep a result, you must assign it: busy <- filter(sales, units > 20).Drag your eye down the widget: the three rows that fail units > 20 are struck out, the three that pass remain.
select() picks columns by name
Where filter() chooses rows, select() chooses columns. You name the columns you want and they are the only ones kept, in the order you list them:
That returns a two-column table, item and revenue, with every row intact. select() only ever touches columns; it never looks at the values inside them and never removes a row.
A few everyday moves:
- Drop a column with a minus:
select(sales, -date)keeps everything exceptdate. - Reorder by listing columns in the order you want:
select(sales, item, date, units, revenue). - Match by pattern with helpers:
select(sales, starts_with("rev")), orwhere(is.numeric)for every number column.
In the widget below, date and units are tinted to show they are being dropped; item and revenue are what select(item, revenue) returns. Notice all six rows survive: choosing columns says nothing about which rows you keep.
Rows or columns?
Maya wants only the Bagel sales, and she wants to keep every column. Which verb does that?
filter() chooses ROWS by a condition on their values, and leaves every column in place. That is exactly "keep the Bagel rows, all columns".arrange() only reorders rows, it never removes any. You would still have all six sales, just shuffled.mutate() adds a derived column
filter() and select() only ever keep what is already there. mutate() is different: it creates a new column from the columns you already have, and adds it to the right-hand side of the table.
Maya wants the price per loaf for each sale, which is simply revenue divided by units:
The expression on the right runs once per row (it is vectorised): row 1 gets 81 / 18 = 4.5, row 2 gets 60 / 40 = 1.5, and so on down the column. The five original columns are untouched; price joins them as a sixth. You can build several at once, and even use a column you just made: mutate(sales, price = revenue / units, dear = price > 4).
transmute(), does the same arithmetic but keeps ONLY the columns you name: transmute(sales, item, price = revenue / units) returns just item and price. Reach for it when the originals are clutter you do not need.Add a margin column
Maya's ingredients cost about 1 per loaf, so the cost of a sale is units and her margin is revenue minus that cost. Add a margin column. Fill in the blank with the expression, then check it.
Show answer
mutate(sales, margin = revenue - units)The pipe reads like a sentence
Every verb so far has had the same shape: verb(sales, ...), with the table named inside the parentheses. There is a second way to write that exact call which, the moment you have more than one verb, reads far more clearly. That arrow, %>%, is the pipe, and it is the idea that makes dplyr a joy to read.
The pipe takes the table on its left and feeds it in as the first argument of the verb on its right. So sales %>% filter(units > 20) means exactly filter(sales, units > 20):
The payoff comes when you stack verbs: each line takes the table from the line above, does one thing, and hands it on.
Read it top to bottom as plain English: "take sales, then keep the busy rows, then add a price, then show item, units and price." No nested parentheses, no naming a half-dozen throwaway tables. Each verb stays simple because the pipe handles the plumbing.
|>, that works almost identically: sales |> filter(units > 20). Use whichever your project prefers; this course uses %>%. And note the whole chain still leaves sales itself untouched, since every verb returns a new table.arrange(), distinct() and count()
Three short verbs round out the everyday toolkit. None of them changes the values in your table; they reorder it or summarise its shape.
arrange() sorts the rows. By default it sorts smallest first (ascending); wrap a column in desc() to sort largest first:
distinct() keeps the unique values, dropping repeats. Maya's six sales cover only three products:
count() goes one step further and tallies how many rows fall in each group, a quick way to ask "how many of each?":
So Maya sold Sourdough three times, Bagels twice and a single Croissant. count() is often the very first thing to run on a new column, to see what is in it.
Did the verb change my data?
You run sales %>% filter(units > 20), see the three busy rows print, and then type sales on its own again. What does sales contain now?
filter() built a new table and printed it, but it never touched sales. To keep the result you must assign it: busy <- sales %>% filter(units > 20).sales itself is never altered by running a verb on it.Build a pipe of your own
Maya wants the cheapest loaves on top. The pipe below keeps the busy sales and adds a price per loaf; finish it by ordering the rows so the lowest price comes first. (Watch the direction: she wants low to high.)
Show answer
sales %>%
filter(units > 20) %>%
mutate(price = revenue / units) %>%
arrange(price)References
A few authoritative places to take this further:
- R for Data Science (2e), Data transformation - the canonical, free chapter on
filter,select,mutate,arrangeand the pipe. - dplyr: Introduction vignette - the package authors' own tour of the one-table verbs.
- dplyr function reference - every argument of every verb, with runnable examples.
- R for Data Science (2e), Workflow: code style - how to format pipes so a chain reads cleanly, including the base
|>pipe.
Lesson 2 complete
You can now do real work on a data frame. You learned the five everyday verbs, filter() to keep rows, select() to keep columns, mutate() to derive new ones, and arrange(), distinct() and count() to order, dedupe and tally, and the pipe that chains them into a single readable line. Each verb does one job, takes a table and returns a new one, and never alters the original.
Next, Lesson 3: Group, Summarise and Clean. So far every verb has worked on the table as a whole. Now you will split it into groups with group_by(), collapse each group to a summary with summarise() (counts, means, several at once), derive categories with case_when(), and handle missing values honestly, the skills that turn a tidy table into an answer.