What Is Non-Standard Evaluation (NSE) and Why Does It Matter?

Learn how to use R’s Non-Standard Evaluation tools — quote(), substitute(), deparse(), and eval() — to capture, inspect, and control code execution. This post breaks down these core functions with clear examples for logging, metaprogramming, and tidy evaluation.
R
NSE
Metaprogramming
Author
Affiliations

R.Andres Castaneda

The World Bank

DECDG

Published

April 30, 2025

1. Intro

Non-Standard Evaluation (NSE) is one of the most powerful — and most confusing — features of the R language. It allows functions to manipulate the expressions that users write, instead of just their evaluated values. This behavior is at the heart of many modeling and data manipulation tools in R, including with(), subset(), and all of the tidyverse.

Historical Roots: with() and subset() in Base R

Before packages like dplyr, base R already offered functions that evaluated expressions in a different context — usually within a data frame — without requiring users to quote variable names.

Here are two classic examples:

with(mtcars, mpg + cyl)
 [1] 27.0 27.0 26.8 27.4 26.7 24.1 22.3 28.4 26.8 25.2 23.8 24.4 25.3 23.2 18.4
[16] 18.4 22.7 36.4 34.4 37.9 25.5 23.5 23.2 21.3 27.2 31.3 30.0 34.4 23.8 25.7
[31] 23.0 25.4
#> Adds mpg and cyl from mtcars — no $ needed

subset(mtcars, mpg > 20)
                mpg cyl  disp  hp drat    wt  qsec vs am gear carb
Mazda RX4      21.0   6 160.0 110 3.90 2.620 16.46  0  1    4    4
Mazda RX4 Wag  21.0   6 160.0 110 3.90 2.875 17.02  0  1    4    4
Datsun 710     22.8   4 108.0  93 3.85 2.320 18.61  1  1    4    1
Hornet 4 Drive 21.4   6 258.0 110 3.08 3.215 19.44  1  0    3    1
Merc 240D      24.4   4 146.7  62 3.69 3.190 20.00  1  0    4    2
Merc 230       22.8   4 140.8  95 3.92 3.150 22.90  1  0    4    2
Fiat 128       32.4   4  78.7  66 4.08 2.200 19.47  1  1    4    1
Honda Civic    30.4   4  75.7  52 4.93 1.615 18.52  1  1    4    2
Toyota Corolla 33.9   4  71.1  65 4.22 1.835 19.90  1  1    4    1
Toyota Corona  21.5   4 120.1  97 3.70 2.465 20.01  1  0    3    1
Fiat X1-9      27.3   4  79.0  66 4.08 1.935 18.90  1  1    4    1
Porsche 914-2  26.0   4 120.3  91 4.43 2.140 16.70  0  1    5    2
Lotus Europa   30.4   4  95.1 113 3.77 1.513 16.90  1  1    5    2
Volvo 142E     21.4   4 121.0 109 4.11 2.780 18.60  1  1    4    2
#> Filters rows where mpg > 20 — again, mpg is unquoted

In both cases, you are allowed to write expressions like mpg + cyl or mpg > 20 directly, without using mtcars$mpg. This works because with() and subset() don’t evaluate their arguments immediately. Instead, they capture the expression you wrote and evaluate it inside the data frame environment.

This ability to manipulate expressions and control where they are evaluated is exactly what Non-Standard Evaluation enables.

What Is Standard Evaluation?

To appreciate what NSE changes, it helps to understand what R does by default — which is standard evaluation (SE).

In SE, R evaluates every name and expression before passing it to a function. For example:

x <- 10
f <- function(y) y + 1
f(x)
[1] 11

Here, x is evaluated to 10, and f() receives that value.

What Is Non-Standard Evaluation?

With NSE, a function can capture what the user typed — the unevaluated expression — and decide:

  • When to evaluate it,
  • Where to evaluate it,
  • Or even whether to evaluate it at all.

Let’s create a minimal example:

print_expr <- function(expr) {
  print(expr)
}
print_expr(x + 1)
[1] 11

Looks standard, right? Now try this:

print_expr <- function(expr) {
  print(substitute(expr))
}
print_expr(x + 1)
x + 1

Now we’re seeing the expression x + 1 itself, rather than the result 11. That’s NSE in action: we intercepted the unevaluated code.

A Concrete Example: Logging

Let’s write a simple logger that prints the code you typed:

log_eval <- function(expr) {
  cat("About to evaluate:", deparse(substitute(expr)), "\n")
  result <- eval(expr)
  result
}

x <- 2
log_eval(x + 3)
About to evaluate: x + 3 
[1] 5

This function:

  • Uses substitute() to capture the expression x + 3,
  • Prints the expression,
  • Then evaluates it.

As an aside, this can also be done using match.call(), which you can read more about using ?match.call(). Note the way the match.call() is subsetted – what does that tell you about the output of the function?

log_eval <- function(expr) {
  p <- match.call()
  cat("calling function:", deparse(p[[1]]), "\n")
  cat("About to evaluate:", deparse(p[[2]]), "\n")
  result <- eval(expr)
  result
}
log_eval(x + 3)
calling function: log_eval 
About to evaluate: x + 3 
[1] 5
log_eval(mean(x + c(5:10)))
calling function: log_eval 
About to evaluate: mean(x + c(5:10)) 
[1] 9.5

2. Expressions Are Not Strings: Understanding How R Represents Code

To understand that piece of code fully, we need to know tools like substitute(), deparse(), or eval(). Yet, there is something even more fundamental to understand first:

In R, code is data.

When you write x + 1, R does not treat that as text or as a string. Instead, it creates a structured object — an expression — that the interpreter can inspect, manipulate, or evaluate later.

This idea is essential to understanding how Non-Standard Evaluation (NSE) works.

What Is an Expression?

In R, an expression is an object that represents unevaluated code.

Here’s how you create one:

expr <- quote(x + 1)
expr
x + 1

This object is not the result of x + 1, and it’s not the character string "x + 1" (notice that it does not have quotes in the console). It’s a special object of class "call" — sometimes also called a language object.

You can verify this:

class(expr)
[1] "call"
typeof(expr)
[1] "language"

This means that R has captured the code itself — as structured, evaluable (i.e., unevaluated) data.

Why Expressions Are Not Strings

Try this:

expr <- quote(x + 1)
is.character(expr)
[1] FALSE
print(expr)
x + 1

Even though it looks like a string when printed, it’s not. R stores the structure of the code, not its textual representation.

In fact, expressions behave like recursive lists. You can inspect their components:

expr[[1]]  # the function being called
`+`
expr[[2]]  # first argument
x
expr[[3]]  # second argument
[1] 1

This kind of structure is what makes metaprogramming possible in R.

quote() – The Basic Tool to Capture Code

quote() is the most direct way to create an expression.

quote(mean(x, na.rm = TRUE))
mean(x, na.rm = TRUE)

This returns a "call" object, which you can store, inspect, and evaluate later.

quote() prevents R from evaluating the expression. It captures it as is, before any names are resolved.

Compare:

x <- 10

# This
quote(x + 1)
x + 1
# To this
x + 1
[1] 11

In the first case, R captures the expression; in the second, it evaluates it.

bquote() allows partial evaluation inside a quoted expression, useful for plotting or math expressions.

e <- rlang::env(x = 100, y = 300)
y <-  1
quote(.(x) + y)
.(x) + y
bquote(.(x) + y) 
10 + y
# part in .() is evaluated in `where` 
bquote(.(x) + y, where = e) 
100 + y
bquote((x) + y)
(x) + y

deparse() – Turning Code into Strings

Now suppose you want to print this expression. If you use print(expr), you get a nice display. But if you need the actual string "x + 1" (e.g. for logging or including it in cat() or paste()), you need deparse():

expr <- quote(x + 1)
deparse(expr)
[1] "x + 1"

So:

  • quote(x + 1) → structured expression
  • deparse(quote(x + 1)) → character string "x + 1"

This distinction is critical. If you treat expressions like strings too early, you lose their structure.

substitute() – Capturing Expressions from Arguments

While quote() is explicit, substitute() works implicitly by intercepting what the user typed as an argument.

Example:

log_expr <- function(arg) {
  code <- substitute(arg)
  print(code)
}

log_expr(x + 1)
x + 1

Behind the scenes, substitute(arg) is doing the same thing as quote(x + 1) — except it’s happening within the calling context. That’s why we often say that substitute() is a dynamic version of quote() — it’s context-sensitive.

To show the equivalence:

quote(x + 1) == substitute(x + 1)
[1] TRUE

But substitute(arg) only works inside a function — because it inspects the actual expression passed to arg.

So, what’s the difference between quote() and substitute()?

Both quote() and substitute() capture unevaluated code, but context matters.

  • quote() always returns the literal symbol or expression, without considering the call context.
  • substitute() captures what the user typed, dynamically, at the function call site.

Example: Compare Their Behavior Inside a Function

inspect <- function(expr) {
  list(
    quoted      = quote(expr),         # Always returns the symbol 'expr'
    substituted = substitute(expr),    # Captures the user's input expression
    evaluated   = eval(expr)           # Executes the expression
  )
}

x <- 3
inspect(x + 1)
$quoted
expr

$substituted
x + 1

$evaluated
[1] 4

Key Point:

  • quote(expr) always gives you expr — the literal name.
  • substitute(expr) gives you x + 1 — what the user typed.
  • eval(expr) computes the result.

Use substitute() when writing functions that need to inspect or log user input. Use quote() when you need to manually build expressions.

Putting It All Together

Let’s summarize the roles:

Function Purpose Returns
quote(expr) Capture unevaluated code Expression / call
substitute(arg) Capture the argument passed to a function Expression / call
deparse(expr) Convert an expression to a string Character string
eval(expr) Evaluate the expression Result of computation

3. Combining substitute(), deparse(), and eval() in Real-World Logging

Now that we understand the difference between quote() and substitute(), and how eval() and deparse() work, let’s build a real-world example that demonstrates why all of this matters in practice.

Imagine you’re writing a logging wrapper to:

  • Report what expression the user typed
  • Evaluate it
  • Record the result (or the error)

A Logging Evaluator

log_and_eval <- function(expr) {
  # Capture the expression (as typed)
  code <- substitute(expr)

  # Turn the code into readable text
  code_text <- deparse(code)

  # Evaluate the expression
  result <- tryCatch(
    expr = {
      x <- eval(code)
          # Print and return
      cli::cli_inform("Successfully evaluated: {code_text}")
      cli::cli_inform("Result: {x}")
    },
    error = function(e) {
      cli::cli_alert_danger("Error while evaluating: {code_text}
                     MSG: {e$message}")
      return(NULL)
    }
  )

  
  invisible(result)
}

Try It Out

x <- 10
log_and_eval(x + 1)
Successfully evaluated: x + 1
Result: 11
log_and_eval(log("hello"))  # will fail
✖ Error while evaluating: log("hello")
MSG: non-numeric argument to mathematical function

What Happens If You Try to Use eval(expr) Without substitute()?

You might wonder: why do we assign code <- substitute(expr) and then evaluate code? Why not just call eval(expr) directly?

Here’s why:

  • When expr is passed to the function log_and_eval(), R evaluates it immediately, unless we explicitly tell it not to.
  • The role of substitute(expr) is to capture the unevaluated expression that the user typed at the call site.
  • If you skip substitute(), then expr has already been evaluated by the time it enters the function — and you’ve lost the original code.

Compare with definition:

log_eval_wrong <- function(expr) {
  # expr has already been evaluated here
  cat("Trying to print the expr. We just get the result: ", 
      deparse(expr), "\n")
  expr 
}

Calling log_eval_wrong(x + 1) can’t show us the expression:

x <- 10
log_eval_wrong(x + 1)
Trying to print the expr. We just get the result:  11 
[1] 11

That’s because by the time deparse(expr) runs, expr has already been evaluated to a number (11), not an expression.

We use substitute() to freeze user input and eval() to control exactly when and where it runs.

4. Controlling Where Code Is Evaluated with eval(expr, envir)

So far, we’ve used eval(expr) to run expressions in the current environment. But a key feature of eval() is that it also lets you choose where the code is evaluated — and this is essential for many real-world use cases.

Changing the Environment

You can use eval(expr, envir = ...) to evaluate the code in a different environment. This lets you build flexible tools like scoped evaluators, delayed execution, and data-aware functions.

# Create a custom environment
custom_env <- rlang::env(x = 100)

expr <- quote(x + 1)
eval(expr, envir = custom_env)
[1] 101

Even though there’s a global x <- 5, the expression is evaluated in custom_env, where x = 100.

Why This Is Powerful

This behavior is foundational for base R tools like with() and subset(), and for tidyverse functions like dplyr::filter():

eval(quote(mpg > 25), envir = mtcars)
 [1] FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE
[13] FALSE FALSE FALSE FALSE FALSE  TRUE  TRUE  TRUE FALSE FALSE FALSE FALSE
[25] FALSE  TRUE  TRUE  TRUE FALSE FALSE FALSE FALSE

Here, mpg is treated as a column inside mtcars, not as a variable in the global environment. This is how dplyr::filter(mtcars, mpg > 25) works internally.

How to Use It in Your Own Code

To really see the importance of substitute(), let’s consider a slightly more advanced case: evaluating code in a different environment.

Suppose you want to evaluate an expression using variables defined in a custom environment, not the global one. Here’s how it works when done correctly:

log_and_eval_env <- function(expr, env = parent.frame()) {
  code <- substitute(expr)
  code_text <- deparse(code)
  
  result <- tryCatch(
    expr = {
      x <- eval(code, envir = env)
           # Print and return
      cli::cli_inform("Evaluated in custom env: {code_text}")
      cli::cli_inform("Result: {x}")
    },
    error = function(e) {
      cli::cli_alert_danger("Error in {code_text}: {e$message}")
      return(NULL)
    }
  )
  
  
  invisible(result)
}
# we create x in env e
e <- rlang::env(x = 100)

# and also have `x` in global 
x <- 10

# Here the code is evaluated in env `e`
log_and_eval_env(x + 1, env = e)
Evaluated in custom env: x + 1
Result: 101
# This is evaluated in global env
log_and_eval_env(x + 1)
Evaluated in custom env: x + 1
Result: 11
# Notice that we could create `y` in `e`
log_and_eval_env(y <- x + 1, env = e)
Evaluated in custom env: y <- x + 1
Result: 101
e$y
[1] 101
# And it was not create in Global because it was evaluated in `e`
y # this gives you error. 
[1] 1
# But here, it was created in `y`
log_and_eval_env(y <- x + 1)
Evaluated in custom env: y <- x + 1
Result: 11
y
[1] 11

This works because substitute() captured the unevaluated expression x + 1, and eval() was able to look up x in the env environment.

But what happens if we don’t use substitute()?

log_eval_env_wrong <- function(expr, env = parent.frame()) {
  # expr has already been evaluated here!
  text <- deparse(expr)  # will  fail
  result <- eval(expr, envir = env)
  cli::cli_inform("Result: {result}")
  invisible(result)
}

Now try:

x <- 10
log_eval_env_wrong(x + 1, env = e)
Result: 11
rm(x)
log_eval_env_wrong(x + 1, env = e)
Error: object 'x' not found

This code first returns 11, then throws an error. This happens because when expr reaches eval() it is already too late because it has already been evaluated to in deparse(), so eval() simply returns 11. In the second case, where x is removed from the Global environment, expr is evaluated in deparse() and fails.

This is also evident in the following code.

Since the expression is y <- x + 1, and e is passed as the evaluation environment, the assignment takes place in e.

In R, eval(expr, envir = some_env) does make assignments into some_env if the expression is syntactically an assignment, like y <- something.

But this fails for assigning into foo()’s environment

# Why this does not work. 
foo <- \() {
  y <- 3 + 2
  goo <- \() {
  
    eval(z <- y + 2, envir = parent.frame())
  }
  goo()
  z   # Error: object 'z' not found
}
foo()
Error in foo(): object 'z' not found

That’s why substitute() is essential in any function that wants to control when and where evaluation happens. Now, this is how it works:

# Why this does not work. 
foo <- \() {
  y <- 3 + 2
  goo <- \() {
  
    expr <- substitute(z <- y + 2) # this makes it work
    eval(expr, envir = parent.frame())
  }
  goo()
  z 
}
foo()
[1] 7

However, as we will see in another post, the best way to do this is assign("z", value, envir = target_env). This is the safest and most readable.