iris[["Petal.Length_std"]] <- (iris[["Petal.Length"]] - mean(iris[["Petal.Length"]])) /
sd(iris[["Petal.Length"]])
iris[["Sepal.Length_std"]] <- (iris[["Sepal.Length"]] - mean(iris[["Sepal.Length"]])) /
sd(iris[["Sepal.Length"]])
iris[["Petal.Width_std"]] <- (iris[["Petal.Width"]] - mean(iris[["Sepal.Width"]])) /
sd(iris[["Petal.Width"]])
iris[["Sepal.Width_std"]] <- (iris[["Sepal.Width"]] - mean(iris[["Sepal.Width"]])) /
sd(iris[["Sepal.Width"]])Writing functions
install.packages(c("checkmate", "rlang"))Introduction
If you have performed any kind of analysis with R, you have used functions. They are everywhere and you cannot do anything without them. R comes with hundreds of functions by default, and thousands more are available via user-written packages.
It is possible to create entire R projects without ever needing to write your own functions. However, knowing how to write a custom function can be extremely useful.
In this module, we will use an example of duplicated code that could be simplified and made more robust by writing a function. We will see how to write a basic function and gradually make it more complex by adding default argument values and input checking.
Demo: standardizing values
Let’s say that you want to standardize values, i.e. run this formula on multiple columns in your data:
\[z = \frac{x - mean(x)}{sd(x)}\]
We could run this by hand on a list of columns in our data:
This works, but what if we want to ignore missing values? We have to add 8 parameters:
iris[["Petal.Length_std"]] <- (iris[["Petal.Length"]] - mean(iris[["Petal.Length"]], na.rm = TRUE)) /
sd(iris[["Petal.Length"]], na.rm = TRUE)
iris[["Sepal.Length_std"]] <- (iris[["Sepal.Length"]] - mean(iris[["Sepal.Length"]], na.rm = TRUE)) /
sd(iris[["Sepal.Length"]], na.rm = TRUE)
iris[["Petal.Width_std"]] <- (iris[["Petal.Width"]] - mean(iris[["Sepal.Width"]], na.rm = TRUE)) /
sd(iris[["Petal.Width"]], na.rm = TRUE)
iris[["Sepal.Width_std"]] <- (iris[["Sepal.Width"]] - mean(iris[["Sepal.Width"]], na.rm = TRUE)) /
sd(iris[["Sepal.Width"]], na.rm = TRUE)Do you notice anything weird about the code in the previous two blocks?
Look again…
On the third line, we used mean(iris[["Sepal.Width"]]) instead of mean(iris[["Petal.Width"]])! By duplicating lines of code with very small variations, we make it harder to notice mistakes like this one, small in size but with important consequences for our results.
Let’s write a function instead of repeating this formula four times.
The most basic function
We start with the template of a function:
my_std <- function() {}my_stdis the function name, meaning that we can use it likemy_std(<more code>);function()is the function definition. In the next steps we will add function arguments (or function parameters) in();{}contains the function body. This is where we will add the code that runs every time we call the function.
For now, this function is useless:
my_std()NULL
Let’s add a simple message:
my_std <- function() {
print("Hello from my_std()!")
}
my_std()[1] "Hello from my_std()!"
This doesn’t take any user input, so it always does the same thing, which is not very interesting for us. Let’s add function parameters!
Introducing function parameters
The body of a function never changes, the variation comes from the inputs passed by the user, aka function parameters. Therefore, to move existing code into a function, we need to identify its moving components and its “stable” components.
In the code below, the parts in red change across lines and the rest stays identical across lines:
iris[["Petal.Length_std"]] <- (iris[["Petal.Length"]] - mean(iris[["Petal.Length"]])) /
sd(iris[["Petal.Length"]])
iris[["Sepal.Length_std"]] <- (iris[["Sepal.Length"]] - mean(iris[["Sepal.Length"]])) /
sd(iris[["Sepal.Length"]])
iris[["Petal.Width_std"]] <- (iris[["Petal.Width"]] - mean(iris[["Petal.Width"]])) /
sd(iris[["Petal.Width"]])
iris[["Sepal.Width_std"]] <- (iris[["Sepal.Width"]] - mean(iris[["Sepal.Width"]])) /
sd(iris[["Sepal.Width"]])These red parts indicate the column name used in the computation, which will become our function parameter:
my_std <- function(column) {}Adding the function body
We added the moving parts as function parameters, now we need to move the stable parts in the function body, replacing the moving parts by the name of the new function parameter:
my_std <- function(column) {
(iris[[column]] - mean(iris[[column]], na.rm = TRUE)) / sd(iris[[column]], na.rm = TRUE)
}Note that we don’t assign the output inside the function: the function merely makes the computation and we assign its output to our object when we call the function:
iris[["Petal.Length_std"]] <- my_std("Petal.Length")
iris[["Sepal.Length_std"]] <- my_std("Sepal.Length")
iris[["Petal.Width_std"]] <- my_std("Petal.Width")
iris[["Sepal.Width_std"]] <- my_std("Sepal.Width")Notice how it is much easier to check whether we made a typo in this code.
Validating function parameters
The user can now pass custom column names to the function, but this also means that they can pass wrong values! This is what happens if they pass a column name that doesn’t exist in the data:
my_std("unknown_column")Warning in mean.default(iris[[column]], na.rm = TRUE): argument is not numeric
or logical: returning NA
numeric(0)
Note that this doesn’t error, it just throws a warning and returns a useless result. To prevent that, we should add some validation steps in our function body to ensure that we don’t run nonsensical code and that we clearly tell the user when they have given wrong inputs to the function. It is good to first list all the requirements of the input so that we can then add one check per requirement. In our case:
- the column should be a single value, e.g.
my_std(c("Sepal.Length", "Petal.Width"))should fail; - the column should be a character value, e.g.
my_std(1)should fail; - the column should exist in the data, e.g.
my_std("column_1")should fail.
Now that we have clarified are requirements, we can add one if() condition for each of them:
my_std <- function(column) {
if (length(column) != 1) {
stop("The `column` parameter must be of length 1.")
}
if (!is.character(column)) {
stop("The `column` parameter must be a character.")
}
if (!(column %in% names(iris))) {
stop("Column '", column, "' doesn't exist in the data.")
}
(iris[[column]] - mean(iris[[column]], na.rm = TRUE)) / sd(iris[[column]], na.rm = TRUE)
}And now we have proper error messages and avoid nonsensical results!
my_std(c("Sepal.Length", "Petal.Width"))Error in `my_std()`:
! The `column` parameter must be of length 1.
my_std(1)Error in `my_std()`:
! The `column` parameter must be a character.
my_std("column_1")Error in `my_std()`:
! Column 'column_1' doesn't exist in the data.
Several packages provide functions to easily check inputs and report useful error messages. There are too many to list all of them here, but you could for instance look at rlang and checkmate.
With rlang
### With rlang
my_std <- function(column) {
rlang::check_string(column)
if (!(column %in% names(iris))) {
stop("Column '", column, "' doesn't exist in the data.")
}
(iris[[column]] - mean(iris[[column]], na.rm = TRUE)) / sd(iris[[column]], na.rm = TRUE)
}
my_std(c("Sepal.Length", "Petal.Width"))Error in `my_std()`:
! `column` must be a single string, not a character vector.
my_std(1)Error in `my_std()`:
! `column` must be a single string, not the number 1.
my_std("column_1")Error in `my_std()`:
! Column 'column_1' doesn't exist in the data.
With checkmate
my_std <- function(column) {
checkmate::assert_string(column)
checkmate::assert_subset(column, names(iris))
(iris[[column]] - mean(iris[[column]], na.rm = TRUE)) / sd(iris[[column]], na.rm = TRUE)
}
my_std(c("Sepal.Length", "Petal.Width"))Error in `my_std()`:
! Assertion on 'column' failed: Must have length 1.
my_std(1)Error in `my_std()`:
! Assertion on 'column' failed: Must be of type 'string', not 'double'.
my_std("column_1")Error in `my_std()`:
! Assertion on 'column' failed: Must be a subset of {'Sepal.Length','Sepal.Width','Petal.Length','Petal.Width','Species','Petal.Length_std','Sepal.Length_std','Petal.Width_std','Sepal.Width_std'}, but has additional elements {'column_1'}.
Setting default value for parameters
So far, we used na.rm = TRUE in the function body but maybe we also want to let the user determine this option. We can add a function parameter na.rm and use its value in mean() and sd():
my_std <- function(column, na.rm) {
# [parameter checks]
(iris[[column]] - mean(iris[[column]], na.rm = na.rm)) / sd(iris[[column]], na.rm = na.rm)
}For conciseness, I hid the parameter checks we have written above.
This works fine but it forces the user to explicitly pass the parameter when calling the function, no matter whether it is TRUE or FALSE:
head(my_std("Sepal.Length", na.rm = FALSE))[1] -0.8976739 -1.1392005 -1.3807271 -1.5014904 -1.0184372 -0.5353840
This isn’t a bad approach since it forces the user to be explicit, but let’s say we want to follow the mean() function and never remove NA by default. We can set the default value in the function definition:
my_std <- function(column, na.rm = FALSE) {
# [parameter checks]
(iris[[column]] - mean(iris[[column]], na.rm = na.rm)) / sd(iris[[column]], na.rm = na.rm)
}and then this default value is implicitly used (i.e. we don’t need to write it in the call):
head(my_std("Sepal.Length"))[1] -0.8976739 -1.1392005 -1.3807271 -1.5014904 -1.0184372 -0.5353840
Exiting a function early
Sometimes, we want to return an object early on instead of executing the rest of the function.
For example, suppose we have a function where we want to return NA if any of the input values is NA. In this case, we could use return() in an if condition to skip the rest of the function:
f <- function(x) {
if (anyNA(x)) {
return(NA)
}
# if x doesn't contain NA, then we continue the function here...
}Note that the last value in the function is implicitly returned, so you don’t need to wrap it in return() (but you can if you want, it’s just a matter of preferences). Note that if the last statement in the function is an assignment, then the function returns the value of this assignment but doesn’t print it when you call it:
# Returns the value of `x` and prints it
f <- function() {
x <- 1
x
}
f()[1] 1
result_1 <- f()
# Also returns the value of `x`, but doesn't print it!
f <- function() {
x <- 1
}
f()
result_2 <- f()
identical(result_1, result_2)[1] TRUE
Documenting functions
[UNSURE: this could fit better in the package development module]
If you plan to keep using custom functions in your project, or if you want to share those with colleagues, then it is important to write documentation. The documentation should include:
- an explanation of the function’s purpose
- an explanation of each function parameter, including the expected type (e.g. “a character value of length 1”)
- details on the expected output
Other ways to use and create functions
Functions as arguments
In R, functions are just another type of objects, meaning that they can be updated by the user, assigned to variables, and passed over to other functions. at the built-in function for the mean as an example.
my_variable <- meanNote that the parentheses are missing from mean here, which means we are not evaluating the function but just refering to it.
Passing functions as arguments is a powerful programming technique, which can be best demonstrated by the family of apply functions. As an example, let’s assume we have data stored in a list:
data_list <- list("a" = rnorm(100),
"b" = rnorm(100, mean = 3))We’re looking for the median values of our data. We would loop through each of the list entries and apply the median function individually. However, the lapply functions provides a more elegant solution, where we can just pass the list and a function as argument to lapply, and the function will be applied to each list entry individually:
lapply(X = data_list, FUN = median)$a
[1] -0.116147
$b
[1] 3.018382
Note again that we pass median (the function) as an argument without parenthesis. Using median() instead equates to calling the median function without passing it any arguments and will result in an error. For the curious: lapply is what is called a “higher-order function”.
Anonymous functions
All functions you have encountered so far have been named either by R, the package developer, or yourself (e.g., the my_std functions above). Generally, it is good practice to give your variables and functions informative names so others and your future self can understand their purpose.
However, sometimes you will just need a quick, short throwaway function that serves one purpose without wanting to bother to write a full size function. For this, you can use anonymous functions. These are functions that are defined on the spot and immediately evaluated without being assigned to a variable. Their syntax is straightforward, as you just skip the variable assignment:
function(x){ some code }A typical usecase is when you want to make some quick, on the spot data manipulation of a data frame or list. As an example, to get the absolute difference between the 3rd and 5th value of our list defined above, we can use
lapply(X = data_list, FUN = function(x) abs(x[3] - x[5]))$a
[1] 1.437356
$b
[1] 0.6964641
Inspecting Source Code
When working with R, you will interact with functions all the time. The primary purpose of functions is to combine a number of complex operations and give them a shared name (the name of the function). However, sometimes it is useful to unwarp a function to understand exactly what it is doing and how your inputs are modified. For this, you can simply enter the name of the function (without parentheses) into the console, which will display the body of the function to give you some insight into how it is implemented:
my_stdfunction (column, na.rm = FALSE)
{
(iris[[column]] - mean(iris[[column]], na.rm = na.rm))/sd(iris[[column]],
na.rm = na.rm)
}
Note that functions themselves will call other functions, so to really understand what a function is doing might require you to iteratively dig through a number of functions.
Working through the source code of functions can be very insightful, but can quickly expose you to a lot of low-level functionality of R that comes with a steep learning curve. As an (advanced) exercise, have a look at the implementation of sd or lapply and try to understand what they are doing using the documentation.
In RStudio and Positron, you can press Ctrl (or Cmd on macOS) and click on a function name in a script to open a tab containing its source code:
Going further
Designing functions and ensuring their robustness is not trivial. There are many choices one can make, such as using positional or named arguments, harmonising parameter names, and whether to use default values.
If you want to explore more, we recommend reading the Tidyverse design book. While the primary audience is package developers, a lot of advice there apply to any custom function.
Conclusion
In this module, we have seen how to convert existing, duplicated code into a function.
Now that you have a function that does something useful, maybe you want to share it with colleagues. You could put it in an .R file and send it via email, but what happens if you notice a small mistake and want to fix it in the future? You’d have to tell all the people to which you sent the original function. This is fine if you shared it with a couple of colleagues, but what if they also shared it with other people?
The best way to share functions with other people is to wrap them in an R package. This might sound intimidating but don’t worry, we have an entire module dedicated to package development!
Session info
─ Session info ───────────────────────────────────────────────────────────────
setting value
version R version 4.6.1 (2026-06-24)
os Ubuntu 24.04.5 LTS
system x86_64, linux-gnu
ui X11
language (EN)
collate C.UTF-8
ctype C.UTF-8
tz UTC
date 2026-10-16
pandoc 3.1.3 @ /usr/bin/pandoc
quarto 1.10.19 @ /usr/local/bin/quarto
─ Packages ───────────────────────────────────────────────────────────────────
package * version date (UTC) lib source
backports 1.5.1 2026-04-03 [1] RSPM
checkmate 2.3.4 2026-02-03 [1] RSPM
rlang 1.3.0 2026-07-05 [1] RSPM
[1] /home/runner/work/_temp/Library
[2] /opt/R/4.6.1/lib/R/library
──────────────────────────────────────────────────────────────────────────────