A patch for Lua 5.5.0 that adds the __update metamethod — a complement to __newindex that intercepts assignments to existing table fields.
Lua's __newindex metamethod only fires when assigning to a key that does not exist in the table. There is no built-in mechanism to intercept updates to existing keys. This forces developers to use proxy-table patterns (an empty proxy + __newindex + __index forwarding), which are complex, error-prone, and have significant performance overhead.
The __update metamethod fills this gap cleanly:
- Reactive data binding: detect when a property changes (e.g., UI frameworks)
- ORM dirty tracking: know which fields were modified before a database flush
- Configuration change monitoring: log or validate config changes at runtime
- Debugging / instrumentation: trace all writes to a table without proxies
local t = setmetatable({}, {
__update = function(t, k, newval)
print("update:", k, t[k], "->", newval)
rawset(t, k, newval)
end
})
t.x = 1 -- update: x nil -> 1 (new key, non-nil)
t.x = 2 -- update: x 1 -> 2 (existing key, different value)
t.x = 2 -- (no output: same value, not triggered)| Scenario | Behavior |
|---|---|
| Existing key, different value | __update fires |
| Existing key, same value | No trigger, direct write |
New key, non-nil value (no __newindex) |
__update fires |
New key, with __newindex |
__newindex fires (__newindex takes priority) |
| New key, nil value | No trigger |
__update is not a function |
Falls back to normal write |
Like __gc, __update must be present in the metatable before calling setmetatable. Adding it afterward has no effect:
-- Correct:
local mt = { __update = function(t, k, v) rawset(t, k, v) end }
local t = setmetatable({}, mt)
-- Wrong (will NOT work):
local mt = {}
local t = setmetatable({}, mt)
mt.__update = function(t, k, v) rawset(t, k, v) end -- too late- Zero overhead: Tables without
__updatehave no measurable performance impact. A single bit flag (hasupdatemt) in the table'sflagsfield enables fast-path bypass. - Clear semantics: Existing keys →
__update. New keys →__newindex. No ambiguity. - Minimal invasion: Only the assignment path is modified. No changes to reads, GC, or other subsystems.
| File | Change |
|---|---|
lobject.h |
BITHASUPDATE flag macros (bit 7 of flags) |
ltm.h |
TM_UPDATE enum, getupdatetm macro, gfasttm assertion |
ltm.c |
"__update" string, luaT_gettm special handling |
lvm.h |
luaV_fastset/luaV_fastseti fast-path interception |
lvm.c |
luaV_finishupdate + core logic in luaV_finishset |
lapi.c |
lua_setmetatable flag caching |
Total: ~95 lines added, ~6 lines modified.
# Build
make SYSCFLAGS="-DLUA_USE_LINUX" SYSLIBS="-Wl,-E -ldl"
# Run regression tests (60 tests, no external dependencies)
./lua update_test/update_spec.lua
# Run benchmark
./lua update_test/benchmark_update_same_vs_diff.lua
# Run full comparison: Vanilla Lua 5.5 vs Patched (requires ~/lua-5.5.0/bin/lua)
bash update_test/benchmark_compare.sh [rounds]All benchmarks: Vanilla Lua 5.5.0 vs Patched Lua 5.5 (with __update), 5 rounds averaged.
Tables without __update show negligible overhead:
baseline_same — Plain table, same-value writes:
| Table Size | Vanilla Lua 5.5.0 | Patched | Diff |
|---|---|---|---|
| 10,000 | 0.033s ± 0.002s | 0.033s ± 0.002s | -0.4% |
| 100,000 | 0.326s ± 0.004s | 0.324s ± 0.003s | -0.5% |
baseline_diff — Plain table, different-value writes:
| Table Size | Vanilla Lua 5.5.0 | Patched | Diff |
|---|---|---|---|
| 10,000 | 0.043s ± 0.000s | 0.046s ± 0.001s | +6.9% |
| 100,000 | 0.435s ± 0.006s | 0.456s ± 0.008s | +4.7% |
empty_mt — Table with empty metatable:
| Table Size | Vanilla Lua 5.5.0 | Patched | Diff |
|---|---|---|---|
| 10,000 | 0.033s ± 0.000s | 0.034s ± 0.004s | +5.7% |
| 100,000 | 0.330s ± 0.007s | 0.321s ± 0.008s | -2.9% |
baseline/empty_mt confirm near-zero overhead for tables without
__update(within noise).
update_same — Same-value writes (skipped by fast-path value comparison):
| Table Size | Vanilla Lua 5.5.0 | Patched | Diff |
|---|---|---|---|
| 10,000 | 0.036s ± 0.003s | 0.043s ± 0.003s | +18.2% |
| 100,000 | 0.331s ± 0.006s | 0.411s ± 0.006s | +23.9% |
update_diff — Different-value writes (callback fires every time):
| Table Size | Vanilla Lua 5.5.0 | Patched | Diff |
|---|---|---|---|
| 10,000 | 0.044s ± 0.000s | 0.379s ± 0.005s | +771.5% |
| 100,000 | 0.448s ± 0.019s | 3.889s ± 0.025s | +767.9% |
str_update_same — Same-value writes:
| Table Size | Vanilla Lua 5.5.0 | Patched | Diff |
|---|---|---|---|
| 10,000 | 0.110s ± 0.003s | 0.125s ± 0.005s | +13.4% |
| 100,000 | 1.521s ± 0.008s | 1.734s ± 0.083s | +14.0% |
str_update_diff — Different-value writes:
| Table Size | Vanilla Lua 5.5.0 | Patched | Diff |
|---|---|---|---|
| 10,000 | 0.124s ± 0.002s | 0.461s ± 0.008s | +270.7% |
| 100,000 | 1.668s ± 0.023s | 5.321s ± 0.135s | +219.0% |
Same-value overhead is low (+14~24%) thanks to the fast-path value comparison in
ltable.c. Different-value overhead is expected — vanilla Lua does plain writes while patched invokes the callback.
The key comparison: traditional proxy-table pattern vs native __update (both on patched Lua 5.5):
| Scenario | Description | 10K | 100K |
|---|---|---|---|
| diff_value | Update with different values | Proxy 0.431s vs Native 0.385s (-10.7%) | Proxy 4.283s vs Native 3.886s (-9.3%) |
| same_value | Write same values | Proxy 0.192s vs Native 0.041s (-78.6%) | Proxy 1.956s vs Native 0.411s (-79.0%) |
| read | Read existing keys | Proxy 0.114s vs Native 0.039s (-65.8%) | Proxy 1.214s vs Native 0.397s (-67.3%) |
| pairs | Iterate with pairs() |
Proxy 0.128s vs Native 0.126s (-1.6%) | Proxy 1.273s vs Native 1.277s (+0.3%) |
Key takeaways:
- Same-value writes: Native
__updateis ~79% faster — fast-path value comparison skips the write entirely, while proxy still invokes__newindexevery time. - Different-value writes: Native
__updateis ~10% faster than proxy. - Reads: Native
__updateis ~66% faster — proxy requires__indexmetamethod on every read, while__updatestores data directly in the table. - Iteration: No meaningful difference — both approaches ultimately call
nexton the backing data. - Code simplicity: Native
__updateeliminates the need for__index,__pairs, and the backing table entirely.
Run bash update_test/benchmark_compare.sh [ROUNDS] to reproduce.
Test environment:
- CPU: 13th Gen Intel Core i7-13700KF (12 cores / 24 threads)
- Memory: 32 GB
- OS: Ubuntu 20.04.6 LTS (WSL2, kernel 6.6.87.2-microsoft-standard-WSL2)
- Compiler: GCC 9.4.0
This patch is based on Lua 5.5.0 (released 18 Mar 2025). See PROPOSAL.md for detailed design documentation.
Same as Lua — MIT License.