← Back to DevBytes

Error Handling Patterns in Lua

Understanding Error Handling in Lua

Lua takes a minimalist approach to error handling compared to languages with formal try/catch mechanisms. Instead of exceptions, Lua provides a set of built-in functions—primarily pcall, xpcall, and error—that form the foundation of all error handling patterns. Understanding how to combine these tools effectively is essential for writing robust, maintainable Lua code.

What Is Error Handling in Lua?

Error handling in Lua revolves around three core functions:

Additionally, assert(condition, message) provides a convenient shorthand: if the condition is falsy, it calls error with the given message.

Here is the simplest demonstration of each:

-- Raising an error
function divide(a, b)
  if b == 0 then
    error("division by zero", 2)  -- level 2 points to the caller
  end
  return a / b
end

-- Catching with pcall
local ok, result = pcall(divide, 10, 0)
if not ok then
  print("Caught error:", result)  -- outputs: division by zero
end

-- Using assert for quick validation
local file = io.open("nonexistent.txt", "r")
assert(file, "Failed to open file")
-- If file is nil, error is raised automatically

Why Error Handling Matters in Lua

Without proper error handling, a single failure can crash an entire Lua application. This is especially critical in embedded contexts—game engines, IoT devices, web servers like OpenResty, or plugin systems where Lua runs inside a host application. A crashing Lua script may take down the host process or leave resources in an inconsistent state.

Key motivations include:

Core Error Handling Patterns

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Pattern 1: Basic Protected Call (pcall)

The most fundamental pattern wraps a function call in pcall and checks the returned status. This is suitable for self-contained operations where you simply need to know whether something succeeded or failed.

local function load_config(path)
  local f = io.open(path, "r")
  if not f then
    error("cannot open config: " .. path)
  end
  local content = f:read("*a")
  f:close()
  return content
end

local ok, config = pcall(load_config, "/etc/app/config.lua")
if ok then
  print("Config loaded:", #config .. " bytes")
else
  print("Warning: using defaults —", config)
  config = "default_settings"
end

Pattern 2: Extended Protected Call with Stack Trace (xpcall)

When an error occurs, you often want a full stack trace showing exactly where the failure originated. xpcall with debug.traceback as the error handler gives you this information without losing the protective wrapper.

local function risky_operation(input)
  if not input then
    error("input is required")
  end
  return #input  -- some computation
end

local function safe_executor(fn, ...)
  local results = {xpcall(fn, debug.traceback, ...)}
  local ok = results[1]
  if not ok then
    -- results[2] contains the full stack trace
    io.stderr:write("ERROR: " .. tostring(results[2]) .. "\n")
    return nil
  end
  -- Remove the status boolean, return actual results
  table.remove(results, 1)
  return unpack(results)
end

local length = safe_executor(risky_operation, nil)
print("Result:", length)  -- nil, error printed to stderr

Pattern 3: Custom Error Objects and Structured Errors

Lua's error function accepts any value, not just strings. This allows you to throw tables that carry structured diagnostic information—error codes, severity levels, timestamps, or nested causes.

local ErrorType = {
  NETWORK = 1,
  VALIDATION = 2,
  DATABASE = 3,
}

local function structured_error(etype, message, details)
  return {
    type = etype,
    message = message,
    details = details or {},
    timestamp = os.time(),
  }
end

local function fetch_user(id)
  if not id or id <= 0 then
    error(structured_error(
      ErrorType.VALIDATION,
      "Invalid user ID",
      { provided_id = id }
    ))
  end
  -- simulate database lookup
  return { id = id, name = "Alice" }
end

local ok, result = pcall(fetch_user, -5)
if not ok then
  local err = result
  print("Error type:", err.type)
  print("Message:", err.message)
  print("Details:", err.details.provided_id)
  print("Occurred at:", os.date("%c", err.timestamp))
end

Pattern 4: The "Try-Finally" Cleanup Pattern

Lua lacks a built-in finally block, but you can achieve deterministic cleanup by using pcall or xpcall combined with a wrapper that always executes cleanup logic. This is essential for releasing resources like file handles or database connections.

local function with_file(path, mode, fn)
  local f, open_err = io.open(path, mode)
  if not f then
    error("open failed: " .. open_err)
  end
  local ok, result = pcall(fn, f)
  -- Always close the file, even if fn raised an error
  f:close()
  if not ok then
    error(result)  -- re-raise after cleanup
  end
  return result
end

-- Usage: the file handle is guaranteed to be closed
local content = with_file("data.txt", "r", function(f)
  local text = f:read("*a")
  if #text == 0 then
    error("file is empty")
  end
  return text:upper()
end)
print(content)

Pattern 5: Error Propagation with Annotation

As errors bubble up through abstraction layers, adding contextual annotations helps pinpoint the origin. Each layer can catch, annotate, and re-raise errors.

local function re_raise(err, context)
  if type(err) == "table" and err.trace then
    -- already a structured error, append context
    err.context = (err.context or "") .. " -> " .. context
    error(err)
  else
    error(context .. ": " .. tostring(err))
  end
end

local function low_level_read(filename)
  local f = io.open(filename, "r")
  if not f then
    error("file not found: " .. filename)
  end
  return f:read("*a")
end

local function mid_level_parser(filename)
  local ok, raw = pcall(low_level_read, filename)
  if not ok then
    re_raise(raw, "parser layer")
  end
  -- parse raw data
  return raw
end

local function high_level_init(config_path)
  local ok, parsed = pcall(mid_level_parser, config_path)
  if not ok then
    re_raise(parsed, "init layer")
  end
  return parsed
end

-- The final error message will show the full chain:
-- init layer: parser layer: file not found: missing.cfg

Pattern 6: Error Handling in Coroutines

Coroutines introduce a unique challenge: an error inside a coroutine does not propagate to the calling thread unless you explicitly handle it. Wrapping coroutine.resume with proper checks is critical.

local function worker_coroutine(tasks)
  for i, task in ipairs(tasks) do
    if task == "fail" then
      error("task failed: " .. task)
    end
    print("Completed task", i)
  end
  return "all done"
end

local co = coroutine.create(worker_coroutine)

-- Safe resume wrapper
local function safe_resume(co, ...)
  local results = {coroutine.resume(co, ...)}
  local ok = results[1]
  if not ok then
    -- The coroutine died; results[2] is the error
    io.stderr:write("Coroutine error: " .. tostring(results[2]) .. "\n")
    io.stderr:write(debug.traceback(co, results[2]) .. "\n")
    return nil
  end
  table.remove(results, 1)
  return unpack(results)
end

local result = safe_resume(co, {"task1", "fail", "task3"})
print("Final result:", result)  -- nil, coroutine is dead

Pattern 7: Defensive Wrapping for Host Callbacks

In embedded Lua environments (game engines, NGINX/OpenResty, Redis), the host application often calls back into Lua scripts. Any unhandled error in these callbacks can crash the host. A universal wrapper protects all entry points.

-- Global safe wrapper for any callback
local function protect(fn, name)
  name = name or "anonymous_callback"
  return function(...)
    local results = {xpcall(function()
      return fn(...)
    end, function(err)
      return debug.traceback(name .. ": " .. tostring(err))
    end)}
    if not results[1] then
      -- Log to host's error stream without crashing
      io.stderr:write("FATAL in " .. name .. ":\n" .. results[2] .. "\n")
      return nil
    end
    table.remove(results, 1)
    return unpack(results)
  end
end

-- Example: a game engine callback
local on_player_move = protect(function(player, x, y)
  if not player then
    error("player object is nil")
  end
  player.x = x
  player.y = y
  return true
end, "on_player_move")

-- Safe even with bad arguments
on_player_move(nil, 10, 20)  -- logs error, does not crash engine

Best Practices for Lua Error Handling

1. Choose the Right Error Type

Use strings for simple, human-readable errors in small scripts. Use tables for structured errors in larger applications where you need error codes, categorization, or metadata. Reserve debug.traceback integration for development and diagnostic paths.

-- Simple script: strings are fine
error("Invalid argument", 2)

-- Complex application: structured tables
error({ code = "E_VALIDATION", field = "email", value = v })

2. Always Specify the Error Level

The second argument to error controls which stack level is blamed. Level 1 (default) blames the location of the error call itself. Level 2 blames the caller of the function that called error, which is usually more useful for API functions.

-- Inside a validation utility
function validate_email(addr)
  if not addr:match("@") then
    error("invalid email", 2)  -- blames the caller, not validate_email
  end
end

-- The traceback will point to wherever validate_email was called

3. Use xpcall for Production Diagnostics

Prefer xpcall with a custom error handler over plain pcall in production code. The handler can write to logs, send metrics, or format errors before they propagate. This centralizes error formatting.

local function production_handler(err)
  local trace = debug.traceback("ERROR: " .. tostring(err), 3)
  -- Write to log file
  local log = io.open("error.log", "a")
  if log then
    log:write(os.date("%Y-%m-%d %H:%M:%S ") .. trace .. "\n")
    log:close()
  end
  return trace  -- returned to xpcall caller
end

local ok = xpcall(risky_function, production_handler, arg1, arg2)

4. Never Let Errors Escape into the Host

In embedded Lua, wrap every entry point that the host can invoke. A single unprotected callback can crash the entire application. Use the defensive wrapping pattern described above consistently.

5. Separate Business Logic Errors from Environmental Failures

Distinguish between expected failures (invalid user input, empty search results) and unexpected system failures (disk full, network down). The former can be handled with nil returns or sentinel values; the latter should use error/pcall.

-- Expected: return nil + message
local function find_user(id)
  local user = db:query("SELECT * FROM users WHERE id = ?", id)
  if not user then
    return nil, "user not found"
  end
  return user
end

-- Unexpected: use error
local function save_to_disk(data)
  local ok, err = os.execute("test -w /data")
  if not ok then
    error("disk is not writable: " .. tostring(err))
  end
  -- write data
end

6. Clean Up Resources Deterministically

Whenever you acquire a resource (file handle, database connection, memory buffer), ensure its release happens regardless of errors. Use the try-finally pattern with pcall as shown earlier. Alternatively, use Lua's __gc metamethod on userdata for automatic cleanup as a safety net, but never rely solely on garbage collection for critical resources.

-- Combine explicit cleanup with __gc safety net
local mt = {
  __gc = function(handle)
    if handle.open then
      handle:close()
    end
  end
}

function open_resource(path)
  local res = { open = true }
  -- ... open file or connection
  return setmetatable(res, mt)
end

7. Test Error Paths Thoroughly

Error handling code is often the least tested part of a codebase because it's triggered by uncommon conditions. Write unit tests that deliberately provoke each error path. Verify that cleanup runs, error messages are informative, and the system returns to a consistent state.

-- Testing error paths with busted/luassert
describe("divide", function()
  it("raises on zero divisor", function()
    assert.has_error(function()
      divide(10, 0)
    end, "division by zero")
  end)

  it("returns correct quotient for valid input", function()
    assert.equals(5, divide(10, 2))
  end)
end)

8. Avoid Silent Error Suppression

Catching an error and doing nothing with it creates debugging nightmares. Always log, report, or propagate errors. The only exception is when you have a sensible fallback value and the error is truly non-critical.

-- Good: log and use fallback
local ok, config = pcall(load_config, path)
if not ok then
  io.stderr:write("Config error: " .. tostring(config) .. "; using defaults\n")
  config = default_config
end

-- Bad: silently ignore
-- local ok, _ = pcall(load_config, path)  -- don't do this

Conclusion

Error handling in Lua may seem simple at first glance, but its minimalist primitives—pcall, xpcall, error, and assert—combine into a rich set of patterns that rival the expressiveness of try/catch systems in other languages. By choosing structured error objects, annotating errors as they propagate, cleaning up resources deterministically, and wrapping every host-facing callback, you build systems that fail gracefully and provide actionable diagnostic information. The key is consistency: adopt a project-wide convention for how errors are raised, caught, logged, and propagated. With these patterns in place, Lua code becomes remarkably resilient even in deeply embedded, high-stakes environments.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles