luaguides

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 function and calling them with arguments, understanding closures
  • Familiarity with setmetatable and getmetatable: 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:

MetamethodOperatorCalled 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
__tostringprint()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