Polyglot
Languages Lua error handling
Lua § error-handling

Error handling

Lua’s error handling admits two principal mechanisms: exception-style via error and pcall (or xpcall), and value-style via return-on-failure with nil, message or false-and-error idioms. The conventional Lua discipline depends on context: return-on-failure for expected failures (admit substantial fluent API design); error for substantial bugs and unrecoverable conditions. The pcall (protected call) admits running a function with error catching — analogous to try in other languages. assert admits compact precondition checks. The combination — explicit error-as-value returns, error/pcall for exception-style, assert for preconditions, the absence of native try/catch syntax — is the substance of Lua’s error-handling surface.

error

The error function raises an error:

error("something went wrong")
error("error: " .. detail, 2)                      -- level 2 (caller's location)

The second argument is the level — admit substantial control over error location reporting:

  • 1 (default) — error reported at the error call site.
  • 2 — at the caller’s location.
  • 0 — no location info.

The error propagates up the stack; if uncaught, it terminates the program (with a stack trace).

Error objects

The error “message” may be any value — typically a string but admit tables for structured errors:

error({code = 404, message = "not found"})

local ok, err = pcall(function()
    error({code = 404, message = "not found"})
end)

print(err.code)                                    -- 404
print(err.message)                                 -- "not found"

The conventional contemporary discipline favours string messages for substantial debug-readability; structured errors admit substantial type-based dispatch.

pcall (protected call)

The pcall admits catching errors:

local ok, result = pcall(risky_function, arg1, arg2)
if ok then
    print("succeeded:", result)
else
    print("error:", result)                        -- result is the error message
end

The pcall returns:

  • On successtrue, <return values of f>.
  • On errorfalse, <error message>.

The pcall is similar to try/catch:

local function risky()
    -- ...
    if condition then
        error("oops")
    end
    return "success"
end

local ok, result = pcall(risky)
if not ok then
    print("caught:", result)
end

xpcall (extended protected call)

The xpcall admits an error handler — called with the error before unwinding:

local function err_handler(err)
    return debug.traceback(err, 2)
end

local ok, result = xpcall(risky_function, err_handler)
if not ok then
    print("traceback:", result)
end

The mechanism admits substantial substrate-aware error handling — particularly for adding stack traces.

In Lua 5.2+, xpcall admits passing additional arguments:

local ok, result = xpcall(f, handler, arg1, arg2)

assert

The assert admits compact precondition checks:

local function divide(a, b)
    assert(b ~= 0, "division by zero")
    return a / b
end

local n = assert(tonumber("42"), "not a number")
local f = assert(io.open(path, "r"), "file not found: " .. path)

The assert(v, msg):

  • If v is truthy — returns v (and any other arguments).
  • If v is falsy — calls error(msg).

The mechanism admits substantial conciseness for value-or-error patterns.

Return-on-failure pattern

The conventional Lua pattern for expected failures:

local function divide(a, b)
    if b == 0 then return nil, "division by zero" end
    return a / b
end

local result, err = divide(10, 0)
if not result then
    print("error:", err)
end

The mechanism is conventional in the Lua standard library:

local f, err = io.open("nonexistent.txt", "r")
if not f then
    print("error:", err)                           -- e.g., "nonexistent.txt: No such file"
    return
end

local n = tonumber("not numeric")                  -- returns nil on failure (no message)

When to use which

The conventional discipline:

  • Use error — for substantial programmer errors and invariant violations.
  • Use return-on-failure — for expected, recoverable failures (file open, parse, network).
  • Use assert — for compact preconditions.
  • Use pcall — at the boundary of substantial subsystems.
-- Programmer error (use error/assert):
local function set_volume(v)
    assert(type(v) == "number", "expected number")
    assert(v >= 0 and v <= 100, "out of range")
    -- ...
end

-- Recoverable failure (use return-on-failure):
local function read_config(path)
    local f = io.open(path)
    if not f then return nil, "config not found" end

    local content = f:read("*a")
    f:close()

    local config, err = parse(content)
    if not config then return nil, "parse error: " .. err end

    return config
end

Common patterns

Wrap risky operation

local function safe_divide(a, b)
    local ok, result = pcall(divide, a, b)
    if ok then return result end
    return 0                                        -- default on error
end

Resource cleanup

Lua does not include a try/finally syntax; the conventional pattern uses pcall and explicit cleanup:

local function with_file(path, action)
    local f, err = io.open(path)
    if not f then return nil, err end

    local ok, result = pcall(action, f)
    f:close()

    if not ok then return nil, result end
    return result
end

local data = with_file("data.txt", function(f)
    return f:read("*a")
end)

For Lua 5.4+, the <close> attribute admits substantial automatic cleanup:

do
    local f <close> = io.open("data.txt", "r")
    -- f:close() called automatically when scope exits, even on error
    if f then process(f:read("*a")) end
end

Validation chain

local function validate_user(user)
    if type(user) ~= "table" then
        return nil, "user must be a table"
    end
    if not user.name or user.name == "" then
        return nil, "name is required"
    end
    if user.age and (user.age < 0 or user.age > 150) then
        return nil, "invalid age"
    end
    if user.email and not user.email:match("@") then
        return nil, "invalid email"
    end
    return user
end

local valid, err = validate_user(input)
if not valid then
    print("validation error:", err)
end

Custom error types via tables

local function NetworkError(message, code)
    return {type = "NetworkError", message = message, code = code}
end

local function ValidationError(field, message)
    return {type = "ValidationError", field = field, message = message}
end

local function dispatch_error(err)
    if type(err) == "table" then
        if err.type == "NetworkError" then
            print("network error:", err.code, err.message)
        elseif err.type == "ValidationError" then
            print("invalid", err.field .. ":", err.message)
        else
            print("unknown error:", err.type)
        end
    else
        print("error:", err)
    end
end

-- Usage:
local ok, err = pcall(function()
    error(NetworkError("timeout", 408))
end)

if not ok then dispatch_error(err) end

Retry with pcall

local function retry(attempts, fn, ...)
    for i = 1, attempts do
        local results = {pcall(fn, ...)}
        if results[1] then
            -- Success; return non-status return values
            return select(2, table.unpack(results))
        end
        if i == attempts then
            error(results[2])                       -- last attempt; re-raise
        end
    end
end

local result = retry(3, function() return fetch_data() end)

Error with traceback

local function trace_handler(err)
    return debug.traceback(err, 2)
end

local ok, err = xpcall(risky, trace_handler)
if not ok then
    print(err)                                     -- includes stack trace
end

Result-style return

local function parse_int(s)
    local n = tonumber(s)
    if n and n == math.floor(n) then
        return n
    end
    return nil, "not an integer: " .. s
end

local n, err = parse_int(input)
if n then
    process(n)
else
    handle_error(err)
end

Asserting types

local function check_type(value, expected, name)
    if type(value) ~= expected then
        error(string.format("expected %s for %s, got %s", expected, name, type(value)), 3)
    end
    return value
end

local function process(name, age)
    check_type(name, "string", "name")
    check_type(age, "number", "age")
    -- ...
end

The level = 3 in error admits the error being reported at the caller’s caller — admit substantial usability.

pcall for boundary

-- At the boundary of a request handler:
local function handle_request(request)
    local ok, response = pcall(process_request, request)
    if ok then
        return {status = 200, body = response}
    else
        log_error(response)
        return {status = 500, body = "internal error"}
    end
end

Multiple returns with pcall

The pcall returns true followed by all of the function’s return values:

local function foo()
    return 1, 2, 3
end

local ok, a, b, c = pcall(foo)
print(ok, a, b, c)                                 -- true, 1, 2, 3

For collecting:

local results = {pcall(foo)}
local ok = results[1]
if ok then
    -- results[2], results[3], ... are the values
end

Default value or error

local n = tonumber(input) or error("not numeric")

-- Or with assert:
local n = assert(tonumber(input), "not numeric")

Custom assert_eq

local function assert_eq(actual, expected, msg)
    if actual ~= expected then
        error(string.format("%s: expected %s, got %s",
            msg or "assertion failed",
            tostring(expected),
            tostring(actual)
        ), 2)
    end
end

assert_eq(2 + 2, 4, "addition")
assert_eq(divide(10, 2), 5, "division")

Wrapping standard library errors

local function read_json(path)
    local f, err = io.open(path)
    if not f then return nil, "open: " .. err end

    local content = f:read("*a")
    f:close()

    local ok, result = pcall(json.decode, content)
    if not ok then return nil, "decode: " .. result end

    return result
end

<close> for RAII (5.4+)

local function process_file(path)
    local file <close> = setmetatable({}, {
        __index = io.open(path, "r"),
        __close = function(self)
            -- cleanup
            print("closing")
        end
    })

    -- use file
end

The <close> attribute (5.4+) admits substantial deterministic cleanup; the variable’s __close metamethod is called when the scope exits.

Chaining error returns

local function process(input)
    local parsed, err = parse(input)
    if not parsed then return nil, "parse: " .. err end

    local validated, err = validate(parsed)
    if not validated then return nil, "validate: " .. err end

    return transform(validated)
end

Error via coroutine

local co = coroutine.create(function()
    error("inside coroutine")
end)

local ok, err = coroutine.resume(co)
print(ok, err)                                     -- false, "...: inside coroutine"

The coroutine.resume is pcall-like — errors are caught and returned.

A note on the absence of try/catch

Lua does not admit try/catch syntax — pcall is the conventional substitute:

-- Other languages:
-- try { risky() } catch (e) { handle(e) }

-- Lua:
local ok, err = pcall(risky)
if not ok then handle(err) end

The mechanism admits substantial flexibility — pcall is a function, not syntax — but is conventionally less readable for substantial multi-statement guarded blocks.

A note on the conventional discipline

The contemporary Lua error-handling advice:

  • Use return-on-failure (nil, message) for expected failures.
  • Use error for substantial programmer errors.
  • Use assert for compact preconditions.
  • Use pcall at boundaries of substantial subsystems.
  • Use xpcall with debug.traceback for substantial debug logging.
  • Use <close> (5.4+) for substantial RAII cleanup.
  • Document error returns — admit substantial caller awareness.
  • Don’t catch errors blindly — admit substantial debug visibility.
  • Use error level argument (error(msg, 2)) for substantial caller-side reporting.

The combination — error/pcall for exception-style, assert for preconditions, return-on-failure for expected failures, xpcall for substantial error handlers, the <close> attribute (5.4+) for RAII — is the substance of Lua’s error-handling surface. The discipline produces explicit, traceable error flows with substantial flexibility through the value-style returns and pcall boundary handling.