Tables
Tables are the only composite data type in Lua. Arrays, hash maps, sets, objects, classes, namespaces and modules are all the same thing with different conventions layered on top. Understanding tables — and the metatable system that extends them — is most of understanding Lua.
Constructing tables
Section titled “Constructing tables”A table is an associative array. Keys can be any value except nil and NaN; values can be anything except nil (storing nil deletes the key).
local empty = {}
local array = { "a", "b", "c" } -- keys 1, 2, 3local dict = { name = "ada", age = 36 } -- keys "name", "age"
local mixed = { "first", "second", -- [1], [2] name = "ada", -- ["name"] [10] = "ten", -- explicit numeric key ["key with spaces"] = true, -- explicit string key}name = v in a constructor is sugar for ["name"] = v, and only works for identifiers. Elements without a key are numbered sequentially from 1, counting only the positional entries.
Separators can be , or ;, and a trailing separator is allowed:
local t = { 1, 2, 3, }Access uses either form; t.name is exactly t["name"]:
print(dict.name) -- => adaprint(dict["name"]) -- => adaprint(array[1]) -- => aprint(array[99]) -- => nil (missing keys are nil, not an error)Key normalization
Section titled “Key normalization”Float keys with an exact integer value are converted to integers, so t[1] and t[1.0] are the same slot:
local t = {}t[1.0] = "x"print(t[1]) -- => xStrings are not converted: t[1] and t["1"] are different keys.
t["1"] = "string key"print(t[1], t["1"]) -- => x string keyt[nil] = v raises table index is nil, and t[0/0] = v raises table index is NaN. Reading either just returns nil.
1-based indexing
Section titled “1-based indexing”Lua arrays conventionally start at index 1, and every standard-library function that works on “lists” (ipairs, table.insert, table.remove, table.concat, table.sort, table.unpack, #) assumes it.
local t = { "a", "b", "c" }print(t[1], t[#t]) -- => a cNothing stops you from using index 0 or negative indices — they are just ordinary keys — but they are invisible to # and ipairs, so mixing conventions is a reliable way to create bugs.
The # operator and holes
Section titled “The # operator and holes”#t returns a border: an index n such that t[n] ~= nil and t[n+1] == nil. For a sequence — a table whose numeric keys are exactly 1..n with no gaps — that is the length, and it is computed in O(log n).
print(#{ "a", "b", "c" }) -- => 3print(#{}) -- => 0If the table has holes, there can be several borders and Lua may return any of them. This is not a bug; it is the documented contract.
local t = { 1, 2, nil, 4 }print(#t) -- => 4 or 2, depending on how the table was builtBoth answers are valid borders. The value can differ between Lua versions, between a literal constructor and incremental assignment, and after unrelated insertions.
The append idiom is:
t[#t + 1] = value -- fast, no function calltable.insert(t, value) -- identical resulttable.insert(t, 1, value) -- insert at position 1, shifting the rest (O(n))Both are correct for sequences. t[#t+1] avoids a call; table.insert is clearer when you also use the positional form.
Internally a table has an array part (dense integer keys) and a hash part (everything else). You never see the split, but it explains why sequences are fast and why sparse integer keys behave like any other hash key.
Iteration
Section titled “Iteration”ipairs
Section titled “ipairs”Walks 1, 2, 3, … and stops at the first nil. Use it for sequences.
for i, v in ipairs({ "a", "b", nil, "d" }) do print(i, v)end-- => 1 a-- => 2 b (stops here; "d" is never visited)Visits every key/value pair, in an unspecified order.
local t = { 10, 20, x = "a", y = "b" }for k, v in pairs(t) do print(k, v) end-- order is not defined: 1, 2, "x", "y" may come in any sequencepairs(t) returns next, t, nil (unless t has a __pairs metamethod, which Lua 5.2+ honors). next(t, key) returns the next key/value pair; next(t) returns the first one, or nil for an empty table — which is the standard emptiness test:
if next(t) == nil then print("empty") endModifying during iteration
Section titled “Modifying during iteration”You may assign to existing fields and you may delete the current key (t[k] = nil) while iterating with pairs. Adding a new key during a pairs loop is undefined behavior — next may skip entries or error with invalid key to 'next'. Build a list of changes and apply them after the loop.
To remove elements from a sequence in place, iterate backwards so the shifting does not skip entries:
for i = #list, 1, -1 do if should_remove(list[i]) then table.remove(list, i) endendThe table library
Section titled “The table library”local t = { "a", "b", "c" }
table.insert(t, "d") -- appendtable.insert(t, 1, "z") -- insert at position, shifting rightprint(table.remove(t)) -- => d (removes and returns last)print(table.remove(t, 1)) -- => z (removes at position, shifting left)
print(table.concat({1,2,3}, "-")) -- => 1-2-3print(table.concat({"a","b","c"}, ", ", 2, 3)) -- => b, c
print(table.unpack({ 10, 20, 30 })) -- => 10 20 30print(table.unpack({ 10, 20, 30 }, 2)) -- => 20 30
local p = table.pack(1, nil, 3)print(p.n, p[1], p[3]) -- => 3 1 3
table.sort(t) -- in place, ascendingtable.sort(t, function(a, b) return a > b end) -- custom comparatortable.concat requires every element to be a string or number; anything else raises invalid value (at index i) in table for 'concat'.
table.sort is not stable — equal elements can be reordered. The comparator must be a strict less-than: comp(a, a) must be false, and it must be consistent. An inconsistent comparator (for example <= instead of <) makes Lua 5.4 raise invalid order function for sorting rather than crash.
table.move(a1, f, e, t [, a2]) (Lua 5.3+) copies the range a1[f..e] to a2[t..], handling overlap correctly. It is the fastest way to shift or clone a slice:
local src = { 1, 2, 3, 4, 5 }local dst = table.move(src, 2, 4, 1, {}) -- => { 2, 3, 4 }Lua has no built-in deep copy, merge, map/filter, or “table length including hash keys”. Write them or use a library.
local function count(t) local n = 0 for _ in pairs(t) do n = n + 1 end return nendMetatables
Section titled “Metatables”A metatable is an ordinary table attached to a value that tells Lua what to do for operations the value has no default behavior for. Tables and userdata have per-value metatables; all strings share one; the other types have none by default.
local t = setmetatable({}, { __index = function(_, k) return k .. "!" end })print(t.hello) -- => hello!print(getmetatable(t)) -- => table: 0x...setmetatable(t, mt) returns t, which is why constructors read return setmetatable({}, Class).
The raw* functions bypass metatables entirely — essential inside metamethods to avoid infinite recursion:
rawget(t, k) -- t[k] without __indexrawset(t, k, v) -- t[k] = v without __newindexrawequal(a, b) -- a == b without __eqrawlen(t) -- #t without __len (5.2+)__index — the lookup fallback
Section titled “__index — the lookup fallback”Triggered when a raw lookup returns nil. It can be a table or a function.
-- As a table: lookups fall through to `defaults`.local defaults = { color = "red", size = 10 }local opts = setmetatable({ size = 20 }, { __index = defaults })print(opts.size, opts.color) -- => 20 red
-- As a function: full control, and you see the key.local strict = setmetatable({}, { __index = function(_, k) error("undefined field: " .. tostring(k), 2) end})When __index is a table, the lookup is repeated on that table — including its own metatable. That chaining is exactly how inheritance works.
__newindex — the assignment hook
Section titled “__newindex — the assignment hook”Triggered when assigning to a key that is not already present.
-- A read-only table.local function readonly(t) return setmetatable({}, { __index = t, __newindex = function() error("attempt to modify a read-only table", 2) end, __len = function() return #t end, __pairs = function() return pairs(t) end, })end
local config = readonly{ host = "localhost", port = 8080 }print(config.host) -- => localhost-- config.host = "x" --> error: attempt to modify a read-only tableA common use is tracking writes while still storing them — note rawset, without which you would recurse forever:
local log = {}local tracked = setmetatable({}, { __newindex = function(t, k, v) log[#log + 1] = k rawset(t, k, v) end})tracked.a = 1tracked.b = 2print(table.concat(log, ",")) -- => a,bThe full metamethod set (Lua 5.4)
Section titled “The full metamethod set (Lua 5.4)”| Metamethod | Fires on |
|---|---|
__index |
reading a missing key |
__newindex |
writing a missing key |
__call |
calling the value: v(args) |
__tostring |
tostring(v), print(v), string.format("%s", v) |
__len |
#v |
__eq |
==, only when both operands are tables (or both userdata) and not raw-equal |
__lt, __le |
<, <= (and >, >= with operands swapped) |
__concat |
.. |
__unm |
unary minus |
__add __sub __mul __div __mod __pow __idiv |
arithmetic |
__band __bor __bxor __bnot __shl __shr |
bitwise (5.3+) |
__mode |
weak-table mode: "k", "v", or "kv" |
__gc |
finalization during garbage collection |
__close |
scope exit of a <close> variable (5.4) |
__name |
a type name used in error messages and default tostring |
__metatable |
value returned by getmetatable; also makes setmetatable error |
__pairs |
pairs(v) (5.2+) |
A worked example — a 2D vector with operators:
local Vec = {}Vec.__index = Vec
function Vec.new(x, y) return setmetatable({ x = x, y = y }, Vec) end
Vec.__add = function(a, b) return Vec.new(a.x + b.x, a.y + b.y) endVec.__sub = function(a, b) return Vec.new(a.x - b.x, a.y - b.y) endVec.__unm = function(a) return Vec.new(-a.x, -a.y) endVec.__eq = function(a, b) return a.x == b.x and a.y == b.y endVec.__len = function(a) return math.floor(math.sqrt(a.x^2 + a.y^2)) endVec.__tostring = function(a) return ("Vec(%g, %g)"):format(a.x, a.y) endVec.__call = function(a, s) return Vec.new(a.x * s, a.y * s) end
local a, b = Vec.new(1, 2), Vec.new(3, 4)print(a + b) -- => Vec(4, 6)print(-a) -- => Vec(-1, -2)print(a == Vec.new(1, 2)) -- => trueprint(a(3)) -- => Vec(3, 6) (via __call)Arithmetic metamethods are looked up on the first operand that has one, so 2 * vec works if Vec.__mul handles a number on either side. Comparison is stricter: __eq is only consulted when both operands are tables, so vec == 5 is always false without any metamethod call.
Weak tables
Section titled “Weak tables”__mode makes keys and/or values weak, so they do not stop the garbage collector. This is the mechanism for caches and object-associated side tables.
local cache = setmetatable({}, { __mode = "k" }) -- weak keys-- entries vanish once nothing else references the key object"v" gives weak values (a memo cache you are happy to lose), "kv" both.
Object orientation
Section titled “Object orientation”Lua has no class keyword. The standard pattern uses a table as the class, __index pointing at itself so instances inherit methods, and a .new constructor.
local Account = {}Account.__index = Account
function Account.new(owner, balance) return setmetatable({ owner = owner, balance = balance or 0 }, Account)end
function Account:deposit(amount) assert(amount > 0, "amount must be positive") self.balance = self.balance + amount return selfend
function Account:withdraw(amount) if amount > self.balance then return nil, "insufficient funds" end self.balance = self.balance - amount return self.balanceend
function Account:__tostring() return ("Account(%s, %.2f)"):format(self.owner, self.balance)end
local acct = Account.new("ada", 100)acct:deposit(50)print(acct) -- => Account(ada, 150.00)print(acct:withdraw(500)) -- => nil insufficient fundsWhat makes this work: acct has no deposit key, so the __index = Account metamethod resolves it to Account.deposit, and the : call passes acct as self. Instance data lives on the instance; methods live once on the class.
Inheritance
Section titled “Inheritance”Give the subclass a metatable whose __index is the parent, so class-level lookups chain upward too.
local Savings = setmetatable({}, { __index = Account })Savings.__index = SavingsSavings.__tostring = Account.__tostring
function Savings.new(owner, balance, rate) local self = Account.new(owner, balance) self.rate = rate return setmetatable(self, Savings)end
function Savings:add_interest() self.balance = self.balance + self.balance * self.rate return selfend
-- Override, calling the parent explicitly:function Savings:withdraw(amount) if amount > 500 then return nil, "savings withdrawal limit" end return Account.withdraw(self, amount) -- note '.' plus explicit selfend
local s = Savings.new("bob", 1000, 0.05)s:add_interest()print(s.balance) -- => 1050.0print(s:withdraw(600)) -- => nil savings withdrawal limitprint(s:deposit(10).balance) -- => 1060.0 (inherited from Account)Two things to notice:
- There is no
super. You callParent.method(self, ...)— with a dot, passingselfyourself. - Metamethods are not inherited through
__index. Lua looks up__tostring(and__add,__eq, …) rawly in the instance’s own metatable. That is whySavings.__tostring = Account.__tostringis written out explicitly. Forgetting this is the most common bug in Lua inheritance.
Privacy
Section titled “Privacy”Fields on the instance are always visible. If you need true privacy, use a closure instead of a table:
local function make_counter() local n = 0 -- unreachable from outside return { inc = function() n = n + 1 return n end, get = function() return n end, }endThe tradeoff is one closure per method per instance, instead of one shared function per class.
The module pattern
Section titled “The module pattern”A Lua module is a file that returns a value — almost always a table. require runs the file once and caches the result.
local M = {}
-- Private: not exported, not global.local function is_space(c) return c:match("%s") ~= nil end
function M.trim(s) return (s:gsub("^%s*(.-)%s*$", "%1"))end
function M.split(s, sep) sep = sep or "," local out = {} for field in s:gmatch("([^" .. sep .. "]+)") do out[#out + 1] = field end return outend
return Mlocal str = require("mylib.strings")print(str.trim(" hi ")) -- => hiprint(table.concat(str.split("a,b,c"), "|")) -- => a|b|cRules that matter:
- The module must
returnits table. A file that only sets globals “works” but pollutes_Gand returnstruefromrequire. - Dots in the module name map to path separators:
require("mylib.strings")looks formylib/strings.luaalongpackage.path. requirecaches inpackage.loaded, so the file body runs once per Lua state no matter how many times you require it.- Never use the Lua 5.0/5.1
module()function — it was deprecated in 5.2 and removed thereafter.
Loading and the search path are covered on the standard library page.
Key points
Section titled “Key points”- One data type: a table with an array part and a hash part. Arrays start at 1.
#treturns a border, not a count. It is only a length for gap-free sequences; holes make it unreliable.ipairsstops at the firstnil;pairssees everything in an order you must not depend on.- Metatables hook operations;
__index/__newindexcover lookup and assignment, andraw*functions bypass them. - OOP is
Class.__index = Classplussetmetatable(instance, Class); inheritance is a metatable on the class itself. - Metamethods are looked up raw in the instance’s own metatable, so they are not inherited — copy them into subclasses.
- A module is a file that builds a local table and returns it.