# The only external package referenced (one example)
# install.packages("rlang")
library(rlang)The Pipe Equivalence Myth: When f() |> g() Is Not the Same as g(f())
Piping and nesting function calls are semantically different operations. A subtle bug in an expression- capturing wrapper reveals how R’s lazy evaluation interacts with the pipe operator.

When pipes meet metaprogramming, the results may be surprising.
1 Introduction
Are f() |> g() and g(f()) truly interchangeable notations? A plot wrapper that captured user expressions for logging revealed otherwise. The wrapper worked perfectly with nested calls but silently produced wrong output when piped. That experience leads directly into R’s evaluation model, revealing that the “equivalence” every tutorial teaches has a significant caveat that is rarely discussed.
Most R programmers internalize the pipe as pure syntactic sugar. The documentation and textbooks reinforce this mental model, and for the vast majority of everyday code, it holds. But there is a class of functions where the assumption breaks down completely, and understanding why reveals something fundamental about how R works under the hood.
We document the interaction between pipes, lazy evaluation, and non-standard evaluation (NSE), walking through the problem, showing exactly when and why the equivalence fails, and presenting practical workarounds for designing pipe-safe APIs.
1.1 Motivations
- To understand a silent bug in a plot wrapper function that only manifested when users piped input instead of nesting calls.
- To develop a deeper understanding of R’s evaluation semantics beyond the surface-level “pipe is just sugar” explanation.
- To document a limitation that tutorials and textbooks rarely mention.
- To determine which utility function patterns using
substitute()andmatch.call()are pipe-safe and which are not. - To understand the relationship between referential transparency, lazy evaluation, and non-standard evaluation in R.
1.2 Objectives
- Demonstrate that
f() |> g()andg(f())produce different results wheng()uses non-standard evaluation. - Explain the evaluation order difference between piped and nested calls, with step-by-step tracing.
- Catalogue which R functions and metaprogramming tools are affected by this problem.
- Present four practical design patterns for writing functions that work correctly regardless of how they are called.
Errors and better approaches are welcome; see the Feedback section at the end.

2 Prerequisites and Setup
This post is conceptual and uses minimal code. No special packages are required beyond base R. The rlang package appears in one example to illustrate tidy evaluation, but the core concepts rely entirely on base R functions.
Background assumed: Familiarity with writing R functions, basic understanding of the pipe operator (|> or %>%), and awareness that R has both standard and non-standard evaluation.
3 What is Pipe Equivalence?
Pipe equivalence is the widely-taught principle that x |> f() means exactly the same thing as f(x). The pipe takes the result on the left and passes it as the first argument to the function on the right. Every R tutorial, textbook, and vignette states this as fact, and for value-based functions it is true. The equivalence breaks, however, when the receiving function inspects the expression it was given rather than just the value. This happens with any function that uses non-standard evaluation (functions like substitute(), match.call(), and their tidy evaluation counterparts).
4 The Assumption Everyone Teaches
Ask any R programmer what x |> f() means, and the answer will be that it is equivalent to f(x). The pipe operator is just syntactic sugar for function composition, right?
This is exactly what the documentation and tutorials tell us:
“
x %>% fis equivalent tof(x)”“
x %>% f(y)is equivalent tof(x, y)”“
x %>% f %>% g %>% his equivalent toh(g(f(x)))”— A popular R package vignette
And from a widely-used data science textbook:
“The pipe takes the thing on its left and passes it along to the function on its right so that
x |> f(y)is equivalent tof(x, y), andx |> f(y) |> g(z)is equivalent tog(f(x, y), z).”
Tutorials reinforce this message:
“Multiple pipes can be chained together, such that
x %>% f() %>% g() %>% h()is equivalent toh(g(f(x))).”— A popular online R tutorial
# These are "the same"
sqrt(16) |> log()
log(sqrt(16))Both return 1.3862944. So far, so good.
This equivalence holds for the vast majority of R code. But there is a class of functions where this assumption breaks down completely, and understanding why reveals something fundamental about how R works.
5 When the Equivalence Breaks
Consider a function that needs to capture the expression passed to it, not just its value:
capture_expr <- function(expr) {
deparse(substitute(expr))
}
# Direct call - captures the expression
capture_expr(sqrt(16))[1] "sqrt(16)"
# Piped call - captures... what?
sqrt(16) |> capture_expr()[1] "sqrt(16)"
The direct call captures "sqrt(16)" as a string. The piped version captures something entirely different – the intermediate result after sqrt(16) has already been evaluated.
This is not a bug. It is a fundamental consequence of how pipes work.
5.1 The Evaluation Order Problem
The pipe operator evaluates left-to-right before passing to the next function. By the time capture_expr() can call substitute(), it only sees the return value, not the original expression.
Here is the sequence of events:
Direct call: capture_expr(sqrt(16))
- R sees the call to
capture_expr() - The argument
sqrt(16)is passed unevaluated (lazy evaluation) - Inside
capture_expr(),substitute(expr)capturessqrt(16) deparse()converts it to the string"sqrt(16)"
Piped call: sqrt(16) |> capture_expr()
- R evaluates
sqrt(16), which returns4 - The value
4is passed tocapture_expr() - Inside
capture_expr(),substitute(expr)captures4 deparse()converts it to the string"4"
The critical difference: with direct calls, R’s lazy evaluation means arguments are passed as promises containing the unevaluated expression. The pipe forces evaluation before the handoff.

Understanding the internals helps explain the surface behaviour.
6 A Real-World Example
This is not just academic. The problem surfaces immediately when building practical tools. Consider building a plot wrapper that captures what the user typed for logging or history:
# Desired behavior: capture "plot(mtcars$wt,
# mtcars$mpg)" for the log
zzplot <- function(expr) {
# Capture what the user typed
code <- deparse(substitute(expr))
# Open device, evaluate, close device
png("plot.png")
eval(
substitute(expr),
envir = parent.frame()
)
dev.off()
# Log the code
message("Rendered: ", code)
}
# This works - captures the expression
zzplot(plot(mtcars$wt, mtcars$mpg))
# Message: Rendered: plot(mtcars$wt, mtcars$mpg)
# This fails - plot already executed, returns NULL
plot(mtcars$wt, mtcars$mpg) |> zzplot()
# Message: Rendered: NULLThe wrapper function needs to:
- Capture the plotting code as an expression
- Evaluate it inside a graphics device context
- Log what was executed
With piping, step 1 fails because the plot has already been drawn to whatever device was active before zzplot() even runs.
6.1 Functions Affected by This Problem
Any function using non-standard evaluation (NSE) is potentially affected:
| Function | Purpose | Pipe-safe? |
|---|---|---|
substitute() |
Capture unevaluated expression | No |
deparse(substitute()) |
Convert expression to string | No |
match.call() |
Capture the entire function call | No |
enquo() / enquos() |
Tidy eval expression capture | No |
rlang::enexpr() |
Capture single expression | No |
quote() |
Quote an expression | Yes* |
bquote() |
Quote with substitution | Yes* |
* These quote the literal argument, so piping changes what gets quoted.
Many tidyverse functions use NSE internally but are designed to work with pipes because they evaluate captured expressions in data contexts. However, when one writes functions that need to capture user expressions, the pipe becomes problematic.
6.2 The Native Pipe vs. Magrittr
Does the choice of pipe operator matter? Not for this problem.
Both |> (native, R 4.1+) and %>% (magrittr) evaluate the left-hand side before passing it to the right. The native pipe is essentially a syntax transformation at parse time (Wickham and Henry 2023):
# Native pipe: parsed as
x |> f(y)
# becomes
f(x, y)
# But x is still evaluated before f() sees itThe magrittr pipe does more work behind the scenes but has the same fundamental behavior: eager evaluation of the left-hand side (Bache and Wickham 2022).
Magrittr does offer %!>%, an “eager pipe” that forces evaluation at each step, but this is for controlling when side effects occur, not for enabling expression capture.
7 Workarounds and Design Patterns
7.1 Pattern 1: Accept the Limitation
Designing the API to require wrapping, not piping, is the most explicit path:
# Document that this is the correct usage
zzplot(
boxplot(mpg ~ cyl, data = mtcars)
)
# Not this
boxplot(mpg ~ cyl, data = mtcars) |> zzplot()This is explicit and unambiguous. The tradeoff is that it breaks the “pipe everything” mental model some users have developed.
7.2 Pattern 2: Provide Both APIs
Offer a standard evaluation version alongside the NSE version:
# NSE version - for interactive use
zzplot(plot(x, y))
# SE version - for programmatic use and piping
zzplot_expr(quote(plot(x, y)))
# Or with a string
zzplot_code("plot(x, y)")7.3 Pattern 3: Use Quosures (Tidy Eval)
The rlang package provides quosures, which bundle an expression with its environment (Henry and Wickham 2023). This enables more robust expression capture in some contexts:
library(rlang)
my_function <- function(expr) {
quo <- enquo(expr)
expr_text <- quo_text(quo)
# ... use the quosure
}However, this still does not solve the pipe problem – enquo() captures what it receives, and the pipe has already evaluated the left-hand side.
7.4 Pattern 4: Redesign to Avoid NSE
Sometimes the cleanest solution is to not use NSE at all:
# Instead of capturing expressions, accept functions
zzplot_fn <- function(plot_fn, ...) {
png("plot.png")
plot_fn(...)
dev.off()
}
# Usage (pipeable with anonymous functions)
\() plot(mtcars$wt, mtcars$mpg) |>
zzplot_fn()This is less elegant but completely unambiguous about evaluation order.
8 Checking Our Work: Referential Transparency
This issue reflects a fundamental property of programming languages: referential transparency. A function is referentially transparent when any expression can be replaced with its value without changing the program’s behavior (Wickham 2019).
# Referentially transparent
f <- function(x) x + 1
f(2 + 2) # Same as f(4)
# NOT referentially transparent
g <- function(x) deparse(substitute(x))
g(2 + 2) # Returns "2 + 2"
g(4) # Returns "4"NSE functions are not referentially transparent by design: they care about how something is expressed, not just what value it produces. Pipes assume referential transparency because they pre-compute values.
Hadley Wickham notes in Advanced R: “The biggest downside of NSE is that functions that use it are no longer referentially transparent” (Wickham 2019).
8.1 Things to Watch Out For
Several situations catch people off guard:
- Plot wrappers with logging. Any function that uses
substitute()to record what the user typed will capture the wrong thing when piped. - Custom assertion functions. If an assertion function captures the expression for error messages (like
stopifnot()does internally), piping changes the error output. - Tidyverse extensions. Writing functions that wrap
dplyrverbs with additionalenquo()logic can behave differently depending on how users call them. - Debugging tools. Functions that reconstruct the call stack or print “you called f(x)” will show the piped intermediate value rather than the original expression.
- Code generation. Any metaprogramming that builds code from captured expressions will generate different code depending on whether the user piped or nested.

The distinction between value semantics and expression semantics runs deep in R.
9 What Did We Learn?
9.1 Lessons Learnt
Conceptual Understanding:
- The pipe equivalence
f() |> g()andg(f())holds for value semantics but fails for expression semantics. This is not a bug but a fundamental property of how R evaluates code. - R’s lazy evaluation passes arguments as unevaluated promises, which NSE functions can inspect. The pipe forces evaluation before the handoff, destroying the promise.
- Referential transparency is the key concept: pipes assume it, and NSE functions violate it by design.
- The tension between eager evaluation (pipes), lazy evaluation (promises), and non-standard evaluation (expression inspection) is inherent in R’s design.
Technical Skills:
- Tracing the evaluation order difference between
capture_expr(sqrt(16))andsqrt(16) |> capture_expr()step by step clarifies the underlying mechanism. - Distinguishing between functions that are pipe-safe (value-based) and those that are not (expression-based) requires checking whether they use
substitute(),match.call(), orenquo(). - Four design patterns exist for building pipe-compatible APIs: accepting the limitation, dual APIs, quosures, and NSE avoidance.
- Tidyverse functions work with pipes despite using NSE internally because they evaluate captured expressions in data contexts rather than inspecting them for the calling expression.
Gotchas and Pitfalls:
- The native pipe (
|>) and magrittr pipe (%>%) both have this limitation. Switching pipe operators does not solve the problem. - The failure is silent: the piped version does not throw an error; it simply captures the wrong thing. This makes the bug difficult to detect without explicit testing.
- Documentation for NSE functions rarely mentions pipe incompatibility. Authors of such functions should document the limitation explicitly.
- Quosures (
enquo()) do not solve the pipe problem – they still receive the pre-evaluated value when the call is piped.
9.2 Limitations
- This post focuses on expression capture via
substitute()and related tools. Other forms of NSE (such as formula evaluation oreval(parse())) may interact with pipes differently and are not covered. - The examples use simple, isolated functions. In production code, the interaction between pipes, NSE, and environments can be more complex, particularly with nested function calls.
- This post did not benchmark the performance implications of the different workaround patterns. For most use cases the performance difference is negligible, but this is not verified.
- The discussion treats
|>and%>%as equivalent for this problem. There are other differences between them (placeholder syntax, anonymous function support) that are outside the scope of this post. - This analysis is based on R 4.1+ behavior. Earlier versions of R did not have the native pipe, and the magrittr pipe has evolved over time.
9.3 Opportunities for Improvement
- Build a comprehensive test suite that validates pipe-safety for a library of utility functions, automatically flagging any function that uses NSE internally.
- Investigate whether R’s native pipe could be extended to support a “lazy” mode that preserves promise semantics, and assess the trade-offs.
- Create a static analysis tool (perhaps using the
codetoolspackage) that warns when NSE functions appear downstream of a pipe operator. - Explore how other languages with pipe operators (Elixir, F#, Julia) handle the tension between eager evaluation and expression capture.
- Document this limitation in the context of specific tidyverse extension patterns, providing a cookbook of pipe-safe alternatives for common NSE use cases.
10 Wrapping Up
The pipe equivalence f() |> g() and g(f()) holds for value semantics but fails for expression semantics. This is not a bug in R or the pipe operators; it is a fundamental tension between:
- Eager evaluation (pipes compute left-to-right)
- Lazy evaluation (functions receive unevaluated promises)
- Non-standard evaluation (functions inspect the expression, not just the value)
The most valuable aspect of this investigation is the understanding it provides of R’s evaluation model. The pipe is not “just sugar”; it changes the evaluation semantics in a way that matters for a specific but important class of functions.
For anyone writing functions that use substitute(), match.call(), or similar metaprogramming tools: documenting that piping will not work as expected is warranted. For anyone using such functions: the distinction between wrapping f(g(x)) and piping x |> g() |> f() is a semantic difference, even when both produce the same final value.
In conclusion, four points merit emphasis. First, pipes force eager evaluation, destroying the lazy promises that NSE functions depend on. Second, the failure is silent: no error is raised, but the output is wrong. Third, four design patterns exist for working around this limitation: accepting it, providing dual APIs, using quosures, or avoiding NSE entirely. Fourth, understanding this distinction is a prerequisite for effective R metaprogramming.
11 See Also
11.2 Key Resources
- Non-standard evaluation in Advanced R (1st ed.) by Hadley Wickham
- Evaluation chapter in Advanced R (2nd ed.)
- Tidy Evaluation book by the tidyverse team
- Standard and Non-Standard Evaluation in R by Brodie Gaslam
- Differences between the base R and magrittr pipes on the tidyverse blog
- Design tradeoffs in magrittr
- Understanding Non-Standard Evaluation, Part 1 by Thomas Adventureson
11.3 References
12 Reproducibility
This post is primarily conceptual with minimal executable code. The few code chunks that do execute require only base R (version 4.1 or later for native pipe support).
sessionInfo()R version 4.5.3 (2026-03-11)
Platform: aarch64-apple-darwin25.3.0
Running under: macOS Tahoe 26.5
Matrix products: default
BLAS: /opt/homebrew/Cellar/openblas/0.3.32/lib/libopenblasp-r0.3.32.dylib
LAPACK: /opt/homebrew/Cellar/r/4.5.3/lib/R/lib/libRlapack.dylib; LAPACK version 3.12.1
locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
time zone: America/Los_Angeles
tzcode source: internal
attached base packages:
[1] stats graphics grDevices utils datasets methods base
loaded via a namespace (and not attached):
[1] htmlwidgets_1.6.4 compiler_4.5.3 fastmap_1.2.0 cli_3.6.6
[5] tools_4.5.3 htmltools_0.5.8.1 parallel_4.5.3 yaml_2.3.10
[9] rmarkdown_2.29 knitr_1.50 jsonlite_2.0.0 xfun_0.56
[13] digest_0.6.37 rlang_1.2.0 evaluate_1.0.5
Files in this analysis:
analysis/report/index.qmd This blog post
references.bib Bibliography (BibTeX)
13 Let’s Connect
Have questions, suggestions, or spot an error? Let me know.
- GitHub: rgt47
- Twitter/X: (rgt47?)
- LinkedIn: Ronald Glenn Thomas
- Email: Contact form
I would enjoy hearing from you if:
- An error or a better approach to any of the code in this post comes to mind.
- There are suggestions for topics to see covered.
- The interest is in discussing R programming, data science, or reproducible research.
- There are questions about anything in this tutorial.
- The goal is simply to say hello and connect.