Building Classes in Lua with Metatables: OOP Step by Step
Intro context
Building classes in Lua is a question of how you wire up metatables, not a question of language syntax. Lua has no class keyword, but the same metatable mechanism that lets you customise table operations also lets you implement constructors, methods, inheritance, and even private members. This tutorial shows how to assemble those pieces step by step.
In the previous tutorial we explored metatables and metamethods, the mechanism that lets you customise Lua’s behaviour for table operations. Building classes in Lua builds on that foundation: a class is a table acting as a prototype, and the metatable links new instances back to it for method lookup. We’ll cover constructors with new, the self parameter, instance methods, single inheritance, and a pattern for keeping members private. For related reading see the Lua inheritance tutorial and the metatables guide.
From tables to classes
A class in Lua is a table acting as a prototype for creating objects. The metatable links new objects to their class, enabling method lookup and inheritance.
The basic class pattern
Here’s how you create a simple class:
-- Define the class (a table with methods)
local Animal = {}
Animal.__index = Animal
-- Constructor: creates a new Animal instance
function Animal.new(name, species)
local self = setmetatable({}, Animal)
self.name = name
self.species = species
return self
end
-- A method
function Animal:speak()
return "Some generic sound"
end
-- Create an instance
local dog = Animal.new("Buddy", "Dog")
print(dog:speak()) -- Output: Some generic sound
print(dog.name) -- Output: Buddy
The key ingredient is setmetatable({}, Animal). This creates a new empty table and sets its metatable to Animal. When you call dog:speak(), Lua searches for speak in dog, fails, then checks the metatable , finding Animal.__index = Animal, it continues searching there and finds the method.
Understanding the self parameter
In the example above, we used function Animal:speak() instead of function Animal.speak(). The colon syntax is syntactic sugar that automatically passes the object as the first argument (traditionally called self).
-- These are equivalent:
function Animal.speak(self)
return self.name .. " makes a sound"
end
function Animal:speak()
return self.name .. " makes a sound"
end
The colon syntax makes code cleaner and is the standard convention for methods. Always use self when you need to access or modify the object’s properties.
One common Lua pitfall arises when you call a colon-defined method with dot syntax—Lua won’t auto-pass self, which typically causes a nil-index error inside the function body. Conversely, calling a dot-defined function with colon syntax shifts arguments by one position and can produce confusing results. Getting comfortable with this distinction early prevents a whole category of bugs down the line. Internalising when Lua supplies self automatically also helps you understand why constructors like new() often use dot syntax: the constructor itself doesn’t receive an instance until setmetatable creates one.
Constructors: multiple approaches
While new() is a common constructor name, you can use any naming convention:
-- Factory function pattern
local Rectangle = {}
Rectangle.__index = Rectangle
function Rectangle.new(width, height)
local self = setmetatable({}, Rectangle)
self.width = width
self.height = height
return self
end
-- Alternative: use the type name itself as constructor
function Rectangle:new(width, height)
local self = setmetatable({}, Rectangle)
self.width = width
self.height = height
return self
end
-- Usage
local rect = Rectangle.new(10, 5)
print(rect.width, rect.height) -- Output: 10 5
Adding more methods
With the constructor pattern working, the next step is giving your class behaviour beyond creation. A class that only holds data offers no advantage over a plain table. Methods turn a passive collection of fields into an active, self-contained unit of computation.
Classes become useful when they have meaningful methods:
function Rectangle:getArea()
return self.width * self.height
end
function Rectangle:getPerimeter()
return 2 * (self.width + self.height)
end
function Rectangle:scale(factor)
self.width = self.width * factor
self.height = self.height * factor
return self -- enable chaining
end
-- Usage
local rect = Rectangle.new(10, 5)
print(rect:getArea()) -- Output: 50
rect:scale(2)
print(rect:getArea()) -- Output: 200
Notice that scale() returns self , this enables method chaining (fluent interface pattern).
Private members in Lua
Once your classes accumulate methods like getArea() and scale(), you’ll almost certainly want to hide some internal state. Lua doesn’t enforce privacy, but you can simulate private members using closures or naming conventions. The right approach depends on how strictly you need to guard your data and whether you’re willing to pay the memory cost of closure-based encapsulation for every object.
Convention-based privacy
local BankAccount = {}
BankAccount.__index = BankAccount
function BankAccount.new(initialBalance)
local self = setmetatable({}, BankAccount)
-- Convention: underscore prefix indicates private
self._balance = initialBalance
return self
end
function BankAccount:deposit(amount)
if amount > 0 then
self._balance = self._balance + amount
return true
end
return false
end
function BankAccount:getBalance()
return self._balance
end
local account = BankAccount.new(100)
account:deposit(50)
print(account:getBalance()) -- Output: 150
-- Direct access still works but convention signals "don't touch"
print(account._balance) -- Output: 150
Closure-based privacy (true privacy)
The underscore convention works because everyone agrees to it, but nothing stops a caller from accessing _balance directly. For true privacy, you need closures, which trap variables in a scope that code outside the function cannot reach. Lua upvalues—locals referenced by nested functions—are invisible to the metatable-based lookup that __index provides, so even print-inspection won’t reveal them.
For stronger privacy, use closures:
function BankAccount.new(initialBalance)
-- Private variable in closure scope
local _balance = initialBalance
local self = setmetatable({}, {__index = BankAccount})
function self:deposit(amount)
if amount > 0 then
_balance = _balance + amount
return true
end
return false
end
function self:getBalance()
return _balance
end
return self
end
local account = BankAccount.new(100)
account:deposit(50)
print(account:getBalance()) -- Output: 150
print(account._balance) -- Output: nil (doesn't exist!)
This approach is more memory-intensive (each object gets its own function closures) but provides true privacy. For most Lua codebases, the underscore convention strikes a practical balance, and closure-based privacy tends to be reserved for library internals or security-sensitive data where visibility must be impossible, not merely inconvenient.
Inheritance
Inheritance in Lua is achieved by setting up the prototype chain through metatables:
-- Base class: Animal
local Animal = {}
Animal.__index = Animal
function Animal.new(name, species)
local self = setmetatable({}, Animal)
self.name = name
self.species = species
return self
end
function Animal:speak()
return "Some generic sound"
end
-- Derived class: Dog
local Dog = {}
Dog.__index = Dog
-- Inherit from Animal
setmetatable(Dog, {__index = Animal})
function Dog.new(name)
local self = setmetatable({}, Dog)
self.name = name
self.species = "Dog"
return self
end
-- Override the speak method
function Dog:speak()
return self.name .. " barks!"
end
-- Usage
local animal = Animal.new("Generic", "Animal")
local dog = Dog.new("Buddy")
print(animal:speak()) -- Output: Some generic sound
print(dog:speak()) -- Output: Buddy barks!
print(dog.name) -- Output: Buddy
print(dog.species) -- Output: Dog
When dog:speak() is called, Lua finds speak in Dog. When accessing dog.name, it’s not in Dog, so Lua checks Animal via the metatable chain.
Multi-level inheritance
Single-level inheritance handles many use cases, but realistic object hierarchies often span three or four levels. Lua’s metatable chain supports this naturally: each class can act as both a prototype for its own instances and a child of another class. The lookup walks the __index links until it either finds the field or reaches a metatable whose __index is nil.
You can create deeper inheritance hierarchies:
local Bird = {}
Bird.__index = Bird
function Bird:fly()
return self.name .. " is flying"
end
local Parrot = {}
Parrot.__index = Parrot
setmetatable(Parrot, {__index = Bird})
function Parrot:speak()
return self.name .. " says Hello!"
end
local polly = Parrot.new("Polly")
print(polly:fly()) -- Output: Polly is flying
print(polly:speak()) -- Output: Polly says Hello!
Super: calling parent methods
When you override a method in a subclass, you often still want access to the parent’s original behaviour—for example, to run validation logic before your own additions, or to post-process the parent’s return value. Lua offers no built-in super keyword, so you must invoke the parent method explicitly, passing self by hand.
To call a parent method from an overridden method:
function Dog:speak()
-- Call the parent's speak method
local parentSpeech = Animal.speak(self)
return self.name .. " says: " .. parentSpeech
end
Calling Animal.speak(self) works, but it hard-codes the parent class name. If you later refactor the inheritance chain, say, by inserting a Mammal class between Animal and Dog, every hard-coded parent reference needs updating. Storing a super reference in each class table makes the relationship explicit and simplifies refactoring. The self argument remains necessary because Lua’s colon syntax only auto-passes the instance, not the class.
Or more elegantly, store a reference:
local Animal = {super = nil}
local Dog = {super = Animal}
setmetatable(Dog, {__index = Animal})
function Dog:speak()
local parentSpeech = Dog.super.speak(self)
return self.name .. " says: " .. parentSpeech
end
Class variables VS instance variables
So far every piece of data has lived on the instance: self.name, self.width, self._balance. But some values belong to the class itself and should be shared across every object created from it, tracking the count of live instances, caching lookup tables, or managing a connection pool all fit this pattern.
Sometimes you want class-level variables (shared across all instances):
local Counter = {}
Counter.__index = Counter
Counter.count = 0 -- Class variable
function Counter.new()
local self = setmetatable({}, Counter)
Counter.count = Counter.count + 1
return self
end
function Counter:getCount()
return Counter.count
end
local a = Counter.new()
local b = Counter.new()
print(a:getCount()) -- Output: 2
print(b:getCount()) -- Output: 2
print(Counter.count) -- Output: 2
Summary
Metatables enable object-oriented programming in Lua:
- Classes are tables with methods, indexed via
__index - Constructors create instances using
setmetatable({}, Class) self(the colon syntax) gives methods access to instance data- Private members can use convention (
_name) or closures - Inheritance works by setting up metatable chains
In the next tutorial, we’ll explore more advanced OOP patterns including mixins, multiple inheritance, and static methods.
Ready to practice? Try building a Shape base class with Circle and Square subclasses. Challenge yourself to implement area calculation for each shape type!
Where to go next
Now that you understand building classes in Lua, deepen the pattern by reading the inheritance in Lua tutorial and the Lua closures guide. For broader object-system patterns, see the Lua functional patterns guide.
See also
- Metatables introduction — The foundation that makes class-based OOP possible in Lua
- Metamethods deep dive — Custom operators and behaviour that classes can override
- Mixins and composition — An alternative to classical inheritance using Lua’s flexible table model