Skip to content

Control Flow and Functions

Lua’s control flow is small and old-fashioned: no continue, no switch, no ternary operator, and every block ends with end. Functions, on the other hand, are unusually capable — multiple return values and closures are first-class parts of the design, not conveniences.

local n = 7
if n < 0 then
print("negative")
elseif n == 0 then
print("zero")
else
print("positive")
end

then and end are mandatory; there is no single-statement form and no braces. Any value works as a condition — only nil and false are false.

There is no switch/match. The idiomatic replacement is a table of functions:

local handlers = {
add = function(a, b) return a + b end,
sub = function(a, b) return a - b end,
}
local function dispatch(op, a, b)
local h = handlers[op] or error("unknown op: " .. tostring(op))
return h(a, b)
end
print(dispatch("add", 2, 3)) -- => 5
local i = 1
while i <= 3 do
print(i)
i = i + 1
end

Runs the body at least once, then tests. The condition is inside the body’s scope, which is unique to Lua and genuinely useful:

local line
repeat
line = io.read("l")
local trimmed = line and line:match("^%s*(.-)%s*$")
until trimmed == nil or trimmed ~= "" -- `trimmed` is visible here

Note the loop exits when the condition is true (until, not while).

for i = 1, 5 do io.write(i, " ") end -- => 1 2 3 4 5
for i = 10, 1, -2 do io.write(i, " ") end -- => 10 8 6 4 2
for x = 0, 1, 0.25 do print(x) end -- => 0.0, 0.25, 0.5, 0.75, 1.0

The form is for var = start, limit, step. The limit is inclusive, step defaults to 1, and a step of 0 raises an error. All three expressions are evaluated exactly once, before the loop starts — changing the limit variable inside the loop has no effect.

The control variable is a fresh local on each iteration. Assigning to it inside the body does not affect the next iteration, and closures created in the loop each capture their own copy:

local fns = {}
for i = 1, 3 do fns[i] = function() return i end end
print(fns[1](), fns[2](), fns[3]()) -- => 1 2 3
for k, v in pairs(t) do ... end
for i, v in ipairs(t) do ... end
for word in ("a b c"):gmatch("%S+") do print(word) end

The expression after in produces up to three values: an iterator function, a state, and an initial control value. Each iteration calls iterator(state, control); the first returned value becomes the new control value, and the loop stops when it is nil.

pairs(t) simply returns next, t, nil. Knowing this, you can write iterators as plain functions:

-- Stateless iterator: no closure allocated per loop.
local function range_iter(limit, i)
i = i + 1
if i <= limit then return i, i * i end
end
local function range(n)
return range_iter, n, 0
end
for i, sq in range(4) do io.write(i, "=", sq, " ") end
-- => 1=1 2=4 3=9 4=16

Or as closures, which is easier to read and allocates one table/upvalue set per loop:

local function chars(s)
local i = 0
return function()
i = i + 1
if i <= #s then return i, s:sub(i, i) end
end
end
for i, c in chars("lua") do print(i, c) end

ipairs vs pairs is covered in depth on the Tables page — the short version is that ipairs walks 1, 2, 3, … and stops at the first nil, while pairs visits every key in unspecified order.

break exits the innermost loop. In Lua 5.1 it had to be the last statement in a block (people wrote do break end to work around it); 5.2+ removed that restriction.

Lua has no continue. Since 5.2 (and in LuaJIT) the idiom is goto:

for i = 1, 10 do
if i % 2 == 0 then goto continue end
print(i)
::continue::
end

goto can only jump to a label in the same or an enclosing block, and never into the scope of a local variable. That makes it safe: you can jump forward out of nested blocks or backward to the top of a loop, but you cannot jump into the middle of one.

-- Breaking out of nested loops in one jump:
local found_row, found_col
for i = 1, #grid do
for j = 1, #grid[i] do
if grid[i][j] == target then
found_row, found_col = i, j
goto done
end
end
end
::done::
print(found_row, found_col)
Category Operators Notes
Arithmetic + - * / // % ^ / and ^ always produce floats; // is floor division (5.3+)
Bitwise (5.3+) & | ~ << >> and unary ~ 64-bit integers only
Comparison == ~= < > <= >= ~= is “not equal” — there is no !=
Logical and or not Short-circuiting; and/or return operands, not booleans
Concat .. Strings and numbers; right-associative
Length # Bytes for strings, a border for tables

Precedence, lowest to highest:

or
and
< > <= >= ~= ==
|
~
&
<< >>
.. (right-associative)
+ -
* / // %
not # - ~ (unary)
^ (right-associative)

Two consequences worth memorizing: .. binds tighter than comparison but looser than +, so "n=" .. 1 + 2 is "n=3"; and ^ is right-associative and binds tighter than unary minus, so -2^2 is -4.0, not 4.

a and b evaluates a; if it is falsy it returns a, otherwise it evaluates and returns b. a or b evaluates a; if it is truthy it returns a, otherwise it evaluates and returns b.

print(nil and 5) -- => nil
print(false or "x") -- => x
print(1 and 2) -- => 2

This produces two idioms you will see constantly:

-- Default values
local function greet(name)
name = name or "world"
print("hello " .. name)
end
-- Conditional expression (Lua's closest thing to a ternary)
local label = ok and "yes" or "no"

or also gives you lazy defaults for table fields:

opts = opts or {}
opts.retries = opts.retries or 3

Unlike in JavaScript, opts.retries or 3 does not override an explicit 0, because 0 is truthy in Lua. The pattern only misbehaves when the legitimate value is false.

local function add(a, b)
return a + b
end
local mul = function(a, b) return a * b end -- same thing, no recursion by name
function Global.helper() end -- assigns into the table Global

Arguments are matched positionally. Extra arguments are discarded; missing ones are nil. There are no default parameter values and no keyword arguments.

local function f(a, b) return a, b end
print(f(1, 2, 3)) -- => 1 2
print(f(1)) -- => 1 nil

The conventional stand-in for keyword arguments is a table, which has dedicated call syntax:

local function connect(opts)
local host = opts.host or "localhost"
local port = opts.port or 5432
return host .. ":" .. port
end
print(connect{ host = "db.internal" }) -- => db.internal:5432

f{...} and f"..." are sugar for f({...}) and f("...") — a single table or string argument needs no parentheses. That is why require "socket" and print [[text]] are valid.

A function can return any number of values, and the call site decides how many it wants.

local function minmax(t)
local lo, hi = t[1], t[1]
for i = 2, #t do
if t[i] < lo then lo = t[i] end
if t[i] > hi then hi = t[i] end
end
return lo, hi
end
local lo, hi = minmax{ 3, 1, 4, 1, 5 }
print(lo, hi) -- => 1 5

The adjustment rules are precise and worth internalizing:

  • A call in the last position of an expression list expands to all its values.
  • A call anywhere else is truncated to exactly one value.
  • Wrapping a call in parentheses truncates it to one value.
local function three() return 1, 2, 3 end
print(three()) -- => 1 2 3 (last position)
print(three(), 10) -- => 1 10 (truncated)
print((three())) -- => 1 (parenthesized)
local t1 = { three() } -- {1, 2, 3}
local t2 = { three(), 10 } -- {1, 10}
local a, b = three(), 0 -- a=1, b=0

This is also why print(#{three()}) is 3 but print(#{three(), 0}) is 2.

The standard library leans on this heavily. string.find returns start and end; pcall returns a success flag followed by results; io.open returns nil, message on failure:

local f, err = io.open("/nope", "r")
if not f then print("failed: " .. err) end

... in a parameter list collects the remaining arguments.

local function log(level, ...)
io.write("[", level, "] ")
print(...)
end
log("info", "user", 42) -- => [info] user 42

... follows the same expansion rules as a function call. To count arguments including nils, use select("#", ...); to skip the first n, use select(n, ...):

local function count(...) return select("#", ...) end
print(count(1, nil, 3)) -- => 3 (a plain #{...} would be unreliable)
local function tail(...) return select(2, ...) end
print(tail("a", "b", "c")) -- => b c

Lua 5.2+ gives you table.pack / table.unpack for round-tripping:

local function safe_call(f, ...)
local packed = table.pack(pcall(f, ...))
if not packed[1] then return nil, packed[2] end
return table.unpack(packed, 2, packed.n)
end

table.pack(...) returns a table with an n field holding the true count — the only reliable way to store a vararg list that may contain nil.

Functions are ordinary values: store them in tables, pass them as arguments, return them.

local function map(t, fn)
local out = {}
for i = 1, #t do out[i] = fn(t[i]) end
return out
end
local doubled = map({1, 2, 3}, function(x) return x * 2 end)
print(table.concat(doubled, ",")) -- => 2,4,6

An inner function captures the variables (not the values) of the enclosing scope. Captured variables are called upvalues, and they persist after the enclosing function returns.

local function counter()
local n = 0
return function()
n = n + 1
return n
end
end
local c1, c2 = counter(), counter()
print(c1(), c1(), c2()) -- => 1 2 1

Two closures created in the same scope share the same upvalue — this is how you build a small object without a table:

local function account(balance)
local function deposit(x) balance = balance + x end
local function get() return balance end
return deposit, get
end
local deposit, get = account(100)
deposit(50)
print(get()) -- => 150

Closures are also the standard way to keep a private cache or memo:

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

return f(args) — with nothing else in the return statement — is a proper tail call. Lua reuses the current stack frame, so the recursion depth is unbounded.

local function loop(i, n)
if i > n then return "done" end
return loop(i + 1, n) -- proper tail call
end
print(loop(1, 5000000)) -- => done, no stack overflow

return f(x) + 1 and return (f(x)) are not tail calls; the extra operation and the parentheses both require the frame to survive. Tail calls also erase the frame from tracebacks, which shows up as (...tail calls...) in error output.

obj:method(a, b) is exactly obj.method(obj, a, b). The receiver is passed as the first argument.

local s = "hello"
print(s:sub(2, 3)) -- => el
print(string.sub(s, 2, 3)) -- => el (identical)

In a definition, function T:m(a) implicitly declares a parameter named self:

local Stack = {}
Stack.__index = Stack
function Stack.new()
return setmetatable({ n = 0 }, Stack)
end
function Stack:push(v) -- self is implicit
self.n = self.n + 1
self[self.n] = v
return self -- enables chaining
end
function Stack:pop()
if self.n == 0 then return nil end
local v = self[self.n]
self[self.n] = nil
self.n = self.n - 1
return v
end
local s = Stack.new()
s:push(1):push(2)
print(s:pop(), s:pop()) -- => 2 1

Note the definition-vs-call symmetry is not enforced — function T.m(self, a) and function T:m(a) compile to the same thing, and either can be called with :.

  • ~= is inequality; .. is concatenation; # is length. There is no !=, +=, ++, continue or ternary.
  • repeat ... until can see the body’s locals in its condition.
  • Numeric for evaluates its bounds once, and the control variable is a fresh local each iteration.
  • Generic for is driven by an iterator function, a state, and a control value.
  • and/or return operands and short-circuit; the cond and a or b ternary fails when a is falsy.
  • Multiple returns expand only in the last position of an expression list; parentheses truncate to one.
  • select("#", ...) is the only correct way to count varargs that may contain nil.
  • obj:m(x) is obj.m(obj, x), and function T:m(x) adds an implicit self.