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.
Prerequisites
You should be comfortable with these Lua fundamentals before diving into metatables. Each topic builds on the previous one, and you will need all three to follow the examples in this tutorial:
- Basic table operations: creating tables, reading and writing keys, understanding that tables are reference types
- Function syntax: defining functions with
functionand calling them with arguments, understanding closures - Familiarity with
setmetatableandgetmetatable: knowing that these functions exist and what they do at a surface level is helpful, though they are covered in detail below
If you are new to tables in Lua, the tables reference is a good starting point.
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 the attachment with getmetatable. It returns the metatable bound to a table, letting you check whether a given table already has one. Both setmetatable and getmetatable only work on tables; passing a string or number raises an error. This is the standard way to inspect whether a table carries a metatable:
print(getmetatable(point)) -- table: 0x55f8b8c3e2a0
The __index Metamethod
Of all the metamethods, developers reach for __index most often. Lua calls __index whenever you read a key that doesn’t exist in a table. This single metamethod powers default values, inheritance, and proxy tables, and you will use it in nearly every metatable you write. Here is the simplest form:
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. The table form of __index does simple lookup delegation: Lua checks the target table first, then forwards the read to the __index table if the key is missing.
__index as a Function
A function __index gives you full control over the fallback path. Instead of a static lookup table, you can log every access, compute values on the fly, or delegate to multiple tables based on the key name. 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 is its write counterpart: it fires on assignments to keys that don’t already exist in the table. The following example shows error-throwing on any write attempt, a pattern that is useful for immutable configuration objects and frozen data structures:
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; Lua creates the key in the table like usual.
You can combine __index and __newindex to make a fully read-only table that blocks both reads to nonexistent keys and all write attempts:
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
Beyond key access, metatables let you define behavior for comparison and arithmetic operators. Lua recognizes a full set of metamethods that fire whenever two tables are added, compared, concatenated, or converted to strings. Here is the complete list:
| 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
The Vector class demonstrates how __add, __eq, and __tostring work together to make table instances behave like native types. Lua looks up metamethods on the metatable itself, not on the table, which is why Vector is set as the metatable of its own instances. When you write a + b, Lua finds __add in Vector, calls it with both operands, and returns the result. The same lookup rule applies to all operator metamethods.
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
The multiply example shows a simple functor: a table that behaves like a callable function. This pattern is also useful for factories that assemble objects on invocation, memoization wrappers that cache results, and wrapping expensive operations behind a clean, familiar interface. Anywhere you want an object with state that also needs to be called, __call bridges the gap.
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 reads a value without triggering __index, and rawset writes without triggering __newindex. These functions bypass metatables entirely. They are essential when building meta-level helpers. For example, implementing __index or __newindex yourself means you must use rawget and rawset inside those metamethods to avoid infinite recursion. Without raw access, any read inside __index would call __index again in an endless loop.
Common patterns with metatables
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)
The default values pattern uses __index pointing to a defaults table, which lets you override individual keys while keeping defaults for the rest. The with_defaults function wraps any table with fallback values, which is cleaner than copying default entries into every table you create. This approach also keeps defaults in a single location; if you update the defaults table later, all wrapped tables reflect the change instantly.
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. The proxy also forwards __len and __pairs so iteration still works: calling #proxy or for k, v in pairs(proxy) behaves exactly like the original table, just without the ability to modify anything. This pattern is common in Lua for configuration objects, shared constants, and API responses that should not be altered downstream.
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.
Next steps
Now that you understand metatables and the core metamethods, try the metamethods deep-dive for a complete reference of every available metamethod and their edge cases. When you are ready to build class hierarchies, the inheritance tutorial shows how __index chains create single and multiple inheritance.
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