Polyglot
Languages Lua scope
Lua § scope

Scope

Lua’s scoping rule is simple: variables are global by default; the local keyword introduces block-scoped locals. The conventional discipline uses local for almost everything — globals are conventionally avoided except for module exports. Functions admit lexical scoping with closures over upvalues (variables in enclosing function scopes). The _G table contains all globals; _ENV (5.2+) admits substantial environment manipulation. The combination — global-by-default with explicit local, lexical block scoping, the upvalue mechanism for closures, the _G/_ENV for globals introspection — is the substance of Lua’s scope model.

Global vs local

x = 10                                             -- global
local y = 20                                       -- local

function foo()
    z = 30                                         -- still global!
    local w = 40                                   -- local to foo
end

The local keyword admits block-scoped variables; assignment without local creates or modifies a global.

The conventional contemporary discipline:

  • Use local for almost everything.
  • Globals are conventionally module-exposed names only.

Block scope

local variables are scoped to the enclosing block:

local x = 5
do
    local y = 10                                   -- block-scoped
    print(x, y)                                    -- 5, 10
end
print(x)                                           -- 5
print(y)                                           -- nil (out of scope)

The principal blocks:

  • function bodiesfunction ... end.
  • control structuresif/then/end, while/do/end, for/do/end, repeat/until.
  • explicit do/end blocks.
for i = 1, 10 do
    local x = i * 2
    print(x)
end
print(x)                                           -- nil (out of scope)
print(i)                                           -- nil (loop variable also local)

local function

The form local function name(params) ... end is sugar for:

local function f(x)
    return x * 2
end

-- Approximately equivalent to:
local f = function(x)
    return x * 2
end

The local function form admits recursive references to the function (the binding is in scope inside the body):

local function factorial(n)
    if n <= 1 then return 1 end
    return n * factorial(n - 1)                    -- self-reference works
end

The non-function-keyword form requires forward declaration for recursion:

local factorial
factorial = function(n)
    if n <= 1 then return 1 end
    return n * factorial(n - 1)
end

The conventional contemporary discipline uses local function.

Upvalues and closures

Lua functions admit closures over their enclosing scope’s locals — these captured variables are called upvalues:

local function make_counter()
    local count = 0
    return function()
        count = count + 1                          -- count is an upvalue
        return count
    end
end

local counter = make_counter()
print(counter())                                   -- 1
print(counter())                                   -- 2
print(counter())                                   -- 3

Each call to make_counter produces a new closure with its own count. The upvalues admit substantial state encapsulation without classes.

For shared upvalues:

local function make_pair()
    local x = 0
    return function() x = x + 1; return x end,     -- both functions share x
           function() return x end
end

local incr, get = make_pair()
incr()
incr()
print(get())                                       -- 2

Variable shadowing

Inner local declarations shadow outer:

local x = 5

do
    local x = 10                                   -- shadows outer
    print(x)                                       -- 10
end

print(x)                                           -- 5

Shadowing is admitted; the conventional discipline avoids it for clarity except in narrow cases.

_G — the globals table

All globals live in the _G table:

x = 42                                             -- create global
print(_G.x)                                        -- 42

-- Iterating all globals:
for k, v in pairs(_G) do
    print(k, v)                                    -- prints all global names
end

-- Setting via _G:
_G.y = 100
print(y)                                           -- 100

The mechanism admits substantial introspection but is rarely needed in routine code.

_ENV (5.2+)

The _ENV is the current environment — admit substantial scope manipulation:

local function with_env(t, fn)
    local _ENV = t                                 -- replace environment
    return fn()
end

-- All "global" references in fn() now look up in t.

The _ENV admits sandboxing and DSL-like environments — admit substantial customisation.

do ... end

The explicit block:

do
    local temp = compute_expensive()
    process(temp)
end
-- temp not visible here; freed for GC

The conventional uses are:

  • Scoping locals tightly — admit substantial cleanup.
  • Grouping for clarity.

Loop variable scope

for i = 1, 10 do
    -- i is local to this iteration
end
print(i)                                           -- nil

for k, v in pairs(t) do
    -- k, v local to iteration
end

while x > 0 do
    local y = compute(x)                           -- new y per iteration
    x = y
end

Loop variables are implicitly local.

repeat ... until

The until condition has visibility into the block’s locals:

local n = 0
repeat
    local doubled = n * 2
    n = n + 1
until doubled > 100                                -- doubled visible here!

The form is unique among Lua control structures — admits “condition computed in the loop body”.

Module scope

A module (file) admits its own global namespace through the conventional pattern:

-- File: mymodule.lua
local M = {}

local function helper()                            -- module-private
    -- ...
end

function M.public_function(x)
    return helper(x) * 2
end

M.constant = 42

return M

Importing:

-- File: main.lua
local mymodule = require("mymodule")
mymodule.public_function(5)
print(mymodule.constant)

The local declarations are module-private; the returned table is the public API. Treated in Modules and packaging.

Common patterns

local everywhere

-- Bad (creates globals):
function compute(x)
    result = x * 2                                 -- global!
    return result
end

-- Good:
local function compute(x)
    local result = x * 2
    return result
end

The conventional discipline applies local to:

  • All local variables.
  • All “private” functions.
  • All function parameters and returns (implicitly local).

Caching globals as locals

For substantial performance in hot loops:

local print = print                                -- local alias
local string_format = string.format
local math_floor = math.floor

for i = 1, 1000000 do
    print(string_format("%d", math_floor(i / 2)))
end

The local alias admits substantial speed improvement — Lua’s local-variable access is faster than global lookup.

Closure for state encapsulation

local function make_observable(initial)
    local value = initial
    local listeners = {}

    local function subscribe(listener)
        listeners[#listeners + 1] = listener
    end

    local function set(new_value)
        if new_value ~= value then
            value = new_value
            for _, listener in ipairs(listeners) do
                listener(new_value)
            end
        end
    end

    local function get()
        return value
    end

    return {subscribe = subscribe, set = set, get = get}
end

local counter = make_observable(0)
counter.subscribe(function(v) print("changed to", v) end)
counter.set(5)                                     -- "changed to 5"
counter.set(10)                                    -- "changed to 10"

The closure admits substantial encapsulated state without classes.

Memoization

local function memoize(f)
    local cache = {}
    return function(x)
        if cache[x] == nil then
            cache[x] = f(x)
        end
        return cache[x]
    end
end

local fib = memoize(function(n)
    if n <= 1 then return n end
    return fib(n - 1) + fib(n - 2)
end)

Module pattern

local M = {}

-- Private state:
local cache = {}

-- Private function:
local function compute(x)
    return x * 2 + 1
end

-- Public API:
function M.process(x)
    if cache[x] then return cache[x] end
    local result = compute(x)
    cache[x] = result
    return result
end

function M.clear_cache()
    cache = {}
end

return M

Const-like via local

local PI = 3.14159
local MAX_RETRIES = 3

-- Lua 5.4 admits the <const> attribute:
local PI <const> = 3.14159

The <const> (5.4+) admits compile-time enforcement; pre-5.4 the local is conventionally treated as constant.

Sandboxed execution

local sandbox_env = {
    print = print,
    pairs = pairs,
    ipairs = ipairs,
    -- ... selected globals ...
}

local code = "x = 5; print(x)"
local fn = load(code, "sandbox", "t", sandbox_env)
if fn then fn() end

The mechanism admits substantial isolation — load with a controlled environment.

do ... end for cleanup

do
    local f = io.open("data.txt", "r")
    if f then
        local content = f:read("*a")
        f:close()
        process(content)
    end
end
-- f and content out of scope; available for GC

For Lua 5.4+, the <close> attribute admits substantial automatic cleanup:

do
    local f <close> = io.open("data.txt", "r")
    -- f:close() called automatically when scope exits
    if f then process(f:read("*a")) end
end

Mutually recursive functions

local odd, even

odd = function(n)
    if n == 0 then return false end
    return even(n - 1)
end

even = function(n)
    if n == 0 then return true end
    return odd(n - 1)
end

print(even(10))                                    -- true
print(odd(7))                                      -- true

The forward declaration admits substantial mutual recursion.

Iterator with closure

local function count_up(max)
    local i = 0
    return function()
        i = i + 1
        if i <= max then return i end
    end
end

for n in count_up(5) do
    print(n)                                       -- 1, 2, 3, 4, 5
end

Treated in Iterators.

Configuration via environment

local function load_config(filename)
    local config = {}
    local fn, err = loadfile(filename, "t", config)
    if not fn then return nil, err end
    fn()                                           -- runs in `config` environment
    return config
end

-- File: settings.lua
host = "localhost"
port = 8080
debug = true

-- Loading:
local cfg = load_config("settings.lua")
print(cfg.host, cfg.port, cfg.debug)

The _ENV-based mechanism admits substantial DSL-style configuration.

A note on global avoidance

Globals are conventionally avoided for several reasons:

  • Performance — global lookup is slower than local.
  • Clarity — explicit local admits substantial readability.
  • Safety — accidental globals (typos in variable names) are common bugs.
  • Module hygiene — globals from one module pollute the global namespace.

The conventional defence:

  • Use local everywhere.
  • Use linters (luacheck) to detect accidental globals.
  • Use module patterns — return tables.
  • Use strict.lua in 5.1 for runtime detection of typo’d globals.

A note on the conventional discipline

The contemporary Lua scope advice:

  • Use local for everything — globals are exceptional.
  • Use local function for declarations.
  • Use closures for state encapsulation.
  • Use the module pattern (local M = {}; ... return M).
  • Cache hot globals as locals for performance.
  • Use <const> (5.4+) for compile-time constants.
  • Use <close> (5.4+) for substantial RAII-like cleanup.
  • Use do ... end for tight scoping.
  • Use _ENV (5.2+) for substantial sandboxing.
  • Use linters to detect accidental globals.

The combination — explicit local declarations, block-scoped lexical visibility, closures over upvalues, the global table _G and the environment _ENV, the <const> and <close> attributes (5.4+) — is the substance of Lua’s scope model. The discipline produces clear, well-encapsulated code with substantial control over visibility.