Data manipulation with dplyr

Pick rows, choose columns, create variables and sort — one verb at a time

Learn the pipe |> and the core dplyr verbs filter(), select(), mutate(), arrange(), rename(), relocate(), distinct(), slice_*() and count(), with a comparison to base R order().

Level 1 · Foundations ⏱ ~75 min Prerequisites: Data frames

NoteLearning objectives

By the end of this lesson you will be able to:

  • chain operations with the native pipe |> and read a pipeline aloud;
  • keep rows with filter() and choose columns with select() and its helpers;
  • create and recode variables with mutate(), if_else() and case_when();
  • sort rows with arrange() and desc(), and relate them to base R order();
  • tidy up a table with rename(), relocate(), distinct(), slice_*() and count().
library(dplyr)
library(tibble)

1. Why a “grammar” of data manipulation?

Most of the time spent on a real analysis is not spent on statistics: it is spent getting the data into shape. Keep only the adult patients. Convert grams to kilograms. Sort by date. Count how many observations per group.

Base R can do all of this with brackets, but the code quickly becomes hard to read. The dplyr package gives each of these tasks a verb — a function with a clear name — and all verbs work the same way:

  1. the first argument is a data frame;
  2. the other arguments describe what to do, using column names without quotes;
  3. the result is a new data frame (the original is never modified).

We will use penguins throughout. Converting it to a tibble once keeps the printed output short:

penguins <- as_tibble(penguins)
penguins
#> # A tibble: 344 × 8
#>    species island    bill_len bill_dep flipper_len body_mass sex     year
#>    <fct>   <fct>        <dbl>    <dbl>       <int>     <int> <fct>  <int>
#>  1 Adelie  Torgersen     39.1     18.7         181      3750 male    2007
#>  2 Adelie  Torgersen     39.5     17.4         186      3800 female  2007
#>  3 Adelie  Torgersen     40.3     18           195      3250 female  2007
#>  4 Adelie  Torgersen     NA       NA            NA        NA <NA>    2007
#>  5 Adelie  Torgersen     36.7     19.3         193      3450 female  2007
#>  6 Adelie  Torgersen     39.3     20.6         190      3650 male    2007
#>  7 Adelie  Torgersen     38.9     17.8         181      3625 female  2007
#>  8 Adelie  Torgersen     39.2     19.6         195      4675 male    2007
#>  9 Adelie  Torgersen     34.1     18.1         193      3475 <NA>    2007
#> 10 Adelie  Torgersen     42       20.2         190      4250 <NA>    2007
#> # ℹ 334 more rows

2. The pipe |>: “and then”

Suppose we want the Gentoo penguins, then only three columns, then sorted by mass. Without a pipe, we either nest the calls (read from the inside out) or create intermediate objects:

arrange(select(filter(penguins, species == "Gentoo"),
               species, sex, body_mass), body_mass)
#> # A tibble: 124 × 3
#>    species sex    body_mass
#>    <fct>   <fct>      <int>
#>  1 Gentoo  female      3950
#>  2 Gentoo  <NA>        4100
#>  3 Gentoo  female      4150
#>  4 Gentoo  female      4200
#>  5 Gentoo  female      4200
#>  6 Gentoo  female      4200
#>  7 Gentoo  female      4300
#>  8 Gentoo  female      4300
#>  9 Gentoo  female      4350
#> 10 Gentoo  female      4375
#> # ℹ 114 more rows

The pipe |> takes what is on its left and passes it as the first argument of the function on its right. x |> f(y) is the same as f(x, y). Read it aloud as “and then”:

penguins |>                              # take penguins, and then
  filter(species == "Gentoo") |>         # keep the Gentoo, and then
  select(species, sex, body_mass) |>     # keep three columns, and then
  arrange(body_mass)                     # sort by body mass
#> # A tibble: 124 × 3
#>    species sex    body_mass
#>    <fct>   <fct>      <int>
#>  1 Gentoo  female      3950
#>  2 Gentoo  <NA>        4100
#>  3 Gentoo  female      4150
#>  4 Gentoo  female      4200
#>  5 Gentoo  female      4200
#>  6 Gentoo  female      4200
#>  7 Gentoo  female      4300
#>  8 Gentoo  female      4300
#>  9 Gentoo  female      4350
#> 10 Gentoo  female      4375
#> # ℹ 114 more rows

The two chunks produce the same result, but the second one reads in the order in which things happen. Put one verb per line, with |> at the end of each line.

The native pipe |> has been part of R since version 4.1. Older code (and many web pages) use %>% from the magrittr package, which behaves almost identically for everyday use. In this course we always use |>. The keyboard shortcut in RStudio and Positron is Ctrl/Cmd + Shift + M (tick “Use native pipe operator” in the options).

3. filter(): keep rows

filter() keeps the rows for which a condition is TRUE.

penguins |>
  filter(species == "Adelie", island == "Dream", flipper_len > 190)
#> # A tibble: 22 × 8
#>    species island bill_len bill_dep flipper_len body_mass sex     year
#>    <fct>   <fct>     <dbl>    <dbl>       <int>     <int> <fct>  <int>
#>  1 Adelie  Dream      36.4     17           195      3325 female  2007
#>  2 Adelie  Dream      39.2     21.1         196      4150 male    2007
#>  3 Adelie  Dream      40.8     18.4         195      3900 male    2007
#>  4 Adelie  Dream      44.1     19.7         196      4400 male    2007
#>  5 Adelie  Dream      42.3     21.2         191      4150 male    2007
#>  6 Adelie  Dream      37.3     17.8         191      3350 female  2008
#>  7 Adelie  Dream      41.3     20.3         194      3550 male    2008
#>  8 Adelie  Dream      35.7     18           202      3550 female  2008
#>  9 Adelie  Dream      41.1     18.1         205      4300 male    2008
#> 10 Adelie  Dream      40.8     18.9         208      4300 male    2008
#> # ℹ 12 more rows

Conditions separated by commas must all be true (logical “and”). For “or” use |. The comparison operators are the ones you met with vectors: ==, !=, <, <=, >, >=.

Three helpers make conditions shorter and clearer:

# Value in a set: %in%
penguins |> filter(species %in% c("Adelie", "Chinstrap")) |> nrow()
#> [1] 220
# Value in a range (both ends included): between()
penguins |> filter(between(body_mass, 3500, 4000)) |> nrow()
#> [1] 99
# Missing values: is.na()
penguins |> filter(is.na(sex))
#> # A tibble: 11 × 8
#>    species island    bill_len bill_dep flipper_len body_mass sex    year
#>    <fct>   <fct>        <dbl>    <dbl>       <int>     <int> <fct> <int>
#>  1 Adelie  Torgersen     NA       NA            NA        NA <NA>   2007
#>  2 Adelie  Torgersen     34.1     18.1         193      3475 <NA>   2007
#>  3 Adelie  Torgersen     42       20.2         190      4250 <NA>   2007
#>  4 Adelie  Torgersen     37.8     17.1         186      3300 <NA>   2007
#>  5 Adelie  Torgersen     37.8     17.3         180      3700 <NA>   2007
#>  6 Adelie  Dream         37.5     18.9         179      2975 <NA>   2007
#>  7 Gentoo  Biscoe        44.5     14.3         216      4100 <NA>   2007
#>  8 Gentoo  Biscoe        46.2     14.4         214      4650 <NA>   2008
#>  9 Gentoo  Biscoe        47.3     13.8         216      4725 <NA>   2009
#> 10 Gentoo  Biscoe        44.5     15.7         217      4875 <NA>   2009
#> 11 Gentoo  Biscoe        NA       NA            NA        NA <NA>   2009
TipHow to read this output
  • %in% is cleaner than species == "Adelie" | species == "Chinstrap".
  • between(x, 3500, 4000) means x >= 3500 & x <= 4000.
  • The last table shows the 11 penguins whose sex was not recorded. filter(!is.na(sex)) keeps the others.
  • filter() drops rows where the condition is NA, so penguins with missing body_mass are silently excluded from the between() count.

4. select(): choose columns

penguins |> select(species, island, body_mass)
#> # A tibble: 344 × 3
#>    species island    body_mass
#>    <fct>   <fct>         <int>
#>  1 Adelie  Torgersen      3750
#>  2 Adelie  Torgersen      3800
#>  3 Adelie  Torgersen      3250
#>  4 Adelie  Torgersen        NA
#>  5 Adelie  Torgersen      3450
#>  6 Adelie  Torgersen      3650
#>  7 Adelie  Torgersen      3625
#>  8 Adelie  Torgersen      4675
#>  9 Adelie  Torgersen      3475
#> 10 Adelie  Torgersen      4250
#> # ℹ 334 more rows
penguins |> select(-year, -island)        # everything except
#> # A tibble: 344 × 6
#>    species bill_len bill_dep flipper_len body_mass sex   
#>    <fct>      <dbl>    <dbl>       <int>     <int> <fct> 
#>  1 Adelie      39.1     18.7         181      3750 male  
#>  2 Adelie      39.5     17.4         186      3800 female
#>  3 Adelie      40.3     18           195      3250 female
#>  4 Adelie      NA       NA            NA        NA <NA>  
#>  5 Adelie      36.7     19.3         193      3450 female
#>  6 Adelie      39.3     20.6         190      3650 male  
#>  7 Adelie      38.9     17.8         181      3625 female
#>  8 Adelie      39.2     19.6         195      4675 male  
#>  9 Adelie      34.1     18.1         193      3475 <NA>  
#> 10 Adelie      42       20.2         190      4250 <NA>  
#> # ℹ 334 more rows
penguins |> select(species:bill_dep)      # a range of columns
#> # A tibble: 344 × 4
#>    species island    bill_len bill_dep
#>    <fct>   <fct>        <dbl>    <dbl>
#>  1 Adelie  Torgersen     39.1     18.7
#>  2 Adelie  Torgersen     39.5     17.4
#>  3 Adelie  Torgersen     40.3     18  
#>  4 Adelie  Torgersen     NA       NA  
#>  5 Adelie  Torgersen     36.7     19.3
#>  6 Adelie  Torgersen     39.3     20.6
#>  7 Adelie  Torgersen     38.9     17.8
#>  8 Adelie  Torgersen     39.2     19.6
#>  9 Adelie  Torgersen     34.1     18.1
#> 10 Adelie  Torgersen     42       20.2
#> # ℹ 334 more rows

Selection helpers pick columns by their name or type:

penguins |> select(species, starts_with("bill"))
#> # A tibble: 344 × 3
#>    species bill_len bill_dep
#>    <fct>      <dbl>    <dbl>
#>  1 Adelie      39.1     18.7
#>  2 Adelie      39.5     17.4
#>  3 Adelie      40.3     18  
#>  4 Adelie      NA       NA  
#>  5 Adelie      36.7     19.3
#>  6 Adelie      39.3     20.6
#>  7 Adelie      38.9     17.8
#>  8 Adelie      39.2     19.6
#>  9 Adelie      34.1     18.1
#> 10 Adelie      42       20.2
#> # ℹ 334 more rows
penguins |> select(where(is.numeric))
#> # A tibble: 344 × 5
#>    bill_len bill_dep flipper_len body_mass  year
#>       <dbl>    <dbl>       <int>     <int> <int>
#>  1     39.1     18.7         181      3750  2007
#>  2     39.5     17.4         186      3800  2007
#>  3     40.3     18           195      3250  2007
#>  4     NA       NA            NA        NA  2007
#>  5     36.7     19.3         193      3450  2007
#>  6     39.3     20.6         190      3650  2007
#>  7     38.9     17.8         181      3625  2007
#>  8     39.2     19.6         195      4675  2007
#>  9     34.1     18.1         193      3475  2007
#> 10     42       20.2         190      4250  2007
#> # ℹ 334 more rows

Other helpers: ends_with(), contains(), everything(), all_of(vec) (names stored in a character vector). where() takes a function that returns TRUE/FALSE for each column: where(is.factor), where(is.numeric).

5. mutate(): create or modify columns

mutate() adds new columns computed from existing ones (or overwrites them, if you reuse a name).

penguins |>
  mutate(
    body_mass_kg = body_mass / 1000,
    bill_ratio   = bill_len / bill_dep
  ) |>
  select(species, body_mass_kg, bill_ratio)
#> # A tibble: 344 × 3
#>    species body_mass_kg bill_ratio
#>    <fct>          <dbl>      <dbl>
#>  1 Adelie         3.75       2.091
#>  2 Adelie         3.8        2.270
#>  3 Adelie         3.25       2.239
#>  4 Adelie        NA         NA    
#>  5 Adelie         3.45       1.902
#>  6 Adelie         3.65       1.908
#>  7 Adelie         3.625      2.185
#>  8 Adelie         4.675      2    
#>  9 Adelie         3.475      1.884
#> 10 Adelie         4.25       2.079
#> # ℹ 334 more rows

Columns created earlier in the same mutate() can be used immediately afterwards.

Two-way recoding: if_else()

penguins |>
  mutate(heavy = if_else(body_mass > 4500, "heavy", "light")) |>
  count(heavy)
#> # A tibble: 3 × 2
#>   heavy     n
#>   <chr> <int>
#> 1 heavy   115
#> 2 light   227
#> 3 <NA>      2

if_else(condition, value_if_true, value_if_false). Where the condition is NA, the result is NA — which is honest: we do not know whether a penguin with unknown mass is heavy.

Many categories: case_when()

penguins |>
  mutate(
    size = case_when(
      body_mass < 3500 ~ "small",
      body_mass < 4500 ~ "medium",
      body_mass >= 4500 ~ "large",
      .default = NA
    )
  ) |>
  count(size)
#> # A tibble: 4 × 2
#>   size       n
#>   <chr>  <int>
#> 1 large    118
#> 2 medium   153
#> 3 small     71
#> 4 <NA>       2

Each line is condition ~ value. Conditions are checked in order, and the first one that is true wins: a penguin of 3000 g matches the first line and never reaches the second. .default gives the value when no condition is true.

Turning a numeric variable into classes like this is called discretisation. It is handy for tables, but it throws information away: for statistical models, keep the original number whenever you can.

6. arrange(): sort rows

Base R first: sort() and order()

x <- c(5, 1, 10)
sort(x)        # the sorted values
#> [1]  1  5 10
order(x)       # the positions that would sort x
#> [1] 2 1 3
x[order(x)]    # using those positions
#> [1]  1  5 10

To sort a data frame in base R we put order() in the row position:

head(penguins[order(penguins$body_mass), c("species", "body_mass")], 3)
#> # A tibble: 3 × 2
#>   species   body_mass
#>   <fct>         <int>
#> 1 Chinstrap      2700
#> 2 Adelie         2850
#> 3 Adelie         2850
# descending: put a minus in front (numeric columns only)
head(penguins[order(-penguins$body_mass), c("species", "body_mass")], 3)
#> # A tibble: 3 × 2
#>   species body_mass
#>   <fct>       <int>
#> 1 Gentoo       6300
#> 2 Gentoo       6050
#> 3 Gentoo       6000
# by species, then by decreasing bill length
head(penguins[order(penguins$species, -penguins$bill_len),
              c("species", "bill_len")], 3)
#> # A tibble: 3 × 2
#>   species bill_len
#>   <fct>      <dbl>
#> 1 Adelie      46  
#> 2 Adelie      45.8
#> 3 Adelie      45.6

The dplyr way

penguins |> arrange(body_mass)
#> # A tibble: 344 × 8
#>    species   island    bill_len bill_dep flipper_len body_mass sex     year
#>    <fct>     <fct>        <dbl>    <dbl>       <int>     <int> <fct>  <int>
#>  1 Chinstrap Dream         46.9     16.6         192      2700 female  2008
#>  2 Adelie    Biscoe        36.5     16.6         181      2850 female  2008
#>  3 Adelie    Biscoe        36.4     17.1         184      2850 female  2008
#>  4 Adelie    Biscoe        34.5     18.1         187      2900 female  2008
#>  5 Adelie    Dream         33.1     16.1         178      2900 female  2008
#>  6 Adelie    Torgersen     38.6     17           188      2900 female  2009
#>  7 Chinstrap Dream         43.2     16.6         187      2900 female  2007
#>  8 Adelie    Biscoe        37.9     18.6         193      2925 female  2009
#>  9 Adelie    Dream         37.5     18.9         179      2975 <NA>    2007
#> 10 Adelie    Dream         37       16.9         185      3000 female  2007
#> # ℹ 334 more rows
penguins |> arrange(desc(body_mass))
#> # A tibble: 344 × 8
#>    species island bill_len bill_dep flipper_len body_mass sex    year
#>    <fct>   <fct>     <dbl>    <dbl>       <int>     <int> <fct> <int>
#>  1 Gentoo  Biscoe     49.2     15.2         221      6300 male   2007
#>  2 Gentoo  Biscoe     59.6     17           230      6050 male   2007
#>  3 Gentoo  Biscoe     51.1     16.3         220      6000 male   2008
#>  4 Gentoo  Biscoe     48.8     16.2         222      6000 male   2009
#>  5 Gentoo  Biscoe     45.2     16.4         223      5950 male   2008
#>  6 Gentoo  Biscoe     49.8     15.9         229      5950 male   2009
#>  7 Gentoo  Biscoe     48.4     14.6         213      5850 male   2007
#>  8 Gentoo  Biscoe     49.3     15.7         217      5850 male   2007
#>  9 Gentoo  Biscoe     55.1     16           230      5850 male   2009
#> 10 Gentoo  Biscoe     49.5     16.2         229      5800 male   2008
#> # ℹ 334 more rows
penguins |> arrange(species, desc(bill_len))
#> # A tibble: 344 × 8
#>    species island    bill_len bill_dep flipper_len body_mass sex    year
#>    <fct>   <fct>        <dbl>    <dbl>       <int>     <int> <fct> <int>
#>  1 Adelie  Torgersen     46       21.5         194      4200 male   2007
#>  2 Adelie  Torgersen     45.8     18.9         197      4150 male   2008
#>  3 Adelie  Biscoe        45.6     20.3         191      4600 male   2009
#>  4 Adelie  Dream         44.1     19.7         196      4400 male   2007
#>  5 Adelie  Torgersen     44.1     18           210      4000 male   2009
#>  6 Adelie  Dream         43.2     18.5         192      4100 male   2008
#>  7 Adelie  Biscoe        43.2     19           197      4775 male   2009
#>  8 Adelie  Torgersen     43.1     19.2         197      3500 male   2009
#>  9 Adelie  Torgersen     42.9     17.6         196      4700 male   2008
#> 10 Adelie  Torgersen     42.8     18.5         195      4250 male   2008
#> # ℹ 334 more rows

desc() works for any type (numbers, text, factors), while the minus trick only works for numbers. With several columns, ties in the first are broken by the second. In both approaches, missing values go last.

7. Small but useful verbs

penguins |>
  rename(mass_g = body_mass, flipper_mm = flipper_len) |>   # new = old
  relocate(year, .before = species)                         # move a column
#> # A tibble: 344 × 8
#>     year species island    bill_len bill_dep flipper_mm mass_g sex   
#>    <int> <fct>   <fct>        <dbl>    <dbl>      <int>  <int> <fct> 
#>  1  2007 Adelie  Torgersen     39.1     18.7        181   3750 male  
#>  2  2007 Adelie  Torgersen     39.5     17.4        186   3800 female
#>  3  2007 Adelie  Torgersen     40.3     18          195   3250 female
#>  4  2007 Adelie  Torgersen     NA       NA           NA     NA <NA>  
#>  5  2007 Adelie  Torgersen     36.7     19.3        193   3450 female
#>  6  2007 Adelie  Torgersen     39.3     20.6        190   3650 male  
#>  7  2007 Adelie  Torgersen     38.9     17.8        181   3625 female
#>  8  2007 Adelie  Torgersen     39.2     19.6        195   4675 male  
#>  9  2007 Adelie  Torgersen     34.1     18.1        193   3475 <NA>  
#> 10  2007 Adelie  Torgersen     42       20.2        190   4250 <NA>  
#> # ℹ 334 more rows

distinct() keeps unique rows (or unique combinations of the columns you name):

penguins |> distinct(species, island)
#> # A tibble: 5 × 2
#>   species   island   
#>   <fct>     <fct>    
#> 1 Adelie    Torgersen
#> 2 Adelie    Biscoe   
#> 3 Adelie    Dream    
#> 4 Gentoo    Biscoe   
#> 5 Chinstrap Dream

The slice_*() family selects rows by position:

penguins |> slice_head(n = 3)                    # first 3 rows
#> # A tibble: 3 × 8
#>   species island    bill_len bill_dep flipper_len body_mass sex     year
#>   <fct>   <fct>        <dbl>    <dbl>       <int>     <int> <fct>  <int>
#> 1 Adelie  Torgersen     39.1     18.7         181      3750 male    2007
#> 2 Adelie  Torgersen     39.5     17.4         186      3800 female  2007
#> 3 Adelie  Torgersen     40.3     18           195      3250 female  2007
penguins |> slice_max(body_mass, n = 3)          # 3 heaviest
#> # A tibble: 4 × 8
#>   species island bill_len bill_dep flipper_len body_mass sex    year
#>   <fct>   <fct>     <dbl>    <dbl>       <int>     <int> <fct> <int>
#> 1 Gentoo  Biscoe     49.2     15.2         221      6300 male   2007
#> 2 Gentoo  Biscoe     59.6     17           230      6050 male   2007
#> 3 Gentoo  Biscoe     51.1     16.3         220      6000 male   2008
#> 4 Gentoo  Biscoe     48.8     16.2         222      6000 male   2009
penguins |> slice_min(bill_len, n = 1, by = species)   # shortest bill per species
#> # A tibble: 3 × 8
#>   species   island bill_len bill_dep flipper_len body_mass sex     year
#>   <fct>     <fct>     <dbl>    <dbl>       <int>     <int> <fct>  <int>
#> 1 Adelie    Dream      32.1     15.5         188      3050 female  2009
#> 2 Gentoo    Biscoe     40.9     13.7         214      4650 female  2007
#> 3 Chinstrap Dream      40.9     16.6         187      3200 female  2008

slice_max() keeps ties: if several penguins share the third-highest mass, they are all returned (use with_ties = FALSE to get exactly n rows). The argument by = repeats the operation within each group — more on groups in the next lesson. For random rows, use slice_sample(n = 5) (after set.seed()).

count() counts the rows for each combination of values:

penguins |> count(species)
#> # A tibble: 3 × 2
#>   species       n
#>   <fct>     <int>
#> 1 Adelie      152
#> 2 Chinstrap    68
#> 3 Gentoo      124
penguins |> count(species, sex, sort = TRUE)
#> # A tibble: 8 × 3
#>   species   sex        n
#>   <fct>     <fct>  <int>
#> 1 Adelie    female    73
#> 2 Adelie    male      73
#> 3 Gentoo    male      61
#> 4 Gentoo    female    58
#> 5 Chinstrap female    34
#> 6 Chinstrap male      34
#> 7 Adelie    <NA>       6
#> 8 Gentoo    <NA>       5
TipHow to read this output

Each row of a count() result is one combination of the grouping variables, and n is the number of units with that combination. These are absolute frequencies; you will turn them into proportions and contingency tables in Frequency tables. Note the NA rows for sex: count() does not hide missing values, which is exactly what you want when checking data.

8. Putting it together

A typical pipeline answers a question from start to finish. Among penguins with known sex, which five have the largest bill-to-depth ratio, and on which island do they live?

penguins |>
  filter(!is.na(sex)) |>
  mutate(bill_ratio = bill_len / bill_dep) |>
  select(species, island, sex, bill_ratio) |>
  arrange(desc(bill_ratio)) |>
  slice_head(n = 5)
#> # A tibble: 5 × 4
#>   species island sex    bill_ratio
#>   <fct>   <fct>  <fct>       <dbl>
#> 1 Gentoo  Biscoe male        3.613
#> 2 Gentoo  Biscoe male        3.510
#> 3 Gentoo  Biscoe male        3.506
#> 4 Gentoo  Biscoe female      3.492
#> 5 Gentoo  Biscoe male        3.459

Notice that nothing has been saved: penguins is unchanged. To keep a result, assign it: result <- penguins |> ....

WarningCommon pitfalls
  1. = instead of == in filter(). filter(species = "Adelie") gives an error; comparison needs ==.
  2. species == "Adelie" | "Gentoo" does not mean what it seems. Write species %in% c("Adelie", "Gentoo").
  3. Forgetting to assign. A pipeline only prints its result; penguins is not modified unless you write penguins <- penguins |> ....
  4. Quoting column names in dplyr verbs (select("species") works, but filter("body_mass" > 4000) compares two constants!). Use bare names.
  5. Expecting exactly n rows from slice_max(). Ties are kept by default.
  6. Order matters in case_when(). Put the most specific conditions first.

9. Check your understanding

“Take penguins, and then keep the rows from 2009, and then count the rows for each island.” The result has one row per island with the number of penguins measured in 2009.

filter() works on rows (units), keeping those that satisfy a condition. select() works on columns (variables), keeping the ones you name. The number of rows changes with filter(); the number of columns changes with select().

NA. The condition NA > 4500 is unknown, so the result is unknown. If you want a separate label, use case_when(is.na(body_mass) ~ "unknown", body_mass > 4500 ~ "heavy", .default = "light").

10. Exercises

Interactive (run in your browser)

★ Exercise 1 — filter and count. How many Adelie penguins from the island of Dream have a flipper longer than 190 mm?

Text values need quotes: "Adelie", "Dream". The third condition is flipper_len > 190.

penguins |>
  filter(species == "Adelie", island == "Dream", flipper_len > 190) |>
  nrow()

22 penguins.

★★ Exercise 2 — mutate and recode. Create body_mass_kg (mass in kilograms) and a variable size that is "small" below 3.5 kg, "medium" from 3.5 up to 4.5 kg (excluded), and "large" from 4.5 kg. Then count the penguins in each size class.

Divide body_mass by 1000. Conditions are checked in order, so the second one can simply be body_mass_kg < 4.5.

penguins |>
  mutate(
    body_mass_kg = body_mass / 1000,
    size = case_when(
      body_mass_kg < 3.5 ~ "small",
      body_mass_kg < 4.5 ~ "medium",
      body_mass_kg >= 4.5 ~ "large"
    )
  ) |>
  count(size)

About 71 small, 153 medium and 118 large penguins, plus 2 with unknown mass (NA), which match no condition.

★★ Exercise 3 — debug. The code below should list species, sex and body mass of Adelie and Chinstrap penguins, from heaviest to lightest. It contains three mistakes. Fix them.

Look at (1) the comparison operator and the “or” condition, (2) the end of the select() line, (3) the sort direction.

penguins |>
  filter(species %in% c("Adelie", "Chinstrap")) |>
  select(species, sex, body_mass) |>
  arrange(desc(body_mass))

The missing |> after select() is the most common bug in pipelines: R runs the first part, prints it, then tries to run arrange(body_mass) alone and fails.

★★★ Exercise 4 — predict the output. Before running, predict how many rows this returns. Then run it and explain any surprise.

Three species × 3 rows = 9 rows… unless there are ties. Look at the Gentoo rows.

penguins |>
  slice_max(body_mass, n = 3, by = species, with_ties = FALSE) |>
  select(species, body_mass)

The original returns 10 rows: two Gentoo penguins share the third-highest mass (6000 g) and slice_max() keeps both. Adding with_ties = FALSE returns exactly 9.

Practice on your own computer

★★ Exercise 5 — base R vs dplyr. With mtcars, list the three cars with the highest mpg among those with 4 cylinders, showing only mpg, cyl and hp. Do it twice: once with order() and brackets, once with dplyr (keep the car names with rownames_to_column()).

# base R
cars4 <- mtcars[mtcars$cyl == 4, c("mpg", "cyl", "hp")]
head(cars4[order(-cars4$mpg), ], 3)

# dplyr
mtcars |>
  rownames_to_column("model") |>
  filter(cyl == 4) |>
  select(model, mpg, cyl, hp) |>
  arrange(desc(mpg)) |>
  slice_head(n = 3)

Both give the Toyota Corolla, Fiat 128 and Honda Civic. The dplyr version is longer but each line does one thing.

Summary

Verb What it does Example
filter() keep rows filter(x > 0, g %in% c("a", "b"))
select() choose columns select(a, starts_with("b"), where(is.numeric))
mutate() create / modify columns mutate(z = x / y)
if_else(), case_when() recode case_when(x < 0 ~ "neg", .default = "pos")
arrange(), desc() sort rows arrange(g, desc(x))
order() (base) sorting positions df[order(-df$x), ]
rename(), relocate() rename / move columns rename(new = old)
distinct() unique rows distinct(g1, g2)
slice_head(), slice_max(), slice_sample() rows by position slice_max(x, n = 3)
count() frequencies count(g, sort = TRUE)
  • Every verb takes a data frame and returns a new one; nothing is modified in place.
  • The pipe |> passes the result on its left as the first argument of the function on its right: read it as “and then”, and write one verb per line.
  • filter() drops rows whose condition is NA; check missing values with filter(is.na(x)) or count().
Back to top