rguides

Functions and Control Flow in R

Functions and control flow form the building blocks of any R program. R functions control the execution path of your code, letting you encapsulate logic into reusable components and branch between different outcomes with conditionals and loops. Whether you are cleaning data, running statistical analyses, or building Shiny applications, understanding how functions and control flow work together is essential for writing code that is efficient, testable, and easy to maintain.

You’ll learn how to write your own functions in R, make decisions with if-else statements, and repeat operations with for and while loops. By the end, you’ll be able to write reusable code that makes your analyses more efficient and easier to maintain.

What you’ll learn

This tutorial covers the key concepts and practical techniques for working with Functions and Control Flow in R. By the end, you will know how to apply the core functions in real data analysis workflows.

Why write functions?

Before diving into the syntax, it’s worth understanding why functions matter. Without functions, you’d copy and paste the same code multiple times throughout your script. This creates several problems:

  • Hard to maintain, If you find a bug, you have to fix it everywhere
  • Hard to read, Long scripts with repeated code are difficult to navigate
  • Error-prone, Subtle differences between copies can cause unexpected results

Functions solve all three problems. A well-written function is:

  • Reusable, Call it anywhere in your code
  • Testable, Verify it works once, then trust it everywhere
  • Self-documenting, A good function name explains what it does

Writing your first function

Functions in R are created using the function() keyword. The basic structure is:

function_name <- function(arguments) {
  # function body
  # computations
  return(value)  # optional
}

The function body is enclosed in curly braces and the function() keyword binds the formal parameters a and b to the values you supply when calling the function. R evaluates each expression inside the body in order and returns the result of the last one — or whatever you pass to return().

# A function that adds two numbers
add_numbers <- function(a, b) {
  result <- a + b
  return(result)
}

# Call the function
add_numbers(5, 3)
# [1] 8

When you call add_numbers(5, 3), R assigns 5 to a and 3 to b, executes the body line by line, computes a + b, and returns 8. Because R functions use pass-by-value semantics, modifying a or b inside the function body does not change the original variables in the calling environment.

Implicit returns

The return() statement is optional. If omitted, R returns the last evaluated expression. This means you can write shorter functions when the final line produces the value you want:

multiply <- function(x, y) {
  x * y  # This gets returned automatically
}

multiply(4, 7)
# [1] 28

This works, but explicit return() statements make your code clearer, especially when returning early from a function. For simple single-expression functions, the implicit return keeps the code concise; for longer functions with branching logic, explicit returns signal intent to anyone reading the code later.

Function arguments with default values

R functions can have default argument values, making them more flexible. Assign a value with = inside the function signature and callers can either accept the default or override it:

greet <- function(name = "World") {
  message <- paste("Hello,", name, "!")
  return(message)
}

greet()              # Uses default
# [1] "Hello, World !"

greet("Alice")       # Overrides default
# [1] "Hello, Alice !"

Default arguments let callers omit values that have sensible defaults while still allowing overrides when needed. This pattern appears throughout the R ecosystem: functions like read.csv(header = TRUE) use defaults so beginners get reasonable behaviour out of the box. You can also require certain arguments and make others optional:

calculate_bmi <- function(weight_kg, height_m, convert = TRUE) {
  bmi <- weight_kg / (height_m ^ 2)
  
  if (convert) {
    return(bmi)
  } else {
    return(round(bmi, 1))
  }
}

calculate_bmi(70, 1.75)           # Returns 22.86
calculate_bmi(70, 1.75, FALSE)    # Returns 22.9

Conditional execution with if-else

Conditions let your code make decisions based on data. Rather than running the same instructions every time, your program can inspect a value and choose between different execution paths. The if statement executes code only when a condition is TRUE:

x <- 10

if (x > 5) {
  print("x is greater than 5")
}
# [1] "x is greater than 5"

The condition inside if() must be a single TRUE or FALSE value. Passing a logical vector with more than one element produces a warning and only the first element is used, which is almost never what you want. If you have a vector, use any() to check whether at least one element meets the condition, or all() to require every element to match:

values <- c(1, 2, 3, 4, 5)

if (any(values > 3)) {
  print("At least one value is greater than 3")
}

if-else chains

For more than two possible outcomes, chain conditions together with else if. R evaluates each condition in order and executes the body of the first one that returns TRUE, skipping the rest. This sequential checking means you should order your conditions from most to least specific:

score <- 75

if (score >= 90) {
  grade <- "A"
} else if (score >= 80) {
  grade <- "B"
} else if (score >= 70) {
  grade <- "C"
} else if (score >= 60) {
  grade <- "D"
} else {
  grade <- "F"
}

grade
# [1] "C"

R evaluates conditions top-to-bottom and stops at the first TRUE. The else clause at the end catches anything that didn’t match the earlier conditions, acting as a fallback.

Vectorized ifelse

The if statement handles scalar conditions, but when you need to apply a condition to every element of a vector, ifelse() is the vectorized alternative. It takes a logical vector, a value for TRUE positions, and a value for FALSE positions, returning a vector the same length as the input:

x <- c(1, 5, 10, -3)

ifelse(x > 0, "positive", "negative")
# [1] "positive" "positive" "positive" "negative"

This is much faster than looping over elements because the condition and both branches are evaluated in a single vectorized pass through C code, rather than one R-level iteration per element.

For loops

For loops iterate over a sequence of values, executing the same block once per element. They are the most common looping construct in R and are useful when each iteration depends on the previous one or when you need to accumulate results step by step:

# Print numbers 1 to 5
for (i in 1:5) {
  print(i)
}
# [1] 1
# [1] 2
# [1] 3
# [1] 4
# [1] 5

The loop variable i takes each value in the sequence 1:5 in turn.

Collecting results

Pre-allocate space for results to make loops faster. Growing a vector with c() inside a loop copies the entire vector on each iteration, degrading to quadratic performance. Instead, create an output vector of the correct size with numeric(n) and assign by index:

squares <- numeric(5)

for (i in 1:5) {
  squares[i] <- i^2
}

squares
# [1]  1  4  9 16 25

Better yet, use vectorized operations when possible. R’s vectorized arithmetic operates on entire vectors at once in compiled C code, eliminating the R-level loop overhead entirely. For simple element-wise transformations like squaring, the vectorized approach is both shorter to write and orders of magnitude faster on large inputs:

squares <- (1:5) ^ 2
squares
# [1]  1  4  9 16 25

Iterating over vectors

Loops aren’t limited to numeric sequences. You can iterate over character vectors, factors, list elements, or any object that for can coerce to a sequence of elements. This is commonly used to apply the same operation across multiple columns or files:

fruits <- c("apple", "banana", "cherry")

for (fruit in fruits) {
  print(paste("I like", fruit))
}
# [1] "I like apple"
# [1] "I like banana"
# [1] "I like cherry"

When you need both the element and its position inside the loop body, iterate over indices instead. seq_along() produces a safe integer sequence matching the length of the input vector, avoiding the off-by-one error that can happen with 1:length(x) when x has zero elements:

for (i in seq_along(fruits)) {
  print(paste(i, ":", fruits[i]))
}

While loops

While loops repeat until a condition becomes FALSE. Unlike for loops, which iterate a fixed number of times, while loops are ideal when you don’t know in advance how many iterations are needed, for example when running an algorithm until convergence or processing user input until a sentinel value appears:

countdown <- 5

while (countdown > 0) {
  print(countdown)
  countdown <- countdown - 1
}

print("Liftoff!")
# [1] 5
# [1] 4
# [1] 3
# [1] 2
# [1] 1
# [1] "Liftoff!"

Be careful with while loops, they can run forever if the condition never becomes FALSE.

Control statements

break, exit the loop

Use break to exit a loop early when a terminating condition is met. This is useful for search algorithms where you want to stop iterating once the target is found, or for loops with a maximum iteration budget where convergence is reached before the budget is exhausted:

for (i in 1:10) {
  if (i == 6) {
    break
  }
  print(i)
}
# Prints 1 through 5

next, skip an iteration

Use next to skip the remainder of the current loop iteration and proceed to the next. This is useful for filtering out cases that should not be processed, like skipping missing values or invalid inputs without wrapping the entire body in an if statement:

for (i in 1:5) {
  if (i == 3) {
    next  # Skip printing 3
  }
  print(i)
}
# Prints 1, 2, 4, 5

Practical example: a temperature converter

The concepts covered so far — functions, conditionals, and control flow — combine naturally in real-world functions. Let’s build a complete function that converts temperatures between Celsius and Fahrenheit, validates its inputs, and stitches all these ideas together in a single cohesive example:

convert_temp <- function(value, unit = "C") {
  # Validate input
  if (!is.numeric(value)) {
    return("Error: Temperature must be a number")
  }
  
  # Convert based on unit
  if (unit == "C") {
    # Celsius to Fahrenheit
    fahrenheit <- (value * 9/5) + 32
    return(fahrenheit)
  } else if (unit == "F") {
    # Fahrenheit to Celsius
    celsius <- (value - 32) * 5/9
    return(celsius)
  } else {
    return("Error: Unit must be 'C' or 'F'")
  }
}

# Test the function
convert_temp(100, "C")
# [1] 212

convert_temp(32, "F")
# [1] 0

convert_temp("hot", "C")
# [1] "Error: Temperature must be a number"

This example demonstrates:

  • Input validation with if
  • Multiple conditions with else if
  • Early return statements
  • Error handling for invalid inputs

Best practices

Follow these guidelines to write clean, maintainable R code:

  1. Use meaningful names, Function names should describe what they do (calculate_bmi not func1)
  2. Keep functions short, Each function should do one thing well
  3. Document your functions, Use comments to explain inputs, outputs, and purpose
  4. Handle errors gracefully, Check for invalid inputs at the start
  5. Prefer vectorization, Use vectorized operations instead of loops when possible
  6. Use snake_case, R conventions use snake_case for variables and functions

Common pitfalls

Watch out for these common mistakes:

Forgetting to return:

# Wrong: returns NULL
f <- function(x) { x + 1 }

# Correct: returns the result
f <- function(x) { return(x + 1) }

The wrong version uses curly braces, which changes the behaviour subtly. Without braces, function(x) x + 1 returns the expression value. With braces, the last expression is still returned implicitly unless braces create confusion. This catches beginners who expect x + 1 inside {} to produce a visible result. Either drop the braces or use return() explicitly when the body spans multiple lines; clarity wins over brevity.

Not vectorizing conditions:

# Wrong: only checks first element
if (c(TRUE, FALSE)) { "yes" }

# Correct: check all elements
if (all(c(TRUE, FALSE))) { "yes" }

A logical vector passed to if() triggers a warning and only evaluates the first element. The code looks like it should branch on the whole vector, but R silently discards the rest. Knowing whether any() or all() is the right check depends on the problem: use all() when every element must satisfy the condition (all values positive, all rows complete) and any() when at least one match is enough (contains an outlier, found the target).

Infinite loops:

# Wrong: never ends
i <- 1
while (i > 0) { i <- i + 1 }

# Correct: has termination condition
i <- 1
while (i <= 5) { print(i); i <- i + 1 }

The wrong version increments i indefinitely because the condition i > 0 never becomes false. Every while loop needs a way for the condition to eventually flip, either by modifying a variable inside the body or by calling break when a goal is reached. Before running a while loop on real data, trace through the first few iterations mentally or insert a temporary counter to guard against runaway execution.

Defining functions

R functions are first-class objects, they can be stored in variables, passed as arguments, and returned from other functions. f <- function(x, y = 10) x + y defines a function with default argument y = 10. f(3) returns 13; f(3, 20) returns 23. Functions return the value of the last evaluated expression, or the value passed to return() for early exits.

Control flow

if (cond) expr_true else expr_false is the basic conditional. In R, if is an expression, it returns a value. ifelse(cond, yes, no) is the vectorized version. switch(value, case1 = result1, case2 = result2, default) dispatches on a string value.

for (item in collection) loops over a collection. while (cond) loops while a condition is true. repeat { ... ; if (done) break } loops until an explicit break. next skips to the next iteration. Prefer vectorized operations over for loops for performance, only use for when iteration order matters or when side effects are the goal.

Function arguments

R uses “promise” evaluation for arguments, they are evaluated lazily when first used. match.arg(arg, choices) validates that arg is one of the allowed choices and performs partial matching. ... (dots) captures additional arguments that can be passed to inner functions with list(...) or forwarded with ..1, ..2 via the ...elt() function. missing(arg) tests whether an optional argument was supplied.

Closures

When a function is defined inside another function, it captures the enclosing environment. make_adder <- function(n) function(x) x + n creates a factory, add5 <- make_adder(5) creates a function that adds 5. The n is captured in the closure. Closures are used for function factories, memoization, and creating callbacks with pre-bound arguments.

Functions as first-Class objects

In R, functions are objects. You can assign a function to a variable, pass a function as an argument to another function, return a function from a function, and store functions in lists. This property enables functional programming patterns that are central to idiomatic R code. The map family from purrr, apply family from base R, and most iteration patterns in R rely on passing functions as arguments.

Anonymous functions, functions without names, are common in R for one-off transformations passed to map or apply. R 4.1 introduced a shorthand: the backslash-parenthesis syntax creates an anonymous function compactly. For simple single-expression functions used in one place, anonymous functions are cleaner than defining a named function. For functions used in multiple places or with complex logic, named functions are better because they can be documented and tested independently.

Scoping rules

R uses lexical scoping: a function finds variables by looking first in its own environment, then in the environment where it was defined, then outward through enclosing environments to the global environment, then through attached packages. This means a function can read variables from the environment where it was created, not where it is called.

The assignment arrow creates variables in the current environment. The double-arrow operator assigns in the parent environment, and by extension the global environment if used enough levels up. The double-arrow is occasionally necessary but should be used sparingly, it creates non-local side effects that make functions harder to understand and test. The clean alternative is to return the value and let the caller assign it.

if/else and vectorized conditionals

R’s if/else is a control flow statement for scalar logic, it evaluates one condition and takes one branch. For vectorized conditionals, where you want to apply different transformations to different elements of a vector based on element-wise conditions, use ifelse or dplyr’s if_else. These return a vector of the same length as the condition, with one value per element chosen from the yes or no argument based on the condition.

For more than two cases, case_when from dplyr provides a vectorized multi-condition switch. It evaluates conditions in order and returns the value corresponding to the first matching condition. Writing case_when with a final TRUE condition as a catch-all handles the default case explicitly. The result type must be consistent across all branches; mixing numeric and character values requires explicit conversion.

Recursion and iteration

R supports recursion but does not optimize tail calls. Deep recursion causes a stack overflow. The practical limit depends on the machine but is typically a few thousand levels. For algorithms that naturally express as recursion, like tree traversal and recursive data structures, R’s recursion works correctly for reasonable depths. For deep recursion, rewrite as iteration with an explicit stack.

For numerical iteration — loops that run a fixed number of times or until convergence — both for loops over an index and while loops are suitable. Pre-allocate the output vector before the loop and assign to it by index rather than growing it with append or c inside the loop, which copies the vector on every iteration. For embarrassingly parallel loops, the future.apply package parallelizes for-loop patterns with minimal code changes.

Summary

You now know how to:

  • Create functions with function() and use arguments
  • Return values explicitly or implicitly
  • Use default argument values for flexibility
  • Control flow with if, else if, and else
  • Iterate with for and while loops
  • Use break and next for loop control
  • Handle errors gracefully with input validation

These fundamentals will serve you well as you build more complex R programs. Functions let you encapsulate logic into reusable components, while control flow lets you handle the diverse conditions you’ll encounter in real-world data analysis.

In the next tutorial, we’ll cover importing and exporting data, the essential skill for reading your data into R and saving your results.

Next steps

Now that you understand functions and control flow in R, explore these related topics to deepen your knowledge and apply these techniques in more complex scenarios.

See also