Functional Programming Patterns in Lua
Lua is not a functional language, but it treats functions as first-class values. That is enough to bring functional ideas into your code: passing behaviour as arguments, composing small functions into larger ones, and processing data through pipelines rather than explicit loops.
This guide covers functional patterns that work well in idiomatic Lua. It assumes you already know how closures and upvalues work — those are the building blocks. What this guide adds is the style.
Functions as Values
The foundation of functional programming is treating functions like any other value: stored in variables, passed as arguments, returned from other functions.
local function add(a, b)
return a + b
end
local function multiply(a, b)
return a * b
end
-- Store a function in a variable
local operation = add
print(operation(2, 3)) -- 5
-- Pass a function as an argument
local function apply(fn, x, y)
return fn(x, y)
end
print(apply(multiply, 4, 5)) -- 20
This is not yet functional programming, but it enables everything that follows.
The Holy Trinity: Map, Filter, and Fold
Three functions cover most data transformation tasks. Once you have them, explicit for loops start feeling verbose.
Map
Apply a function to every element in a list, returning a new list:
local function map(fn, list)
local result = {}
for i, v in ipairs(list) do
result[i] = fn(v)
end
return result
end
local numbers = {1, 2, 3, 4, 5}
local doubled = map(function(n) return n * 2 end, numbers)
-- {2, 4, 6, 8, 10}
Filter
Keep only elements that satisfy a predicate:
local function filter(pred, list)
local result = {}
for _, v in ipairs(list) do
if pred(v) then
result[#result + 1] = v
end
end
return result
end
local evens = filter(function(n) return n % 2 == 0 end, numbers)
-- {2, 4}
Fold
Reduce a list to a single value by applying a function cumulatively:
local function fold(fn, init, list)
local acc = init
for _, v in ipairs(list) do
acc = fn(acc, v)
end
return acc
end
local sum = fold(function(acc, n) return acc + n end, 0, numbers)
-- 15
local product = fold(function(acc, n) return acc * n end, 1, numbers)
-- 120
Fold is the most general. Map and filter are both special cases of it. Once you have fold, you rarely need to write a custom accumulation loop.
Chaining Them
The real power comes from composing these together:
local result = fold(
function(acc, n) return acc + n end,
0,
map(
function(n) return n * n end,
filter(function(n) return n % 2 == 0 end, numbers)
)
)
-- Sum of squares of even numbers: 2² + 4² = 20
This reads bottom-up: filter the evens, square them, sum the result.
Function Composition
Composition lets you build complex behaviour from simple pieces without anonymous function wrappers:
local function compose(f, g)
return function(...)
return f(g(...))
end
end
local function double(x) return x * 2 end
local function add_one(x) return x + 1 end
local double_then_add_one = compose(add_one, double)
print(double_then_add_one(5)) -- 11: (5 * 2) + 1
compose(f, g) applies g first, then f on the result. The name reflects the mathematical convention.
For more than two functions, compose chains:
local function pipe(...)
local fns = {...}
return function(x)
local result = x
for _, fn in ipairs(fns) do
result = fn(result)
end
return result
end
end
local transform = pipe(
function(n) return n - 1 end, -- subtract 1
function(n) return n * 2 end, -- double
function(n) return n + 5 end -- add 5
)
print(transform(10)) -- 23: ((10 - 1) * 2) + 5
pipe applies functions left-to-right, which matches how data flows: start with a value, pass it through a series of transformations. compose applies right-to-left, which matches mathematical function notation.
Pipelines
A pipeline passes a value through a series of functions. It is a readable alternative to nesting:
-- Nested: hard to read
local result = string.format(
"%.2f",
math.sqrt(
fold(function(acc, n) return acc + n end, 0, numbers)
)
)
-- Pipeline: clear step-by-step
local function pipeline(value, ...)
for _, fn in ipairs({...}) do
value = fn(value)
end
return value
end
local result = pipeline(
numbers,
function(ns) return fold(function(a, n) return a + n end, 0, ns) end, -- sum
function(total) return math.sqrt(total) end, -- sqrt
function(sqrt_val) return string.format("%.2f", sqrt_val) end -- format
)
Each function receives the output of the previous one. Read the pipeline top-to-bottom to follow the data transformation.
Recursion and Tail Calls
Functional code tends toward recursion rather than loops. Lua supports proper tail calls, which means a recursive call in tail position does not grow the stack:
-- Not a tail call: the addition happens after the recursive call returns
local function factorial(n)
if n <= 1 then return 1 end
return n * factorial(n - 1) -- must wait for recursive result
end
-- Tail call version: the recursive call IS the final result
local function factorial_tail(n, acc)
acc = acc or 1
if n <= 1 then return acc end
return factorial_tail(n - 1, n * acc) -- tail position
end
Tail calls reuse the current stack frame, so the recursive version can handle much larger inputs without overflowing the stack. The trade-off is an extra accumulator argument.
For tree traversal, recursion is the natural style:
local function sumTree(node)
if node.value then
return node.value
end
local leftSum = node.left and sumTree(node.left) or 0
local rightSum = node.right and sumTree(node.right) or 0
return leftSum + rightSum
end
Immutability Patterns
Functional programming prefers not to mutate existing data. In Lua, you can adopt this by treating tables as immutable values when you need to preserve them.
The simplest approach: instead of modifying a table, return a new one:
local function addField(record, key, value)
local copy = {}
for k, v in pairs(record) do
copy[k] = v
end
copy[key] = value
return copy
end
local user = {name = "Helena", active = true}
local updated = addField(user, "role", "admin")
-- user is unchanged, updated has the new field
For high-frequency updates, a more practical middle ground is local mutation: modify a copy, then pass it along. You do not mutate shared state — only a newly created copy.
For arrays, operations that “modify” actually return new arrays:
local function append(list, value)
local copy = {}
for i, v in ipairs(list) do copy[i] = v end
copy[#copy + 1] = value
return copy
end
local function updateAt(list, index, fn)
local copy = {}
for i, v in ipairs(list) do
copy[i] = (i == index) and fn(v) or v
end
return copy
end
This avoids the accidental sharing bugs that come from mutating a shared table passed as an argument.
Closures for Computed Values
Closures can carry computed results, memoizing expensive operations without polluting global state:
local function once(fn)
local done = false
local result
return function(...)
if not done then
result = fn(...)
done = true
end
return result
end
end
local initConfig = once(function()
-- This runs only on the first call
print("Loading config...")
return {host = "localhost", port = 8080}
end)
print(initConfig()) -- Loading config... (runs the function)
print(initConfig()) -- (no output, returns cached result)
once wraps a function so it only executes on the first invocation. Subsequent calls return the cached result. The computed value lives in the closure’s upvalue, invisible to the outside.
When to Use These Patterns
Functional patterns are not always the right tool. They shine when:
- Data transformation chains — map/filter/fold and pipelines replace nested loops with readable step-by-step operations
- Reusable behaviour — composing small functions is easier than copying loop bodies
- Testable code — pure functions with no side effects are trivial to unit test
They add overhead when:
- Hot paths with millions of iterations — function call overhead per element adds up; a hand-written loop is faster
- Debugging — stack traces through composed functions are harder to follow
- Codebase culture — mixing FP style with an imperative codebase creates friction
Start with map/filter/fold for data processing, add composition when you find yourself writing the same transformation sequence multiple times. Leave recursion for tree-structured data. The goal is readability, not purity.
See Also
- /guides/lua-closures/ — closure mechanics and the upvalue model that makes these patterns possible
- /guides/lua-iterators-guide/ — building custom iterators, including stateless and coroutine-based patterns
- /guides/lua-performance-tips/ — benchmarking these patterns against imperative alternatives