Part of Learn Lua with NeoVim

Lua with Neovim: Closures — Upvalues, Counter Factory & Functions That Remember | Episode 16

Sandy LaneSandy Lane

Video: Lua with Neovim: Closures — Upvalues, Counter Factory & Functions That Remember | Episode 16 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Lua Closures: Functions That Remember

A function that captures variables from its enclosing scope. The captured variables (called upvalues) live as long as the closure does. Build a counter factory and a multiplier factory.

A closure is a function plus the environment it was created in. When you return a function from another function, the inner function "closes over" the outer function's locals — they keep existing after the outer function returns.

This is what lets us build factories, callbacks with state, and the foundation for OOP later.

The counter factory

function make_counter()
  local count = 0
  return function()
    count = count + 1
    return count
  end
end

local counter_a = make_counter()
local counter_b = make_counter()

print("A: " .. counter_a())   -- 1
print("A: " .. counter_a())   -- 2
print("A: " .. counter_a())   -- 3
print("B: " .. counter_b())   -- 1
print("B: " .. counter_b())   -- 2
print("A: " .. counter_a())   -- 4

make_counter returns an anonymous function that reads and increments count. Even after make_counter returns, the returned function keeps count alive.

Each call to make_counter creates a new count. counter_a and counter_b are independent — each has its own private count.

What just happened

When you call make_counter(), three things happen:

  1. A new local variable count is created on the stack.
  2. An anonymous function is created that references count.
  3. The anonymous function is returned.

Normally, when a function returns, its locals are reaped. But because the returned function still references count, Lua keeps count alive for as long as the returned function exists. That captured variable is called an upvalue.

counter_a has its own count upvalue. counter_b has its own, independent count upvalue. They don't share state.

The multiplier factory

function make_multiplier(factor)
  return function(x)
    return x * factor
  end
end

local double = make_multiplier(2)
local triple = make_multiplier(3)

print("double(5): " .. double(5))    -- 10
print("triple(5): " .. triple(5))    -- 15
print("double(10): " .. double(10))  -- 20

Same pattern: make_multiplier returns a function that captures factor. Each call to make_multiplier creates a new closure with a new captured factor.

double and triple look like different functions, but they share their body — the difference is the captured upvalue.

Closures are by reference, not by value

local x = 5
local fn = function() return x end

x = 10
print(fn())   -- 10

The closure captures the variable, not the value. Reassign x outside, the captured reference sees the new value.

This is mostly what you want, but it bites in loops:

local fns = {}
for i = 1, 3 do
  fns[i] = function() return i end
end

print(fns[1]())  -- 1 (in Lua 5.0 this would be 4!)
print(fns[2]())  -- 2
print(fns[3]())  -- 3

In Lua 5.1+, each for iteration creates a fresh local i, so each closure captures a distinct value. Pre-5.0 (and many older languages) shared one i, so all closures returned the same final value. Modern Lua does the right thing.

If you ever need to manually capture-by-value:

local fns = {}
for i = 1, 3 do
  local copy = i
  fns[i] = function() return copy end
end

Each iteration creates its own copy.

Two closures sharing an upvalue

function make_account(balance)
  local function deposit(n)
    balance = balance + n
    return balance
  end
  local function withdraw(n)
    balance = balance - n
    return balance
  end
  return deposit, withdraw
end

local d, w = make_account(100)
print(d(50))    -- 150
print(w(30))    -- 120
print(d(10))    -- 130

Two functions can share the same upvalue. Both deposit and withdraw see and mutate the same balance. They're closures over a shared environment.

This is the foundation of "objects" in Lua — a table of methods sharing state, just expressed as separate functions.

Returning a table of closures (proto-OOP)

function make_account(balance)
  return {
    deposit = function(n) balance = balance + n; return balance end,
    withdraw = function(n) balance = balance - n; return balance end,
    get_balance = function() return balance end,
  }
end

local acc = make_account(100)
acc.deposit(50)
acc.deposit(25)
print(acc.get_balance())  -- 175

The table holds three closures. They all share the upvalue balance. From outside, balance is hidden — only the methods can touch it. That's encapsulation, no class or private keywords needed.

This is a real pattern in Lua. We'll see it again with metatables in episode 29 — but closures alone get you far.

Closures and memory

Each closure holds references to its upvalues. An upvalue stays alive as long as any closure references it. So:

function make_pair()
  local big_array = {}
  for i = 1, 1_000_000 do big_array[i] = i end
  return function() return big_array[1] end
end

The returned closure keeps big_array alive even though we only need [1]. To release the rest, capture only what's needed:

function make_pair()
  local big_array = {}
  for i = 1, 1_000_000 do big_array[i] = i end
  local first = big_array[1]
  return function() return first end
end

Now big_array is released after make_pair returns; the closure only keeps first.

Common stumbles

Expecting "by value" capture. Closures capture references; mutations outside affect the captured variable.

Sharing state across closures unintentionally. If two closures should be independent, return them from separate factory calls — not the same one.

Memory leaks via closures. A closure holds onto everything it references. Captured-but-unused locals stay alive.

Recursion through closures. A local function self-references work; assigning to a local first then a function literal does not. Use local function f(...) ... end or the two-step local f; f = function(...) ... end.

Confusing closures with classes. Closures give you data + behaviour, but they're not proper classes. They're a lighter-weight alternative for many cases.

What's next

Episode 17: tables as arrays. Lua's universal data structure. 1-based indexing, table.insert, table.remove, # length, and the basic patterns for working with sequences.

Recap

A closure = function + the environment it was created in. Captured variables are upvalues — they live as long as the closure does. Each call to the outer function creates new upvalues, so each closure has its own state. Closures capture by reference, not by value. Two closures from the same factory share state; two from separate factories don't. The factory + closures pattern gets you encapsulation without explicit classes.

Next episode: tables as arrays.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.