Here are 21 Lua interview questions with model answers, written for senior engineers who embed or script with Lua: game and engine teams, OpenResty and Kong gateway developers, Redis scripting, Neovim plugins and products that expose Lua as an extension language. They cover tables and metatables, closures, coroutines, error handling, the C API, sandboxing, performance and the differences between Lua 5.4, Lua 5.5, LuaJIT and Luau. At Ryz, senior engineers are sourced by recruiters, assessed in structured NTRVSTA AI interviews, and reviewed by recruiters before and after. The AI scores are one input. People make the decisions.
"Lua" means different runtimes in different jobs. PUC-Rio Lua 5.4 is still widely embedded, Lua 5.5 was released in December 2025, LuaJIT 2.1 follows Lua 5.1 semantics with its own extensions and powers OpenResty and Neovim, Redis scripts run Lua 5.1, and Roblox uses Luau, a typed dialect derived from Lua 5.1. Tell the candidate which runtime you ship and accept answers that name the differences. Someone who knows where 5.1 and 5.4 diverge has usually maintained real Lua code.
The strongest signal is how a candidate reasons about tables, scope and the host boundary: what lives in Lua, what lives in C or the engine, and what crosses between them on every frame or request. Ask them to explain a metatable they built, then have them debug a script live.
#t return?A table is an associative array with an array part for consecutive integer keys and a hash part for everything else, and the runtime moves keys between them as the table grows. Arrays are 1-based by convention. The length operator returns a border: an index where t[n] is non-nil and t[n+1] is nil. A sequence with no holes has exactly one border, so #t is reliable. A table with holes can have several, and #t may return any of them.
local t = {1, 2, nil, 4}
print(#t) -- 4 or 2: both are valid borders
local packed = table.pack(1, nil, 3)
print(packed.n) -- 3, stored explicitly
What a strong answer shows: They never rely on # for tables with holes, they store an explicit count when nils are valid values, and they know that assigning nil removes a key.
pairs and ipairs guarantee about order?ipairs walks keys 1, 2, 3 and stops at the first nil. pairs visits every key in an unspecified order that can change between runs and versions. You may assign nil to existing fields during a pairs loop, but adding new keys during traversal is undefined. For stable output, collect the keys, sort them, then iterate.
What a strong answer shows: They have been bitten by test output or serialized files that depended on pairs order and fixed it with sorted keys.
local Account = {}
Account.__index = Account
function Account.new(owner, balance)
return setmetatable({ owner = owner, balance = balance or 0 }, Account)
end
function Account:deposit(amount)
assert(amount > 0, "amount must be positive")
self.balance = self.balance + amount
end
local Savings = setmetatable({}, { __index = Account })
Savings.__index = Savings
function Savings.new(owner, balance, rate)
local self = Account.new(owner, balance)
self.rate = rate
return setmetatable(self, Savings)
end
function Savings:accrue()
self:deposit(self.balance * self.rate)
end
The colon is sugar: obj:deposit(10) is obj.deposit(obj, 10). A missing field on the instance falls through __index to Savings, then to Account.
What a strong answer shows: They explain the lookup chain, know that deep chains cost a lookup per level on hot paths, and can say when they would copy methods down instead.
rawget and rawset for?Common ones are __index and __newindex for defaults, proxies and read-only tables, __call for callable objects, __tostring and __name for debugging, __eq, __lt and __le for comparisons, __close for to-be-closed variables, __gc for finalizers and __mode for weak tables. rawget and rawset bypass metamethods, which you need inside a __newindex handler to avoid infinite recursion. Setting a __metatable field hides and locks the metatable from getmetatable and setmetatable.
What a strong answer shows: They can build a read-only config table and explain why __newindex only fires for keys that are absent.
A table whose metatable has __mode = "k", "v" or "kv" holds its keys, values or both weakly, so the garbage collector can remove entries whose objects are otherwise unreferenced. Typical uses are caches keyed by objects and side tables that attach data to objects you don't own. Since Lua 5.2, weak-keyed tables are ephemerons: a value that refers back to its own key does not keep the entry alive.
What a strong answer shows: They know strings and numbers are never removed from weak tables, because only objects with an explicit construction are, and they don't use weak tables as a memory-pressure cache.
local fns = {}
for i = 1, 3 do
fns[i] = function() return i end
end
print(fns[1](), fns[2](), fns[3]())
It prints 1, 2, 3. A closure captures variables, not values, and those captured variables are its upvalues. The numeric for loop creates a fresh local i on every iteration, so each closure captures a different variable. Two closures created in the same scope share the same upvalue, so a change through one is visible through the other.
What a strong answer shows: They know the per-iteration binding, contrast it with older JavaScript var behavior, and know that Lua 5.5 makes the loop control variable read-only.
local almost everywhere?Globals are fields of the environment table _ENV, so every global read is a table lookup, and a typo silently creates a new global instead of raising an error. Locals live in VM registers and are faster, and caching library functions such as local insert = table.insert helps in hot loops. Lua 5.5 adds global declarations: once a chunk declares globals, undeclared names become compile errors, and global<const> * makes every undeclared global read-only.
What a strong answer shows: They use a linter such as luacheck or selene, or strict mode, to catch accidental globals, and know a function can have at most 200 local variables in PUC-Rio Lua.
{...}?A function declared with ... receives extra arguments as a list. select("#", ...) counts them, including trailing nils, while #{...} can be wrong when any argument is nil. Use table.pack(...), which stores the count in n, and table.unpack(t, 1, t.n) to spread it again. Lua 5.5 adds named vararg tables, so function f(...args) gets a table with the count in args.n.
What a strong answer shows: They preserve nils when forwarding arguments, for example in a logging or middleware wrapper.
<const> and <close> locals in Lua 5.4?local function with_file(path, fn)
local f <close> = assert(io.open(path, "r"))
return fn(f) -- f:close() runs on return, break or error
end
<const> makes a local read-only after initialization. <close> calls the value's __close metamethod when the variable goes out of scope, including when an error unwinds the block, which gives Lua deterministic cleanup for files, locks and connections. File handles from io.open already have __close.
What a strong answer shows: They use to-be-closed variables instead of relying on __gc for cleanup, because finalizers run at an unpredictable time.
local function producer(lines)
return coroutine.wrap(function()
for _, line in ipairs(lines) do
coroutine.yield(line)
end
end)
end
for line in producer({"a", "b", "c"}) do
print(line)
end
Lua coroutines are asymmetric and cooperative: a coroutine runs until it calls coroutine.yield, which returns control and values to whoever called coroutine.resume. There is no preemption and no parallelism. coroutine.wrap returns a function that resumes on every call and raises errors instead of returning them, which makes it a neat iterator. Lua 5.4 adds coroutine.close to close a suspended coroutine and its pending to-be-closed variables.
What a strong answer shows: They know resume returns false, err on error while wrap rethrows, and they check coroutine.status before resuming.
Engines run per-entity scripts as coroutines so a script can write wait(2) or move_to(x, y) as straight-line code, with the scheduler resuming it on a later frame. OpenResty runs each request handler in a coroutine and yields whenever a cosocket would block on network input or output, so a single Nginx worker serves thousands of concurrent requests with no callbacks in the code.
What a strong answer shows: They know the trap: blocking calls such as LuaSocket or os.execute inside an OpenResty handler stall the whole worker, so they use the non-blocking ngx APIs.
A coroutine can't yield while a C function that doesn't support continuations is on its stack. In Lua 5.1 that included pcall, metamethods and iterators. Lua 5.2 and later make pcall and metamethods yieldable, and C functions can support yielding through lua_yieldk, lua_callk and lua_pcallk with a continuation function. LuaJIT's VM is fully resumable, so it also allows yields across pcall and metamethods.
What a strong answer shows: They can explain why a host-provided C function breaks yielding and how a continuation fixes it.
xpcall?error(value, level) raises any value, and the level makes the message point at the caller rather than at the helper. pcall(f, ...) returns true plus results, or false plus the error value. xpcall(f, handler, ...) runs the handler before the stack unwinds, which is the only place you can capture a full traceback with debug.traceback. Libraries often follow the convention of returning nil, err for expected failures and raising for programmer errors.
local ok, err = xpcall(run_job, function(e)
return { message = tostring(e), trace = debug.traceback(e, 2) }
end, job)
if not ok then log.error(err.message, err.trace) end
What a strong answer shows: They use table error objects for structured errors, and know that string errors from error("msg") get position information added while tables do not.
Since Lua 5.3, numbers have two subtypes: 64-bit integers and double-precision floats. / always returns a float, // is floor division, and bitwise operators work on integers. Integer arithmetic wraps on overflow, integer division by zero raises an error, and math.type reports which subtype a value has. LuaJIT follows Lua 5.1, where every number is a double, so integers above 2^53 lose precision. It offers 64-bit integers only as FFI cdata and bit operations through its bit module.
What a strong answer shows: They know that IDs from a database or a 64-bit hash need care in LuaJIT and in Redis scripts, and they check which runtime they are on before doing integer math.
static int l_clamp(lua_State *L) {
lua_Number x = luaL_checknumber(L, 1);
lua_Number lo = luaL_checknumber(L, 2);
lua_Number hi = luaL_checknumber(L, 3);
luaL_argcheck(L, lo <= hi, 2, "lo must be <= hi");
lua_pushnumber(L, x < lo ? lo : (x > hi ? hi : x));
return 1; /* number of results */
}
/* registration: lua_register(L, "clamp", l_clamp); */
Every call gets its own virtual stack. Arguments arrive at indexes 1 to n, the function pushes its results and returns how many it pushed. Negative indexes count from the top. The luaL_check* helpers raise a Lua error with a useful message when an argument has the wrong type.
What a strong answer shows: They track stack balance, know lua_checkstack exists for deep pushes, and use luaL_ref on the registry to hold Lua values from C safely.
By default Lua raises errors with longjmp, which skips C++ destructors, so RAII objects on the stack leak or leave locks held. Options are compiling Lua as C++ so it uses exceptions, or never holding owning C++ objects across calls that can raise. Engines also need to decide object lifetime: full userdata with a metatable and __gc for objects Lua owns, or handles and IDs for objects the engine owns, so a script can't keep a pointer to a destroyed entity.
What a strong answer shows: They have debugged a dangling pointer from a script and moved to handles or generation counters.
Run them with a custom environment: load(code, "user", "t", env), where env exposes only safe functions. The "t" mode refuses precompiled bytecode, which matters because Lua does not verify bytecode and malformed bytecode can crash the host or worse. Leave out os, io, debug, load, require, dofile, string.dump and collectgarbage. Limit CPU with an instruction-count hook from debug.sethook on the host side, and limit memory with a custom allocator passed to lua_newstate. LuaJIT and Lua 5.1 use setfenv instead of _ENV.
What a strong answer shows: They treat bytecode as unsafe, enforce both CPU and memory limits, and give each tenant its own lua_State when isolation matters.
Periodic stalls usually mean garbage collection. Measure with collectgarbage("count") per frame, then find the allocation sources: tables created in loops, closures created per call, string concatenation and varargs packing. Reuse tables, hoist closures, and build strings with table.concat. Then tune the collector: Lua 5.4 offers an incremental mode and a generational mode through collectgarbage("incremental") and collectgarbage("generational"), and Lua 5.5 performs major collections incrementally. Some engines step the collector manually during idle frame time.
What a strong answer shows: They measure allocations before tuning the collector, and they know why s = s .. x in a loop is quadratic.
LuaJIT is a tracing JIT: it records hot loops and paths and compiles them to machine code with type specialization. Code stays fast when its types are stable and its operations are supported by the trace compiler. Some built-ins and patterns are not compiled, listed as NYI, and a trace that hits one aborts to the interpreter. Use -jv or jit.v to see trace aborts and -jdump for detail. The FFI calls C functions and uses C structs directly, which is very fast in compiled code and slower in the interpreter.
What a strong answer shows: They profile trace aborts instead of guessing, and they know LuaJIT 2.1 ships as a rolling release from its git branch, so they pin a commit.
Lua 5.5 was released on 22 December 2025, and Lua 5.4.9, released in August 2026, is the last 5.4 release. The main changes are global declarations, named vararg tables, read-only for-loop control variables, more compact arrays that use about 60% less memory for large arrays, incremental major collections and table.create for preallocation. Before upgrading, check for code that uses global as a name, code that assigns to loop variables, and garbage-collector tuning calls, whose parameters changed. Recompile every C module, because Lua versions are not binary compatible, and regenerate any precompiled bytecode.
What a strong answer shows: They know Lua versions break the ABI and bytecode format, and they test with the compatibility options in luaconf.h turned off.
Redis runs scripts on an embedded Lua 5.1 interpreter. A script runs atomically and blocks the server while it runs, so long loops hurt every client. Keys must be passed through KEYS so Redis Cluster can route them, and Redis 7 adds Functions for named server-side libraries. Luau, from Roblox, derives from Lua 5.1 and adds gradual typing with --!strict, continue, compound assignment such as += and string interpolation, and runs in a sandbox without io or os.execute.
What a strong answer shows: They adapt to the host: short atomic Redis scripts, typed Luau modules, and no assumptions that 5.4 features exist everywhere.
#t on tables that can contain nils and trusts the result... inside loops.__gc for timely cleanup of files or locks.Run a 90-minute pairing session on a prepared repo: a Lua 5.4 module that runs per-entity behavior scripts as coroutines, with a tiny C host or a test harness that calls it every frame. Seed it with problems: an accidental global created by a typo, a #t loop over a list with holes, a string built with .. in a loop, a script environment that exposes os, and a missing pcall around a script resume. Ask the candidate to fix what matters most, explaining priorities as they go.
If you need engineers who already think this way, hire senior game developers and Lua engineers through Ryz, or C++ developers for the engine side. They come from the top 1% of candidates we interview, work on your team and in your repos, and work on US business hours. Our vetting process shows how we evaluate them.
Interview for the runtime you ship, and accept answers in the others' vocabulary. Lua 5.4 and 5.5 share integers, to-be-closed variables and _ENV. LuaJIT follows Lua 5.1 with FFI and JIT extensions. A strong candidate can name the differences that matter, such as integers, setfenv versus _ENV, and the global declarations new in 5.5.
The language is learned in days, but the idioms take longer: tables with holes, metatables, coroutines as schedulers and the host boundary. Ask two or three questions on tables, closures and embedding to see whether an engineer from another language adjusts quickly.
Six to eight. Pick two on tables and metatables, one on closures, one on coroutines, one on errors, and one on embedding or LuaJIT if that is your stack. Then spend the remaining time on a short live exercise.
Questions we didn't answer? Email info@ryzlabs.com.