Implementing Classes and Single Inheritance in Lua with Metatables
Build reusable classes and single inheritance in Lua using metatables and __index. Covers constructor patterns, colon syntax, inheritance chains, rawget/rawset, shared metatables, and verification steps with a complete runnable example.
03 Mar 2026, 10:45 UTC

Desired Outcome
Create reusable class-like structures in Lua that support method definitions, instance initialization, and single inheritance—without external libraries—so that method dispatch works predictably and memory overhead stays low.
Prerequisites
- Lua 5.1+ or LuaJIT installed (run
lua -vto confirm). - Basic familiarity with tables, functions, and the module pattern (
local M = {}; return M). - Optional:
luacheckfor static analysis in CI.
Core Mechanism: Metatables and __index
Lua has no class keyword. Object orientation is built from tables and metatables. The essential trick: a class table assigns itself to its own __index field. When an instance lacks a key, Lua consults the metatable's __index, which points back to the class where methods live.
local Entity = {}
Entity.__index = Entity -- enables fallback lookup
function Entity:new(x, y)
local obj = setmetatable({}, self) -- self is Entity (or subclass)
obj.x = x or 0
obj.y = y or 0
return obj
end
function Entity:move(dx, dy)
self.x = self.x + dx
self.y = self.y + dy
end
return Entitysetmetatable(obj, self) attaches the class as the instance's metatable. Since setmetatable returns its first argument (Lua 5.1+), the constructor can be a one-liner. The colon in Entity:new is syntactic sugar: function Entity.new(self, ...); the caller writes Entity:new() and self is passed implicitly.
Single Inheritance
Give the subclass its own __index self-reference, then set the subclass's metatable so its __index falls back to the superclass.
local Player = {}
Player.__index = Player
setmetatable(Player, { __index = Entity }) -- inheritance link
function Player:new(x, y, name)
local obj = Entity.new(self, x, y) -- call parent constructor
obj.name = name or "Player"
obj.health = 100
return setmetatable(obj, self) -- re-attach subclass metatable
end
function Player:takeDamage(amount)
self.health = self.health - amount
if self.health <= 0 then self:die() end
end
function Player:die()
print(self.name .. " has been defeated.")
end
return PlayerMethod lookup now walks: instance → Player → Entity. A subclass method can invoke the overridden parent with Entity.move(self, dx, dy) (dot syntax, explicit self).
Constructor Pattern Details
Two equivalent styles exist. Choose one and stay consistent.
Style A: setmetatable inside new
function Entity:new(x, y)
local obj = { x = x or 0, y = y or 0 }
return setmetatable(obj, self)
endStyle B: One-liner (relies on setmetatable return value)
function Entity:new(x, y)
return setmetatable({ x = x or 0, y = y or 0 }, self)
endBoth work because self is the class table (or subclass when inherited). The instance receives the class as its metatable, so getmetatable(instance).__index == Class holds true.
Colon vs. Dot: Avoiding nil Self Bugs
Define methods with colon, call with colon. Mixing causes self to be nil or the wrong table.
-- Correct
function Entity:move(dx, dy) ... end
local e = Entity:new()
e:move(1, 2) -- self == e
-- Bug: dot call drops self
e.move(1, 2) -- self == 1, dx == 2, dy == nil
-- Bug: dot definition expects explicit self
function Entity.move(self, dx, dy) ... end
e:move(1, 2) -- self == e, dx == 1, dy == 2 (works but inconsistent)
Rule: always use colon for both definition and call.
Using rawget and rawset
Inside metamethods or initializers, bypass __index/__newindex to avoid infinite recursion.
function Entity:__newindex(key, value)
if key == "readonly" then error("cannot modify") end
rawset(self, key, value) -- direct write, no metamethod trigger
end
-- In constructor, rawset is optional but safe:
function Entity:new(x, y)
local obj = setmetatable({}, self)
rawset(obj, "x", x or 0)
rawset(obj, "y", y or 0)
return obj
endMemory Efficiency: Shared Metatables
All instances of the same class share one metatable. Do not create a fresh metatable per instance.
-- Bad: new metatable every instance
function BadClass:new()
return setmetatable({}, { __index = self })
end
-- Good: reuse class metatable
function GoodClass:new()
return setmetatable({}, self)
endShared metatables keep getmetatable(obj).__index == Class checks working and reduce GC pressure.
Verification Checklist
- Interpreter version: run
lua -vand record output (e.g., "Lua 5.4.6" or "LuaJIT 2.1.0-beta3"). - Basic dispatch: execute a minimal script that defines
Entity, creates an instance, calls a method, and printsgetmetatable(e).__index == Entity(expecttrue). - Inheritance: create a
Playerinstance, callplayer:move(5, 0)(inherited) andplayer:takeDamage(10)(own). Both must succeed. - Parent call: in
Player:move, callEntity.move(self, dx, dy)and verify coordinates update. - Metamethod proof:
print(rawget(player, "move"))→nil;print(player.move)→ function. Confirms__indexdispatch. - Static analysis: run
luacheckon the module; treat warnings as failures in CI. - Performance sanity: loop 1,000,000 method calls; compare against a version that caches
local move = obj.moveoutside the loop. Metamethod overhead is typically <5% on LuaJIT, higher on PUC Lua.
Limitations and Cautions
- Accidental globals:
health = 10inside a method creates a global. Always writeself.health = 10.luacheckcatches this. __indexcycles: ifA.__index = BandB.__index = A, lookup recurses until stack overflow. Lua reports a generic stack-overflow error.- Deep hierarchies: each miss walks the chain. Keep inheritance shallow; prefer composition for cross-cutting behavior.
- LuaJIT differences: based on Lua 5.1 with select 5.2 features. Code using
//, bitwise operators, orgotomay not run on PUC Lua 5.1. __gcfor tables: requires Lua 5.2+ (LuaJIT supports as extension). Do not rely on finalizers for external resources in portable code.- Metatables are per-value: two instances share one metatable. Storing per-instance state in the metatable is a bug.
Recovery Options
If method dispatch fails:
- Print
getmetatable(instance)and verify its__indexpoints to the intended class. - Check that the method is defined with colon syntax and called with colon syntax.
- Ensure no
__newindexmetamethod blocks field writes during construction. - If inheritance breaks, confirm
setmetatable(Subclass, { __index = Superclass })executed before any subclass instances are created.
Complete Minimal Example
-- entity.lua
local Entity = {}
Entity.__index = Entity
function Entity:new(x, y)
return setmetatable({ x = x or 0, y = y or 0 }, self)
end
function Entity:move(dx, dy)
self.x = self.x + dx
self.y = self.y + dy
end
return Entity
-- player.lua
local Entity = require "entity"
local Player = {}
Player.__index = Player
setmetatable(Player, { __index = Entity })
function Player:new(x, y, name)
local obj = Entity.new(self, x, y)
obj.name = name or "Player"
obj.health = 100
return setmetatable(obj, self)
end
function Player:takeDamage(amount)
self.health = self.health - amount
if self.health <= 0 then self:die() end
end
function Player:die()
print(self.name .. " defeated")
end
return Player
-- test.lua
local Player = require "player"
local p = Player:new(10, 20, "Hero")
print("pos:", p.x, p.y) -- 10 20
p:move(5, -3)
print("after move:", p.x, p.y) -- 15 17
print("meta check:", getmetatable(p).__index == Player) -- true
p:takeDamage(120) -- prints "Hero defeated"
print("rawget move:", rawget(p, "move")) -- nil
print("dispatch move:", p.move ~= nil) -- trueRun lua test.lua to verify the full chain works.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.