Metatables: What They Are and Why They Matter
Every table in Lua is just a collection of key-value pairs. Nothing special about it — until you attach a metatable. A metatable is a table that sits behind another table and intercepts operations you wouldn’t normally think twice about: reading a missing key, assigning to a missing key, adding two tables, or even calling a table like a function.
This interception happens through metamethods — special keys like __index and __add that Lua recognizes and calls automatically under specific conditions. Metatables are the mechanism; metamethods are the hooks. Together they let you extend Lua with powerful, built-in-feeling patterns.
Attaching a Metatable
You attach a metatable to a table with setmetatable. It takes two arguments: the table and the metatable. It returns the table (with the metatable now attached).
local point = { x = 10, y = 20 }
local meta = {}
setmetatable(point, meta)
That’s it. Right now point has meta as its metatable, but meta is empty so nothing interesting happens. The moment you add metamethods to meta, things change.
You can confirm a table’s metatable with getmetatable:
print(getmetatable(point)) -- table: 0x55f8b8c3e2a0
Both setmetatable and getmetatable only work on tables. If you try setmetatable on a non-table (like a string or number), Lua raises an error.
The __index Metamethod
developers reach for __index most often. Lua calls __index whenever you read a key that doesn’t exist in a table.
local defaults = { health = 100, speed = 10, name = "Unnamed" }
local player = setmetatable({}, {
__index = defaults
})
print(player.health) -- Output: 100 (from defaults)
print(player.speed) -- Output: 10 (from defaults)
print(player.name) -- Output: Unnamed (from defaults)
print(player.armor) -- Output: nil (no fallback)
When you access player.health, Lua looks in player, finds nothing, then checks the metatable. The __index value is defaults, so Lua looks there and finds health = 100.
This pattern gives you default values for any table. It’s clean, reusable, and doesn’t require copying values.
__index as a Function
__index doesn’t have to be a table — it can be a function. The function receives the table and the key as arguments:
local player = setmetatable({}, {
__index = function(self, key)
-- You could log, compute, or fall back to another table
return "unknown:" .. key
end
})
print(player.health) -- Output: unknown:health
print(player.weapon) -- Output: unknown:weapon
The function version gives you full control. You can implement caching, compute values on the fly, or delegate to multiple tables.
The __newindex Metamethod
Where __index handles reads, __newindex handles assignments to keys that don’t exist:
local frozen = {}
setmetatable(frozen, {
__newindex = function(self, key, value)
error("Cannot set " .. key .. " — table is read-only")
end
})
frozen.name = "test" -- Error: Cannot set name — table is read-only
Every assignment to a missing key calls __newindex. If you don’t define it, the assignment happens normally.
You can combine both to make a fully read-only table:
local read_only = {}
local meta = {
__index = function(self, key) return nil end,
__newindex = function(self, key, value)
error("Cannot modify read-only table")
end
}
setmetatable(read_only, meta)
read_only.score = 10 -- Error
print(read_only.score) -- nil (no error, just missing)
Operators: __add, __eq, __lt, and More
Metatables let you define behavior for operators. Lua has a full set of arithmetic and comparison metamethods:
| Metamethod | Operator | Called When |
|---|---|---|
__add | + | Adding two tables |
__sub | - | Subtracting |
__mul | * | Multiplying |
__div | / | Dividing |
__eq | == | Equality comparison |
__lt | < | Less than |
__le | <= | Less or equal |
__concat | .. | Concatenation |
__call | () | Calling a table as a function |
__tostring | print() | Converting to string |
__len | # | Length operator |
Here’s a vector class using __add and __eq:
local Vector = {}
Vector.__index = Vector
function Vector.new(x, y)
return setmetatable({ x = x, y = y }, Vector)
end
function Vector.__add(a, b)
return Vector.new(a.x + b.x, a.y + b.y)
end
function Vector.__eq(a, b)
return a.x == b.x and a.y == b.y
end
function Vector:__tostring()
return "(" .. self.x .. ", " .. self.y .. ")"
end
local a = Vector.new(1, 2)
local b = Vector.new(3, 4)
print(a + b) -- Output: (4, 6)
print(a == Vector.new(1, 2)) -- Output: true
Lua calls Vector.__add when it encounters a + b. Note that metamethods in the metatable are looked up on the metatable itself, not on the table. That’s why Vector is set as the metatable of its instances — so lookups find __add in Vector.
Making Tables Callable: __call
The __call metamethod lets you invoke a table like a function:
local multiply = setmetatable({}, {
__call = function(self, a, b)
return a * b
end
})
print(multiply(3, 4)) -- Output: 12
This is handy for factories, cached computations, or wrapping expensive operations behind a clean interface.
Raw Access: rawget and rawset
Sometimes you want to bypass the metatable entirely. Lua provides rawget and rawset for this:
local defaults = { theme = "dark" }
local config = setmetatable({}, { __index = defaults })
print(rawget(config, "theme")) -- Output: nil (not in config itself)
rawset(config, "theme", "light") -- Sets directly, skips __newindex
print(rawget(config, "theme")) -- Output: light
rawget returns a value without triggering __index. rawset assigns without triggering __newindex. You’ll reach for these when building meta-level helpers or when you explicitly need to avoid recursion.
Common Patterns
Default Values
The __index = defaults pattern covers most default-value needs:
local defaults = { debug = false, timeout = 30 }
local function with_defaults(t)
return setmetatable(t or {}, { __index = defaults })
end
local opts = with_defaults({ timeout = 60 })
print(opts.debug) -- Output: false (from defaults)
print(opts.timeout) -- Output: 60 (from opts)
Pass an existing table to with_defaults to override defaults with specific values while keeping the rest.
Read-Only Tables
For constants, configuration, or anything that shouldn’t change mid-execution:
local function readonly(t)
local proxy = {}
setmetatable(proxy, {
__index = t,
__newindex = function() error("readonly") end,
__len = function() return #t end,
__pairs = function() return pairs(t) end
})
return proxy
end
local COLORS = readonly({ red = 0xff0000, green = 0x00ff00 })
print(COLORS.red) -- Output: 16711680
COLORS.blue = 0x0000ff -- Error
This creates a proxy table. The original t is kept private and never exposed. All access goes through the proxy and all mutation is blocked.
Module-Like Namespacing
Metatables let you build encapsulated modules where internal functions are hidden:
local counter = (function()
local count = 0
return setmetatable({}, {
__call = function(self, n)
count = count + (n or 1)
return count
end,
reset = function(self)
count = 0
end
})
end)()
print(counter()) -- Output: 1
print(counter(5)) -- Output: 6
counter:reset()
print(counter()) -- Output: 1
The count variable lives in the closure and is inaccessible from outside. Only the exposed methods in the metatable are callable.
See Also
- Single and Multiple Inheritance in Lua — extend classes using
__indexdelegation chains - Tables in Lua: A Complete Reference — the foundation every metatable builds on
- setmetatable Reference — API documentation for attaching metatables