Lua metatables in practice: defaults, proxies and read-only tables
Lua’s metatables let you intercept reads and writes, inject defaults, and create read‑only proxies—all without a full OO framework. Here’s how __index and __newindex work, with a concrete example and version caveats.
15 Oct 2025, 00:00 UTC

Problem: default‑filled tables without the boilerplate
\nWhen you write a Lua module that accepts configuration, you quickly face two competing needs: supply sensible defaults for any key the caller omits, and catch misspelled or accidental writes before they reach production. Adding an if key == nil then ... end guard for every option is noisy, and there’s no built‑in way to make a table “read‑only” except by convention.
Thesis: one metatable, three jobs
\nThe practical takeaway is simple: attach a metatable to your config table, set __index to a defaults table, and set __newindex to a handler that validates or logs writes. The same mechanism also lets you build proxy tables that forward missing reads to another table, effectively giving you prototype‑style inheritance without copying methods per instance.
1. Prototype‑style inheritance with __index
\nA metatable’s __index can be either a table or a function. When it’s a table, Lua checks that table for any key that’s missing in the original. This is the idiomatic way to give every instance a shared method table:
-- method table lives once, shared by all instances
local methods = {
render = function(self) print('rendering...') end,
update = function(self, dt) end
}
-- instance tables only hold their own data
local instance = {x = 0, y = 0}
setmetatable(instance, { __index = methods })
\nNow instance.render() resolves through the metatable’s __index to the shared methods.render. The instance stays tiny; methods are stored once. If you read a key that exists directly on the instance, Lua returns it immediately—__index only fires for absent keys.
If __index is a function, it receives the table and the key, letting you compute defaults on the fly, memoize results, or build a read‑only proxy. This pattern is useful when defaults depend on other state or when you want to hide the source table entirely.
Crucial tip: inside any __index handler, use rawget to read the original table’s own keys. This prevents infinite recursion if the handler itself triggers another metamethod call, and it keeps the common case (key present in the table) a direct memory access.
2. Write interception and read‑only tables with __newindex
\nThe counterpart __newindex fires whenever you assign to a key that doesn’t already exist in the table (or, in some implementations, on every assignment). It can be a table (rare, usually a sign of a misconfigured metatable) or, more commonly, a function that receives the table, the key, and the value.
-- validate that a key isn’t being overwritten after first write
local executed = false
local config = {timeout = 30}
setmetatable(config, {
__newindex = function(t, k, v)
if executed then
error('cannot add new keys to a read-only config after initialisation', 2)
end
if k == 'timeout' then
if type(v) ~= 'number' then
error('timeout must be a number', 2)
end
if v <= 0 then
error('timeout must be positive', 2)
end
end
rawset(t, k, v)
if k == 'timeout' then
executed = true
end
end
})
\nAfter config.timeout = 60 succeeds, any attempt to add a new key like config.debug = true raises an error. The rawset inside the handler writes directly to the table, bypassing the metamethod and avoiding recursion. This pattern also serves as a read‑only barrier: if you set __metatable to a non‑nil value, getmetatable will return that value, and Lua will raise an error if code tries to call setmetatable again—commonly used to make a table opaque or immutable.
You can also redirect writes: the handler might store the value in a different table, forward it to a logger, or silently drop it, depending on your security or debugging needs.
\n3. The raw‑access trade‑off and performance
\nMetamethod calls are ordinary Lua function calls, so they carry a small overhead compared to a bare table lookup. In hot paths—say, a tight loop accessing a config key every iteration—each metamethod call can add up and, more importantly, can prevent the interpreter’s optimiser from inlining or vectorising the access. The standard defence is to use rawget inside your metamethod to read the underlying table’s own fields directly, and to keep the metamethod body minimal.
__index = function(t, k)
return rawget(t, k) or rawget(_METATABLE_DEFAULTS, k)
\nthe first rawget checks the instance table itself; only if the key is absent does it fall back to the defaults table. This two‑step lookup is faster than a full chain of metamethod resolutions and makes it clear where the “real” data lives.
Beware: the length operator # on tables with nil holes has undefined behaviour, and building __len or __index logic that assumes a contiguous sequence will break on tables that mix numeric and string keys. Always test the specific shape of your data.
4. Version‑aware caveats and sandboxing
\nLua’s metatable semantics have evolved. The # operator, __pairs and __ipairs behaviour, and the availability of __close or differ across 5.1, 5.2, 5.3 and 5.4. If you’re targeting multiple interpreters, consult the reference manual for your specific version before deploying a metatable‑heavy pattern.
Sandboxing with metatables relies on the _ENV mechanism introduced in Lua 5.2. Passing an environment table to load restricts which globals a script can access. In Lua 5.1, the same effect was achieved with setfenv and getfenv, but the API is deprecated and removed in later versions. A restricted _ENV is not a security boundary on its own—the debug library, string.dump, require of C modules, and direct metatable access can still be abused unless the environment is pruned carefully or the script runs in a separate process.
LuaJIT and Luau diverge from the reference implementation in various ways: Luau adds strict typing and a different metatable model, while LuaJIT extends 5.1 with bitwise operators and an integer subtype. Code written for one dialect may not compile or behave identically on another.
\nPractical verification: run lua -v in your shell, note the major version, then test the worked example from the previous sections in a REPL. Try reading a missing key, then use rawget to see the difference. Load a one‑line probe such as return 1 & 2 (Lua 5.3+ bitwise) or local _ = ... end (to‑be‑closed) to confirm your interpreter supports the syntax you’re relying on.
Getting started
\n- \n
- Pick one table in your codebase that could benefit from defaults or write‑time validation. \n
- Create a small defaults table holding the sensible fallbacks. \n
- Attach a metatable with
__index = defaultsand a__newindexhandler that callsrawsetafter its checks. \n - In the handler, use
rawgetto read the instance table first, avoiding recursion. \n - Run
lua -vand verify the pattern works on your interpreter’s version. \n
The metatable pattern is lightweight, but version awareness and raw‑access discipline keep it from becoming a debugging trap. Start small, profile your hot paths, and iterate from there.
\n0 replies
A thoughtful contribution can make all the difference. Be the first to share one.