Understanding Lua Environments and _ENV
In most languages, “global variables” are handled by the interpreter or compiler using internal data structures you can’t see or manipulate. Lua takes a different approach: global variables live in a regular table, and you can access and change that table like any other. The mechanism that makes this work is _ENV.
Understanding _ENV clarifies a lot of Lua’s behavior — why global variables are just table entries, how load() can run code with restricted permissions, and why closures capturing “globals” still work correctly even when you change the environment.
What _ENV Actually Is
When Lua compiles any chunk of code, it wraps the entire chunk in an invisible function that has one extra upvalue: _ENV. This isn’t a global variable — it’s a closed-over local variable, the same kind of variable you’d create with a closure.
Every free name (a variable that isn’t declared locally) gets rewritten by the compiler to prefix it with _ENV.. So this code:
x = 10
print(x)
Gets internally rewritten to something equivalent to:
_ENV.x = 10
print(_ENV.x)
The name x never actually exists as a bare global — it always goes through _ENV.
The Global Environment and _G
Lua maintains one special environment called the global environment. This is the default value of _ENV for any chunk loaded normally. Lua also stores a reference to this table in the global variable _G.
print(_G == _ENV) -- true in top-level code
_G and _ENV point to the same table in normal circumstances, which is why people sometimes use them interchangeably. But they’re not the same thing:
_Gis a global variable that always points to the global environment_ENVis the upvalue that a chunk uses to resolve free names — it can be any table
You can verify that _G is just a regular table entry:
_G.foo = "hello"
print(foo) -- "hello"
_ENV Can Be Shadowed
Since _ENV is a regular name (not a keyword), you can shadow it with a local variable:
local _ENV = { x = 100 }
print(x) -- 100
This is sometimes done deliberately in modules to prevent accidental reliance on globals, though it has the side effect of blocking access to the real global environment unless explicitly referenced.
Loading Code with Custom Environments
This is where environments become powerful. The load() and loadfile() functions accept an environment as an argument. The loaded chunk’s _ENV will be set to whatever table you provide:
local my_env = {
print = print,
math = math,
ipairs = ipairs,
pairs = pairs,
}
local fn = load("print(x + y)", "t", my_env)
fn() -- error: attempt to index a nil value (x and y not in my_env)
The third argument to load is the environment. The chunk can’t access x or y because they don’t exist in my_env.
Change the environment to include them:
local my_env = {
x = 10,
y = 20,
print = print,
}
local fn = load("print(x + y)", "t", my_env)
fn() -- 30
loadfile() works the same way:
local env = { require = require, print = print }
local chunk = loadfile("module.lua", "t", env)
chunk()
This is the foundation of Lua’s module system. When you require a module, the module’s code runs with its own environment that starts empty, and the module populates it with the values it wants to export.
Environments and Closures
Each function has its own _ENV upvalue. When you create a closure, the function captures whatever _ENV is visible at its creation site — and that reference stays with the function.
local function make_counter(start)
local count = start or 0
return function()
count = count + 1
return count
end
end
local c1 = make_counter(0)
local c2 = make_counter(0)
print(c1()) -- 1
print(c1()) -- 2
print(c2()) -- 1
print(c2()) -- 2
The inner function captures count as a local upvalue (not through _ENV). This is the correct way to create true private state in Lua — using upvalues, not environment tables.
But if the inner function refers to a bare name without a local declaration, it resolves through _ENV:
local env = { value = 100 }
function get_value()
return value -- reads env.value via _ENV
end
print(get_value()) -- 100
env.value = 200
print(get_value()) -- 200
The function get_value was created in a scope where _ENV pointed to env, and that reference persists. Changing env.value later is reflected in the function’s behavior.
Sandboxing with Environments
Environments let you restrict what a chunk can do. A common pattern is to create a locked-down environment that only exposes specific functions:
local sandbox_env = {
print = print,
table = table,
string = string,
math = math,
pairs = pairs,
ipairs = ipairs,
}
setmetatable(sandbox_env, {
__index = function(_, k)
error("global '" .. k .. "' is not available in sandbox", 2)
end
})
local fn = load("os.execute('rm -rf /')", "t", sandbox_env)
fn() -- error: global 'os' is not available in sandbox
The __index metamethod intercepts any attempt to read a key that isn’t explicitly in the table, raising an error with a clear message.
You can also allow selective globals by adding them to the environment:
local restricted_env = {
print = print,
math = math,
_G = _G, -- optionally give access to full globals
}
local fn = load("print(_G.os)", "t", restricted_env)
fn() -- table: 0x... (the os table, accessible via _G)
See Also
- lua-closures — how closures capture upvalues, the mechanism behind environment capture
- lua-metatables — metatables for controlling table access, used in sandboxing patterns
- lua-sandboxing — practical sandboxing patterns using environments and the debug library