luaguides

Error Handling Patterns and Best Practices

Lua’s error handling model is deliberately simple: functions either succeed or raise an error that propagates up the call stack until something catches it. Unlike languages with exception hierarchies and try/catch/finally blocks, Lua gives you just two primitives for catching errors — pcall and xpcall — plus error() for throwing them. That simplicity is a strength, but it means you need to understand these tools thoroughly to write resilient Lua code.

Protecting Function Calls with pcall and xpcall

The core of Lua error handling is the protected call. When you call a function directly, any error it raises terminates your program (or propagates to your caller). Wrapping a call in pcall intercepts errors and guarantees control returns to you.

local success, result = pcall(math.sqrt, 16)
-- success = true, result = 4.0

local success, err = pcall(math.sqrt, "hello")
-- success = false, err = "attempt to perform arithmetic on a string 'hello'"

pcall always returns at least two values: a boolean indicating success, and then either the function’s return values or the error object. Extra return values from the wrapped function are preserved after the status boolean.

local ok, a, b = pcall(function()
    return 1, 2, 3
end)
-- ok = true, a = 1, b = 2  (third return value dropped during unpacking)

xpcall takes this further by letting you supply a custom error handler. The handler receives the raw error object before Lua unwinds the stack, and its return value becomes the error returned from xpcall. The most common handler is debug.traceback, which converts the error into a readable stack trace.

local ok, trace = xpcall(
    function() error("something went wrong") end,
    debug.traceback
)
-- ok = false, trace = full stack traceback string

This distinction matters when you need to debug: pcall gives you the error message, but xpcall with debug.traceback gives you the full call stack, including which line inside the wrapped function failed.

Raising Errors: error() and assert()

error() raises a run-time error, terminating the current execution path. The first argument is any Lua value — usually a string, but tables are valid too. The second argument is the level, which controls which stack frame gets blamed in the traceback.

function divide(a, b)
    if b == 0 then
        error("division by zero", 2)  -- level 2: blames the caller's line
    end
    return a / b
end

Using level = 2 in library code is almost always the right choice. It makes errors appear to originate from the caller’s code rather than from inside your library, which is what users expect when they see a stack trace.

assert() is syntactic sugar for a conditional that calls error if the condition is falsy. It’s appropriate for programming invariants — things that should never be false if the program is correct.

assert(type(data) == "table", "data must be a table")

The critical thing to understand is that assert is meant for bugs, not for expected runtime failures. Using it for things like file-not-found or bad-user-input is a mistake, because assertions can be compiled out with -DNDEBUG in C, and semantically they signal “this should never happen” rather than “this might go wrong.”

Common Error Handling Patterns

Returning nil Plus an Error String

The most idiomatic way to signal failure from a Lua function is to return nil followed by an error description.

local function read_config(path)
    local file, err = io.open(path, "r")
    if not file then
        return nil, "read_config: " .. err
    end
    local content = file:read("*a")
    file:close()
    return content
end

local config, err = read_config("app.cfg")
if not config then
    print("Failed to load config: " .. err)
end

This pattern composes well. Callers can check the first return value and decide how to handle failure, or propagate the error upward.

Guard Clauses for Input Validation

Validate inputs at function entry and return early with a descriptive error if invariants are violated.

local function process_user(user)
    if type(user) ~= "table" then
        return nil, "user must be a table"
    end
    if type(user.name) ~= "string" or #user.name == 0 then
        return nil, "user.name must be a non-empty string"
    end
    if type(user.age) ~= "number" or user.age < 0 then
        return nil, "user.age must be a non-negative number"
    end

    return { display_name = user.name:upper(), level = user.age }
end

Guard clauses keep the main logic clean and surface errors at the boundary where they are easiest to diagnose.

Wrapping Unsafe Operations

Repeatedly writing pcall around calls to the same unsafe function gets tedious. A thin wrapper makes the call site cleaner and centralizes the error handling logic.

local function safe(fn, ...)
    return pcall(fn, ...)
end

local ok, result = safe(json.decode, '{"a":1}')
if not ok then
    print("JSON decode failed: " .. tostring(result))
end

You can extend this pattern with default values, error logging, or re-throwing with added context.

Collecting Errors Across Multiple Operations

When running a batch of operations, you may want to let all of them execute rather than stopping at the first failure.

local function run_all(funcs)
    local errors = {}
    for i, fn in ipairs(funcs) do
        local ok, err = pcall(fn)
        if not ok then
            table.insert(errors, string.format("[%d] %s", i, err))
        end
    end
    if #errors > 0 then
        return nil, table.concat(errors, "; ")
    end
    return true
end

This is useful for configuration loading, plugin initialization, or test suites where a partial failure should not prevent other operations from running.

Custom Error Objects and Error Levels

error() accepts any value, not just strings. Passing a table lets you attach structured data — a code, a field name, a context table — that callers can inspect programmatically rather than parsing a string.

local function validate_config(config)
    if not config.port then
        error({
            kind = "validation",
            field = "port",
            message = "config.port is required",
            code = "MISSING_PORT"
        }, 2)
    end
end

local ok, err = pcall(validate_config, {})
if not ok and type(err) == "table" then
    print(err.code)      -- "MISSING_PORT"
    print(err.message)   -- "config.port is required"
end

You can build a simple error factory with a metatable for a consistent interface across your library.

local function new_error(message, code, context)
    local err = {
        message = message,
        code = code or "UNKNOWN",
        context = context or {},
    }
    setmetatable(err, { __tostring = function(self) return self.message end })
    return err
end

The level parameter in error() is frequently overlooked. Library functions that raise errors should pass level = 2 so the error is attributed to the caller’s line, not the library internals. Without this, users see tracebacks that blame your library rather than their code.

Debugging with Stack Traces

debug.traceback() returns a stack trace as a string without throwing. Call it from xpcall as the error handler, or directly when you need to inspect the call stack.

-- Trace the current thread
print(debug.traceback())

-- Trace a dead coroutine
local co = coroutine.create(function() error("fail") end)
coroutine.resume(co)
print(debug.traceback(co, "Coroutine error: "))

debug.getinfo() returns structured data about a function or stack level: the function name, source file, current line, and whether it is Lua or C code.

local info = debug.getinfo(1, "nSl")
print(info.name)          -- function name (nil if anonymous)
print(info.currentline)   -- line currently executing
print(info.short_src)    -- source file or string
print(info.what)          -- "Lua" or "C"

A practical stack dumper:

local function dump_stack(msg)
    print("--- Stack: " .. (msg or ""))
    for level = 1, math.huge do
        local info = debug.getinfo(level, "nSl")
        if not info then break end
        print(string.format("  [%d] %s -- %s:%d",
            level, info.name or "(unnamed)",
            info.short_src, info.currentline))
    end
end

debug.getlocal() lets you inspect local variables at a given stack level, though it is slow and fragile — use it only in debugging code, never in production hot paths.

Common Mistakes to Avoid

Using error() for flow control is a common misstep. If a value not being found is an expected case, return nil. Reserve error() for things that indicate a bug — invariants that should never be violated when the program is correct.

-- Wrong: error for an expected case
local function find_user(users, id)
    for _, u in ipairs(users) do
        if u.id == id then return u end
    end
    error("user not found")
end

-- Correct: return nil for a missing value
local function find_user(users, id)
    for _, u in ipairs(users) do
        if u.id == id then return u end
    end
    return nil
end

Silently swallowing errors is another frequent problem. If you cannot handle a failure meaningfully, propagate it — a crash with a good stack trace is far better than silent corruption or confusing behavior later.

Nested pcall calls can hide error context. If an inner pcall catches an error and then the outer scope raises a new error, the original context is lost. Keep error handling at a single level where possible.

Finally, coroutine.resume does not protect its body the way pcall does. If a coroutine errors, resume returns false plus the error message, but without a traceback. Wrap the coroutine body with xpcall inside the coroutine if you need stack traces from coroutine failures.

Conclusion

Lua’s error handling is minimal but complete. pcall and xpcall give you protected calls, error() raises errors with controllable attribution, and assert handles programming invariants. The patterns — returning nil-plus-error, guard clauses, safe wrappers, collecting errors across batches — cover the vast majority of real-world needs.

The key discipline is distinguishing expected failures (missing files, bad input) from programming errors (broken invariants). Return nil for the former; use error() for the latter. Keep this distinction clear and your Lua code will be predictable even when things go wrong.

See Also

  • Functions in Lua — Understand how Lua functions work as first-class values before applying error patterns to them.
  • Modules and require — See how error handling fits into Lua’s module system for writing robust, maintainable code.