Skip to content

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.

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, 3
local 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) -- => ada
print(dict["name"]) -- => ada
print(array[1]) -- => a
print(array[99]) -- => nil (missing keys are nil, not an error)

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]) -- => x

Strings are not converted: t[1] and t["1"] are different keys.

t["1"] = "string key"
print(t[1], t["1"]) -- => x string key

t[nil] = v raises table index is nil, and t[0/0] = v raises table index is NaN. Reading either just returns nil.

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 c

Nothing 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.

#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" }) -- => 3
print(#{}) -- => 0

If 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 built

Both 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 call
table.insert(t, value) -- identical result
table.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.

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 sequence

pairs(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") end

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) end
end
local t = { "a", "b", "c" }
table.insert(t, "d") -- append
table.insert(t, 1, "z") -- insert at position, shifting right
print(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-3
print(table.concat({"a","b","c"}, ", ", 2, 3)) -- => b, c
print(table.unpack({ 10, 20, 30 })) -- => 10 20 30
print(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, ascending
table.sort(t, function(a, b) return a > b end) -- custom comparator

table.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 n
end

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 __index
rawset(t, k, v) -- t[k] = v without __newindex
rawequal(a, b) -- a == b without __eq
rawlen(t) -- #t without __len (5.2+)

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.

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 table

A 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 = 1
tracked.b = 2
print(table.concat(log, ",")) -- => a,b
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) end
Vec.__sub = function(a, b) return Vec.new(a.x - b.x, a.y - b.y) end
Vec.__unm = function(a) return Vec.new(-a.x, -a.y) end
Vec.__eq = function(a, b) return a.x == b.x and a.y == b.y end
Vec.__len = function(a) return math.floor(math.sqrt(a.x^2 + a.y^2)) end
Vec.__tostring = function(a) return ("Vec(%g, %g)"):format(a.x, a.y) end
Vec.__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)) -- => true
print(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.

__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.

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 self
end
function Account:withdraw(amount)
if amount > self.balance then
return nil, "insufficient funds"
end
self.balance = self.balance - amount
return self.balance
end
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 funds

What 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.

Give the subclass a metatable whose __index is the parent, so class-level lookups chain upward too.

local Savings = setmetatable({}, { __index = Account })
Savings.__index = Savings
Savings.__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 self
end
-- 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 self
end
local s = Savings.new("bob", 1000, 0.05)
s:add_interest()
print(s.balance) -- => 1050.0
print(s:withdraw(600)) -- => nil savings withdrawal limit
print(s:deposit(10).balance) -- => 1060.0 (inherited from Account)

Two things to notice:

  1. There is no super. You call Parent.method(self, ...) — with a dot, passing self yourself.
  2. Metamethods are not inherited through __index. Lua looks up __tostring (and __add, __eq, …) rawly in the instance’s own metatable. That is why Savings.__tostring = Account.__tostring is written out explicitly. Forgetting this is the most common bug in Lua inheritance.

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,
}
end

The tradeoff is one closure per method per instance, instead of one shared function per class.

A Lua module is a file that returns a value — almost always a table. require runs the file once and caches the result.

mylib/strings.lua
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 out
end
return M
main.lua
local str = require("mylib.strings")
print(str.trim(" hi ")) -- => hi
print(table.concat(str.split("a,b,c"), "|")) -- => a|b|c

Rules that matter:

  • The module must return its table. A file that only sets globals “works” but pollutes _G and returns true from require.
  • Dots in the module name map to path separators: require("mylib.strings") looks for mylib/strings.lua along package.path.
  • require caches in package.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.

  • One data type: a table with an array part and a hash part. Arrays start at 1.
  • #t returns a border, not a count. It is only a length for gap-free sequences; holes make it unreliable.
  • ipairs stops at the first nil; pairs sees everything in an order you must not depend on.
  • Metatables hook operations; __index/__newindex cover lookup and assignment, and raw* functions bypass them.
  • OOP is Class.__index = Class plus setmetatable(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.