Metatables let you customize how tables behave — arithmetic, comparison, indexing, and more. Build a full class system in 20 lines of Lua.
Every Lua table can have a metatable — another table that holds metamethods. When Lua encounters an operation on a table that it doesn't know how to handle (like addition or indexing a missing key), it looks in the metatable for a corresponding metamethod and calls it.
__index (read missing keys), __newindex (write missing keys), __tostring (string conversion), __add/__sub/__mul/__div (arithmetic), __eq/__lt/__le (comparison), __len (# operator), __call (call as function).-- Attach a metatable to a table
local vec = {x = 3, y = 4}
local mt = {}
mt.__tostring = function(v)
return string.format("Vec(%g, %g)", v.x, v.y)
end
setmetatable(vec, mt)
print(tostring(vec)) --> Vec(3, 4)
print(vec) --> Vec(3, 4) (print calls tostring)
-- getmetatable retrieves the metatable
print(getmetatable(vec) == mt) --> true
-- You can protect a metatable from outside access
mt.__metatable = "protected"
print(getmetatable(vec)) --> protected
-- setmetatable(vec, {}) -- would now error
local Vec2 = {}
Vec2.__index = Vec2
function Vec2.new(x, y)
return setmetatable({x=x, y=y}, Vec2)
end
Vec2.__tostring = function(v)
return string.format("(%g, %g)", v.x, v.y)
end
Vec2.__add = function(a, b)
return Vec2.new(a.x + b.x, a.y + b.y)
end
Vec2.__sub = function(a, b)
return Vec2.new(a.x - b.x, a.y - b.y)
end
Vec2.__mul = function(a, b)
if type(a) == "number" then return Vec2.new(a*b.x, a*b.y) end
if type(b) == "number" then return Vec2.new(a.x*b, a.y*b) end
return a.x*b.x + a.y*b.y -- dot product
end
Vec2.__eq = function(a, b)
return a.x == b.x and a.y == b.y
end
Vec2.__len = function(v)
return math.sqrt(v.x^2 + v.y^2)
end
-- Methods
function Vec2:normalize()
local len = #self
return Vec2.new(self.x/len, self.y/len)
end
-- Usage
local a = Vec2.new(1, 0)
local b = Vec2.new(0, 1)
local c = a + b * 3
print(tostring(c)) --> (1, 3)
print(#c) --> 3.1623...
print(a == Vec2.new(1,0)) --> true
The __index metamethod is the key to OOP in Lua. When you read a key that doesn't exist in a table, Lua checks the metatable's __index. If it's a table, Lua looks there. If it's a function, Lua calls it.
-- Minimal class factory (20 lines)
local function class(base)
local cls = {}
cls.__index = cls
if base then
setmetatable(cls, {__index = base})
end
cls.new = function(...)
local instance = setmetatable({}, cls)
if instance.init then instance:init(...) end
return instance
end
cls.is_a = function(self, klass)
local mt = getmetatable(self)
while mt do
if mt == klass then return true end
local parent_mt = getmetatable(mt)
mt = parent_mt and parent_mt.__index
end
return false
end
return cls
end
-- ── Define classes ────────────────────────────
local Animal = class()
function Animal:init(name, sound)
self.name = name
self.sound = sound
end
function Animal:speak()
return self.name .. " says " .. self.sound
end
function Animal:__tostring()
return "Animal(" .. self.name .. ")"
end
-- ── Inheritance ───────────────────────────────
local Dog = class(Animal)
function Dog:init(name)
Animal.init(self, name, "Woof")
self.tricks = {}
end
function Dog:learn(trick)
table.insert(self.tricks, trick)
end
function Dog:show_tricks()
if #self.tricks == 0 then
return self.name .. " knows no tricks yet"
end
return self.name .. " knows: " .. table.concat(self.tricks, ", ")
end
-- Override parent method
function Dog:speak()
return Animal.speak(self) .. "!" -- call super
end
-- ── Usage ─────────────────────────────────────
local cat = Animal.new("Whiskers", "Meow")
local dog = Dog.new("Rex")
dog:learn("sit")
dog:learn("shake")
print(cat:speak()) --> Whiskers says Meow
print(dog:speak()) --> Rex says Woof!
print(dog:show_tricks()) --> Rex knows: sit, shake
print(dog:is_a(Dog)) --> true
print(dog:is_a(Animal)) --> true
print(cat:is_a(Dog)) --> false
-- Make a table read-only using __newindex + proxy
local function readonly(t)
local proxy = {}
local mt = {
__index = t,
__newindex = function(_, k, v)
error("attempt to update read-only table key: " .. tostring(k), 2)
end,
__len = function() return #t end,
__ipairs = function() return ipairs(t) end,
__pairs = function() return pairs(t) end,
}
setmetatable(proxy, mt)
return proxy
end
local config = readonly({
host = "localhost",
port = 5432,
timeout = 30,
})
print(config.host) --> localhost
-- config.host = "prod" -- error: attempt to update read-only table
-- Observe writes with __newindex (logging proxy)
local function observable(t, on_set)
local shadow = {}
for k, v in pairs(t) do shadow[k] = v end
return setmetatable({}, {
__index = shadow,
__newindex = function(_, k, v)
on_set(k, shadow[k], v)
shadow[k] = v
end,
})
end
local state = observable({count=0}, function(k, old, new)
print(string.format(" %s: %s -> %s", k, tostring(old), tostring(new)))
end)
state.count = 1 --> count: 0 -> 1
state.count = 5 --> count: 1 -> 5
Vec3 class with x, y, z components. Support __add, __sub, __mul (scalar and dot product), __unm (negation), and __tostring.:cross(other) method that returns the cross product as a new Vec3.:normalize() method and a :length() method (use __len for the latter).Matrix4 class backed by a flat array of 16 numbers. Add identity, multiply (matrix × matrix and matrix × Vec3), and transpose.Implement a mixin function that copies methods from one or more source tables into a target class. Then create three mixins — Serializable (adds :to_json()), Cloneable (adds :clone()), and Observable (adds :on(event, fn) and :emit(event, ...)) — and apply them to your Animal class from the lesson.
The foundations from today carry directly into Day 4. In the next session the focus shifts to Day 4 — building directly on everything covered here.
Before moving on, verify you can answer these without looking:
Live Bootcamp
Learn this in person — 2 days, 5 cities
Thu–Fri sessions in Denver, Los Angeles, New York, Chicago, and Dallas. $1,490 per seat. June–October 2026.
Reserve Your Seat →