Environments & Scoping
Reviewed & published by Brayan K
An environment is the container that maps variable names to values, and scoping is the set of rules R follows to decide which environment a name is looked up in.
Part of the free R course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
This lesson explains lexical scoping, how the global and function environments relate, the superassignment operator for reaching outward, why functions capture their defining environment as closures, and the new.env()/get/assign toolkit.
What You'll Learn in This Lesson
- How lexical scoping resolves variable names
- The difference between global and function environments
- Why a local assignment never touches the global copy
- Superassignment for updating an enclosing variable
- How closures capture their defining environment
- Building environments with new.env(), get(), assign()
1️⃣ Local vs Global: Lexical Scoping
When a function runs, it gets its own environment. A variable assigned inside with <- is local — it shadows any global of the same name and vanishes when the function returns. The global variable is left untouched.
# Lexical scoping: a function looks for a variable FIRST in its own
# body, then in the environment where it was DEFINED, then outward.
x <- 10 # x in the global environment
f <- function() {
x <- 99 # a LOCAL x, separate from the global one
x * 2
}
f() # uses the local x -> [1] 198
x # the global x is untouched -> [1] 10If a name isn't found locally, R doesn't fail — it searches outward to the environment where the function was defined.
# If a name is NOT found locally, R searches outward to the defining
# environment (here, the global one) — it does NOT error.
y <- 5
g <- function() {
y + 1 # no local y, so R finds the global y = 5
}
g() # -> [1] 6
# A plain <- inside a function only ever creates/updates a LOCAL copy:
h <- function() { y <- 100 }
h()
y # global y is still 5 -> [1] 52️⃣ Superassignment with <<-
The superassignment operator <<- deliberately reaches outward: it searches enclosing environments for an existing variable and updates it in place, rather than creating a local copy. It's the tool for a function that needs to mutate state living in a wider scope.
# <<- (superassignment) walks OUTWARD to modify an existing variable
# in an enclosing environment, instead of making a local copy.
counter <- 0
bump <- function() {
counter <<- counter + 1 # updates the GLOBAL counter
}
bump(); bump(); bump()
counter # -> [1] 33️⃣ Closures: Captured Environments
A closure is a function bundled with the environment it was defined in. Because that environment stays alive, each returned function "remembers" its own captured values — giving you private, persistent state without globals.
# A function captures (closes over) the environment where it was
# defined. make_adder() returns functions that remember their own n.
make_adder <- function(n) {
function(x) x + n # n comes from the enclosing environment
}
add5 <- make_adder(5)
add10 <- make_adder(10)
add5(100) # remembers n = 5 -> [1] 105
add10(100) # remembers n = 10 -> [1] 110add5 and add10 are the same code but carry different captured environments — that's the whole idea of a closure.
4️⃣ The Environment Toolkit
Environments are real objects. new.env() makes an empty one, assign() and get() write and read bindings by name, exists() checks for a name, and environment() / globalenv() hand you references to them.
# Environments are first-class objects you can build and poke at.
e <- new.env() # create a fresh, empty environment
assign("z", 42, envir = e) # put a binding into it
get("z", envir = e) # read it back -> [1] 42
exists("z", envir = e) # -> [1] TRUE
environmentName(globalenv()) # -> [1] "R_GlobalEnv"Your turn. Fill in the ___ blank, run it, and compare with the expected output.
# 🎯 YOUR TURN — replace each ___ then run it.
total <- 0
add_to_total <- function(n) {
total ___ total + n # 👉 replace ___ with <<-
}
add_to_total(10)
add_to_total(25)
print(total)
# ✅ Expected output:
# [1] 35Mini-Challenge: A Bank Account
The classic closure exercise. The account's balance lives in the enclosing environment and survives between calls — no global variable needed. deposit uses <<- to update it.
# 🎯 MINI-CHALLENGE: build a bank-account closure.
#
# Write make_account(balance) that returns a LIST of two functions:
# deposit(amount) -> adds to balance using <<-
# getBalance() -> returns the current balance
#
# The balance lives in the enclosing environment, so it persists
# between calls WITHOUT a global variable.
#
# acct <- make_account(100)
# acct$deposit(50)
# acct$deposit(25)
# acct$getBalance()
#
# ✅ Example output:
# [1] 175
# your code hereCommon Errors (and the fix)
- ❌ Expecting x <- ... inside a function to change the global x. ✅ It only makes a local copy — use <<- to reach out.
- ❌ Assuming R uses dynamic scoping (looking at the CALLER). ✅ R is lexical — it looks at where the function was defined.
- ❌ Overusing <<- to edit globals everywhere. ✅ Prefer returning values; reserve <<- for closures.
- ❌ Expecting environments to copy on assignment like vectors. ✅ Environments are reference objects — changes are shared.
Pro Tips
- 💡 Inspect a closure: environment(add5) returns the environment it captured.
- 💡 List bindings: ls(envir = e) shows every name stored in an environment.
- 💡 local() runs code in a throwaway environment — handy for keeping helpers out of the global scope.
📋 Quick Reference — Environments
| Task | R Syntax |
|---|---|
| Local assignment | x <- 1 |
| Update enclosing var | x <<- 1 |
| New environment | e <- new.env() |
| Write / read a binding | assign("z", 1, e); get("z", e) |
| Closure environment | environment(f) |
| The global environment | globalenv() |
🎉 Lesson Complete!
- ✅ R uses lexical scoping — names resolve where the function was defined
- ✅ <- makes a local; <<- reaches outward to update an enclosing variable
- ✅ Closures capture their defining environment, giving persistent private state
- ✅ new.env(), assign(), get() let you manage environments directly
Practice quiz
What kind of scoping does R use to resolve variable names?
- Dynamic scoping (where the function is called)
- Lexical scoping (where the function is defined)
- Random scoping
- No scoping at all
Answer: Lexical scoping (where the function is defined). R uses lexical (static) scoping: it looks where the function was defined.
Inside a function, what does a normal assignment x <- 5 affect?
- A local variable in the function's environment
- Always the global x
- The base environment
- Every environment at once
Answer: A local variable in the function's environment. Plain <- creates or updates a variable in the current (local) environment.
Which operator reaches OUTWARD to update an existing variable in an enclosing environment?
- <-
- =
- <<-
- ->
Answer: <<-. The superassignment operator <<- searches enclosing environments and updates the variable there.
If a name is not found locally, where does R look next under lexical scoping?
- It immediately errors
- In the calling function's environment
- In a random environment
- In the environment where the function was defined
Answer: In the environment where the function was defined. R searches outward through the enclosing (defining) environments.
What is a closure in R?
- A function bundled with the environment it was defined in
- A function with no arguments
- A way to delete environments
- A loop construct
Answer: A function bundled with the environment it was defined in. A closure captures its defining environment, giving it persistent private state.
Which function creates a new, empty environment?
- env.new()
- new.env()
- make.env()
- environment.create()
Answer: new.env(). new.env() returns a fresh empty environment object.
How do you write a binding named z into an environment e?
- set("z", 42, e)
- e.z <- 42
- assign("z", 42, envir = e)
- put(e, z, 42)
Answer: assign("z", 42, envir = e). assign("z", 42, envir = e) stores the binding in e; get() reads it back.
Reading a global variable inside a function (without assigning) does what?
- Errors immediately
- Modifies the global variable
- Works fine because R searches outward for it
- Creates a local copy automatically
Answer: Works fine because R searches outward for it. A function can read outer variables via lexical scoping; reading does not modify them.
Why does an inner function returned by make_adder(5) still remember n = 5?
- Because n is global
- Because of dynamic scoping
- Because R copies n into a file
- Because the closure keeps a live link to its defining environment
Answer: Because the closure keeps a live link to its defining environment. The captured environment holding n stays alive as long as the closure does.
Which function returns the environment a closure captured?
- environment(f)
- globalenv()
- ls(f)
- parent(f)
Answer: environment(f). environment(f) returns the environment associated with function f.
Continue this course
- Previous: Error Handling (tryCatch)
- Next: Checkpoint: R Basics — Combine subsetting, NA handling, regex and tables in a build — then a quiz
- Quick reference: R cheat sheet
Frequently asked questions
What is lexical scoping in R?
Lexical (also called static) scoping means that when a function needs the value of a variable it cannot find locally, R looks it up in the environment where the function was DEFINED — not where it was called from. The search proceeds outward through enclosing environments until the name is found or it reaches the global and then the base environment. This is predictable from reading the source code alone, which is why it is called 'lexical'. It is the foundation of closures and is different from the dynamic scoping used by some other languages.
What is the difference between <- and <<- ?
A normal assignment with <- always creates or updates a variable in the CURRENT (usually local) environment. The superassignment operator <<- instead searches OUTWARD through the enclosing environments for an existing variable of that name and updates it where it is found; if it finds none, it creates the variable in the global environment. So inside a function, counter <- 0 makes a throwaway local, while counter <<- counter + 1 modifies the counter that lives in an enclosing scope. Use <<- sparingly and deliberately, mainly inside closures.
Why does a function 'remember' values after it returns?
Because of closures. Every function in R carries a reference to the environment in which it was created, and that environment stays alive as long as the function does. So when make_adder(5) returns an inner function, that inner function keeps a live link to the environment holding n = 5, even though make_adder has finished. This captured state is what lets closures act like tiny objects with private data — the basis of patterns like counters, accumulators, and the bank-account example.
When would I create my own environment with new.env()?
Most everyday R code never needs new.env() — lists and closures cover the common cases. You reach for it when you want mutable, reference-style storage that several functions can share and update in place (environments are not copied on modification the way most R objects are), for example a cache or a registry. You manipulate it with assign(), get(), exists(), and ls(), passing envir = to target the right environment. It is an advanced tool, powerful for building stateful systems but easy to overuse.