Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

R (4.6)  Module 5 – Pipes, functionals and iteration

The native pipe |> and anonymous functions

Rewrite nested R calls as pipelines with the native pipe |>, send a value to any argument with _, write \(x) functions and compare |> with %>%.

  • Beginner
  • 15 minutes
  • Examples run with R 4.6.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Rewrite nested calls as pipelines with the native pipe |>
  • Use the _ placeholder for non-first arguments and for extraction
  • Write anonymous functions with \(x), and choose a named function when it reads better
  • Explain how |> differs from magrittr's %>%

Before you start

What you’ll learn · Before you start

What you will learn

  • Rewrite nested calls as pipelines with the native pipe |>
  • Use the _ placeholder for non-first arguments and for extraction
  • Write anonymous functions with \(x), and choose a named function when it reads better
  • Explain how |> differs from magrittr's %>%

Before you start

On this page

round(mean(as.numeric(weights), na.rm = TRUE), 1) computes 68.2 from five weights typed into a form, but it has to be read from the inside out: the first step to run, as.numeric(), sits in the middle, and the 1 that round() needs comes at the very end, separated from round( by the rest of the line. The native pipe writes the same call in the order the steps run: weights |> as.numeric() |> mean(na.rm = TRUE) |> round(1).

On recorded runs of R 4.6.1 you will rewrite nested calls as pipelines, send a value to an argument other than the first with the placeholder _, write small functions in place with \(x), and read code written with the older %>% of the magrittr package, which follows different rules from |> in four places that matter when you convert one into the other.

A pipeline runs its steps in the order you read them

x |> f(y) means f(x, y): the native pipe: R's built-in |> operator, in R 4.1.0 and later. The parser rewrites x |> f(y) as f(x, y), so the value on the left becomes the first argument of the call on the right. takes the value on its left and passes it to the call on its right as that call’s first argument. A chain passes each result on to the next call, so x |> f() |> g() is g(f(x)), and the steps appear on the page in the order R runs them. Step through the weights:

Interactive · stage by stage Five weights passed through three calls Step through weights |> as.numeric() |> mean(na.rm = TRUE) |> round(1). Each stage gets the value of the stage above it as its first argument.
Five weights passed through three calls, step 5 of 5. Showing weights, as.numeric() (61.5 70.2 NA 58.9 82.0), mean(na.rm = TRUE) (68.15), round(1) (68.2). In focus: weights, as.numeric(), mean(na.rm = TRUE), round(1).weights5 texts, one of them NAas.numeric()61.5 70.2 NA 58.9 82.0mean(na.rm = TRUE)68.15round(1)68.2

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The value on the left. weights holds five texts from a form, "61.5", "70.2", NA, "58.9" and "82.0". It is the left-hand side of the first pipe.
  2. The first call. The first pipe passes weights to as.numeric() as its first argument, so this step runs as.numeric(weights) and the texts become numbers.
  3. Own arguments stay put. The next pipe puts those numbers in front of mean()'s own argument, so the call is mean(numbers, na.rm = TRUE). The missing weight is dropped and the mean is 68.15.
  4. The last call. The last pipe makes round(68.15, 1), which is 68.2. The 1 belongs to round(), and in the pipeline it is written right beside it.
  5. The same call, nested. Without pipes the same three calls are round(mean(as.numeric(weights), na.rm = TRUE), 1), read from the inside out, with the 1 for round() at the far end.

Each stage received the value from the stage above it as its first argument, and kept its own arguments where you wrote them: na.rm = TRUE stayed with mean() and the 1 with round(). The run computes the nested call and the pipeline, then the pipeline again with one step per line, the layout most R code uses:

pipe_rewrite.R, typed at the R console Python · pipe_rewrite_session.py
from session import run, version

version()
# R reads pipe_rewrite.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f pipe_rewrite.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f pipe_rewrite.R
> # Weights in kg from a clinic's form, read as text, one of them missing.
> weights <- c("61.5", "70.2", NA, "58.9", "82.0")
> # Nested: the first step to run, as.numeric(), is the innermost call.
> round(mean(as.numeric(weights), na.rm = TRUE), 1)
[1] 68.2
> # Piped: the steps read left to right, in the order they run.
> weights |> as.numeric() |> mean(na.rm = TRUE) |> round(1)
[1] 68.2
> # One step per line: a line that ends with |> continues on the next one.
> weights |>
+   as.numeric() |>
+   mean(na.rm = TRUE) |>
+   round(1)
[1] 68.2
> # The same pipeline without its last step, to see what reaches round().
> weights |> as.numeric() |> mean(na.rm = TRUE)
[1] 68.15
> # A line that starts with |> cannot continue the line before it.
> tryCatch(parse(text = c("weights", "  |> as.numeric()")), error = function(e) writeLines(conditionMessage(e)))
<text>:2:3: unexpected '|>'
1: weights
2:   |>
     ^

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipe_rewrite_session.py

All three give 68.2. A line that ends with |> is not finished, so R reads on to the next line; the console shows that with its + prompt. The next line drops the final step and prints 68.15, the value round() received. Cutting a pipeline short like this is the quickest way to see what reaches any step.

Putting |> at the start of a line

weights on one line and |> as.numeric() on the next cannot be joined: the first line is already a complete expression, so the second starts with |> and is a syntax error, “unexpected ‘|>’”, as the last line of the run shows. End each line with |> instead, so the expression is visibly unfinished.

Checkpoint

Check: Reading and writing a pipeline

  1. Question 1 of 2 What does this pipeline return?

    Read the code, then choose one answer.

    c(4, 9, 16) |> sqrt() |> rev() |> head(2)
    Show the answer: Question 1 of 2

    Answer: 4 3

    Read a pipeline from left to right: each step receives the result of the step before it as its first argument. The nested form is head(rev(sqrt(c(4, 9, 16))), 2).

  2. Question 2 of 2 Which call does x |> f(1, b = 2) run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: f(x, 1, b = 2)

    The help page of the pipe gives the rule: the lhs is inserted as the first argument in the call, so x |> f(y) is f(x, y).

The pipe is rewritten by the parser before anything runs

quote(weights |> mean(na.rm = TRUE)) prints mean(weights, na.rm = TRUE): there is no pipe left in the expression. The help page of the pipe says that pipe operations are implemented as a syntax transformation, so x |> f(y) is parsed as f(x, y). The pipe is not a function that runs; the parser rewrites your code into the nested call, and R then runs that call as if you had typed it. Follow three lines from the parser to the evaluator:

Interactive · message by message What R does with a pipe before and while it runs Step through three lines of the run. The parser reads each line first; the evaluator runs only what the parser hands it.
What R does with a pipe before and while it runs, step 5 of 5. Messages so far: Your code to The parser: weights |> mean(na.rm = TRUE); The parser to The evaluator: mean(weights, na.rm = TRUE); Your code to The parser: c(1, 4) |> sqrt; The parser to The evaluator: f(stop(...)).Your codewhat you typedThe parserparse error: not a callThe evaluatorstop() never ranweights |> mean(na.rm = TRUE)mean(weights, na.rm = TRUE)c(1, 4) |> sqrtf(stop(...))

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Typed with a pipe. You type weights |> mean(na.rm = TRUE). Before any of it runs, the parser reads the whole line.
  2. Rewritten. The parser moves weights into the call as its first argument. The expression it builds is mean(weights, na.rm = TRUE), with no pipe left in it, which is what quote() printed.
  3. Run as usual. The evaluator receives an ordinary call and runs it as if you had typed it nested, so the pipe costs nothing at run time and needs no package.
  4. Not a call. c(1, 4) |> sqrt has a name, not a call, on its right. The parser stops with "The pipe operator requires a function call as RHS", and nothing runs.
  5. Laziness is kept. The last line pipes stop() into a function that ignores its argument. The rewritten call passes stop() as that argument, R computes an argument only when it is used, and so nothing stops.

Three things follow from the rewrite, and the run shows each:

pipe_syntax.R, typed at the R console Python · pipe_syntax_session.py
from session import run, version

version()
# R reads pipe_syntax.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f pipe_syntax.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f pipe_syntax.R
> # The parser rewrites a pipe into an ordinary call, before anything runs.
> quote(weights |> mean(na.rm = TRUE))
mean(weights, na.rm = TRUE)
> quote(weights |> as.numeric() |> mean(na.rm = TRUE) |> round(1))
round(mean(as.numeric(weights), na.rm = TRUE), 1)
> # So a pipeline and the nested call are one and the same expression.
> identical(quote(w |> as.numeric() |> mean()), quote(mean(as.numeric(w))))
[1] TRUE
> # parse_message() returns "parsed", or the message of the parse error.
> parse_message <- function(code) tryCatch({ str2lang(code); "parsed" }, error = conditionMessage)
> # The right-hand side must be a call: sqrt is a name, sqrt() is a call.
> parse_message("c(1, 4) |> sqrt")
[1] "The pipe operator requires a function call as RHS (<input>:1:12)"
> parse_message("c(1, 4) |> sqrt()")
[1] "parsed"
> # Syntactically special functions, such as + and {, are refused.
> parse_message("x |> `+`(1)")
[1] "function '+' not supported in RHS call of a pipe (<input>:1:6)"
> parse_message("x |> { . + 1 }")
[1] "function '{' not supported in RHS call of a pipe (<input>:1:6)"
> # A function written in place must be wrapped in parentheses and called.
> parse_message("x |> function(v) v + 1")
[1] "function 'function' not supported in RHS call of a pipe (<input>:1:6)"
> parse_message("x |> (function(v) v + 1)()")
[1] "parsed"
> # Laziness still applies: the piped value is computed only if the function uses it.
> stop("this is never computed") |> (function(...) "the piped value was not used")()
[1] "the piped value was not used"

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipe_syntax_session.py

  • A pipeline costs nothing extra. identical() found the piped and the nested expression to be the same, so they run the same code at the same speed, and |> needs no package, only R 4.1.0 or later.
  • The right-hand side must be a call. c(1, 4) |> sqrt stopped the parser with “The pipe operator requires a function call as RHS”; sqrt() with its brackets parsed. Syntactically special functions, such as + and {, are refused even when written as a call, and so is a function written in place unless it is wrapped in parentheses and called, (function(v) v + 1)(), a form the section on anonymous functions uses.
  • Arguments are still lazy. The last line piped stop() into a function that never uses its argument, and nothing stopped: the rewritten call passes the piped expression as an argument, and R computes an argument only when the function first uses it, as the help page points out.

The messages came from str2lang(), which parses one line of text into an expression without running it, wrapped in tryCatch() so that each error becomes a message to print. Written into a script, the same mistake stops R before any tryCatch() could catch it, and how much of the script runs first depends on how the script is run:

parse_late.R, run with Rscript and with source() Python · parse_late_session.py
from session import run, version

version()
# The same script run two ways: Rscript reads it one expression at a time, source() parses all of it first.
run("""
!Rscript parse_late.R
!R --vanilla --quiet -e 'source("parse_late.R")'
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ Rscript parse_late.R
Line 2 ran.
Error in sum : 
  The pipe operator requires a function call as RHS (<input>:1:27)
Execution halted
[exit status 1]
$ R --vanilla --quiet -e 'source("parse_late.R")'
> source("parse_late.R")
Error in sum : 
  The pipe operator requires a function call as RHS (<input>:3:27)
Calls: source
Execution halted
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parse_late_session.py

Rscript reads a file one expression at a time, so line 2 printed its message before line 3 stopped the script. source() parses the whole file before it runs anything, so the same file printed nothing but the error, whose 3:27 is the line and column of the mistake.

Checkpoint

Check: The pipe as a rewrite by the parser

  1. Question 1 of 2 Why does c(1, 4) |> sqrt fail when c(1, 4) |> sqrt() works?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The right-hand side of the pipe must be a call, and sqrt without brackets is only a name

    The pipe is a rewrite done by the parser, and a rewrite into f(x, ...) needs the f(...) to be there. The error comes before anything runs.

  2. Question 2 of 2 A colleague avoids the native pipe inside a loop that runs a million times, to save time. What did the lesson's run show?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The pipeline is rewritten into the nested call before it runs, so both forms run the same code

    The help page says pipe operations are implemented as syntax transformations: x |> f(y) is parsed as f(x, y).

A placeholder sends the value to a named argument

d |> lm(weight ~ visit) stops with an error saying that a formula cannot be turned into a data frame. lm() takes the formula first and the data second, so the plain pipe made d the formula and moved the formula into data. Since R 4.2.0 the pipe placeholder: The _ in x |> f(data = _): it marks the named argument that receives the piped value instead of the first one. It may appear once, and since R 4.3.0 it may also start an extraction such as _$coef. _ fixes that: name the argument that should receive the value and pass _ to it, as in d |> lm(weight ~ visit, data = _). Watch where d goes in both calls:

Interactive · step by step Where the piped data frame lands in lm() Step through the plain pipe and the placeholder. The boxes on the right are the first two arguments of lm().
Where the piped data frame lands in lm(), step 5 of 5. Showing d, formula (weight ~ visit), data (d), the fitted model (weight = 73.45 - 1.10 visit), the slope (-1.1).plain pipedata = __$coefficientsda data frame, 4 visitsformulaweight ~ visitdatadthe fitted modelweight = 73.45 - 1.10 visitthe slope-1.1_$coefficients

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The plain pipe. d |> lm(weight ~ visit) passes d as the first argument. In lm() that is formula, so d becomes the formula and weight ~ visit is pushed into data.
  2. The error. lm() cannot use a formula as its data, and stops with an error about turning a formula into a data frame. The message names the symptom, not the pipe.
  3. The placeholder. d |> lm(weight ~ visit, data = _) names the argument that gets d. The parser turns it into lm(weight ~ visit, data = d), and the formula stays first.
  4. The model. lm() fits the line. Weight falls 1.10 kg a visit from 73.45 kg, and the model's printed Call shows data = d, where the parser put the data frame.
  5. An extraction. The placeholder can also start an extraction. |> _$coefficients[["visit"]] reads one number from the model, the slope, -1.1.

The rules are short. The placeholder must be given to a named argument, it may appear once, and since R 4.3.0 it may also head a chain of extractions with $, [[, [ or @, such as _$coefficients[["visit"]]. Where _ is not available, as on R 4.1, naming the arguments before the one you mean works too, because the piped value fills the first argument not yet matched: d |> lm(formula = weight ~ visit) sends d to data. The run tries each form:

placeholder.R, typed at the R console Python · placeholder_session.py
from session import run, version

version()
# R reads placeholder.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f placeholder.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f placeholder.R
> # Weight at four visits of a small study.
> d <- data.frame(visit = c(1, 2, 3, 4), weight = c(72.4, 71.1, 70.3, 69.0))
> # lm() takes the formula first and the data second, so the plain pipe sends d to the wrong argument.
> tryCatch(d |> lm(weight ~ visit), error = conditionMessage)
[1] "cannot coerce class ‘\"formula\"’ to a data.frame"
> # Name the argument that should get the value, and pass the placeholder _.
> d |> lm(weight ~ visit, data = _)

Call:
lm(formula = weight ~ visit, data = d)

Coefficients:
(Intercept)        visit  
      73.45        -1.10  

> # The placeholder can head a chain of extractions (R 4.3.0 and later).
> d |> lm(weight ~ visit, data = _) |> _$coefficients[["visit"]]
[1] -1.1
> # Both pipes are rewritten into ordinary calls.
> quote(d |> lm(weight ~ visit, data = _) |> _$coefficients[["visit"]])
lm(weight ~ visit, data = d)$coefficients[["visit"]]
> # The placeholder must be a named argument, and it may appear once.
> parse_message <- function(code) tryCatch({ str2lang(code); "parsed" }, error = conditionMessage)
> parse_message("d |> lm(weight ~ visit, _)")
[1] "pipe placeholder can only be used as a named argument (<input>:1:6)"
> parse_message("d |> merge(x = _, y = _)")
[1] "pipe placeholder may only appear once (<input>:1:6)"
> # Without _, name the arguments before it: the value fills the first one left.
> d |> lm(formula = weight ~ visit) |> coef()
(Intercept)       visit 
      73.45       -1.10 
> # A slip that gives no error: grepl() takes the pattern first.
> fruits <- c("apple", "kiwi", "mango")
> fruits |> grepl("a")
[1] FALSE
Warning message:
In grepl(fruits, "a") :
  argument 'pattern' has length > 1 and only the first element will be used
> fruits |> grepl("a", x = _)
[1]  TRUE FALSE  TRUE

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 placeholder_session.py

The model’s Call: line shows data = d: the parser rewrote the pipeline into lm(weight ~ visit, data = d), as quote() printed, and lm() shows that call with its first argument named. The slope read through _ is -1.1 kg a visit. An unnamed _ and a second _ both stopped at parse time with their own messages.

Piping into a function that takes the data second

fruits |> grepl("a") returns one FALSE and a warning, not three answers: it became grepl(fruits, "a"), so the three fruit names were used as the pattern. The warning shows the rewritten call. Write fruits |> grepl("a", x = _), or name the pattern, grepl(pattern = "a"), and the value goes to x.

These pieces arrived in three releases, so code that uses one needs at least that version of R:

Timeline · 3 events in order The pipe syntax, release by release Walk through the releases in order. Code that uses a piece of the syntax needs at least the release that added it.
  1. R 4.1.0

    The native pipe and the function shorthand new syntax

    x |> f(y) is parsed as f(x, y), always filling the first argument, and \(x) x + 1 is parsed as function(x) x + 1.

  2. R 4.2.0

    The placeholder for a named argument new syntax

    x |> f(y, data = _) sends x to data instead of the first argument. The placeholder must be given to a named argument and may appear only once on the right-hand side.

  3. R 4.3.0

    The placeholder as the head of an extraction new syntax

    x |> _$coef[[2]] reads from the piped value with $, [[, [ or @. Parse errors of pipes also became classed errors that give the line and column.

The timeline has 3 events, from R 4.1.0 to R 4.3.0.

Checkpoint

Check: The placeholder _ and extractions

  1. Question 1 of 2 Which line sends the data frame d to the data argument of lm()?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: d |> lm(weight ~ visit, data = _)

    Since R 4.2.0, x |> f(arg = _) sends x to arg. Naming the arguments before it, lm(formula = weight ~ visit), also works, because the value then fills the first argument not yet matched, data.

  2. Question 2 of 2 What does the last line return?

    Read the code, then choose one answer.

    d <- data.frame(x = c(1, 2, 3), y = c(2, 4, 6))
    d |> lm(y ~ x, data = _) |> _$coefficients[["x"]]
    Show the answer: Question 2 of 2

    Answer: 2

    The parser turns the line into lm(y ~ x, data = d)$coefficients[["x"]]: an ordinary call followed by two extractions.

Anonymous functions are written where they are used

sapply(prices, \(p) p * 1.18) adds 18 % tax to four prices without naming a function: \(p) p * 1.18 is an anonymous function: A function used without being given a name, written where it is needed, such as \(x) x * 2 passed to sapply(). \(x) is short for function(x), in R 4.1.0 and later., written where it is needed and passed straight to sapply(). The help page of function says the shorthand \(x) x + 1 is parsed as function(x) x + 1; it arrived with the pipe in R 4.1.0. Step through the four calls sapply() makes:

Interactive · message by message sapply() calls the anonymous function once per price Step through sapply(prices, \(p) p * 1.18). The function has no name of its own; sapply() holds it as its argument FUN.
sapply() calls the anonymous function once per price, step 5 of 5. Messages so far: sapply() to \(p) p * 1.18: p = 250; \(p) p * 1.18 to sapply(): returns 295; sapply() to \(p) p * 1.18: p = 1200; \(p) p * 1.18 to sapply(): returns 1416.sapply()returned 4 numbers\(p) p * 1.18no name, only FUNp = 250returns 295p = 1200returns 1416

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The first price. sapply() takes the first price, 250, and calls the function with it as p.
  2. The first result. The body, p * 1.18, gives 295, and the function returns it. sapply() keeps it as the first result.
  3. The second price. The same function is called again, now with p = 1200. Nothing from the first call is left over in it.
  4. The second result. 1200 * 1.18 is 1416. The two other prices, 89 and 640, follow in the same way and give 105.02 and 755.2.
  5. One vector. With every result a single number, sapply() returns them as one numeric vector, 295.00 1416.00 105.02 755.20, the line the run printed.

The function was called once per price, and sapply() collected the four results into one vector. The run shows the shorthand, the uses, and one rule from the pipe’s help page: to pipe into a function written in place, wrap it in parentheses and call it, prices |> (\(p) p[p > 500])().

lambdas.R, typed at the R console Python · lambdas_session.py
from session import run, version

version()
# R reads lambdas.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f lambdas.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f lambdas.R
> # \(x) is a shorter way to write function(x): the parser makes the same function.
> quote(\(price) price * 1.18)
function(price) price * 1.18
> add_tax <- \(price) price * 1.18
> add_tax(250)
[1] 295
> # Anonymous functions are made for functions that take functions.
> prices <- c(250, 1200, 89, 640)
> sapply(prices, \(p) p * 1.18)
[1]  295.00 1416.00  105.02  755.20
> Filter(\(p) p > 500, prices)
[1] 1200  640
> # To pipe into one, wrap it in parentheses and call it.
> prices |> (\(p) p[p > 500])()
[1] 1200  640
> # A named function is clearer when it is reused, longer than a line, or tested.
> with_tax <- function(price, rate = 0.18) {
+   stopifnot(is.numeric(price), price >= 0)
+   round(price * (1 + rate), 2)
+ }
> sapply(prices, with_tax)
[1]  295.00 1416.00  105.02  755.20
> prices |> with_tax(rate = 0.05)
[1]  262.50 1260.00   93.45  672.00

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lambdas_session.py

quote() printed the shorthand back as function(price) price * 1.18, so the two spellings make the same function. Anonymous functions suit one short expression used in one place, such as the test that Filter() applies. Give the function a name instead when you use it twice, when it grows past one line, when it checks its input, or when it deserves a test: with_tax() in the run does all four, and its name tells the reader what the pipeline step means.

Key idea

Write \(x) for a one-line function used once; as soon as a function is reused, checked or tested, give it a name with function().

Checkpoint

Check: Anonymous functions and when to name them

  1. Question 1 of 2 How do you pipe a vector into a function written in place?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: x |> (\(v) v * 2)()

    The pipe's help page shows the form: mtcars |> subset(cyl == 4) |> (\(d) lm(mpg ~ disp, data = d))().

  2. Question 2 of 2 A step of three scripts must check that prices are numbers of 0 or more, then add tax and round to 2 decimals. What should it be?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A named function written with function(), with its checks in its first lines

    Anonymous functions suit one short expression used once, such as the test Filter() applies. Anything reused, checked or tested deserves a name.

The magrittr pipe follows four different rules

Before R 4.1.0, R had no pipe of its own, and R code used %>%, the magrittr pipe: The %>% operator of the magrittr package: a function that passes its left side into the call or the function on its right, with a dot as its placeholder., which you will still meet in older scripts, answers and package documentation. It reads like |>, but it is a function of the magrittr package rather than a rewrite by the parser, and its rules differ in four places: bare function names, the placeholder, a value used inside a nested call, and braces. The run loads magrittr 2.0.5 and tries each:

magrittr.R, typed at the R console Python · magrittr_session.py
from session import run, version

version()
# R reads magrittr.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f magrittr.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f magrittr.R
> library(magrittr)
> packageVersion("magrittr")
[1] ‘2.0.5’
> # %>% accepts a bare function name, which |> refuses.
> c(1, 4, 9) %>% sqrt
[1] 1 2 3
> # Its placeholder is a dot.
> d <- data.frame(visit = c(1, 2, 3, 4), weight = c(72.4, 71.1, 70.3, 69.0))
> d %>% lm(weight ~ visit, data = .) %>% coef()
(Intercept)       visit 
      73.45       -1.10 
> # A dot inside a nested call does not stop %>% from adding the value first.
> show_args <- function(...) str(list(...))
> c(5, 6) %>% show_args(length(.))
List of 2
 $ : num [1:2] 5 6
 $ : int 2
> c(5, 6) %>% show_args(., length(.))
List of 2
 $ : num [1:2] 5 6
 $ : int 2
> # Braces make one step a small block, with the dot as its value.
> c(9, 16) %>% { . + 1 }
[1] 10 17
> # %>% is a function: the pipeline stays a call to %>%, while |> is rewritten.
> quote(c(1, 4) %>% sqrt())
c(1, 4) %>% sqrt()
> quote(c(1, 4) |> sqrt())
sqrt(c(1, 4))

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 magrittr_session.py

Rule Native pipe %>% (magrittr)
Needs R 4.1.0 or later the magrittr package
Right-hand side a call: sqrt() a call or a bare name: sqrt
Placeholder _, once, to a named argument ., any argument, any number of times
Value inside a nested call not allowed f(length(.)) still adds the value first
Braces refused { . + 1 } runs as one step
What R runs the rewritten call, sqrt(x) a call to the function %>%

The fourth row is the one that surprises people. magrittr’s documentation calls it the first-argument rule: when the dot appears only inside a nested call, the value is still inserted as the first argument, so c(5, 6) %>% show_args(length(.)) passed two arguments, the vector and its length. The last two lines of the run show the last row: quote() kept %>% as a call, while the native pipe vanished into sqrt(c(1, 4)).

That last row also shows when something fails. Each script below converts two weights to kilograms with a conversion factor typed as text by mistake, one through %>% and one through |>:

error_magrittr.R and error_native.R, run as scripts Python · pipe_errors_session.py
from session import run, version

version()
# Both scripts stop with an error, and R names the calls that were running when it stopped.
run("""
!R --vanilla --quiet -f error_magrittr.R
!R --vanilla --quiet -f error_native.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f error_magrittr.R
> library(magrittr)
> # A slip: the conversion factor was typed as text.
> to_kg <- function(lb) lb * "0.4536"
> c(150, 172) %>% round() %>% to_kg()
Error in lb * "0.4536" : non-numeric argument to binary operator
Calls: %>% -> to_kg
Execution halted
[exit status 1]
$ R --vanilla --quiet -f error_native.R
> # The same slip, in a pipeline written with the native pipe.
> to_kg <- function(lb) lb * "0.4536"
> c(150, 172) |> round() |> to_kg()
Error in lb * "0.4536" : non-numeric argument to binary operator
Calls: to_kg
Execution halted
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipe_errors_session.py

Both stop with the same error, but the list of calls that R prints differs: %>% -> to_kg for magrittr, because the pipe is a function that was running, and only to_kg for the native pipe, whose pipeline had become the nested call to_kg(round(c(150, 172))) before it ran.

Converting %>% code

Search for a dot used as an argument, data = ., which becomes data = _; for bare function names, which need (); and for braces or a nested dot, which need a named function or \(x) instead. The tidyverse style guide now recommends |> over %>%, and the lessons here use |>.

The same guide gives the rule for where a pipeline should end: pipes suit a chain of steps that all work on one main object. When a step needs a second object, or a result in the middle deserves a name of its own, assign it and start a new pipeline from there.

Checkpoint

Check: How the magrittr pipe differs

  1. Question 1 of 2 What does the last line show?

    Read the code, then choose one answer.

    library(magrittr)
    show_args <- function(...) str(list(...))
    c(5, 6) %>% show_args(n = length(.))
    Show the answer: Question 1 of 2

    Answer: Two arguments: the vector 5 6 first, then n = 2

    magrittr's documentation: when the placeholder appears only in nested expressions, the first-argument rule still applies. The native pipe has no such rule, because its placeholder may only stand for a whole argument.

  2. Question 2 of 2 You are converting x %>% sort %>% unique to the native pipe. Which line is right?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: x |> sort() |> unique()

    A bare function name is the easiest difference to fix: add the brackets. The dot, braces and a nested dot need more thought.

Practise: predict, then check

Write down each answer before you run anything; the recorded run below then marks your work.

Your predictions

Predict the value of c(16, 25, 36) |> sqrt() |> sum(), the call that quote(scores |> subset(score > 50) |> nrow()) prints, the slope of effect ~ dose for doses 1, 2, 3 and effects 2.1, 3.9, 6.2 read with _$coefficients[["dose"]], and what c(3, 8, 12) |> (\(v) v[v > 5] * 10)() returns. Then type the lines into R.

practice.R, typed at the R console Python · practice_session.py
from session import run, version

version()
# R reads practice.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f practice.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f practice.R
> # 1. The value of a pipeline:
> c(16, 25, 36) |> sqrt() |> sum()
[1] 15
> # 2. Where each value goes:
> quote(scores |> subset(score > 50) |> nrow())
nrow(subset(scores, score > 50))
> # 3. A placeholder and an extraction:
> d <- data.frame(dose = c(1, 2, 3), effect = c(2.1, 3.9, 6.2))
> d |> lm(effect ~ dose, data = _) |> _$coefficients[["dose"]]
[1] 2.05
> # 4. A lambda in a pipeline:
> c(3, 8, 12) |> (\(v) v[v > 5] * 10)()
[1]  80 120

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

The roots 4, 5 and 6 add up to 15; the parser nests the calls from the last step outwards; the slope is 2.05; and the lambda kept 8 and 12 and multiplied them by 10.

The questions below pick up where your answers so far are weakest. Pick a choice and it tells you at once what it gets right or which rule of the pipe it misses:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 Which pipeline is the same as round(sqrt(sum(x)), 2)?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: x |> sum() |> sqrt() |> round(2)

    Rewrite a nested call by starting from the innermost call and moving out, carrying each call's own arguments along with it.

  2. Question 2 of 15 How can you see the value that reaches the last step of a pipeline?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: Run the pipeline without its last step

    Cutting a pipeline short after any step shows exactly what the next step receives.

  3. Question 3 of 15 Why is this a syntax error?

    Read the code, then choose one answer.

    total <- marks
      |> sum()
    Show the answer: Question 3 of 15

    Answer: The first line is a complete expression, so the second line starts with a pipe that has nothing on its left

    The run parsed two lines of the same shape and got "unexpected '|>'" at line 2. A line that ends with the pipe is unfinished, so R reads on.

  4. Question 4 of 15 What does this print?

    Read the code, then choose one answer.

    quote(x |> f(y = 1) |> g())
    Show the answer: Question 4 of 15

    Answer: g(f(x, y = 1))

    quote() returns the expression as the parser built it, which is why it is the best way to see what a pipeline means.

  5. Question 5 of 15 Which right-hand side does the native pipe refuse?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: ` +(1) `

    The help page: to avoid ambiguities, functions in rhs calls may not be syntactically special, such as + or if.

  6. Question 6 of 15 f <- function(a, b) b. What does stop("A") |> f(b = 1) do?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: It returns 1: stop("A") is passed as a, which f() never uses

    The pipe's help page: regular R semantics for evaluation apply, so piped expressions are evaluated only when first used in the rhs expression.

  7. Question 7 of 15 Where does the value go in x |> f(a = 1, b = _)?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: To b

    The placeholder must be a named argument, b = _ here, and it may appear once on the right-hand side.

  8. Question 8 of 15 fruits <- c("apple", "kiwi"). Why does fruits |> grepl("i") return one FALSE and a warning?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: It runs grepl(fruits, "i"), so the fruit names became the pattern and "i" the text searched

    Write fruits |> grepl("i", x = _), or name the pattern, grepl(pattern = "i"), so the value goes to x.

  9. Question 9 of 15 A package must also run on R 4.2.3. Which line may its code use?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: d |> lm(y ~ x, data = _)

    The R NEWS entries date each piece: the pipe and \(x) in 4.1.0, the named placeholder in 4.2.0, extractions in 4.3.0.

  10. Question 10 of 15 What is \(x) x^2?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: The same function as function(x) x^2

    quote(\(price) price * 1.18) printed function(price) price * 1.18 in the run.

  11. Question 11 of 15 What does this return?

    Read the code, then choose one answer.

    Filter(\(n) n %% 2 == 0, c(3, 8, 10, 7))
    Show the answer: Question 11 of 15

    Answer: 8 10

    The anonymous function is the test. It is used once, in one place, which is what \(x) suits.

  12. Question 12 of 15 How many times does sapply(c(5, 10, 15), \(v) v + 1) call the anonymous function?

    Type a number.

    A hint

    Count the elements sapply() goes through.

    Show the answer: Question 12 of 15

    Answer: 3 calls

    sapply() calls its function once for each element, 5, 10 and 15, and collects 6, 11 and 16 into one vector.

  13. Question 13 of 15 What is the placeholder of magrittr's %>%?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: A dot, .

    The dot may appear in any argument, any number of times, which is why code written with %>% often needs more than a search and replace.

  14. Question 14 of 15 How do you write x %>% { . * 2 + 1 } with the native pipe?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: x |> (\(v) v * 2 + 1)()

    A named function is the other good answer when the block is longer than a line.

  15. Question 15 of 15 Why does quote(x %>% f()) print x %>% f() while quote(x |> f()) prints f(x)?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: %>% is a function, so the call to it stays in the expression; the native pipe is rewritten by the parser

    This is also why errors inside a %>% pipeline show calls to the pipe in a traceback, while a native pipeline shows only the nested calls.

Interview questions

Warm-up: what does the native pipe |> do in R?

It passes the value on its left into the call on its right, as that call’s first argument: x |> f(y) means f(x, y), and a chain such as x |> f() |> g() means g(f(x)). It has been part of R since 4.1.0 and needs no package. It is not a function: the parser rewrites the pipeline into the nested call before anything runs, so it adds no cost at run time, and R’s lazy evaluation of arguments applies as usual. The right-hand side must be a call, so x |> sqrt is a parse error and x |> sqrt() is right. To send the value to another argument, name that argument with the placeholder, as in d |> lm(y ~ x, data = _), which needs R 4.2.0 or later.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Your team wants to replace every %>% in a 3,000-line analysis with the native |>, using find and replace. Which lines will break, which could change their results without an error, how do you find both before running the analysis, and how do you show that the results did not change?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A function in your team's package pipes data into lm() and returns the slope of the model. The package promises to work on R 4.1.2. Which forms of the native pipe may the function use, how do you write its two steps for that version, and how do you keep a later edit from breaking the promise?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • x |> f(y) passes x to f() as its first argument, so a pipeline lists its steps in the order they run and keeps each step’s own arguments beside it.
  • The parser rewrites a pipeline into the nested call before anything runs, so |> costs nothing, needs R 4.1.0 or later, keeps lazy evaluation, and refuses a right-hand side that is not a call.
  • The placeholder _ sends the value to a named argument, once per call, and since R 4.3.0 it can head an extraction such as _$coefficients; naming the arguments before it is the other way to skip the first one.
  • \(x) is short for function(x): use it for a one-line function passed once, wrap it in parentheses to pipe into it, and give a function a name when it is reused, checked or tested.
  • magrittr’s %>% is a function that accepts bare names, braces and a dot placeholder that may appear anywhere, so converting it to |> means rewriting those steps rather than replacing the symbol.

Terms in this lesson

anonymous function
A function used without being given a name, written where it is needed, such as \(x) x * 2 passed to sapply(). \(x) is short for function(x), in R 4.1.0 and later.
magrittr pipe
The %>% operator of the magrittr package: a function that passes its left side into the call or the function on its right, with a dot as its placeholder.
native pipe
R's built-in |> operator, in R 4.1.0 and later. The parser rewrites x |> f(y) as f(x, y), so the value on the left becomes the first argument of the call on the right.
pipe placeholder
The _ in x |> f(data = _): it marks the named argument that receives the piped value instead of the first one. It may appear once, and since R 4.3.0 it may also start an extraction such as _$coef.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.