So… what is rlang, anyway? And why is it so powerful? If you’re familiar with the tidyverse and/or looked at package dependencies, you’ve probably seen rlang pop up and wondered what on earth it’s doing there. In this article, we’ll take a look at what rlang actually is, how it fits into R’s world, how you can use it yourself, and what kinds of things it makes possible. We’ll keep it simple, skip deep technical explanations, and look at a few short examples to make sense of why this little package shows up everywhere. Basically just like you’re used to when it comes to my writing!
What rlang actually is
Usually a good place to start understanding a package is by reading the package documentation. In rlang‘s documentation we find: “rlang is a collection of frameworks and APIs for programming with R”. Hmmm… Not quite the one-liner that explains it all. Let’s dig a bit further: “Two comprehensive frameworks are implemented in rlang … tidy eval, a programmable data-masking framework used in tidyverse packages like dplyr and ggplot2 … and rlang errors, a set of tools to signal and display errors”. Besides those frameworks, rlang also provides “a set of tools help you check, validate, and preprocess arguments” and “various interfaces for working with R and R objects”. That’s already a more detailed explanation, but I can imagine this sounds a bit like “blabla R functions blabla a data mask? blabla very useful”: aka a bit technical!
In plain English, rlang is a helper package that lets R work with its own language. That might sound a bit abstract, but here’s the idea: most R code runs immediately, but sometimes you don’t want that. You write code, R executes it, and you get a result. But sometimes, you might want to capture what someone wrote, inspect it, or even change it before running it. rlang gives you the tools to do that. You can grab pieces of R code (called expressions), hold onto them, and then evaluate them later when and how you want.
Ok, so what does actually that mean? Well, you can write something like filter(mtcars, mpg > 20) instead of filter(mtcars, mtcars$mpg > 20), and it just works. That’s thanks to tidy evaluation, or tidy eval. It’s one of rlang’s biggest frameworks. Tidy eval handles data masking, which lets you refer to columns directly as if they were regular variables. This is what makes the tidyverse feel so natural to use. But it also goes further: it allows you to write your own functions that behave like tidyverse ones. You can take in user input as an expression, modify it, combine it with something else, and then evaluate it later. It sounds simple, but to achieve that, you need some mysterious bits of syntax. Hello double bangs !!, triple bangs !!!, and curly braces {{ }} (or, an even fancier word: implicit injection). They’re rlang’s way of saying “hey, I want to control when this code actually runs”. The same goes for quosures, which are basically little containers that remember both the code and the environment it came from, and data pronouns like .data$mpg, which help R know exactly where to look for things. Right… You might see that something like that can come in handy (or not, but don’t worry!), and we’ll make it much more practical with examples a little bit later.
Tidy eval is just one side of rlang. It also provides a clean, consistent system for handling errors and messages, or, as referred to in the documentation: rlang errors. Functions like abort(), warn(), and inform() make it easier for package developers to signal problems or share information with users in a tidyverse-friendly way. Instead of R’s sometimes cryptic error messages, rlang errors are structured, visually appealing (yes, errors can be pretty), and easy to catch programmatically.
So while you might not load rlang yourself, it’s quietly powering a lot of what makes modern R feel smooth and expressive. It’s part of the tidyverse’s engine room and used by packages like dplyr, ggplot2, and tidyr, making sure they can capture, manipulate, and evaluate your code safely and elegantly. You may not see it much, but you’re definitely using it every time you write tidyverse code that “just works”.
The place of rlang in the R universe
rlang builds on top of base R. Base R already lets you do almost everything you need: create functions, handle environments, and evaluate expressions. But the tidyverse relies on a more consistent and user-friendly way to handle those things, especially when code needs to behave differently depending on what the user typed. That’s where rlang helps out: it wraps base R’s language features in a nice and clean interface.
For example, R already has functions like quote(), eval(), and substitute() for capturing and evaluating code. They work, but they can get clunky when you start writing functions that take other pieces of code as input. rlang provides modern versions of these ideas (expr(), enquo(), eval_tidy(), to name a few) that behave the same way across tidyverse packages.
As mentioned previously, rlang also extends R’s built-in error system. Base R errors are functional but plain, while rlang errors are structured and easy to catch or format in a consistent way across packages. The same goes for argument checking, environment handling, and working with symbols: it’s an extension to let R work better/easier with itself.
So... What can you do with it?
rlang? Below you can find some examples where rlang is useful. Data masking
filter(mtcars, mpg > 20) without typing mtcars$mpg. Tidy evaluation, on the other hand, is the programming framework behind the scenes that makes this possible. It gives you the tools to capture, inspect, and evaluate those expressions. So, data masking is what you see as a user, and tidy evaluation is the system that powers it. Let’s say I have a little data frame of my dog Loulou’s training stats:
library(dplyr)
loulou <- tibble(
day = c("Mon", "Tue", "Wed"),
treats = c(3, 5, 2),
walks = c(2, 3, 1)
)
filter() from dplyr, I can write this:
filter(loulou, walks > 1)
I don’t have to type loulou$walks > 1. That’s because data masking lets me refer to the columns of the data frame as if they were regular variables. It’s talking to my data instead of talking to my data frame (subtle difference).
Behind the scenes, filter() temporarily “masks” the data frame: it puts the columns into a special environment where they’re treated like variables you can use directly.
Data pronouns
walks <- 99 # Oops, a variable in our environment, and a lot of walks too...
filter(loulou, walks > 1)
So… Do we mean the walks column in the loulou data frame, or the walks variable we defined in our environment?
This is where data pronouns come in. By using .data, we can make it explicit:
filter(loulou, .data$walks > 1)
.data$walks clearly tells R (and other developers!) that you mean the walks column from the data frame. It’s especially handy if you’re writing functions or working in bigger projects where variable names can overlap. Note that it often will play out the way you intended, but it’s better to be safe than sorry.
Implicit injection
Injection is about splicing values or expressions into other expressions (splicing = unpacking a bunch of things from a list and inserting them somewhere else). Ok, bear with me: sometimes we want to write functions that take a column name or even multiple column names or expressions as input, and evaluate them inside a data mask. That’s what implicit injection is for: it lets you “splice” expressions into other expressions so they get evaluated in the right place. It makes more sense with the following example:
library(rlang)
my_filter <- function(data, col, threshold) {
filter(data, !!sym(col) > threshold)
}
my_filter(loulou, "walks", 1)
sym(col) turns the string "walks" into a symbol (like if you would write walks instead).
!! injects that symbol into the expression before it’s evaluated.
So why take a string and turn it into a symbol you ask? Good question, and we don’t have to do that! We could also tackle this with curly braces:
my_filter2 <- function(data, col, threshold) {
filter(data, {{ col }} > threshold)
}
my_filter2(loulou, walks, 1)
Simple, elegant, modern. {{ col }} automatically captures the argument and injects it, no need for !!.
What about the triple bangs (!!!)? They can be used to inject multiple expressions. Imagine I want to check several conditions for Loulou’s training stats at once. For example, I want to know if she had more than 2 treats or more than 1 walk:
conds <- list(
quote(treats > 2),
quote(walks > 1)
)
filter(loulou, !!!conds)
Before we can use !!!, we need a list of expressions. That’s what quote() does: it takes a piece of R code and turns it into an expression without running it. conds is a list of two expressions: one for treats > 2 and one for walks > 1. No code is executed yet. Then, using !!!conds in filter() tells R to take all the expressions in the list and inject them here so they get evaluated inside the data frame. Obviously, in this example, there’s no point putting the conditions outside of the filter function. !!! really shines is when you’re writing functions that take an arbitrary number of expressions as input, especially when the number and content of those expressions isn’t known ahead of time.
Quosures
A quosure (short for quoted closure) is a quoted expression that remembers the environment it came from. Personally, the word quosure alone is already confusing me… 😅. Anyways, it’s rlang’s way of keeping both an expression and the environment it came from together in one object.
Let’s first explain the word quosure a bit. Perhaps you’ve come across the term quotation (or the quote() function when we were talking about implicit injection). It means capturing an expression without running it. A quosure is a quotation + environment. It captures not only the expression, but where that expression came from.
A little example: when you write R code like x + y, that’s an expression (aka a piece of code), that has not been evaluated yet. But expressions on their own don’t remember where x and y came from. If you evaluate that code somewhere else, R might not know what x or y mean anymore. A quosure solves that by “wrapping” the expression together with the environment where it was created.
Generally speaking you don’t need to use quosures yourself. There are some advanced situations where they might be needed, but it’s not something you will encounter on a daily (or even yearly) basis. Still, I want to give a quick example:
x <- 2
y <- 3
make_quosure <- function(expr) {
enquo(expr) # captures the expression + its environment
}
q <- make_quosure(x + y)
Let’s first think about this. What would you expect to happen? q holds a quosure: the code x + y and the environment where x and y exist. So we expect the quosure to remember the values 2 and 3 from the global environment. If we runeval_tidy(q) (a function to evaluate quosures) the output will be 5.
Let’s say we call our quosure in a different environment (like a function):
evaluate_quosure <- function(quosure) {
x <- 10
y <- 20
eval_tidy(quosure) # evaluates the captured expression in its environment
}
evaluate_quosure(q)
#> 5
Since our closure contains information from the global environment, the result will still be 5.
This would be another example:
make_quosure <- function(expr) {
local_env <- env(x = 2, y = 3)
new_quosure(enexpr(expr), env = local_env)
}
q <- make_quosure(x + y)
x <- 10
y <- 20
rlang::eval_tidy(q)
#> 5
The function make_quosure(x + y) captures the expression x + y without evaluating it using enexpr(). It then wraps that expression in a quosure tied to a new local environment where x = 2 and y = 3. Later, when you run eval_tidy(q), it evaluates x + y inside that local environment, ignoring any changes to x and y in the global environment, so the result is 5.
So, you can see a quosure as some kind of snapshot. As said, it’s not something you would, generally speaking, use as a user of the tidyverse. But behind the scenes it powers a lot of the behaviour.
Errors
Unfortunately, errors (and debugging) are part of a programmer’s life. You already read about how rlang’s errors are structured and easy to format, but with rlang you can signal other conditions too.
With rlang, you can use:
abort()to stop with an error (equivalent tostop()from base R)warn()to issue a warning (equivalent towarning()from base R)inform()to display a friendly message (equivalent tomessage()from base R)
Let’s go back to Loulou:
give_treat <- function(treats) {
if (treats < 0) {
abort("Negative treats!? How's she supposed to do that?")
} else if (treats == 0) {
warn("Are you sure there's no treats hiding in your pocket?")
} else if (treats < 2) {
inform("A modest snack.")
} else {
inform("Loulou is living her best life!")
}
}
give_treat(0)
give_treat(3)
give_treat(-1)
cli, which lets you format text with styles, emojis, and colours. For example:
abort(cli::format_error("{.strong Oh no!} {.emph Loulou} can't have negative treats!"))
Who knew errors could be that pretty?! If you’re looking for more tips and tricks be sure to check out this video by Emil Hvitfeldt.
Having pretty errors is nice, but it’s not all about the looks. Errors need to be debugged and that’s much easier to do when there’s a proper backtrace. A full backtrace will show you which functions were called before the error happened. Using abort, you’ll automatically get the backtrace (but the looks/functionality depend on your IDE). You can also control the default display of the backtrace.
There’s actually much more to tell about rlang errors, which you can read all about in the docs.
Argument checking
give_treat functions we do some form of input validation: we check if treats is not negative. But rlang has much more tools for argument checking, which make your functions more predictable and your errors more informative without having to write too much yourself.
Let’s say we have the following function:
status_check <- function(treat_type, mood = c("happy", "grumpy")) {
mood <- arg_match(mood) # check that mood matches one of the options
if (is_missing(treat_type)) {
check_required(treat_type) # make sure a treat was supplied
}
paste("Loulou is", mood, "with her", treat_type)
}
status_check("cookie", "happy")
#> "Loulou is happy with her cookie"
status_check("cookie", "sleepy")
#> Error in `status_check()`:
#> ! `mood` must be one of "happy" or "grumpy", not "sleepy".
status_check()
#> Error in `status_check()`:
#> ! `treat_type` is absent but must be supplied.
Here’s what happens:
arg_match()ensures the argument matches one of the expected values.is_missing()is equivalent tomissing()from base R, but with a few extras like testing for missing arguments contained in other objects like lists.check_required()ensures you didn’t forget a must-have argument.
Besides these handy checks, there’s also functionality to work with dynamic dots. Let’s to back to our one of our earlier examples and write a “wrapper” filter function:
loulou <- tibble(
day = c("Mon", "Tue", "Wed"),
treats = c(3, 5, 2),
walks = c(2, 3, 1)
)
dynamic_filter <- function(data, ...) {
conditions <- list2(...) # collect extra conditions
filter(data, !!!conditions) # splice them into filter
}
# Example: filter rows where treats > 2 and walks > 1
conds <- list(
quote(treats > 2),
quote(walks > 1)
)
dynamic_filter(loulou, !!!conds)
#> A tibble: 2 × 3
#> day treats walks
#>
#> 1 Mon 3 2
#> 2 Tue 5 3
In the example above, list2(...) collects all extra conditions passed into .... Then !!! splices them into filter() so each condition is evaluated as its own argument. The result? You can pass any number of conditions dynamically, and it just works. This is thanks to list2, which can actually work injection (whereas list from base R can’t).
Again, this is just a simple example to demonstrate basic functionality. You can learn more about dynamic dots in this little article.
Dealing with your R session and environments
So far, we’ve seen how rlang works with data, tidy eval, errors and functions, but rlang can also help you with your R session and environments.
In R, every object lives somewhere. It could be in your global environment, inside a package, or inside a function. And sometimes, you want to check if something is present in an environment:
# Loulou’s data in your global environment
loulou <- tibble(day = c("Mon", "Tue"), treats = c(3, 5))
env_has(global_env(), "loulou")
#> loulou
#> TRUE
And you can check what environments are “attached” to begin with:
> search_envs()
[[1]] $
[[2]] $
[[3]] $
[[4]] $
[[5]] $
[[6]] $
[[7]] $
[[8]] $
[[9]] $
[[10]] $
[[11]] $
[[12]] $
[[13]] $
search_envs() lets you see all those boxes. What else? Well, checking the session to see if it’s interactive or not! Because sometimes, you want your code to behave differently when you’re running it in an IDE like Positron or RStudio (aka, interactive) vs. an automated script (aka, batch mode). Hello, is_interactive():
if (is_interactive()) {
inform("Loulou says hi! 🐶")
} else {
inform("Automatic non-interactive bark")
}
interactive()). Again, rlang‘s version is an extension. is_installed() and check_installed(). The former, is_installed(), simply returns TRUE or FALSE depending on whether the packages are installed. But in interactive sessions, check_installed() asks the user whether to install missing packages. Nice! Thanks, rlang!
All in all, rlang makes modern R programming smoother and more predictable.
In this article, we look at how it enables tidy evaluation and data masking, letting functions like filter() or summarise() handle user-supplied columns. We looked at quosures, which remember expressions along with their environments, and how implicit injection (!!, !!!, {{ }}) allows dynamic and programmable code. We also covered argument checking with functions like arg_match() and check_required(), and how to handle ... dynamically using list2() . On top of that, rlang gives us structured errors, warnings, and messages with functions like abort(), warn(), and inform(). And finally, we had a quick look into R’s environments and session where rlang provides multiple helpers to help you sort things out.
So yes, rlang quietly (or, not so quietly now you know this…) powers much of what makes the tidyverse feel natural and intuitive. That’s the deal with rlang. And I say: thank you Lionel Henrey and Hadley Wickham!
Other resources
rlang. Here are some good resources (besides the official documentation):
- These slides from The Data Science Learning Community.
- The metaprogramming chapter in Advanced R by Hadley Wickham.
Wrap up
Did you like this article and would you like to see more? If you want to support me, you can:
☕️ Buy me a coffee!
📚 Take a look at my online Shiny courses: ProductioniZing Shiny , CustomiZing WidgetZ and Mobile StructureZ.
🤓 Let me help you with your R and Shiny questions. Do you need help with your project? Let’s have a chat!
