Error handling: error, pcall and xpcall

Raise errors with useful messages, catch them with pcall and xpcall, add tracebacks, and write cleanup that always runs.

Raising errors

-- error raises a value; if it is a string, Lua prefixes the position
local function divide(a, b)
  if b == 0 then error("division by zero", 2) end   -- level 2: blame the caller
  return a / b
end

print(pcall(divide, 10, 0))    --> false   file.lua:5: division by zero

-- level 0: no position information added
error("plain message", 0)
-- level 1 (default): blame the function that called error
-- level 2: blame the caller of that function

-- error with a table: the value is passed through untouched
local function validate(v)
  if type(v) ~= "number" then
    error({ code = "E_TYPE", message = "expected a number, got " .. type(v) })
  end
  return v
end

local ok, err = pcall(validate, "text")
print(ok, err.code, err.message)   --> false   E_TYPE   expected a number, got string

-- assert is the terse form: it raises the message only when the value is falsey
local function openConfig(path)
  local f = assert(io.open(path, "rb"), "cannot open config: " .. path)
  local content = f:read("a")
  f:close()
  return content
end

-- a custom message loses the position unless you pass the level
local function check(n)
  assert(type(n) == "number", "n must be a number")   -- message only
  return n
end
  • Any value can be an error: a string, a table, a number. A table lets you attach a code and structured detail, which a message string cannot.
  • error with a level is how library code blames the caller rather than itself. Without it, every message points at the error call inside your function.
  • assert returns all its arguments when the first is truthy, so it composes: local f = assert(io.open(path)). It also means assert(false, ...) with no second argument raises "assertion failed!", which tells the user nothing.
  • Remember that only false and nil are falsey. assert(0) passes.

pcall, xpcall and tracebacks

-- pcall: protected call, returns ok plus every result or the error
local ok, result = pcall(function() return 1 + 1 end)
print(ok, result)               --> true   2

local ok2, err = pcall(function() error("boom") end)
print(ok2, err)                 --> false   file.lua:5: boom

-- xpcall: run a handler before the stack unwinds, so it can capture a traceback
local function handler(err)
  return {
    message   = tostring(err),
    traceback = debug.traceback("", 2),
  }
end

local ok3, info = xpcall(function()
  local function inner() error("deep failure") end
  inner()
end, handler)

print(ok3)                       --> false
print(info.message)
print(info.traceback)            -- the full call stack, still intact

-- in Lua 5.1 xpcall took no arguments after the handler;
-- 5.2+ forwards extra arguments to the called function
local ok4, value = xpcall(function(a, b) return a * b end, handler, 6, 7)
print(ok4, value)                --> true   42

-- a retry wrapper is a common and useful pattern
local function retry(times, f, ...)
  local results = table.pack(pcall(f, ...))
  local attempt = 1
  while not results[1] and attempt < times do
    attempt = attempt + 1
    results = table.pack(pcall(f, ...))
  end
  return table.unpack(results, 1, results.n)
end
FunctionOn successOn failure
pcall(f, ...)true plus resultsfalse plus the error value
xpcall(f, h, ...)true plus resultsfalse plus the handler's result
assertReturns its argumentsRaises the message
errorNever returnsRaises with position info by default
debug.tracebackA stringThe stack as text; useful in a handler

Use xpcall whenever you want a traceback. By the time a plain pcall returns, the stack has unwound and debug.traceback inside the caller shows the wrong frames.

Cleanup that always runs

-- the standard pattern: catch, clean up, rethrow
local function withResource(path, body)
  local f, err = io.open(path, "rb")
  if not f then error(err, 2) end

  local ok, result = pcall(body, f)

  f:close()                       -- runs on both paths
  if not ok then error(result, 0) end   -- rethrow, keeping the original message
  return result
end

withResource("data.txt", function(f)
  return f:read("a")
end)

-- Lua 5.4 has to-be-closed variables: the cleanup is attached to the scope
local function withFile54(path, body)
  local f <close> = assert(io.open(path, "rb"))
  return body(f)
  -- f:close() is called automatically here, even on an error
end

-- a function can be called at scope exit if it has a __close metamethod
local guard = setmetatable({}, {
  __close = function(_, err)
    print("closing, error was " .. tostring(err))
  end,
})

do
  local g <close> = guard
  -- the guard fires when this block ends
end
  • Cleanup must not raise. A failing close inside an error path replaces the original error with a less useful one.
  • Rethrow with error(err, 0) when the error value already carries position information, or it will be prefixed twice.
  • In Lua 5.1 and LuaJIT there are no to-be-closed variables, so the pcall plus explicit close pattern is the only portable option.
  • Never use pcall to swallow errors you do not understand. Returning a default value for a bug turns a loud failure into silent corruption.
💡
Prefer returning nil, message for an expected failure, and raising an error for a broken precondition. Mixing the two makes every caller do both kinds of checking, and one of them will be forgotten.

FAQ

Does pcall catch every error?
It catches errors raised by error and by run-time faults inside the protected call. A C-level crash, an out-of-memory abort or an infinite loop is not catchable. The protected call also costs a little performance, so do not wrap a hot inner function.
Why is my error message missing the line number?
Something called error(msg, 0), or the message was produced by assert with a literal string, or you rethrew with level 0 after the position was already added. Check the level argument at the point where the message is created.

Functions, closures and multiple returns Modules, require and package.path

Last refreshed 2026-09-18.