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 programming patterns 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
The map function transforms every element in a list without changing the list’s length. Filter, on the other hand, produces a subset — possibly shorter, possibly empty — by testing each element against a predicate and keeping only those for which the predicate returns a truthy value. Together, map and filter form the two most common list-processing operations in functional code. You can first filter to narrow down your data, then map to reshape what remains, or map first to extract fields and then filter on the transformed values.
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
While map and filter operate element-by-element and produce collections, fold walks the entire list and collapses it into a single result. The accumulator tracks state across iterations — it starts at the provided initial value and gets updated by the combining function on each step. This pattern is sometimes called reduce in other languages. Folds come in two varieties: left fold processes elements from first to last (what we show here), while right fold starts from the end and works backwards, which matters when the combining operation is not associative.
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.
Fold is the Swiss Army knife of functional programming — you can implement map and filter in terms of it. To express map as a fold, your combining function builds up a new table by appending the transformed element to the accumulator. To express filter, the combining function either appends the element (if it passes the predicate) or returns the accumulator unchanged. This generality comes at a readability cost, which is why most Lua codebases keep map, filter, and fold as separate primitives and reach for fold only when the transformation truly needs custom accumulation logic.
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.
The nesting pattern shown above works for short chains but becomes unwieldy with more steps. Another approach is to accumulate results in a local table, applying one operation per pass — less elegant mathematically but easier to debug when something goes wrong in the middle of the pipeline. Whichever style you choose, the important discipline is that each step produces a fresh table rather than mutating the input, so the original data stays intact for reuse or inspection.
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.
In practice, composition is most useful when you have a library of small, single-purpose functions that you combine on the fly. A common Lua pattern is to store transformation functions in a table and loop over them, applying each in sequence. This is effectively what pipe does but without requiring a dedicated composition operator. The advantage of explicit compose and pipe functions is clarity: the code says exactly what it does without the reader needing to trace a loop body.
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.
The pipeline pattern shines when you have a sequence of data-processing steps that would otherwise require deeply nested function calls or intermediate variables. Each stage in the pipeline is a pure function — it takes one input and produces one output, with no side effects on external state. This makes pipelines straightforward to test: you can verify each stage independently by feeding it known inputs and checking the output, then trust that the composed pipeline behaves correctly. For long-running data pipelines in Lua, consider wrapping functions to catch and propagate errors through the chain rather than letting a single failure bring down the entire 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.
Lua’s proper tail call support sets it apart from many scripting languages. When a function returns the result of another function call directly — without performing any additional computation on the returned value — the compiler reuses the current stack frame instead of allocating a new one. This means a tail-recursive function can run indefinitely without stack overflow. The catch is that the call must be in true tail position: no arithmetic, no field access, no wrapping in parentheses after the recursive call returns. When debugging tail-recursive functions, note that Lua’s stack trace will not show the intermediate calls since they were optimized away. This makes tail recursion both powerful and slightly harder to debug because you lose the call history that a normal stack trace would provide.
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 tree traversal you just saw creates no new data structures — it walks the existing tree and computes a scalar result. Immutability goes further: it says every function that appears to “modify” something should instead return a brand-new copy with the change applied. This leaves the original intact for any other part of the program that still holds a reference to it. Immutability patterns in Lua require some discipline since tables are mutable by default, but the payoff is elimination of entire categories of state-sharing bugs.
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.
Shared mutable state is the source of many hard-to-trace bugs: one part of the program changes a table that another part is still using, and the resulting behaviour depends on the order of operations. By returning fresh tables from every transformation, you eliminate this class of bug entirely. The cost is memory allocation and garbage collection pressure. For small to medium-sized tables, this overhead is negligible on modern Lua implementations like LuaJIT. For very large datasets or hot loops, profile first and only then consider in-place mutation with clear ownership conventions defined in the module’s public API. This discipline of never mutating inputs makes your functions trivially composable and safe to call from anywhere in the codebase.
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.
The array helpers above follow a consistent pattern: every function that changes state returns a fresh table. This style pairs naturally with the concept of closures that memoize results, which is the next technique.
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 programming 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