Lesson 4 • Beginner
Functions
By the end of this lesson you'll define and call functions, return several values at once, accept any number of arguments with ..., give arguments sensible defaults, pass functions around as values, capture state in closures, and solve nested problems with recursion — the toolkit behind every real Lua script.
What You'll Learn
- Define and call functions, preferring local function for scope
- Return multiple values with return a, b — and catch them
- Accept any number of arguments using varargs (... and select)
- Give arguments defaults with the x = x or fallback idiom
- Treat functions as first-class values: store, pass, and return them
- Build closures that remember state, and solve problems with recursion
local, .., and nil. Lua doesn't run in the browser — try each example at onecompiler.com/lua or with the lua command. We'll build everything else here.💡 Real-World Analogy
A function is a recipe card. The ingredients you hand it are the arguments; the finished dish it gives back is the return value. Write the recipe once and you can cook it any night without re-deriving it. Lua's recipes have two superpowers most kitchens lack: one card can plate up several dishes at once (multiple returns), and a recipe can keep a private pantry that remembers what you used last time (a closure). Master the recipe card and you stop repeating yourself.
1. Defining and Calling Functions
A function bundles a piece of work under a name so you can run it again whenever you like. The shape is function name(args) ... end, and you put local in front to keep it scoped — the same safe default you learned for variables. Inside, return hands a value back to whoever called it; a function with no return still runs (often for a side effect like printing) and quietly gives back nil. Read this worked example and run it.
-- A function packages up some work so you can run it again and again.
-- The shape is: function name(arguments) ... return value end
-- Put 'local' in front so the function stays scoped (your default!).
local function greet(name)
return "Hello, " .. name .. "!" -- '..' joins text together
end
-- Call it by writing its name followed by (arguments):
print(greet("Ada")) -- prints: Hello, Ada!
print(greet("Lin")) -- prints: Hello, Lin!
-- Without 'return' a function just DOES something and hands back nil:
local function shout(word)
print(word .. "!!!") -- this prints as a side effect
end
shout("go") -- prints: go!!!
local result = shout("hi") -- prints: hi!!!
print(result) -- prints: nil (shout returned nothing)
-- Leaving off 'local' makes a GLOBAL function — visible everywhere,
-- which is exactly the namespace pollution you want to avoid.2. Multiple Return Values
Here's something most languages can't do: a Lua function can return several values at once — just separate them with commas after return. You catch them by listing the same number of variables on the left: local q, r = divide(17, 5). There's one rule that trips everyone up: a function call is cut down to a single value unless it's the last thing in a list. Put the multi-return call last and all its values survive; put it earlier and only the first is kept.
-- Lua functions can return MORE THAN ONE value. Just list them after return.
local function divide(a, b)
return a // b, a % b -- // is integer divide, % is remainder
end
-- Catch each returned value in its own variable, left to right:
local q, r = divide(17, 5)
print(q, r) -- prints: 3 2
-- A handy real example: split a full name into first and last.
local function splitName(full)
return full:match("(%w+)%s+(%w+)") -- pattern returns TWO captures
end
local first, last = splitName("Grace Hopper")
print(first, last) -- prints: Grace Hopper
-- ⚠️ A function call is TRUNCATED to one value unless it's LAST in a list.
local function pair() return 1, 2 end
print(pair(), 99) -- prints: 1 99 (pair() cut to just 1)
print(99, pair()) -- prints: 99 1 2 (pair() is last -> all kept)Your turn. Finish the minMax function so it returns the smaller value first and the larger second, then catch both returned values. Fill in the blanks marked ___ and compare with the expected output.
-- 🎯 YOUR TURN — replace each ___ then run the code.
-- Write a function that returns BOTH the min and the max of two numbers.
local function minMax(a, b)
-- 1) If a is smaller, the order is a, b — otherwise b, a
if a < b then
return ___, ___ -- 👉 smaller first, then larger: a, b
else
return ___, ___ -- 👉 b, a
end
end
-- 2) Catch BOTH returned values in two variables, left to right
local low, high = ___ -- 👉 minMax(8, 3)
print(low, high)
-- ✅ Expected output:
-- 3 83. Varargs: Any Number of Arguments
Sometimes you don't know in advance how many arguments a function will get — think print, which accepts as many as you throw at it. Lua's answer is varargs, written as ... (three dots) in the parameter list. Pack them into a table with {...} so you can loop over them, count them with select("#", ...), or read from a position with select(i, ...). When some arguments might be nil, use table.pack(...) instead — it records an accurate count in .n that {...} can't promise.
-- '...' (three dots) means "any number of extra arguments".
-- It collects everything the caller passed into a vararg list.
local function sum(...)
local total = 0
for _, n in ipairs({...}) do -- {...} packs the args into a table
total = total + n
end
return total
end
print(sum(1, 2, 3)) -- prints: 6
print(sum(10, 20, 30, 40)) -- prints: 100
-- select("#", ...) counts how many varargs arrived.
-- select(i, ...) returns everything from position i onward.
local function describe(...)
print("got " .. select("#", ...) .. " arguments")
print("first one is " .. select(1, ...))
end
describe("a", "b", "c")
-- prints: got 3 arguments
-- prints: first one is a
-- table.pack keeps a correct count even when some args are nil.
local packed = table.pack(1, nil, 3)
print(packed.n, packed[1], packed[3]) -- prints: 3 1 3Now you try. Build an average function that accepts any number of scores using ..., sums them, and divides by how many there were. Fill in the three blanks:
-- 🎯 YOUR TURN — build an average() that accepts any number of scores.
-- 1) Use ... so the caller can pass as many numbers as they like
local function average(...)
local total = 0
local nums = {___} -- 👉 pack the varargs into a table: ...
for _, n in ipairs(nums) do
total = total + n
end
-- 2) How many numbers came in? Use the # length operator on the table
local count = ___ -- 👉 #nums
-- 3) Avoid divide-by-zero: if count is 0, the answer is 0
if count == 0 then return 0 end
return total / count
end
print(average(10, 20, 30)) -- 👉 call it with three numbers
print(average(4, 8))
-- ✅ Expected output:
-- 20.0
-- 6.04. Default Argument Values
Lua has no special syntax for default arguments, but there's a one-line idiom every Lua programmer uses: x = x or fallback. The or operator returns its first truthy value, so when an argument is missing (it arrives as nil) the fallback kicks in. The single gotcha: or also treats false as "missing", so for boolean flags compare against nil directly (if flag == nil then ... end) — otherwise a deliberately passed false would be overwritten.
-- Lua has no built-in default arguments, so use the 'or' idiom:
-- local x = x or fallback
-- 'or' returns its first TRUTHY value, so a missing (nil) arg falls back.
local function greet(name, greeting)
name = name or "stranger" -- if name is nil, use "stranger"
greeting = greeting or "Hello" -- if greeting is nil, use "Hello"
return greeting .. ", " .. name .. "!"
end
print(greet()) -- prints: Hello, stranger!
print(greet("Ada")) -- prints: Hello, Ada!
print(greet("Ada", "Hi")) -- prints: Hi, Ada!
-- ⚠️ Watch out: 'or' treats false and nil the same.
-- For boolean flags, compare to nil instead so 'false' survives:
local function setup(verbose)
if verbose == nil then verbose = true end -- only default when truly missing
return verbose
end
print(setup()) -- prints: true (defaulted)
print(setup(false)) -- prints: false (kept!)5. Functions as Values & Closures
In Lua, functions are first-class values: you can store one in a variable, pass it into another function, return it, or drop it in a table — exactly like a number or a string. This unlocks the closure: a function that remembers the local variables alive where it was created. Those captured variables stay private and persist between calls, which is how you build counters and accumulators without reaching for globals. Crucially, each closure gets its own copy of the captured state.
-- Functions are FIRST-CLASS VALUES: store them, pass them, return them.
local function square(x) return x * x end
local f = square -- store a function in a variable
print(f(5)) -- prints: 25
-- Pass a function as an argument (here, apply it to every item):
local function applyAll(fn, list)
local out = {}
for i, v in ipairs(list) do out[i] = fn(v) end
return out
end
local doubled = applyAll(function(n) return n * 2 end, {1, 2, 3})
print(doubled[1], doubled[2], doubled[3]) -- prints: 2 4 6
-- A CLOSURE is a function that remembers variables from where it was made.
-- 'count' lives on between calls because the inner function captured it.
local function makeCounter()
local count = 0
return function()
count = count + 1 -- updates the captured 'count'
return count
end
end
local next = makeCounter()
print(next(), next(), next()) -- prints: 1 2 3
-- Each closure gets its OWN private copy — they don't share state:
local a, b = makeCounter(), makeCounter()
print(a(), a(), b()) -- prints: 1 2 16. Recursion
Recursion is a function that calls itself, solving a big problem by reducing it to a smaller version of the same problem. Every recursive function needs two parts: a base case that stops the chain, and a recursive step that moves toward that base case by shrinking the input. Forget the base case and the calls never stop — you'll get a stack overflow. Recursion is the natural fit for anything nested: folder trees, JSON, or maths like factorial defined in terms of itself.
-- RECURSION: a function that calls itself, shrinking the problem each time.
-- Every recursion needs a BASE CASE so it eventually stops.
local function factorial(n)
if n <= 1 then return 1 end -- base case: stop here
return n * factorial(n - 1) -- recursive step: gets smaller
end
print(factorial(5)) -- prints: 120 (5*4*3*2*1)
-- Recursion shines on nested data, like counting files in a folder tree.
local function countdown(n)
if n == 0 then
print("liftoff!")
return
end
print(n)
countdown(n - 1) -- call self with a smaller number
end
countdown(3)
-- prints: 3
-- prints: 2
-- prints: 1
-- prints: liftoff!Pro Tips
- 💡 Default to
local function. A global function is namespace pollution unless you truly need it everywhere — and the local form lets the function call itself for recursion. - 💡 Put multi-return calls last. If you only got the first value back, your call wasn't the final item in the list. Wrap a call in
(parentheses)to force exactly one value. - 💡
x = x or defaulthandles missing arguments in one line — but use== nilfor boolean flags so a realfalseisn't clobbered. - 💡 Closures replace globals for state. Need a counter or a running total? Capture it in a closure instead of leaking a global variable.
Common Errors (and the fix)
- Only the first return value shows up: a multi-return call gets truncated to one value unless it's last in the list —
print(pair(), 99)drops pair()'s second value. Fix: put the call last, or assign all values withlocal a, b = pair(). - "'end' expected (to close 'function' ...)": you forgot the
endthat closes the function (or anif/forinside it). Fix: everyfunction,if, andforneeds a matchingend. - A function "works" but pollutes globals: writing
function helper()withoutlocalcreates a global that any library can overwrite. Fix: writelocal function helper(). - "stack overflow": a recursive function with no reachable base case calls itself forever. Fix: add a base case (e.g.
if n <= 1 then return ... end) and make sure each call shrinks the input.
📋 Quick Reference
| Task | Code | Result / Note |
|---|---|---|
| Local function | local function f(x) end | scoped to block |
| Anonymous function | local f = function(x) end | stored in a variable |
| Multiple return | return a, b | local x, y = f() |
| Varargs | function f(...) end | {...} packs them |
| Count varargs | select("#", ...) | how many passed |
| Default argument | x = x or 0 | fallback when nil |
| Force one value | (f()) | truncates to 1 |
Frequently Asked Questions
Q: Why do I only get the first value back from a multi-return function?
A function call is trimmed to a single value whenever it is NOT the last item in a list. So print(pair(), 99) keeps only pair()'s first value, while print(99, pair()) keeps them all because the call sits last. To capture several returns, assign them: local a, b = pair(). To force just one value anywhere, wrap the call in parentheses — (pair()) is always exactly one value.
Q: What is the difference between local function f() and function f()?
Both create a function, but 'function f()' creates a GLOBAL named f that the entire program and every library can see and overwrite. 'local function f()' keeps f scoped to the current block, which is safer and slightly faster to call. Default to local: a global function is just namespace pollution unless you specifically need it everywhere. 'local function' also lets the function refer to itself for recursion.
Q: How do I give a function a default argument value?
Lua has no built-in defaults, so use the 'or' idiom inside the function: write 'name = name or "stranger"'. Because 'or' returns its first truthy operand, a missing (nil) argument falls back to your default. One catch: 'or' also rejects false, so for boolean flags compare to nil instead — 'if flag == nil then flag = true end' — otherwise passing false would be overwritten.
Q: What exactly is a closure, and why is it useful?
A closure is a function bundled with the outer local variables it referenced when it was created — those variables stay alive and private after the outer function returns. That lets you build counters, accumulators, and bank-account style state without globals: each call to makeCounter() returns a fresh closure with its own private 'count'. Two closures made separately do not share state.
Q: When should I use recursion instead of a loop?
Reach for recursion when the data is itself nested or tree-shaped — walking a folder tree, traversing JSON, or maths defined in terms of itself like factorial. Every recursion needs a BASE CASE that stops it, plus a step that makes the problem smaller each call; miss either and you get a 'stack overflow'. For simple repetition over a list, a plain for-loop is usually clearer and lighter.
Q: What do the three dots (...) mean in a parameter list?
'...' is the vararg expression: it collects any extra arguments the caller passed. Capture them with {...} to make a table, count them with select("#", ...), or grab from a position with select(i, ...). When some arguments might be nil, use table.pack(...) instead of {...} because it stores an accurate count in the .n field, which {...} and # cannot guarantee.
Mini-Challenge: Bank Account Closure
No blanks this time — just a brief and an outline to keep you on track. You'll combine three of this lesson's ideas: the default-argument idiom, returning multiple functions, and closures that share captured state. Build it, run it, and check your output against the example in the comments.
-- 🎯 MINI-CHALLENGE: a bank-account closure
-- 1. Write makeAccount(balance) that takes a starting balance.
-- Use the default idiom so a missing balance starts at 0:
-- balance = balance or 0
-- 2. Inside, return TWO closures that both capture 'balance':
-- deposit(amount) -> adds to balance, returns the new balance
-- withdraw(amount) -> subtracts, returns the new balance
-- (return them as: return deposit, withdraw)
-- 3. Create an account, then deposit and withdraw and print the results.
--
-- ✅ Example run:
-- local deposit, withdraw = makeAccount(100)
-- print(deposit(50)) -- 150
-- print(withdraw(30)) -- 120
-- print(deposit(10)) -- 130 (balance persisted between calls!)
-- your code here🎉 Lesson Complete!
- ✅ Define functions with
function name(args) ... end; preferlocal function - ✅ Return several values with
return a, b— and a call keeps them all only when it's last in a list - ✅ Accept any number of arguments with varargs
...; count withselect("#", ...), pack safely withtable.pack - ✅ Give defaults with
x = x or fallback(use== nilfor boolean flags) - ✅ Functions are first-class values; closures capture and remember local state
- ✅ Recursion solves nested problems — always include a base case to avoid a stack overflow
- ✅ Next lesson: Control Flow and Loops — make decisions and repeat work
Sign up for free to track which lessons you've completed and get learning reminders.