luaguides

The debug Library: Inspection and Hooks

Lua ships with a standard debug library that gives you a window into the runtime behaviour of your program. Unlike the other standard libraries, it is not about data processing or I/O. It is about introspection — reading the call stack, peeking at local variables, and intercepting execution events through hooks.

This library is deliberately low-level. It exposes raw primitives that let you build debuggers, profilers, and coverage tools from scratch. It also breaks normal encapsulation rules, which is why you should never ship it in production code. This guide covers both introspection functions and the hook system using Lua 5.4.

Introspection: Reading the Stack

The most fundamental introspective operation is asking “where am I right now?” debug.getinfo answers that and more.

debug.getinfo(fn, [what])

Pass a function or a stack level number and get back a table of information. Stack levels start at 1, which is the function that called getinfo itself. Level 2 is the caller of that function, and so on.

local function greet()
    local info = debug.getinfo(1, "Sl")
    print(string.format("%s:%d", info.short_src, info.currentline))
end

greet()
-- Output: stdin:3

The second argument is a mask of option letters. It controls which fields are returned, and leaving it off returns everything. The common letters are:

  • "S" — source, short_src, what, linedefined
  • "l" — currentline
  • "n" — name and namewhat
  • "f" — func (the function object itself)
  • "u" — nups (number of upvalues)

Getting only the fields you need is significantly faster when you call getinfo frequently, such as inside a hook.

The what field in the returned table tells you what kind of chunk the function is: "Lua", "C", or "main". The name field is a best-effort name found by consulting the call site. If a function has no discoverable name, namewhat will be empty.

Reading and Writing Local Variables

Once you know which function is running, you can read its local variables with debug.getlocal.

function foo(a, b)
    local x = 10
    for i = 1, 10 do
        local name, val = debug.getlocal(1, i)
        if not name then break end
        print(name, val)
    end
end

foo("hello", "world")
-- a    hello
-- b    world
-- x    10

Index 1 is the first local in declaration order, counting only variables currently in scope. When a block exits, its locals drop out of the numbering, so you will not see loop variables after the loop ends.

To modify a local, use debug.setlocal(thread, level, index, value). It returns the variable name or nil if the index is out of range.

function demo(n)
    local name, val = debug.setlocal(1, 1, 999)  -- try to set param n
    print(name, val)  --> n  999
end
demo(5)

Both functions accept a thread as the first argument. Pass 0 or omit it for the current thread.

Accessing Upvalues

Closures capture variables from their enclosing scope. These captured variables are called upvalues. The debug.getupvalue function reads them.

function outer(msg)
    local function inner()
        return msg
    end
    return inner
end

local closure = outer("secret")
local name, val = debug.getupvalue(closure, 1)
print(name, val)  --> msg  secret

debug.setupvalue modifies the stored value. It returns the upvalue name or nil.

debug.setupvalue(closure, 1, "changed")
print(closure())  --> changed

Unlike locals, upvalues exist as long as the closure exists, even when the enclosing function has returned. That is what makes closures useful.

Sharing Upvalue Storage with upvaluejoin

Lua 5.2 introduced two functions that manipulate upvalue identity at a deeper level.

debug.upvalueid(func, n) returns a light userdata that uniquely identifies the nth upvalue of a function. If two upvalues share the same storage slot, they return the same id.

local id1 = debug.upvalueid(foo, 1)
local id2 = debug.upvalueid(bar, 2)
if id1 == id2 then
    print("these two upvalues share storage")
end

debug.upvaluejoin(f1, n1, f2, n2) makes the nth upvalue of f1 and the nth upvalue of f2 point to the same storage slot. This is permanent and one-directional.

A practical use is memoization: share a cache table between your wrapper and the original function so that writes from the wrapper are visible to the original.

local cache = {}

local function original(n)
    return cache[n] or (cache[n] = heavy_computation(n))
end

-- Force original's first upvalue to share with our cache getter
debug.upvaluejoin(original, 1, function() return cache end, 1)

The call to upvaluejoin is irreversible. You cannot unshare an upvalue slot once it has been joined.

The Registry and Traceback

debug.getregistry() returns the registry table — a hidden table used by the C API to store per-global state. From Lua it behaves like a normal table. Most Lua code never needs it directly, but C extensions and embedded Lua applications use it to pass data between C and Lua.

debug.traceback(thread, [msg], [level]) builds a stack traceback as a string. It accepts an optional message prepended to the output and a level at which to start the trace.

local function level3()
    return debug.traceback(nil, "Stack trace:", 1)
end

local trace = level3()
print(trace)
-- Stack trace:
--   Stack traceback:
--   [C]: in function 'traceback'
--   stdin:2: in function 'level3'
--   stdin:5: in main chunk

Hooks: Intercepting Execution

The hook system lets you register a callback that runs when specific execution events occur. You can hook on function calls, function returns, line changes, or instruction counts.

Setting a Hook with debug.sethook

debug.sethook([thread], hook, mask, [count])

The hook is a function that receives two arguments: the event name and, for line events, the line number.

The mask is a string combining any of these characters:

MaskEventTrigger
"c"callFunction entry
"r"returnFunction exit
"l"lineBefore a new source line
(count)countEvery n VM instructions

Pass nil as the hook to remove the current hook entirely.

Tracing Line Execution

The most common hook is "l" for line-by-line tracing. This is useful for understanding execution flow without a debugger.

local function trace(event, line)
    local info = debug.getinfo(2, "Sl")
    if info then
        print(string.format("[%s]:%d", info.short_src, line))
    end
end

debug.sethook(trace, "l")

-- Example code
local sum = 0
for i = 1, 3 do
    sum = sum + i
end
print("Total:", sum)

debug.sethook(nil)  -- stop tracing

The output shows every line before it executes, letting you trace the path of your program.

Counting Function Calls

Use the "c" mask to count how many times each function runs.

local counts = {}

local function profile(event)
    local info = debug.getinfo(3, "Sn")
    if info then
        local key = info.short_src .. ":" .. info.linedefined
        counts[key] = (counts[key] or 0) + 1
    end
end

debug.sethook(profile, "c")

require("something")  -- or any code to profile

debug.sethook(nil)

for k, v in pairs(counts) do
    print(v, k)
end

Combine masks by concatenating: "cr" monitors both calls and returns, giving you a complete call tree. "crl" adds line events.

Instruction Counting

The third form uses the count parameter without a mask character. The hook fires after every count VM instructions. This is useful for rough performance profiling, though the instruction count itself is implementation-defined.

local instruction_budget = 10000
local count = 0

local function step(event)
    count = count + 1
    if count >= instruction_budget then
        debug.sethook(nil)
        error("instruction limit exceeded")
    end
end

debug.sethook(step, "", 1000)  -- check every 1000 instructions

Saving and Restoring Hooks

debug.gethook() returns the current hook function, its mask, and the count as three separate values. Call it before replacing a hook so you can restore it later.

local old_hook, old_mask, old_count = debug.gethook()
debug.sethook(my_new_hook, "l")
-- ... run some code ...
debug.sethook(old_hook, old_mask, old_count)

This pattern matters in library code. A library that silently replaces the hook breaks any debugging the host application already has running.

Hooks and Coroutines

Hooks are set per-thread. When you pass a coroutine thread to sethook, you instrument only that coroutine. Other coroutines and the main thread run unaffected.

local co = coroutine.create(function()
    for i = 1, 3 do
        print("coroutine", i)
        coroutine.yield()
    end
end)

local function co_hook(event, line)
    print("hook:", event, line)
end

debug.sethook(co, co_hook, "l")
coroutine.resume(co)  -- hook fires inside the coroutine
debug.sethook(co, nil)  -- remove hook for this thread

This isolation makes it possible to debug concurrent programs without hooks interfering with each other.

Practical Applications

Breakpoints

You can implement a simple breakpoint by setting a line hook that checks a table of breakpoint lines.

local breakpoints = {["myscript.lua"] = {12 = true}}

local function breakpoint_hook(event, line)
    local info = debug.getinfo(2, "Sl")
    if info and breakpoints[info.short_src] and breakpoints[info.short_src][line] then
        print(string.format("Breakpoint at %s:%d", info.short_src, line))
        debug.debug()
    end
end

debug.sethook(breakpoint_hook, "l")

Calling debug.debug() drops you into an interactive prompt where you can inspect locals and resume execution with return.

Coverage Measurement

A line hook that records every line visited gives you per-line coverage data.

local covered = {}

local function coverage_hook(event, line)
    local info = debug.getinfo(2, "Sl")
    if info then
        covered[info.short_src] = covered[info.short_src] or {}
        covered[info.short_src][line] = true
    end
end

After running your test suite, compare the covered lines against the lines in your source files. Any uncovered line is a potential gap in your tests.

Security and Performance

The debug library violates normal scoping rules. Any Lua code can read locals from any active stack frame, modify upvalues, and intercept execution. This is intentional for development purposes, but it means the library must not exist in production.

The common idiom to strip it is:

debug = nil
package.loaded.debug = nil

Or in Lua 5.4+, you can simply assign nil to the global.

Hooks carry a severe performance penalty. A line hook can slow a program by 10 to 100 times depending on how much work the hook does. Instruction-count hooks are similarly expensive. Profile the overhead before using hooks in performance-critical paths.

See Also

Written

  • File: sites/luaguides/src/content/guides/lua-debug-library.md
  • Words: ~1150
  • Read time: 5 min
  • Topics covered: debug.getinfo, getlocal/setlocal, getupvalue/setupvalue, upvalueid, upvaluejoin, getregistry, traceback, debug.debug, sethook, gethook, line hooks, call hooks, instruction counting, coroutine hooks, breakpoints, coverage, production security
  • Verified via: https://www.lua.org/pil/23.html, https://www.lua.org/pil/23.1.html, https://www.lua.org/pil/23.2.html
  • Unverified items: none