Metatables and Metamethods in Lua
If you have worked with Lua tables, you have probably noticed something curious: tables do not always behave the way you would expect. Adding two tables does not concatenate them—you get an error. Printing a table shows something cryptic like table: 0x7f9b2c3d4e5f. And trying to access a missing key in a deeply nested structure can be frustrating.
This is where metatables come in. Metatables are one of Lua is most powerful features, allowing you to customize how tables behave. They form the foundation for object-oriented programming in Lua, operator overloading, and much more.
What Are Metatables?
Every table in Lua can have an associated table called its metatable. This metatable contains metamethods—special functions that define behavior for operations like addition, indexing, string conversion, and more.
Think of a metatable as a table is “personality.” By default, tables are rather plain. But attach a metatable, and suddenly your table can respond to arithmetic operators, handle missing keys intelligently, and even mimic object-oriented behavior.
Here is the simplest possible example:
local my_table = {1, 2, 3}
local my_metatable = {}
setmetatable(my_table, my_metatable)
print(getmetatable(my_table)) -- table: 0x...
The setmetatable() function associates a metatable with a table. You can retrieve it with getmetatable().
The __index Metamethod
The most commonly used metamethod is __index. It defines what happens when you try to access a key that does not exist in a table.
This is incredibly useful for inheritance-like behavior:
local animal = {
species = "unknown",
legs = 0
}
local cat = {
species = "Felis catus",
legs = 4
}
setmetatable(cat, { __index = animal })
print(cat.species) -- Felis catus (found in cat)
print(cat.legs) -- 4 (found in cat)
print(cat.voice) -- nil... but wait, let us check the metatable
-- With __index, we can delegate to the parent:
local animal = {
species = "unknown",
legs = 0,
speak = function(self)
return "..."
end
}
local cat = {
species = "Felis catus",
legs = 4
}
setmetatable(cat, { __index = animal })
print(cat.species) -- Felis catus
print(cat.legs) -- 4
print(cat:speak()) -- ... (delegated to animal)
When cat.legs is accessed, Lua first looks in cat itself. When cat.speak is accessed and not found, Lua follows the metatable is __index to animal and finds it there.
Function-Based __index
You can also make __index a function for more dynamic behavior:
local cache = {
data = {
user_1 = {name = "Alice", email = "alice@example.com"},
user_2 = {name = "Bob", email = "bob@example.com"}
}
}
local users = {}
setmetatable(users, {
__index = function(table, key)
-- Simulate database lookup
if cache.data[key] then
return cache.data[key]
else
return {name = "Unknown", email = "not found"}
end
end
})
print(users.user_1.name) -- Alice
print(users.user_99.name) -- Unknown (fallback)
This pattern is common in lazy-loading scenarios, caching systems, and proxies.
The __newindex Metamethod
If __index controls what happens when you read a missing key, __newindex controls what happens when you write to a missing key.
This is useful for creating read-only tables or implementing custom storage:
local read_only = {
pi = 3.14159,
e = 2.71828
}
setmetatable(read_only, {
__newindex = function(table, key, value)
error("Cannot write to read-only table! Key: " .. tostring(key))
end
})
read_only.pi = 3.14 -- Error: Cannot write to read-only table!
read_only.new_key = 42 -- Error: Cannot write to read-only table!
print(read_only.pi) -- 3.14159 (reading is fine)
Here is a more practical example—a table that automatically stores changes to a log:
local change_log = {}
local tracked = {}
setmetatable(tracked, {
__newindex = function(table, key, value)
print("Setting " .. tostring(key) .. " = " .. tostring(value))
change_log[key] = value
end,
__index = function(table, key)
return change_log[key]
end
})
tracked.name = "Alice" -- Setting name = Alice
tracked.age = 30 -- Setting age = 30
print(tracked.name) -- Alice
Operator Overloading with Metamethods
Metatables shine when implementing operator overloading. Lua supports many metamethods for arithmetic and comparison operations:
| Metamethod | Operator |
|---|---|
| __add | + |
| __sub | - |
| __mul | * |
| __div | / |
| __mod | % |
| __unm | unary - |
| __eq | == |
| __lt | < |
| __le | <= |
| __tostring | tostring() |
Here is a practical example—a vector class:
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.__mul(v, scalar)
return Vector.new(v.x * scalar, v.y * scalar)
end
function Vector.__tostring(v)
return "(" .. v.x .. ", " .. v.y .. ")"
end
-- Usage
local v1 = Vector.new(1, 2)
local v2 = Vector.new(3, 4)
local v3 = v1 + v2 -- Uses __add
local v4 = v1 * 2 -- Uses __mul
print(v1) -- (1, 2)
print(v3) -- (4, 6)
print(v4) -- (2, 4)
This is remarkably similar to how you would implement a class in other languages—except it is all built on tables and metatables.
Combining It All: A Simple Prototype System
Now let us see how these pieces fit together to create something resembling classical OOP:
-- Base "class" (prototype)
local Animal = {}
function Animal:speak()
return "..."
end
function Animal:describe()
return "I am a " .. self.species
end
-- Create a new "instance"
function Animal:new(species, legs)
local instance = {
species = species,
legs = legs
}
-- Set up delegation via metatable
setmetatable(instance, { __index = self })
return instance
end
-- Usage
local cat = Animal:new("cat", 4)
print(cat:speak()) -- ... (inherited from Animal)
print(cat:describe()) -- I am a cat
-- Modify the "class" and see it reflect in instances
function Animal:speak()
return "Woof!"
end
local dog = Animal:new("dog", 4)
print(dog:speak()) -- Woof! (new function is picked up)
print(cat:speak()) -- Woof! (existing instance also sees the change)
This pattern—sometimes called “prototypal inheritance”—is how many OOP systems work in Lua. The metatable is __index creates a delegation chain from instances back to their “class” (which is really just another table).
Why Metatables Matter
Metatables are essential in Lua for several reasons:
-
Operator overloading: Make your custom types work with
+,-,*, and other operators naturally. -
Default values: Implement tables with default values for missing keys without wasting memory.
-
Object-oriented programming: Build classes, inheritance, and encapsulation using tables and metatables.
-
Proxies and access control: Create read-only tables, lazy-loaded data, or transparent access to external resources.
-
Custom behavior: Control how tables are printed, compared, or used as keys in other tables.
Summary
Metatables attach “personalities” to tables through metamethods. The __index metamethod controls access to missing keys (enabling inheritance), while __newindex controls writes to missing keys (enabling read-only tables and change tracking). Metamethods like __add, __mul, and __tostring let you define how tables respond to operators and built-in functions.
This is only the beginning. In the next article in this series, we will build on these concepts to create full-featured classes with constructors, private fields, and method inheritance—all using metatables as the foundation.