Lua Interview Questions and Answers

Last updated:

Check out 40 of the most common Lua interview questions, then take an AI-powered practice interview

Game DevelopmentScriptingEmbedded SystemsCorona SDKLove2D
40+
Questions
14
Basic
17
Intermediate
9
Advanced
Q1

What is Lua and where is it typically used?

BasicFundamentals

Answer

Lua is a lightweight, embeddable scripting language created in 1993 at PUC-Rio in Brazil. Its design goals were portability (pure ANSI C, ~200 KB binary), simple C API for embedding, and minimal language core. That niche has made it the default scripting layer for software that needs user-customizable logic without shipping a heavyweight interpreter: Roblox (Luau), World of Warcraft addons, Adobe Lightroom plugins, Wireshark dissectors, OpenResty (nginx), HAProxy, Redis (eval), and Neovim configuration.

In India, game studios like Mech Mocha and DotsByLab use Lua as their gameplay scripting layer on top of C++ engines, and several CDNs deploy OpenResty for edge logic. Lua is not typically a 'main' application language, it's a glue layer running inside a host program. Interviewers usually follow up with 'why Lua and not JavaScript or Python for embedding'.

The honest answers are binary size, deterministic memory behaviour (the host hands Lua its own allocator through lua_newstate), a single-header C API, and the fact that one lua_State is fully independent, so a host can run hundreds of isolated script contexts in one process. The tradeoff you should volunteer: a deliberately small standard library with no built-in JSON, HTTP, regex or date parsing beyond os.date and os.time, so every real project pulls in LuaRocks modules such as lua-cjson, luasocket, lpeg or penlight. Be ready to say which Lua you mean, because 'Lua' in production is almost always one of three incompatible runtimes: PUC Lua 5.4 (standalone scripts, Neovim-adjacent tooling), LuaJIT 2.1 with Lua 5.1 semantics (OpenResty, most game engines), or Roblox Luau. Code that uses `<close>` or integer division semantics will not run on LuaJIT, and code that uses ffi will not run on PUC Lua.

Key Points

  • Designed to be embedded, not standalone
  • Pure ANSI C, ~200 KB runtime
  • Dominant in game scripting, edge networking, editor config
  • Roblox's Luau is a typed superset; Neovim's config is now Lua-first
Q2

What are the basic data types in Lua?

BasicFundamentals

Answer

Lua has only eight types: nil, boolean, number, string, function, userdata, thread, and table. That's it. Notably absent: no separate integer vs float type until Lua 5.3 (which split number into integer/float subtypes). 'table' is the only structured data type, arrays, hash maps, objects, modules and namespaces are all implemented as tables. 'userdata' wraps C pointers for native interop. 'thread' represents coroutines, not OS threads.

The minimalism is deliberate, Lua's entire standard library fits in a few hundred KB. Two follow-ups come up constantly. First, type() always returns a string, so `type(x) == 'number'` is the idiom, and from 5.3 you need math.type(x) to separate 'integer' from 'float' (it returns nil for non-numbers).

Numbers are IEEE doubles by default, exact for integers up to 2^53, which is exactly why 5.3 added a real 64-bit integer subtype for IDs, hashes and bit operations. Second, userdata has two flavours: full userdata is a GC-managed block allocated by lua_newuserdatauv that can carry a metatable and a __gc finalizer, while light userdata is a bare C pointer with no metatable that compares by address. The 'thread' type is a coroutine, not an OS thread, so type(coroutine.create(f)) returns 'thread' even though nothing is scheduled by the kernel. Strings are interned and immutable in PUC Lua, so equality is a pointer comparison and every distinct string you build occupies a slot in the global string table until it is collected.

print(type(nil))       -- nil
print(type(true))      -- boolean
print(type(42))        -- number
print(type("hi"))      -- string
print(type(print))     -- function
print(type({}))        -- table
print(type(coroutine.create(function() end)))  -- thread
Q3

Why is Lua 1-based indexed and what does this mean in practice?

BasicGotchas

Answer

Lua arrays start at index 1, not 0. This trips up almost every developer coming from C/Java/Python. The standard library is consistent, string.sub, table.insert, ipairs all treat 1 as the first index.

Doing `t[0] = 'x'` is legal (tables are hash maps under the hood) but ipairs and the length operator # will ignore the 0 slot. In practice: always start array loops at 1, remember string.sub(s, 1, 3) returns the first 3 chars (not first 4), and never assume FFI/C arrays imported via LuaJIT follow this rule, they don't, they're 0-indexed because they're raw C memory. The reason is historical: Lua grew out of DEL and SOL, data-description languages written for Petrobras engineers, where counting from one matched the domain rather than pointer arithmetic.

Consequences worth naming in an interview: table.remove(t, 1) shifts every remaining element and is O(n), so a queue should use a head index or a ring buffer instead; table.insert(t, pos, v) with pos outside 1..#t+1 raises 'bad argument #2 to insert (position out of bounds)' in 5.3 and later; and negative indices count from the end in the string library, so string.sub(s, -3) returns the last three characters and string.sub(s, 2, -2) strips one character from each end. On the LuaJIT side, ffi.new('int[10]') has valid indices 0 through 9, reading buf[10] is an out-of-bounds access with no error at all, just corrupted data or a segfault that takes the whole process down, so teams that mix Lua tables and FFI buffers usually adopt a naming convention (buf_ prefix) to mark which objects are 0-based.

local t = {"a", "b", "c"}
print(t[1])  -- a   (NOT t[0])
print(#t)    -- 3

for i, v in ipairs(t) do print(i, v) end  -- 1 a, 2 b, 3 c

-- string.sub is also 1-based, inclusive on both ends
print(string.sub("hello", 1, 3))  -- hel
💡 Pro Tip: When mixing Lua with LuaJIT FFI arrays, remember the C array is 0-indexed even though Lua tables aren't. This is the source of countless off-by-one bugs.
Q4

What is the difference between local and global variables in Lua?

BasicScoping

Answer

A variable in Lua is global by default, assigning `x = 10` at any scope creates (or overwrites) a global. To create a function-local variable, you must use the `local` keyword. This is the opposite of most modern languages and the #1 source of bugs in Lua code.

Globals are slower (every access is a hash lookup in `_G`), pollute the namespace, and cause action-at-a-distance bugs when two unrelated files use the same name. Production Lua code uses `local` everywhere, and many teams run a linter (luacheck) that flags accidental globals. Roblox's Luau even has a `--!strict` mode that errors on undeclared globals.

Mechanically, an undeclared name compiles to a table lookup on the environment (GETTABUP on the _ENV upvalue in 5.2+, GETGLOBAL in 5.1), while a local compiles to a register access, so a loop that reads math.sqrt through two global lookups every iteration is measurably slower than one that caches `local sqrt = math.sqrt` at the top of the file. There are hard limits too: a function may hold at most 200 locals and 255 upvalues, and exceeding them fails at compile time with 'too many local variables', which occasionally bites generated or heavily inlined code. Embedded hosts add a sharper failure mode.

In OpenResty, a global written inside an access_by_lua or content_by_lua block lives for the life of the nginx worker and is visible to every later request that worker serves, so an accidental global holding a user ID leaks data between users; ngx_lua ships a checker that logs 'attempt to write to undeclared variable'. Standard tooling: luacheck reports 'setting non-standard global variable', and a .luacheckrc that declares your legitimate globals keeps CI honest.

x = 10            -- GLOBAL, bad practice
local y = 20      -- local to chunk, preferred

local function greet(name)
    local msg = "hi " .. name  -- local, OK
    farewell = "bye"            -- accidental GLOBAL, bug
end

Key Points

  • Variables are global by default
  • Always use `local` unless you genuinely need a global
  • Run luacheck in CI to catch accidental globals
Q5

What is a table in Lua and how is it different from arrays/objects in other languages?

BasicTables

Answer

Tables are Lua's only structured type. A table is an associative array, keys can be any non-nil value (numbers, strings, booleans, even tables or functions), and values can be anything including nil (which actually deletes the key). When keys are consecutive integers starting at 1, the runtime stores them in a packed array part for fast access; otherwise they go into a hash part.

This means a single table can serve as: an array (numeric keys), a hash map (string keys), a struct (named fields), a namespace (module table), or an object (with a metatable). The same `{}` syntax creates all of them. Mastering tables IS mastering Lua.

The implementation detail interviewers probe is the two-part layout: the array part is sized to a power of two and the hash part uses closed hashing with Brent's variation, and a rehash happens on insert when the relevant part is full. That is why filling t[1] through t[n] in ascending order is cheap while building the same table backwards from t[n] down to t[1] triggers repeated rehashes and can be several times slower. Preallocation is exposed differently per runtime: PUC Lua only offers it from C via lua_createtable, LuaJIT adds table.new(narr, nhash) and table.clear(t), and Roblox Luau has table.create(n, value).

Key rules that bite: nil as a key raises 'table index is nil', NaN raises 'table index is NaN', and from 5.3 a float key with an exact integer value is normalised, so t[2.0] and t[2] are the same slot while t['2'] is a different one. Tables compare by identity, so {} ~= {} and two structurally identical config tables are unequal unless you define __eq.

-- Array-like
local arr = {10, 20, 30}

-- Hash map
local user = {name = "Asha", age = 28}

-- Mixed (legal but rarely useful)
local mix = {1, 2, 3, name = "Asha"}

-- Nested
local config = {
    db = {host = "localhost", port = 5432},
    cache = {ttl = 60},
}
print(config.db.host)  -- localhost
Q6

What does the # operator do and when does it give wrong results?

BasicGotchas

Answer

The `#` operator returns the 'length' of a table or string. For strings, it's reliable, `#'hello'` is 5. For tables it's only reliable when the table is a proper sequence: keys 1..n with no nil holes.

On a sparse table (nil holes), `#` is allowed to return ANY border, meaning any index n such that t[n] is non-nil and t[n+1] is nil. With `{1, 2, nil, 4}` it might return 2 or 4 depending on the implementation. Always use a counter or the table's known length; never use `#` to check 'is this table empty' on tables that may have nil holes.

Use `next(t) == nil` for emptiness. The underlying reason is that when the slot after the array part is nil, Lua runs a binary search for a border, so the answer depends on the internal array size rather than on the data you wrote. That makes the bug intermittent: the same logical table can report 4 on one run and 2 after a rehash or after being built in a different order.

Practical rules: build sequences with table.insert or an explicit counter, never assign nil into the middle, and if you must delete, either swap the last element into the hole and shrink or store a sentinel such as false. From 5.2 you can define __len on your own container types, and rawlen(t) deliberately skips that metamethod. The failure usually surfaces downstream rather than at the assignment: lua-cjson raises 'Cannot serialise table: excessively sparse array' when it hits a hole, and ipairs quietly truncates the payload so an API returns three of five records with no error at all.

print(#{1, 2, 3})         -- 3 (proper sequence)
print(#{1, 2, nil, 4})    -- 2 or 4, UNDEFINED
print(#{[1]="a", [3]="c"}) -- 1 or 3, UNDEFINED

-- Safe emptiness check
local function is_empty(t) return next(t) == nil end
Q7

How do you write a function in Lua?

BasicFunctions

Answer

Functions are first-class values. Two equivalent syntaxes: `function name(args) ... end` and `name = function(args) ... end`. They can return multiple values (no tuple type, values are returned as a comma-separated list).

Functions defined as table fields with the colon (`function obj:method(x)`) get an implicit `self` parameter, the syntactic basis of Lua OOP. Functions can take a variable number of arguments using `...` (the vararg expression). Details a senior interviewer chases: Lua never checks arity, extra arguments are discarded and missing ones become nil, so a typo at the call site fails later and further away, usually as 'attempt to index a nil value (local opts)'.

Multiple returns are adjusted to one value in most contexts and expand only in the last position of an argument list, a return statement, or a table constructor, so {f()} keeps every result while {f(), 1} keeps exactly one; wrapping in parentheses forces truncation, so (f()) is always a single value. A call whose only argument is a string or table literal may omit parentheses, which is why require 'socket' and setmetatable{...} parse. Lua also guarantees proper tail calls: `return f()` reuses the current stack frame, so a tail-recursive state machine runs forever in constant stack space and never raises 'stack overflow', but the frame vanishes from tracebacks and shows as '(...tail calls...)', which surprises people debugging production stacks. Finally, `function t.a.b.c:f()` is valid sugar and creates the function inside an existing nested table, it does not create the intermediate tables for you.

local function add(a, b)
    return a + b
end

-- Multiple return values
local function divmod(a, b)
    return a // b, a % b
end
local q, r = divmod(17, 5)  -- q=3, r=2

-- Varargs
local function sum(...)
    local total = 0
    for _, v in ipairs({...}) do total = total + v end
    return total
end
print(sum(1, 2, 3, 4))  -- 10
Q8

What is nil and how is it different from false?

BasicFundamentals

Answer

nil represents 'no value' and is its own type. false is a boolean. Both are falsy in conditional contexts (`if x then` is false for both nil and false), and only those two, every other value including 0 and empty string `''` are truthy. This is a common surprise for C/Python programmers.

Key differences: nil in a table deletes the key (`t.x = nil` removes x), false stores a real boolean. To distinguish them: use `if x == nil then` (NOT `if not x then`, which conflates the two). Two production consequences follow.

First, you cannot tell 'key absent' from 'key present with value nil', so any API that needs a real tri-state stores an explicit sentinel; lua-cjson does this with cjson.null, a light userdata, so a JSON null survives a decode/encode round trip instead of deleting the field. Second, a nil inside an argument list truncates: select('#', 1, nil, 3) correctly reports 3 while #{1, nil, 3} is undefined, which is exactly why table.pack (5.2+) records the real count in an n field. The default-value idiom hides a classic bug: `local x = opts.enabled or true` silently becomes true when the caller explicitly passed false, so boolean options need `if opts.enabled == nil then opts.enabled = true end`. Error messages key on nil too, and 5.4 improved them to name the source, so 'attempt to index a nil value (field 'config')' tells you which field returned nil, while 'attempt to call a nil value (method 'connect')' usually means a missing metatable or a dot where a colon belonged.

if 0 then print("yes") end       -- yes, 0 is truthy in Lua!
if "" then print("yes") end      -- yes, empty string is truthy
if nil then print("yes") end     -- (nothing)
if false then print("yes") end   -- (nothing)

local t = {a = false}
print(t.a)        -- false (key exists)
t.a = nil
print(t.a)        -- nil (key deleted)

Key Points

  • Only nil and false are falsy
  • 0 and '' are TRUTHY in Lua
  • Setting a table key to nil deletes the key
Q9

How do you concatenate strings in Lua?

BasicStrings

Answer

Use the `..` operator. Lua strings are immutable, so each `..` allocates a new string, building strings in a loop with `..` is O(n²). For repeated concatenation, build a table of pieces and call `table.concat(pieces, separator)` once at the end.

The `string.format` function is the equivalent of C's printf and is the cleanest way to mix variables into a template. Two behaviours worth naming. Concatenation coerces numbers automatically, so 1 .. 2 produces the string '12', but the space before .. is mandatory after a numeric literal because 1..2 lexes as a malformed number and gives 'malformed number near 1..2'.

Concatenating nil or a table raises 'attempt to concatenate a nil value (local name)' unless the value's metatable defines __concat, so building log lines from optional fields needs tostring() or an `or ''` guard. On performance, PUC Lua interns every string in a global table, so a million temporary strings means a million hashes plus a million objects for the collector to sweep, which is the usual cause of sawtooth memory graphs in log-heavy services. table.concat accepts only strings and numbers and raises 'invalid value (at index 3) in table for concat' on anything else. Faster options exist per runtime: LuaJIT 2.1 has the string.buffer library (buf:put(x), buf:tostring()), Roblox Luau has buffer plus table.concat, and in OpenResty ngx.say and ngx.print accept a nested table of pieces directly so the concatenation happens in C.

local s = "hello " .. "world"   -- hello world

-- BAD: O(n²) in a loop
local out = ""
for i = 1, 1000 do out = out .. tostring(i) end

-- GOOD: O(n)
local parts = {}
for i = 1, 1000 do parts[i] = tostring(i) end
local result = table.concat(parts, ",")

-- Templating
local msg = string.format("User %s is %d years old", "Asha", 28)
Q10

What is the difference between pairs() and ipairs()?

BasicTables

Answer

`ipairs(t)` iterates the integer-keyed part of t starting at 1, stopping at the first nil. It guarantees order but skips non-integer keys and stops at holes. `pairs(t)` iterates ALL key-value pairs in unspecified order, including non-integer keys. Rule of thumb: ipairs for arrays (when order matters), pairs for hash maps. ipairs is also slightly faster because it walks the array part directly.

If you need ordered iteration over arbitrary keys, build a sorted list of keys first. Mechanically the generic for calls an iterator with (state, control) until the first return value is nil. ipairs is a stateless iterator that just indexes t[i] and stops at the first hole, and since 5.3 it goes through __index, so an ipairs walk over a proxy table does trigger metamethods. pairs is built on next, and in 5.2 and 5.3 it honours a __pairs metamethod (deprecated and removed again in 5.4, so check your runtime before relying on it). Two ordering traps matter in production.

Iteration order over string keys can differ between processes because the string hash is seeded per state, so serialising a config with pairs produces diffs that churn, and you should table.sort the keys first. And mutating a table during pairs is undefined except for assigning nil to the key you are currently on; adding a new key can raise 'invalid key to next' or silently skip entries. Roblox Luau adds generalised iteration, so `for k, v in t do` works directly and dispatches through __iter.

local t = {10, 20, 30, foo = "bar"}

for i, v in ipairs(t) do print(i, v) end
-- 1 10
-- 2 20
-- 3 30   (stops here, doesn't reach foo)

for k, v in pairs(t) do print(k, v) end
-- 1 10, 2 20, 3 30, foo bar  (order not guaranteed)
Q11

How do you write conditionals and loops in Lua?

BasicControl Flow

Answer

Conditionals use `if/elseif/else/end`. Loops have three forms: numeric for (`for i = 1, 10, step do ... end`), generic for (`for k, v in pairs(t) do ... end`), and `while/repeat...until`. Lua has no `break` value, no `continue` (use `goto continue` with a label), and no `switch`, use a table of functions instead.

The `repeat...until` loop is unusual: the condition is evaluated AFTER the body and can see local variables declared inside the body. Details that separate rehearsed answers from real use: the numeric for evaluates its start, limit and step expressions exactly once before the loop begins, so mutating the limit inside the body changes nothing, and the control variable is a fresh local each iteration, which matters when you capture it in a closure. A step of zero raises 'for step is zero'.

Lua 5.4 made the numeric for integer-typed when all three expressions are integers and fixed the wraparound at math.maxinteger that could loop forever in 5.3. `break` must be the last statement in its block, so code after it is a syntax error unless you wrap it in do ... end. The goto continue idiom needs the label as the final statement of the loop body, and jumping past a local declaration fails at compile time with 'jumps into the scope of local'. There is no ternary operator, and the usual substitute `cond and a or b` is quietly wrong whenever a is false or nil, in which case it returns b.

-- numeric for
for i = 1, 10, 2 do print(i) end  -- 1, 3, 5, 7, 9

-- while
local n = 10
while n > 0 do n = n - 1 end

-- repeat-until (post-condition)
repeat
    local input = io.read()
until input == "quit"

-- goto continue (no native continue)
for i = 1, 5 do
    if i % 2 == 0 then goto continue end
    print(i)
    ::continue::
end
Q12

How do you require and create modules in Lua?

BasicModules

Answer

A module is just a Lua file that returns a table. Caller uses `local M = require('modname')`, Lua searches package.path for `modname.lua` (or modname/init.lua), runs it once, caches the result in `package.loaded`, and returns it. Subsequent requires get the cache.

The convention: build a local table M, attach functions to it, and return M at the end of the file. This keeps everything namespaced and avoids polluting globals. Resolution details get asked about constantly. require walks the searchers in package.searchers (package.loaders in 5.1), the Lua searcher expands package.path, a semicolon-separated list of templates such as ./?.lua;/usr/local/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?/init.lua, and on failure raises 'module modname not found' listing every path it tried.

Dots map to directory separators, so require('app.db.pool') looks for app/db/pool.lua, and a C module found on package.cpath must export the symbol luaopen_app_db_pool. Caching is keyed by the exact string you passed, so require('app.db') and a dofile('app/db.lua') produce two independent copies with two independent states, which is the usual explanation for 'my connection pool has two instances'. Setting package.loaded['modname'] = nil forces a reload, but anything that already captured the old table keeps using it. In OpenResty modules are cached per worker for the process lifetime unless lua_code_cache off is set, which reloads every file on every request and is strictly a development switch.

-- file: math_utils.lua
local M = {}

function M.square(x) return x * x end
function M.cube(x) return x * x * x end

return M

-- file: main.lua
local mu = require("math_utils")
print(mu.square(5))  -- 25
💡 Pro Tip: If you `require` the same module 100 times, the file is only executed once. Side effects at module top level run only on first require.
Q13

How do you convert between numbers and strings in Lua?

BasicFundamentals

Answer

tonumber(v) parses a string into a number and returns nil on failure instead of raising, so validation reads `local n = tonumber(input) or error('not a number')`. It tolerates leading and trailing whitespace, decimal and exponent forms, and hex literals such as '0x1F', and it takes an optional base, so tonumber('1011', 2) is 11 and tonumber('ff', 16) is 255. tostring(v) goes the other way and respects a __tostring metamethod, which is why a plain table prints as 'table: 0x55a1c8'. Lua also coerces automatically in arithmetic, so '10' + 5 is 15, but never in comparison, where '10' < 5 raises 'attempt to compare string with number'; leaning on that coercion is a smell because it hides bad input until much later.

From 5.3 the integer/float split shows up everywhere in this area: print(3.0) prints '3.0' while LuaJIT and 5.1 print '3', math.type(x) reports 'integer' or 'float', math.tointeger(3.0) returns 3 while math.tointeger(3.5) returns nil, and string.format('%d', 3.5) raises 'bad argument #2 to format (number has no integer representation)'. Floats render with %.14g, so tostring(0.1 + 0.2) shows 0.3 even though 0.1 + 0.2 == 0.3 is false. For money, store paise as integers rather than rupees as floats.

print(tonumber("42"))       -- 42
print(tonumber("  42  "))   -- 42   (whitespace tolerated)
print(tonumber("42abc"))    -- nil  (no error raised)
print(tonumber("0x1F"))     -- 31
print(tonumber("1011", 2))  -- 11   (base 2)

print(tostring(3.0))        -- 3.0 on 5.3+, 3 on 5.1/LuaJIT
print(math.type(3), math.type(3.0))  -- integer   float
print(math.tointeger(3.0))  -- 3
print(math.tointeger(3.5))  -- nil

print("10" + 5)             -- 15  (arithmetic coerces)
-- print("10" < 5)          -- error: attempt to compare string with number
print(string.format("%5.2f|%d|%s", 3.14159, 42, {}))

Key Points

  • tonumber returns nil on failure, it never raises
  • Arithmetic coerces strings, comparison does not
  • math.type / math.tointeger for the 5.3+ integer-float split
  • string.format('%d', 3.5) errors: no integer representation
Q14

How do you read and write files in Lua?

BasicStandard Library

Answer

io.open(path, mode) returns a file handle, or nil plus an error string and an errno, so the idiom is `local f, err = io.open(path, 'r')` or `local f = assert(io.open(path, 'r'))`; a missing file gives 'config.txt: No such file or directory'. Modes are 'r', 'w', 'a', 'r+', 'w+' and 'a+', with a 'b' suffix that only matters on Windows because Lua strings are 8-bit clean and can hold embedded zero bytes, so binary reads need no special handling. Read formats were respelled in 5.3: 'a' reads the whole file, 'l' a line without its newline, 'L' a line with it, 'n' parses a number, and an integer reads that many bytes; the older '*a' spelling still works. io.lines(path) opens the file and closes it when iteration reaches EOF, but f:lines() closes nothing, so a loop with an early break leaks the handle until the GC finalizer eventually runs.

Writes are buffered: f:write returns the file so calls chain, f:setvbuf('no'|'line'|'full') controls flushing, and data really is lost if the process exits without f:close() or f:flush(). f:seek('end') returns the byte size. Lua 5.4's `local f <close>` closes deterministically even on error. In OpenResty, never touch io in the request path, it blocks the whole nginx worker.

local f, err = io.open("config.txt", "r")
if not f then error("open failed: " .. err) end
local whole = f:read("a")   -- "*a" also accepted
f:close()

-- io.lines closes the handle at EOF; f:lines() does not
for line in io.lines("access.log") do
    local ip = line:match("^(%S+)")
end

-- Lua 5.4: to-be-closed handle survives an error path
local function append(path, text)
    local out <close> = assert(io.open(path, "a"))
    out:setvbuf("line")
    out:write(text, "\n")
end

-- Binary: read a fixed 8-byte header, then measure the file
local bin = assert(io.open("frame.bin", "rb"))
local header = bin:read(8)
print(bin:seek("end"))      -- size in bytes
bin:close()
💡 Pro Tip: f:write() does not flush. A crash after write but before close loses the buffer, which is why log writers call setvbuf('line') or flush explicitly.
Q15

What is a metatable and how does it work?

IntermediateMetatables

Answer

A metatable is a regular table attached to another table (via `setmetatable`) that defines what happens for certain operations on that table. Lua looks up special keys (called metamethods) in the metatable when an operator is used or a key is missing: `__index` (table lookup miss), `__newindex` (key assignment), `__add/__sub/__mul/__div` (arithmetic), `__eq/__lt/__le` (comparison), `__tostring`, `__len`, `__call` (call the table like a function), `__gc` (finalizer). Metatables are the basis for OOP, operator overloading, default values, read-only tables, and proxies.

Functions and userdata can also have metatables; strings have a shared metatable so `('hello'):upper()` works. Lookup semantics matter. A metamethod is fetched with a raw access on the metatable, so metamethods themselves are not inherited through a metatable on the metatable.

For binary operators Lua tries the left operand's metamethod first and then the right, which is how 2 * vec works when only vec defines __mul. __eq fires only when both operands are tables (or both full userdata) and are not already primitively equal, so comparing a table to a number never calls it. In 5.4, __lt and __le must both be defined because __le no longer falls back to `not (b < a)`. Other hooks worth naming: __concat, __unm, __idiv and the bitwise set (__band, __bor, __bxor, __shl, __shr) added in 5.3, __mode for weak tables, __name to make error messages read 'Vector expected, got table', __close for 5.4 to-be-closed variables, and __metatable to lock the metatable so getmetatable returns your chosen value and setmetatable raises 'cannot change a protected metatable'. Inside a metamethod, always use rawget, rawset, rawequal and rawlen to bypass dispatch, otherwise you recurse into yourself and get 'stack overflow'.

local vec = {x = 1, y = 2}
local mt = {
    __add = function(a, b) return {x = a.x + b.x, y = a.y + b.y} end,
    __tostring = function(v) return string.format("(%d,%d)", v.x, v.y) end,
}
setmetatable(vec, mt)

local sum = vec + {x = 10, y = 20}  -- triggers __add
print(tostring(vec))                -- (1,2) via __tostring
Q16

How do you implement OOP (classes and inheritance) in Lua?

IntermediateOOP

Answer

Lua has no class keyword, OOP is built from tables and metatables. The standard pattern: define a class as a table, set its __index metamethod to itself, and use a `new`/`create` function to set instances' metatable to the class. Method calls via `obj:method(x)` desugar to `obj.method(obj, x)`, the colon passes self implicitly.

Inheritance is achieved by setting the child class's metatable __index to the parent class, creating a lookup chain. This is the prototype model, no separate type hierarchy, just chained tables. For complex hierarchies (mixins, multiple inheritance), build a small framework or use middleclass, a popular library.

Follow-ups usually go two ways. On cost: every miss on the instance walks one metatable hop to the class table, so a three-level hierarchy pays three hash lookups per method call, and hot code either flattens the chain or copies the parent's methods into the child table once at class-creation time. On correctness: calling obj.method(x) instead of obj:method(x) passes x as self and typically explodes later with 'attempt to index a nil value (local self)' rather than at the call site, which is why Luau's --!strict mode flags it.

Private state has three usual implementations, each with a real tradeoff: closures over locals give true privacy but allocate one closure per method per instance, a naming convention like self._count costs nothing and enforces nothing, and a weak-keyed side table keyed by the instance keeps data private without touching the object. There is no built-in instanceof, so store a class reference on each instance and walk the chain yourself. On Roblox, note that Instance objects such as Part are userdata with engine-owned metatables, so you cannot attach your own class metatable to them; the standard workaround is composition with an attributes table.

local Animal = {}
Animal.__index = Animal

function Animal.new(name)
    local self = setmetatable({}, Animal)
    self.name = name
    return self
end

function Animal:speak()
    print(self.name .. " makes a sound")
end

-- Inheritance
local Dog = setmetatable({}, {__index = Animal})
Dog.__index = Dog

function Dog.new(name)
    local self = Animal.new(name)
    return setmetatable(self, Dog)
end

function Dog:speak() print(self.name .. " barks") end

local d = Dog.new("Bruno")
d:speak()  -- Bruno barks

Key Points

  • Class = table with __index = self
  • obj:method(x) is sugar for obj.method(obj, x)
  • Inheritance via metatable chain
  • Use middleclass or 30log for complex setups
Q17

Explain __index and __newindex metamethods.

IntermediateMetatables

Answer

`__index` is called when a key is read and NOT present in the table. It can be a function `(t, k) -> value` or another table (in which case Lua does a recursive lookup in that table). This is how method dispatch and inheritance work. `__newindex` is called when a key is written and NOT already present in the table, once the key exists, future writes go directly to the table and bypass __newindex.

To intercept ALL writes, store the actual data in a private 'shadow' table and proxy via the original table; this is the pattern for read-only tables and property setters. Two mechanics get tested. First, __index chains: if the __index target is itself a table with a metatable, the lookup recurses, and a cycle terminates with ''__index' chain too long; possibly looping'.

Second, the rawset escape: inside a __newindex function you must call rawset(t, k, v) to actually store the value, otherwise you re-enter your own __newindex and blow the C stack with 'stack overflow'. That is also why the empty-proxy pattern is the only reliable way to intercept every write, since a real table with existing keys bypasses __newindex on updates, so validating setters, change tracking and dirty flags all keep their data in a shadow table. The costs are real: a proxy turns every field read into a Lua function call, which is fine for a config object read at startup and unacceptable for per-frame game state. Also remember that __index on the shared string metatable is what makes s:upper() work, and getmetatable('').__index.trim = function(s) ... end adds a method to every string in the process, which is convenient and highly contentious in shared codebases because two libraries doing it will collide.

-- Default value via __index function
local defaults = setmetatable({}, {__index = function(t, k) return 0 end})
print(defaults.missing)  -- 0

-- Read-only table via __newindex
local function readonly(t)
    return setmetatable({}, {
        __index = t,
        __newindex = function() error("read-only") end,
    })
end
local config = readonly({port = 5432})
print(config.port)  -- 5432
config.port = 1234  -- error: read-only
Q18

What is a closure in Lua and how is it different from a regular function?

IntermediateFunctions

Answer

A closure is a function that captures and references variables from its enclosing scope (upvalues). In Lua, every function is technically a closure, even a function with no captured variables. Closures are the standard way to make stateful functions, build counters, memoize, and implement iterators.

Each call to a function definition creates a NEW closure with its own copy of upvalues. Upvalues persist as long as the closure does, and multiple closures sharing the same upvalue see each other's modifications. The implementation detail worth knowing: an upvalue lives on the stack while the enclosing function is still running and is 'closed' (copied to the heap) when that scope exits, which is why the VM has a dedicated CLOSE opcode and why debug.upvalueid and debug.upvaluejoin exist for tooling and hot reload.

Two closures created in the same scope share one upvalue object, so a getter and setter pair genuinely observe each other's writes. A loop variable is a fresh local per iteration in Lua, so building a closure inside `for i = 1, 3 do` gives three closures with three independent upvalues, the opposite of the classic JavaScript `var` capture bug. Costs are real: every closure creation allocates, so returning a new closure per call inside a hot loop is a steady GC source, and LuaJIT cannot always specialise across closure boundaries. In OpenResty this becomes a correctness issue: closures created in init_by_lua or at module top level are shared by every request that worker handles, so a mutable upvalue there is per-worker global state and a common cause of one user seeing another user's data.

local function make_counter()
    local count = 0  -- upvalue
    return function()
        count = count + 1
        return count
    end
end

local c1 = make_counter()
local c2 = make_counter()
print(c1())  -- 1
print(c1())  -- 2
print(c2())  -- 1   (separate upvalue)
Q19

What are coroutines and how are they different from OS threads?

IntermediateCoroutines

Answer

Coroutines are cooperative, single-threaded units of execution, they yield control explicitly to other coroutines rather than being preempted by an OS scheduler. Lua coroutines run on a single OS thread, so they cannot use multiple CPU cores in parallel, but they're cheap (~1 KB each), have no race conditions, and switch in nanoseconds. They're ideal for async I/O patterns, game AI state machines, generators/iterators, and producer-consumer pipelines.

OpenResty uses coroutines to handle 100K+ concurrent connections per nginx worker, each request runs in its own coroutine that yields on I/O. API details to have ready: coroutine.resume returns false plus the error message instead of propagating, so an unchecked bug inside a coroutine looks like a silent no-op, while coroutine.wrap returns a plain function that re-raises errors and is usually what you want for iterators. coroutine.status reports 'suspended', 'running', 'normal' or 'dead', and resuming a finished one gives 'cannot resume dead coroutine'. Values flow both ways: the extra arguments to resume become the return values of the matching yield, which is exactly how an event loop feeds a socket read back into a suspended handler.

The classic failure is 'attempt to yield across a C-call boundary', raised when the current frame was entered from C, for example yielding inside a table.sort comparator, inside a metamethod on Lua 5.1 or LuaJIT, or inside a for-iterator; Lua 5.3 made pcall, metamethods and iterators yieldable, and LuaJIT covers some of those cases but not all. Lua 5.4 adds coroutine.close, which unwinds pending to-be-closed variables in a suspended coroutine so an abandoned request does not leak its file handles or locks.

local co = coroutine.create(function(start)
    for i = start, start + 3 do
        coroutine.yield(i)
    end
end)

print(coroutine.resume(co, 10))  -- true, 10
print(coroutine.resume(co))      -- true, 11
print(coroutine.resume(co))      -- true, 12
print(coroutine.resume(co))      -- true, 13
print(coroutine.resume(co))      -- true        (finished)
print(coroutine.status(co))      -- dead

Key Points

  • Cooperative, not preemptive
  • Single-threaded, no parallelism
  • Cheap (~1 KB each)
  • Foundation of OpenResty's concurrency model
Q20

How does the string library and pattern matching work in Lua?

IntermediateStrings

Answer

Lua has its own pattern syntax, it's NOT regex. Patterns are smaller (~6 KB of C code in the interpreter) but less powerful: no alternation `|`, no `?` quantifier on groups, no backreferences in replacement (though captures work). Character classes use `%` not `\`: `%a` letter, `%d` digit, `%s` whitespace, `%w` alphanumeric.

Quantifiers are `*` `+` `-` (lazy) and `?`. Anchors `^` `$` work as expected. Captures use `()`.

For real regex you need an external library (lrexlib, LuaJIT's `re` module). Pattern matching is fast and used heavily for parsing config files, log lines, and URLs. More specifics that show real use: %b() matches a balanced pair of delimiters, which handles nested parentheses that a regex cannot, and %f[%w] is a frontier pattern that emulates a word boundary.

Inside a pattern, `-` is the lazy quantifier rather than a range, so literal magic characters must be escaped with %, and the safe way to escape user input before matching is text:gsub('%p', '%%%0'). string.gsub returns two values, the new string and the replacement count, so `local s = str:gsub(...)` drops the count correctly but passing str:gsub(...) directly as a function argument silently forwards both values, a bug that shows up as an extra number in log output. The replacement can be a string with %1 capture references, a table keyed by the first capture, or a function whose return value is substituted, and returning nil or false from that function keeps the original text. Patterns are unanchored by default, so validation needs ^ and $. On performance, gmatch allocates a closure per call and gsub allocates a new string, so in OpenResty hot paths people switch to ngx.re.match and ngx.re.gsub, which use PCRE with the 'jo' flags for JIT plus compiled-pattern caching.

-- Extract IPv4 from a log line
local ip = string.match("client 10.0.0.1 connected", "(%d+%.%d+%.%d+%.%d+)")
print(ip)  -- 10.0.0.1

-- Replace all whitespace runs with a single space
local cleaned = string.gsub("  hello   world  ", "%s+", " ")
print(cleaned)  -- ' hello world '

-- Iterate words
for word in string.gmatch("the quick brown fox", "%a+") do print(word) end
💡 Pro Tip: If you need backreferences in replacement or alternation, install lrexlib-pcre, Lua's native patterns won't help.
Q21

What is LuaJIT and how does it differ from PUC Lua?

IntermediateLuaJIT

Answer

LuaJIT is a tracing just-in-time compiler for Lua 5.1 by Mike Pall. It runs the same Lua source code 10-100× faster than the reference interpreter (PUC-Rio Lua) on numeric/loop-heavy workloads, at times within 2× of hand-tuned C. The tracing JIT compiles hot loops to machine code and aggressively specializes types.

LuaJIT also includes the FFI library for calling C functions and using C structs without writing binding code, a `bit` library for bitwise ops, and a much faster string interner. Used by OpenResty, World of Warcraft, Garry's Mod, and most performance-sensitive game engines. Important caveat: LuaJIT tracks Lua 5.1 + a few 5.2/5.3 features, it does NOT track current PUC Lua (5.4).

For Lua 5.4 features (integer subtype, `<close>`, `<const>`), you need PUC Lua. Practical notes for 2026: Mike Pall stepped back from day-to-day maintenance years ago, and what ships in distributions and OpenResty today is the LuaJIT 2.1 rolling branch plus the openresty/luajit2 fork, so there is no 2.2 release to wait for and 'which commit' matters more than 'which version'. The 5.2 and 5.3 features LuaJIT does carry (goto, table.pack, table.unpack, some tostring behaviour) are opt-in at build time through -DLUAJIT_ENABLE_LUA52COMPAT, and enabling it changes semantics for existing code, so check how your host was compiled before assuming. Other differences that matter: LuaJIT numbers are plain doubles with no integer subtype, so you use the bit library or int64 cdata for bitwise and 64-bit work; string.buffer, table.new and table.clear are LuaJIT-only extras with no PUC equivalent; and jit.off(func, true) lets you exclude a misbehaving function from compilation without disabling the JIT globally.

local jit = require("jit")
print(jit.version)          -- LuaJIT 2.1.xxxxxxx
print(jit.status())         -- true  followed by enabled optimisations
print(jit.arch, jit.os)     -- x64   Linux

-- LuaJIT-only extras (absent from PUC Lua)
local bit = require("bit")
print(bit.band(0xF0, 0x3C), bit.bxor(5, 3))  -- 48  6

local ok, tnew = pcall(require, "table.new")
if ok then
    local t = tnew(1000, 0)   -- preallocate 1000 array slots
end

-- Exclude one function from JIT compilation
local function flaky() end
jit.off(flaky, true)

Key Points

  • Tracing JIT, often 10-100× faster than PUC Lua
  • Tracks Lua 5.1 semantics, not 5.4
  • FFI library = no binding boilerplate
  • Powers OpenResty, Roblox (before Luau), Garry's Mod
Q22

What is the LuaJIT FFI and when would you use it?

IntermediateLuaJIT

Answer

The FFI (Foreign Function Interface) lets Lua code declare C types and call C functions directly using normal C declaration syntax, no binding glue code needed. Faster than the standard Lua-C API because the JIT can inline FFI calls into compiled traces. Common uses: zero-copy parsing of binary protocols, fast struct access (cdata vs Lua tables), calling OS APIs (sockets, file I/O), embedding existing C libraries (sqlite3, OpenSSL) without writing a binding.

Caveats: FFI cdata are 0-indexed (raw C arrays), there's no GC for malloc'd memory (use `ffi.gc` to attach a finalizer), and FFI code crashes hard on errors, no safety net. Details that show hands-on experience: ffi.cdef only parses declarations, it loads nothing, so a library that is not already linked into the host still needs ffi.load('sqlite3'), and a missing one raises 'cannot load module sqlite3'. Struct layout follows the platform ABI, so verify with ffi.sizeof and ffi.offsetof rather than assuming, and use ffi.cast plus ffi.string for zero-copy views over an existing buffer. cdata numbers are not Lua numbers: an int64_t prints as '42LL', is not equal to the Lua number 42 without tonumber, and used as a table key it hashes by identity so every lookup misses and your cache silently grows forever.

Memory from ffi.new is GC-tracked; memory from a C malloc is not, hence ffi.gc(ptr, ffi.C.free). Callbacks from C back into Lua work but are slow, limited to a few hundred live at once, and must be released with cb:free() or you exhaust the callback slots. The operational risk to state plainly: an out-of-bounds cdata write segfaults the entire process, which in OpenResty kills every in-flight request on that worker.

local ffi = require("ffi")
ffi.cdef[[
    int printf(const char *fmt, ...);
    typedef struct { double x, y; } point_t;
]]

ffi.C.printf("hello %s\n", "world")

-- Allocate a C struct (much faster than a Lua table for hot loops)
local p = ffi.new("point_t", 1.0, 2.0)
print(p.x, p.y)  -- 1 2
Q23

What is Luau and how does it differ from standard Lua?

IntermediateLuau

Answer

Luau is Roblox's fork of Lua 5.1, open-sourced in 2021 and now used by 70M+ daily users on the Roblox platform. The headline feature is gradual typing, you can annotate variables and functions with types, get IDE autocomplete and type errors at compile time, while remaining backward-compatible with untyped Lua. Other differences from standard Lua: improved performance (custom VM and bytecode), additional standard library (`buffer`, `vector`), no `loadstring` / no `os.execute` for sandboxing, generalized iteration (you can write `for k, v in t do` without pairs), and string interpolation with backticks.

Luau is the largest active Lua fork by user count and many of its improvements (type system, buffer library) are being studied for upstream Lua. Type-system specifics worth naming: the checking mode is set by a comment on the first line, --!nocheck, --!nonstrict (the default) or --!strict, and inference is local and bidirectional rather than whole-program, so annotating function boundaries buys most of the value. The syntax supports optional types with ?, unions and intersections, generics, aliases declared with the type keyword, and typeof(expr) to reuse an existing shape.

Runtime features that matter for gameplay code: buffer for packed binary data (buffer.create, buffer.writeu8, buffer.readf32), vector as a native value type rather than a table, table.create and table.clone for preallocation and shallow copies, and a --!native comment that opts a script into native code generation on supported platforms. Removed for sandboxing: loadstring is disabled, getfenv and setfenv deoptimise any script that uses them, and there is no io, no os.execute and no package library at all, so require works only on ModuleScript instances. Luau is Apache-2.0 licensed and usable outside Roblox through the standalone luau CLI, which is why other engines and tools have started embedding it.

-- Luau (Roblox), gradual typing
local function greet(name: string, times: number?): string
    local n = times or 1
    return string.rep("hi " .. name, n)
end

-- Type errors caught at edit-time
greet(42)  -- error: number is not a string
Q24

How does OpenResty use Lua inside nginx?

IntermediateOpenResty

Answer

OpenResty bundles nginx with LuaJIT and the ngx_lua module, letting you script every phase of request handling in Lua: access control, rewriting, content generation, response filtering, and timers. Each request runs inside a LuaJIT coroutine that yields on I/O (socket reads, Redis calls, upstream connections), so a single nginx worker can handle 10K+ concurrent requests on commodity hardware. Typical uses in India: API gateways at Indian CDNs (CtrlS, Tata Communications), rate limiting and auth at the edge for FinTech companies, WAF rule engines, A/B testing routers, dynamic upstream selection.

The two main Lua handlers are `access_by_lua_block` (auth/rate-limit) and `content_by_lua_block` (generate response). Avoid blocking syscalls, they freeze the entire worker; use `ngx.socket.tcp` / `ngx.timer.at` / cosockets instead. Phase rules are where candidates slip. init_by_lua runs once in the master before workers fork, so it cannot use cosockets or ngx.sleep and anything it allocates is shared copy-on-write by every worker. init_worker_by_lua runs per worker and is the right place to start ngx.timer.every background jobs. set_by_lua must be synchronous and nonblocking. log_by_lua cannot yield or emit output.

Calling a forbidden API gives 'API disabled in the context of init_by_lua*', which names the phase for you. Shared state across workers lives in lua_shared_dict, a fixed-size shared memory zone with atomic get, set, incr and LRU eviction; when it fills, set returns nil plus 'no memory' and starts forcibly evicting, so always check the second return value rather than assuming the write landed. Per-worker caching uses lua-resty-lrucache, which is faster because it stores real Lua values with no serialisation, and lua-resty-lock prevents a dogpile of upstream requests when a hot key expires. Anything blocking (os.execute, io.read, luasocket, a synchronous C library) freezes every request in that worker, and the symptom is a worker-wide latency cliff rather than one slow request.

# nginx.conf
location /api {
    access_by_lua_block {
        local token = ngx.var.http_authorization
        if not token then
            ngx.status = 401
            ngx.exit(401)
        end
    }

    content_by_lua_block {
        local redis = require("resty.redis"):new()
        redis:connect("127.0.0.1", 6379)
        local count = redis:incr("hits:" .. ngx.var.remote_addr)
        ngx.say("hit count: ", count)
    }
}

Key Points

  • Each request is a coroutine inside nginx worker
  • Never block, use ngx cosockets
  • Common at Indian CDNs and FinTech API gateways
Q25

How do you configure Neovim with Lua?

IntermediateNeovim

Answer

Neovim (since 0.5, 2021) supports Lua as a first-class configuration language alongside Vimscript. The standard location is `~/.config/nvim/init.lua` (replaces `init.vim`). The `vim` global exposes: `vim.opt` for options, `vim.keymap.set` for keymaps, `vim.api.nvim_*` for the C API, `vim.lsp` for LSP, `vim.treesitter` for syntax highlighting, and `vim.uv` (libuv) for async I/O.

Plugin managers like `lazy.nvim` and `packer.nvim` are themselves written in Lua and let you declare plugins as Lua tables. Most Neovim plugins built since 2022 are Lua-first, Vimscript plugins still work but are no longer the norm. Neovim configs benefit hugely from LuaJIT, startup time is ~30 ms with hundreds of plugins thanks to lazy loading and JIT-compiled config.

Details that show real config work: runtime Lua files load from a lua/ directory under any runtimepath entry, so require('myconfig.keymaps') resolves to lua/myconfig/keymaps.lua, and Neovim patches package.path itself rather than expecting you to. vim.opt returns an Option object with :append, :remove, :prepend and :get, while vim.o, vim.bo, vim.wo and vim.g map to global, buffer-local, window-local and Vimscript variables. Async code must hop back to the main loop with vim.schedule, because most API calls are illegal inside a libuv callback and fail with 'E5560: nvim_xxx must not be called in a fast event context'. vim.system (0.10) replaced jobstart for one-shot subprocesses, and vim.uv is the current name for what used to be vim.loop. Neovim 0.11 shipped vim.lsp.config plus vim.lsp.enable and built-in completion through vim.lsp.completion, which is steadily replacing nvim-lspconfig boilerplate, so an interviewer may ask whether your config still uses the old require('lspconfig').xxx.setup pattern. Debugging tools: :checkhealth, vim.print for inspecting tables, :Lazy profile, and nvim --startuptime to find the plugin that costs you 200 ms.

-- ~/.config/nvim/init.lua
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.tabstop = 4
vim.opt.shiftwidth = 4

vim.keymap.set("n", "<leader>w", ":w<CR>", {desc = "Save file"})

require("lazy").setup({
    {"nvim-treesitter/nvim-treesitter"},
    {"neovim/nvim-lspconfig"},
})
Q26

How do you handle errors in Lua (pcall, xpcall, assert, error)?

IntermediateError Handling

Answer

Lua errors propagate up the call stack until something catches them with `pcall` (protected call) or `xpcall` (protected call with an error handler). `error(msg)` raises an error. `assert(cond, msg)` raises if cond is false/nil. `pcall(f, args...)` returns `true, results...` on success or `false, errmsg` on error, never throws. `xpcall(f, handler, args...)` lets you provide a function that runs in the failing coroutine's stack frame, so you can call `debug.traceback()` to capture the full traceback. There's no try/catch syntax, pcall IS the catch. The common pattern at module boundaries: pcall the work, log the error, return a clean error to the caller.

Mechanics worth stating: error(msg, level) controls the position prefix, level 1 (the default) points at the line that called error, level 2 blames the caller and is what library argument-validation should use, and level 0 adds no prefix at all. error can raise any value, so raising a table is how you carry a structured error code, but a top-level handler that stringifies it prints 'error object is a table value' unless the table has __tostring. assert returns all of its arguments on success, which is why `local f = assert(io.open(path))` is idiomatic, and note that assert always evaluates its message argument, so assert(x, 'failed: ' .. expensive()) pays that cost on every call, while an if-then-error does not. Wrap narrowly: a pcall around a whole request handler swallows genuine programming bugs along with expected failures, and by the time pcall returns, the stack is gone, so use xpcall with debug.traceback when you want to log where it broke. One LuaJIT-specific caveat: pcall inside a hot loop can abort the trace, so hoist it outside the loop and keep the loop body itself unprotected.

local function risky(x)
    if x < 0 then error("negative not allowed") end
    return math.sqrt(x)
end

local ok, result = pcall(risky, -1)
if not ok then
    print("caught:", result)  -- caught: negative not allowed
end

-- With traceback
local ok, err = xpcall(function() risky(-1) end, debug.traceback)
if not ok then print(err) end  -- full stack trace
Q27

What changed in Lua 5.4 that's worth knowing in 2026?

IntermediateLua 5.4

Answer

Lua 5.4 (2020) introduced several features that interviewers like to probe. (1) Integer subtype, `number` now splits into integer and float; `5 // 2` returns integer 2, `5 / 2` returns float 2.5; convert with `math.tointeger`. Integer overflow wraps silently. (2) `<const>` attribute, `local PI <const> = 3.14` makes a local read-only; assignment is a compile error. (3) `<close>` attribute, `local f <close> = io.open(...)` calls `f:__close()` when the variable goes out of scope; this is Lua's RAII / try-with-resources mechanism. (4) Generational GC mode (covered in the GC question). (5) Warnings system via `warn(...)` and `warn('@off')`. Important caveat: LuaJIT tracks Lua 5.1, so none of these are available there.

Roblox Luau also doesn't follow 5.4 exactly, it has its own attribute system. If your interview is for an OpenResty or Roblox role, 5.4 features are NOT what runs in production. A few more details that come up: 5.4 dropped the compatibility aliases many older scripts leaned on unless the build enables LUA_COMPAT_5_3, so unpack, loadstring, table.getn and math.pow are gone from a stock build and calling them gives 'attempt to call a nil value (global 'unpack')'.

Integer division and modulo by zero now raise 'attempt to perform n//0' for integers while the float forms still produce inf or nan, which is a genuine behaviour change from 5.3 that surfaces in metrics code dividing by a zero count. Coroutines gained coroutine.close so a suspended coroutine's to-be-closed variables can be unwound. On the C side, lua_newuserdata became lua_newuserdatauv with multiple user values and several stack APIs changed, so every C extension needs a recompile and some need a patch, which is the practical reason many embedders are still on 5.3 or 5.1. Treat the 5.4.x point releases as bugfix-only, and expect 'which Lua does this run on' to be the real interview question behind every 5.4 feature you mention.

-- 5.4 features
local MAX <const> = 100
-- MAX = 200  -- compile error: attempt to assign to const variable

local function process()
    local f <close> = io.open("data.txt", "r")
    return f:read("*a")
    -- f:close() called automatically when scope exits, even on error
end

print(math.type(3))     -- integer
print(math.type(3.0))   -- float
print(3 // 2)           -- 1 (integer division)
print(3 / 2)            -- 1.5 (float division)

Key Points

  • Integer/float subtypes; // = integer division, / = float division
  • `<const>` for read-only locals, `<close>` for RAII cleanup
  • Generational GC mode, `warn()` system
  • Not available in LuaJIT (5.1) or fully in Roblox Luau
Q28

How do varargs work in Lua, and what problem do select() and table.pack solve?

IntermediateFunctions

Answer

The `...` expression exists only inside a function declared with a trailing `...`, and it carries every argument past the named parameters. The obvious approach, `local args = {...}`, is wrong the moment a nil can appear: {1, nil, 3} is not a proper sequence, so #args may be 1 or 3, and a logging wrapper built that way silently drops arguments in production while passing every test that uses non-nil values. The correct tools are select('#', ...), which counts arguments including nils, and select(n, ...), which returns everything from position n onward, with select(-1, ...) giving the last argument.

Lua 5.2 added table.pack(...), which stores the true count in the field n, and table.unpack(t, 1, t.n), which expands it back holes and all; on Lua 5.1 and LuaJIT the function is the global unpack instead of table.unpack. Forwarding rules matter too: `return f(...)` preserves every result and is a proper tail call, `return (f(...))` truncates to one, and varargs expand fully only in the final position, so f(..., 1) passes just the first one. On performance, {...} allocates a table on every call, so hot loggers and event dispatchers loop with select instead, which LuaJIT can often keep allocation-free.

local function log(fmt, ...)
    local n = select("#", ...)      -- counts nils correctly
    for i = 1, n do
        print(i, (select(i, ...)))
    end
end
log("msg", 1, nil, 3)               -- n = 3, all three printed

-- {...} is lossy when an argument is nil
local function bad(...) return #{...} end
print(bad(1, nil, 3))               -- 1 or 3, undefined

-- table.pack / table.unpack round-trip (5.1 and LuaJIT: global unpack)
local function retry(fn, ...)
    local args = table.pack(...)
    for _ = 1, 3 do
        local ok, res = pcall(fn, table.unpack(args, 1, args.n))
        if ok then return res end
    end
    error("all retries failed", 2)
end

Key Points

  • select('#', ...) is the only reliable argument count
  • #{...} is undefined when any argument is nil
  • table.pack stores the real count in field n
  • return f(...) forwards all results as a tail call
Q29

How do you write a custom iterator for the generic for loop?

IntermediateIterators

Answer

The generic for is a protocol rather than magic. `for a, b in explist do` evaluates explist to at most three values: an iterator function, a state, and an initial control value (Lua 5.4 accepts a fourth, a to-be-closed value). Each pass calls iterator(state, control); the loop ends when the first return value is nil, and otherwise that first return becomes the next control value. Three implementation styles follow.

A stateless iterator keeps everything in the state and control arguments, allocates nothing per loop, and is how ipairs is built, which makes it the right choice inside a game frame or a request handler. A closure-based iterator holds its cursor in upvalues, which is easier to write but allocates one closure per loop and is what most library code uses. A coroutine.wrap iterator lets you yield from arbitrarily deep recursion, which is the clean way to walk a tree or a directory, at the cost of roughly a kilobyte per coroutine and the fact that it cannot be restarted once exhausted.

The 5.4 fourth value earns its keep for resources: an iterator over an open file can hand the handle back as a to-be-closed value so the file closes even when the loop exits via break or an error. Common bugs: forgetting to return nil at the end (an infinite loop) and inserting keys mid-iteration, which is undefined.

-- 1. Stateless: zero allocation per loop (the ipairs style)
local function step(limit, i)
    i = i + 2
    if i <= limit then return i end
end
for i in step, 10, 0 do io.write(i, " ") end   -- 2 4 6 8 10

-- 2. Closure-based: cursor lives in an upvalue
local function sorted_pairs(t)
    local keys = {}
    for k in pairs(t) do keys[#keys + 1] = k end
    table.sort(keys)
    local i = 0
    return function()
        i = i + 1
        local k = keys[i]
        if k ~= nil then return k, t[k] end
    end
end

-- 3. Coroutine: yield out of deep recursion
local function inorder(node)
    return coroutine.wrap(function()
        local function visit(n)
            if not n then return end
            visit(n.left)
            coroutine.yield(n.value)
            visit(n.right)
        end
        visit(node)
    end)
end
💡 Pro Tip: Prefer a stateless iterator in per-frame or per-request code. A closure iterator allocates on every loop, and that allocation is what shows up as GC churn in a profiler.
Q30

How do you test Lua code, and what does a Lua CI pipeline run?

IntermediateTesting

Answer

busted is the default framework: describe/it blocks, before_each and after_each, and assertions from luassert such as assert.are.same for deep equality, assert.is_nil, assert.has_error and assert.spy(s).was_called_with. It ships spies, stubs and mocks, so stub(redis, 'get') covers most isolation needs. luaunit is the xUnit-style alternative and is easier on build boxes where you cannot install LuaRocks. Coverage comes from luacov (driven by a .luacov file, reported with luacov-console), and linting from luacheck, whose .luacheckrc declares your legitimate globals with std = 'luajit+ngx_lua' for OpenResty or 'love' for Love2D; that lint step is what actually catches accidental globals and unused arguments before review does.

The idiomatic way to isolate a dependency is to assign package.loaded['resty.redis'] = fake before requiring the module under test, and to clear package.loaded for the module itself in before_each so each test gets fresh state. In CI, run a matrix, because 5.1, 5.4 and LuaJIT differ in ways your tests will find: hererocks or the gh-actions-lua action installs each interpreter per job, and busted --output=TAP or the junit output feeds the reporter. OpenResty needs a real nginx, so the standard harness is Test::Nginx::Socket driven by prove -r t/. Roblox teams use TestEZ or Jest-Lua, run in Studio or headlessly through run-in-roblox.

-- spec/cache_spec.lua   run with:  busted --coverage --output=TAP
describe("cache", function()
    local cache

    before_each(function()
        package.loaded["app.cache"] = nil        -- fresh module per test
        package.loaded["resty.redis"] = {        -- inject a fake dependency
            new = function()
                return {get = function() return nil end}
            end,
        }
        cache = require("app.cache")
    end)

    it("returns nil on a miss", function()
        assert.is_nil(cache.get("absent"))
    end)

    it("deep-compares tables", function()
        assert.are.same({a = 1, b = {2}}, cache.shape())
    end)

    it("rejects a nil key", function()
        assert.has_error(function() cache.get(nil) end, "key required")
    end)

    it("notifies eviction listeners", function()
        local s = spy.new(function() end)
        cache.on_evict(s)
        cache.evict("k")
        assert.spy(s).was_called_with("k")
    end)
end)
Q31

How do you manage dependencies in a Lua project with LuaRocks?

IntermediateTooling

Answer

LuaRocks is the package manager, and a rock is described by a rockspec: a Lua file declaring package, version with a rockspec revision (so '1.2-1', where the trailing number is the packaging revision), source (a url plus tag), dependencies as constraint strings like 'lua >= 5.1, < 5.5', and a build table of type 'builtin', 'make' or 'cmake'. luarocks install lua-cjson fetches from luarocks.org; --local installs into ~/.luarocks, after which you must run eval $(luarocks path --bin) or the interpreter simply will not find the module, because LuaRocks works by extending package.path and package.cpath rather than by owning a single site directory. Two flags come up constantly: --lua-version=5.1 to target LuaJIT, and --tree ./lua_modules to vendor everything into the project, which is how you get a build that does not change under you. luarocks make builds the rockspec in the current directory during development. C rocks compile against Lua headers, so 'lua.h: No such file or directory' means the -dev package is missing or LUA_INCDIR points at the wrong interpreter, a very common CI failure on boxes that carry both 5.1 and 5.4.

Pinning is weak, since >= constraints resolve at install time, so teams that need reproducibility vendor the tree or pin exact versions. In OpenResty the parallel tool is opm, and lua_package_path in nginx.conf decides what require can see.

# Project-local tree, targeted at LuaJIT, exact versions pinned
luarocks --lua-version=5.1 --tree ./lua_modules install lua-cjson 2.1.0.10-1
eval "$(luarocks --lua-version=5.1 --tree ./lua_modules path --bin)"
luajit -e 'print(require("cjson").encode({ok = true}))'

-- myapp-1.2-1.rockspec
package = "myapp"
version = "1.2-1"
source = {
   url = "git+https://github.com/acme/myapp",
   tag = "v1.2",
}
dependencies = {
   "lua >= 5.1, < 5.5",
   "lpeg >= 1.0",
   "lua-cjson >= 2.1.0",
}
build = {
   type = "builtin",
   modules = {["myapp.init"] = "src/init.lua"},
}
Q32

How does Lua's garbage collector work and how do you tune it?

AdvancedGC

Answer

Lua uses an incremental mark-and-sweep collector by default (PUC Lua 5.1-5.3), work happens in small steps interleaved with normal execution to keep pause times short. Lua 5.4 added a generational mode (`collectgarbage('generational')`) that's often better for short-lived allocations. LuaJIT uses a different collector (currently incremental mark-and-sweep, with a new GC being developed).

Tune via `collectgarbage`: 'setpause' (next collection threshold, default 200 = wait until memory doubles), 'setstepmul' (work rate per allocation, default 200). For latency-sensitive code (games, real-time loops): set step multiplier high (300-500) so GC keeps up incrementally and never has to do a big sweep mid-frame; pre-allocate tables to avoid churn; reuse instead of recreating. Weak tables (`__mode = 'k'` or 'v') let GC reclaim entries when keys or values become otherwise unreachable, useful for caches.

Numbers and knobs to quote: collectgarbage('count') returns the live heap in kilobytes and is the metric you graph, and 'setpause' is a percentage, so 200 means the next cycle starts once the heap has doubled. Lua 5.4 replaced the individual setters with collectgarbage('incremental', pause, stepmul, stepsize) and collectgarbage('generational', minormul, majormul), so an answer that only cites setpause dates you. Finalizers have a rule that catches everyone: __gc only runs if the metatable already contained a __gc field at the moment setmetatable was called, so adding it afterwards does nothing at all, and an object can resurrect itself inside its finalizer and survive an extra cycle.

Weak tables have their own subtlety: a strong value that references its own weak key keeps the entry alive forever, which is why ephemeron semantics for 'k' mode were fixed in 5.2, and why an object-to-metadata cache must not store values that point back at the key. On the diagnostic side, a leak in Lua is almost always a live reference (a global registry, an event listener list, a closure captured in a timer), not a collector bug, so dump reachable roots before you start tuning.

-- Manual control in a frame loop
collectgarbage("stop")               -- pause GC during frame
-- ...game logic...
collectgarbage("step", 100)          -- run a fixed chunk of GC work

-- Weak-value cache: entries vanish when nothing else references them
local cache = setmetatable({}, {__mode = "v"})
cache["key"] = some_big_object
-- when some_big_object is freed elsewhere, the cache entry disappears

Key Points

  • Incremental mark-and-sweep by default; generational in 5.4
  • Tune with `collectgarbage('setpause', n)` and `('setstepmul', n)`
  • Use weak tables (__mode='k' or 'v') for caches
  • Pre-allocate hot tables in real-time loops
Q33

How does the LuaJIT tracing JIT actually work, and how do you keep traces hot?

AdvancedLuaJIT

Answer

LuaJIT's compiler doesn't compile functions, it traces hot LOOPS. When a loop runs past a threshold (default 56 iterations), the recorder starts capturing the executed bytecode path along with type information. The recorded trace is compiled to machine code with type-specialized assumptions baked in.

If a future execution violates an assumption (different type, NYI builtin), the trace 'falls off' (a 'side trace') back into the interpreter. Things that prevent good tracing ('NYI' = not yet implemented): pcall/xpcall in hot loops (use `ffi` errors or pre-validate), coroutine.yield across traces, certain string functions, varargs in some positions, table.* metamethods on non-trivial keys. Tools: run with `-jv=verbose.log` to see what's tracing and what aborts; `-jdump` prints the IR and machine code; `jit.opt.start('hotloop=N')` tunes the threshold.

Best practice for hot code: keep loop bodies type-stable, avoid NYI calls inside loops, prefer FFI cdata to Lua tables for numeric work, and benchmark with `jit.flush()` between runs to compare cold-start fairly. Reading the -jv output is the skill being tested: a line like 'TRACE 12 abort script.lua:41 -- NYI: bytecode 51' names the unsupported operation and the exact line, while 'blacklisted' means a trace aborted so many times that LuaJIT gave up on that entry point permanently and will interpret it for the rest of the process, which is the state a hot path must never reach. The other limits are trace count and machine-code space, tunable with jit.opt.start('maxtrace=1000', 'maxrecord=8000', 'maxmcode=512'); on x64 the mcode area must sit within 2 GB of the interpreter, so a very large codebase can report 'not enough memory' while the machine still has free RAM. Practical workflow: reproduce the hot path in isolation, run with -jv to list aborts, fix the top abort, and re-measure, because one NYI call inside a loop body can prevent the entire loop from ever compiling.

-- Type-unstable loop, JIT will struggle
for i = 1, 1e6 do
    local x = (i % 2 == 0) and i or tostring(i)  -- type changes
    work(x)
end

-- Type-stable, FFI-backed, JIT loves this
local ffi = require("ffi")
local buf = ffi.new("double[?]", 1e6)
for i = 0, 999999 do buf[i] = math.sqrt(i) end
Q34

How would you sandbox untrusted Lua code (e.g. user scripts in a game)?

AdvancedSecurity

Answer

Lua's environment-based design makes sandboxing feasible but easy to get wrong. The architecture: (1) Run the untrusted code in a separate environment table with `load(code, name, 't', env)` (the 't' restricts to text-only, blocking pre-compiled bytecode which is a known exploit vector). (2) Build `env` with only the safe stdlib subset: `math`, `string`, `table`, selected `os` functions (`time`, `clock` but NOT `execute`/`exit`/`getenv`), zero `io`, zero `debug`, no `load`/`loadstring`/`dofile`/`require`. (3) Set a debug hook to enforce instruction counts: `debug.sethook(co, function() error('time limit') end, '', 1e6)`, kills runaway loops. (4) Use coroutines for time slicing so one user's script can't starve others. (5) Block metatable escapes: rawset/rawget/getmetatable on string can leak, replace or remove them. (6) For production untrusted hosting (Roblox, AWS Lambda Lua runtime), do all this PLUS run in an OS-level sandbox (seccomp, gVisor) because Lua VM bugs can still crash the host. Roblox's Luau took the further step of removing `loadstring` and `os.execute` entirely from the language.

Two further caveats separate a working sandbox from a demo. A debug hook that raises can itself be caught by a pcall inside the untrusted script, so pair the instruction-count hook with a budget enforced by the host resume loop, and either remove pcall from the environment or wrap it so your kill error is rethrown rather than swallowed. And neither PUC Lua nor LuaJIT caps allocation by default, so a script doing `local t = {} while true do t[#t+1] = ('x'):rep(1e6) end` exhausts host memory before any instruction limit feels slow; the real fix is a custom allocator passed to lua_newstate in the C host, with a collectgarbage('count') check inside the same hook as a pure-Lua approximation.

local SAFE = {
    pairs = pairs, ipairs = ipairs, select = select, error = error,
    tostring = tostring, tonumber = tonumber, print = print,
    math = math, string = string, table = table,
    os = {time = os.time, clock = os.clock},   -- no execute/exit/getenv
}

local function run_untrusted(src, budget)
    -- mode "t" rejects precompiled bytecode, a known VM exploit vector
    local fn, err = load(src, "=user_script", "t", SAFE)
    if not fn then return nil, "compile: " .. err end

    local co = coroutine.create(fn)
    debug.sethook(co, function()
        debug.sethook(co)                      -- do not re-enter the hook
        error("instruction budget exceeded", 0)
    end, "", budget or 1e6)

    local ok, res = coroutine.resume(co)
    debug.sethook(co)
    return ok and res or nil, (not ok) and res or nil
end

print(run_untrusted("return os.execute('rm -rf /')"))
-- nil   user_script:1: attempt to call a nil value (field 'execute')
print(run_untrusted("while true do end", 1e5))
-- nil   instruction budget exceeded
Q35

Explain Lua's stack-based C API and how to write a C extension.

AdvancedC API

Answer

Lua-C communication is mediated by a virtual stack, C never touches Lua values directly, only pushes onto and pops from the stack. This is the entire reason Lua is so portable and embeddable: no shared memory layout, no GC interaction from C code. Key API patterns: `lua_pushinteger(L, 42)`, `lua_pushstring(L, 'hi')`, `lua_tointeger(L, idx)`, `lua_call(L, nargs, nresults)`.

A C function exposed to Lua has signature `int func(lua_State *L)`, it reads arguments from the stack, pushes results, returns the number of results. Module registration: build a `luaL_Reg` array, call `luaL_newlib(L, funcs)` in the module's `luaopen_*` function. Memory rule: every value on the stack is anchored against GC; values you reference from C between API calls must be stored in the registry (`luaL_ref`).

Performance tip: for hot paths, prefer LuaJIT FFI over writing a binding, no glue code and the JIT can inline through it. Three things break real bindings. Stack indices are relative, so a negative index such as -1 shifts every time you push something, and mixing absolute and negative indices in one function is the classic source of 'bad argument #1 (table expected, got no value)'.

The stack is only guaranteed to have LUA_MINSTACK (20) free slots, so a loop that pushes N values must call luaL_checkstack first or it corrupts memory. And error handling uses longjmp in a C build (or C++ exceptions in a C++ build), so luaL_error never returns, which means any malloc or file handle you took before it leaks unless you attached it to a full userdata with a __gc finalizer. Also build both sides against the same headers: linking a 5.1-era binding into 5.4 gives 'undefined symbol: lua_objlen' at load time if you are lucky, and silent ABI corruption if you are not.

// mymod.c, Lua C extension skeleton
#include <lua.h>
#include <lauxlib.h>

static int l_square(lua_State *L) {
    double x = luaL_checknumber(L, 1);
    lua_pushnumber(L, x * x);
    return 1;  // one return value
}

static const luaL_Reg funcs[] = {
    {"square", l_square},
    {NULL, NULL},
};

int luaopen_mymod(lua_State *L) {
    luaL_newlib(L, funcs);
    return 1;
}
Q36

You're shipping a Roblox/mobile game in India and players report lag spikes. How do you profile and fix Lua-side performance?

AdvancedPerformance

Answer

Approach in priority order: (1) Measure first. Use the platform profiler (Roblox MicroProfiler, Love2D's `love.profiler`, or for LuaJIT outside games `luaprofiler` / `jit.p`). Capture frames during the lag spike specifically, average-case profilers miss the spike. (2) Identify the bottleneck class: GC pause, JIT trace abort, allocator churn, or genuine CPU work.

GC pauses show as sawtooth memory + spikes; trace aborts show in `-jv` output. (3) GC fix: pre-allocate tables (`table.create(n)` on Roblox), reuse instead of creating, use weak caches, schedule manual `collectgarbage('step', n)` between frames. (4) Allocation fix: hot loops should not create tables or concatenate strings; use FFI buffers (LuaJIT) or Roblox's `buffer` library. (5) Algorithmic fix: profile shows function X is hot, does it have to run every frame? Cache results, throttle updates, move from per-entity to spatial-hash queries. (6) Network/asset fix: on Indian mobile networks (2G/3G fallback common in tier-2/3 cities), large asset downloads cause stalls, preload, chunk, and gate on connectivity. Indian studios shipping to feature-phone tier hardware (Reliance JioPhone, sub-3 GB RAM Android) typically target a strict frame budget of 6-10 ms for Lua-side work and instrument with custom timers around hot subsystems.

Key Points

  • Always profile during the spike, not average case
  • Pre-allocate; never alloc in hot loops
  • Manual `collectgarbage('step', n)` between frames
  • Use buffer/FFI for numeric work; Lua tables are heavy
  • Target tier-2/3 mobile hardware budgets explicitly
Q37

What is _ENV, and how did environment handling change from Lua 5.1 to 5.2 and later?

AdvancedEnvironments

Answer

From Lua 5.2 the VM has no global namespace at all. A free name x compiles to _ENV.x, where _ENV is an ordinary upvalue handed to every chunk, so what people call globals are simply fields of whatever table _ENV currently points at. Changing the environment therefore needs no dedicated API: declaring `local _ENV = my_table` makes every following free name resolve inside my_table, and load(chunk, chunkname, mode, env) sets it for loaded code.

Lua 5.1 did the same job with setfenv and getfenv, which 5.2 removed but LuaJIT still provides because it tracks 5.1 semantics, and Roblox Luau keeps them while warning that any script using them is deoptimised. _G survives, but it is only a normal global variable holding the default table, so assigning to _G changes nothing about which table a chunk resolves against, and code that assumes otherwise is wrong on both sides of the version boundary. Real uses: sandboxing untrusted scripts, config DSLs where a file is loaded with an environment exposing only the allowed directives, per-plugin isolation in a host application, and a strict mode built from __newindex on the environment. To retarget an already-created function, use debug.setupvalue or debug.upvaluejoin to swap its _ENV upvalue, which is exactly how hot-reload systems rebind a replaced module. On LuaJIT, dynamic _ENV manipulation is also a common cause of trace aborts, so keep it out of hot paths.

-- 5.2+: _ENV is just an upvalue, so a local shadows every global
local function isolated()
    local _ENV = {print = print, x = 41}
    x = x + 1
    print(x)        -- 42
    -- os.time()    -- attempt to index a nil value (upvalue '_ENV')
end
isolated()

-- Config DSL: the file may only call the directives we expose
local cfg, dsl = {}, {}
dsl.listen  = function(p) cfg.port = p end
dsl.workers = function(n) cfg.workers = n end
assert(load("listen(8080) workers(4)", "=site.conf", "t", dsl))()
print(cfg.port, cfg.workers)        -- 8080   4

-- Strict mode: turn accidental globals into runtime errors
setmetatable(_G, {
    __newindex = function(_, k) error("undeclared global: " .. k, 2) end,
})

-- Rebind _ENV (upvalue 1 of a main chunk) after the fact
local f = load("return answer")
debug.setupvalue(f, 1, {answer = 42})
print(f())                          -- 42

Key Points

  • 5.2+ compiles every free name to _ENV.name
  • setfenv/getfenv removed in 5.2, still present in LuaJIT and Luau
  • _G is a plain variable, not the environment itself
  • debug.setupvalue swaps _ENV on an existing function
Q38

A Lua service is failing in production. How do you use the debug library to find the cause?

AdvancedDebugging

Answer

Capture the stack where it breaks, not where you catch it: pcall has already unwound by the time it returns, so use xpcall(fn, function(err) return debug.traceback(err, 2) end) and log that string. debug.getinfo(level, 'nSl') gives name, short_src, linedefined and currentline for any frame, which is how you build an error report that names file and line, and debug.getinfo(f, 'f') hands back the function object itself. debug.getlocal(level, i) walks the failing frame's locals (and negative indices reach its varargs), so you can dump the actual arguments of a crashed handler instead of guessing, with debug.setlocal available if you are patching interactively. debug.sethook(f, 'l') fires per line for tracing, 'cr' for call and return events, and a count hook gives you a watchdog, but hooks are expensive and in LuaJIT they force interpretation, so they never stay enabled under load. Learn the error strings: 'attempt to index a nil value (field 'db')' means a require or a field returned nil, 'attempt to call a nil value (method 'connect')' is usually a dot where a colon belonged, 'stack overflow' is runaway recursion or an __index cycle, and 'attempt to compare nil with number' points straight at unvalidated input. In OpenResty, log with ngx.log(ngx.ERR, debug.traceback()) and correlate on the request id; for CPU spikes reach for the openresty-systemtap-toolkit flamegraph scripts or LuaJIT's jit.p profiler rather than adding hooks.

-- Capture the stack plus the failing frame's locals
local function handler(err)
    local info = debug.getinfo(2, "nSl")
    local locals = {}
    for i = 1, 200 do
        local name, value = debug.getlocal(2, i)
        if not name then break end
        locals[#locals + 1] = name .. "=" .. tostring(value)
    end
    return string.format("%s at %s:%d [%s]\n%s",
        err, info.short_src, info.currentline,
        table.concat(locals, ", "), debug.traceback("", 2))
end

local function charge(user, amount)
    return user.balance - amount        -- user is nil in production
end

local ok, report = xpcall(charge, handler, nil, 100)
print(ok, report)
-- false  charge.lua:16: attempt to index a nil value (local 'user')
--        at charge.lua:16 [user=nil, amount=100]  <traceback>

-- Watchdog: abort anything running past 10M VM instructions
debug.sethook(function() error("watchdog: runaway loop", 2) end, "", 1e7)
-- ...work...
debug.sethook()                         -- always clear it
💡 Pro Tip: Never ship with a line hook enabled. On LuaJIT a hook disables trace compilation, so a debug build can be 20x slower than the same code in production.
Q39

An OpenResty endpoint intermittently 500s and error.log shows 'attempt to yield across C-call boundary'. How do you diagnose and fix it?

AdvancedOpenResty

Answer

That error means Lua tried to yield while the current frame was entered from C, and every nonblocking OpenResty API yields internally: ngx.sleep, cosockets, ngx.location.capture, resty.redis, resty.http and lua-resty-lock all do. So the bug is always the same shape, I/O in a place that cannot yield. Diagnosis starts with the context tag in error.log, for example 'in set_by_lua*' or 'in header_filter_by_lua*', which pins it to a phase that forbids yielding; set_by_lua, header_filter_by_lua, body_filter_by_lua, log_by_lua and init_by_lua are all in that set, and the sibling message 'API disabled in the context of init_by_lua*' confirms a phase problem rather than a callback problem.

If the phase is legal, the yield is inside a C-invoked callback: a table.sort comparator, a metamethod, an ngx.re.gsub replacement function, an FFI callback, or a coroutine created with the bare coroutine API instead of ngx.thread. Intermittency almost always means the path only does I/O on a cache miss, which is why staging never reproduces it. The fixes: hoist the I/O out of the callback and precompute values before sorting or filtering, move mandatory I/O into ngx.timer.at(0, fn) which runs in a fresh light thread that may yield, use ngx.thread.spawn with ngx.thread.wait for in-request concurrency, and confirm lua-resty-core is loaded since its FFI reimplementations remove several boundary cases. Then add a Test::Nginx case that forces a cache miss.

-- BROKEN: table.sort calls the comparator from C, red:get yields
table.sort(ids, function(a, b)
    return red:get("score:" .. a) > red:get("score:" .. b)
end)
-- error: attempt to yield across C-call boundary

-- FIXED: finish all I/O first, then sort pure data
local score = {}
for _, id in ipairs(ids) do
    score[id] = tonumber(red:get("score:" .. id)) or 0
end
table.sort(ids, function(a, b) return score[a] > score[b] end)

-- I/O in a non-yieldable phase must move to a timer thread
log_by_lua_block {
    local ok, err = ngx.timer.at(0, function(premature, id)
        if premature then return end
        local http = require("resty.http").new()
        http:request_uri("http://audit.internal/e", {method = "POST", body = id})
    end, ngx.var.request_id)
    if not ok then ngx.log(ngx.ERR, "timer failed: ", err) end
}

Key Points

  • Every nonblocking ngx API yields; C-invoked callbacks cannot
  • The context tag in error.log names the offending phase
  • Precompute I/O results before sort/gsub callbacks
  • ngx.timer.at(0, fn) is the escape hatch for non-yieldable phases
Q40

How do you serialize Lua tables safely, and what breaks in production?

AdvancedSerialization

Answer

JSON is the common case and lua-cjson is the standard choice, with cjson.safe as the variant that returns nil plus an error instead of raising, which is what belongs on a request path. Its failure modes are the interview material. An empty table is ambiguous and encodes as {} by default, so use cjson.encode_empty_table_as_object(false) or the cjson.empty_array sentinel when the consumer expects [].

A table with holes raises 'Cannot serialise table: excessively sparse array' unless you call cjson.encode_sparse_array(true, 2, 10), which converts it to an object instead. NaN and infinity raise 'Cannot serialise number: must not be NaN or Infinity' unless encode_invalid_numbers is on, and that one usually appears the first time a metrics endpoint divides by a zero count. Nesting past the configured limit (1000 by default) raises 'Cannot serialise, excessive nesting', which is how a cyclic table announces itself, because cjson does not detect cycles.

Numeric keys are silently stringified, so {[3]='c'} round-trips as an object and the trip is lossy. JSON numbers past 2^53 lose precision, so 64-bit IDs must travel as strings. For binary, string.pack and string.unpack (5.3+) give exact layouts through format strings like '<I4I4s4', lua-MessagePack or cmsgpack cover compact interchange, and LuaJIT 2.1 ships string.buffer with encode and decode that handle cycles and cdata. Never move string.dump bytecode across versions or architectures, and always load untrusted input with mode 't'.

local cjson = require("cjson.safe")

cjson.encode_empty_table_as_object(false)  -- {} encodes as []
cjson.encode_sparse_array(true, 2, 10)     -- holes become an object
cjson.encode_invalid_numbers(false)        -- NaN/inf remain an error

print(cjson.encode({1, nil, 3}))
-- with sparse handling off: nil  Cannot serialise table: excessively sparse array

local d = cjson.decode('{"id": 9007199254740993, "tag": null}')
print(d.id)                    -- 9007199254740992, precision lost
print(d.tag == cjson.null)     -- true, null survives as a sentinel

-- Numeric keys do not survive a JSON round trip
print(cjson.encode({[1] = "a", [2] = "b"}))  -- ["a","b"]
print(cjson.encode({[3] = "c"}))             -- {"3":"c"}

-- Exact binary layout with string.pack (Lua 5.3+)
local frame = string.pack("<I4I4s4", 7, 42, "payload")
print(string.unpack("<I4I4s4", frame))       -- 7  42  payload  17
💡 Pro Tip: Send 64-bit IDs as JSON strings. A Lua 5.3 integer survives the encode, but the consumer's JSON parser (and cjson itself on decode) rounds anything past 2^53.

Companies Hiring Lua

Roblox
Cloudflare
Kong
Blizzard (WoW)
Adobe Lightroom
Wikipedia (Scribunto)
DotsByLab
Mech Mocha

Salary Insights

Average in India
₹6-18 LPA

Frequently Asked Questions

Is Lua still relevant as a career skill in 2026?

Niche but well-paid where it's used. The hiring pockets are game studios (Roblox, Mech Mocha, DotsByLab, JetSynthesys), edge networking (any team running OpenResty/Kong), and embedded plugin ecosystems (Adobe, Wireshark, Neovim). Outside those niches, Lua rarely stands alone on a job spec, you'll be hired for game dev + Lua, or backend + OpenResty.

How much does a Lua developer earn in India?

₹6-18 LPA in 2026, depending on the niche. Game scripting roles at Indian studios (Mech Mocha, DotsByLab) start at ₹6-10 LPA. OpenResty / edge networking roles at CDNs and FinTechs typically pay ₹12-18 LPA because the role usually requires nginx + Linux ops + Lua together. Roblox Luau developers freelancing for studios abroad can clear ₹20+ LPA equivalent.

Should I learn Lua or LuaJIT or Luau?

Learn the Lua 5.1 / 5.2 core first, that's what Luau and LuaJIT both build on. If you're going into game scripting, Roblox Luau is the largest job market, learn its type system on top. If you're going into edge / backend, LuaJIT + OpenResty is the stack. Lua 5.4 (PUC Lua) is what you'll see in standalone scripts and Neovim configs.

Is Lua slower than Python or JavaScript?

PUC Lua is similar to CPython on most workloads. LuaJIT is in a different league, often 10-100× faster than PUC Lua and within 2× of hand-written C on numeric loops, beating Node.js comfortably on most non-V8-specialized benchmarks. The headline: 'fast Lua' means LuaJIT.

How long does it take to prepare, and what differs between a fresher and an experienced Lua interview?

The language itself is small enough that a working programmer can cover tables, metatables, closures, coroutines and error handling in about two focused weeks. The host platform is what takes longer: another three to four weeks to be credible on Roblox Luau (DataStores, RemoteEvents, the type checker) or on OpenResty (phases, cosockets, shared dicts, lua-resty-lock). Freshers are asked to write code on the spot: reverse a table in place, implement a class with inheritance, explain why #t is unreliable on a sparse table, write a closure-based counter. Experienced candidates get scenario questions instead: why a worker's latency cliffs under load, how you would sandbox user scripts, what you changed to remove a GC pause from a frame loop, and what the -jv output told you. Bring one profiling or debugging story with real numbers, that single answer separates most senior candidates from the rest.

Do I need to know C to be a good Lua developer?

Helpful but not required. Most Lua jobs (game scripting, OpenResty, Neovim plugins) don't need C, you'll work entirely in Lua against pre-built APIs. C becomes essential only if you're writing the engine that hosts Lua (game engines, custom embedded products) or building C extensions / FFI bindings to existing native libraries.

Introduction

Lua remains the embedded scripting language of choice in 2026. Its tiny runtime (~200 KB), C-friendly API, and straightforward semantics make it the lingua franca for game scripting (Roblox, World of Warcraft, Love2D), network plugins (OpenResty/nginx, HAProxy, Wireshark), and editor configuration (Neovim is now Lua-first).

If you're interviewing for a Lua role in India today, expect deep questions on tables (Lua's only structured data type), metatables and metamethods, OOP patterns, coroutines, and the LuaJIT FFI. Game studios like Mech Mocha, DotsByLab and JetSynthesys, and CDN/edge teams running OpenResty are the largest employers.

This guide covers the 40 most-asked Lua interview questions in 2026, grouped by difficulty. Each answer includes the underlying concept, common gotchas (1-based indexing, the # operator on sparse tables, accidental globals), and code examples where they add clarity.

Ready to practice Lua interviews?

Don't just read, practice these Lua questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview