Tables: Lua's Universal Data Structure
Tables are the only data structure in Lua. Unlike languages that give you separate arrays, hash maps, and objects, Lua packs everything into a single, flexible table type. This simplicity is one of Lua’s greatest strengths — once you understand tables, you can express almost any data structure. In this tutorial, you’ll learn how tables work as arrays, dictionaries, and objects, how to iterate over them with pairs() and ipairs(), and which common pitfalls to avoid.
What is a table?
A table is a collection of key-value pairs. Keys can be any value except nil. Values can be anything, including other tables. This flexibility is what makes tables so powerful.
Creating a table
The simplest way to create a table is using curly braces:
-- An empty table
local empty = {}
-- A table with some values
local person = {
name = "Alice",
age = 30,
city = "London"
}
Each key in person maps to a value through an explicit name — this is the dictionary pattern at work. Omit the keys entirely and Lua fills in the blanks with consecutive integers, always starting at 1 rather than 0. Beginners coming from Python or JavaScript often trip on this off-by-one shift, especially when porting loop counters or slice logic. Arrays are merely tables wearing integer-key clothing: #, table.insert, and table.remove all resolve to the same key-value machinery you already touched with person. The constructor syntax stays identical while the semantics pivot from unordered dictionary to ordered sequence:
-- This creates an array-like table
local fruits = {"apple", "banana", "cherry"}
print(fruits[1]) -- apple
print(fruits[2]) -- banana
print(fruits[3]) -- cherry
Note: Lua arrays are 1-indexed, not 0-indexed like in many other languages. This is a deliberate design choice that makes Lua more readable for non-programmers.
Tables as dictionaries
The name-value syntax creates what other languages call a dictionary, hash map, or associative array:
local config = {
host = "localhost",
port = 8080,
debug = true
}
-- Access values using dot notation or bracket notation
print(config.host) -- localhost
print(config["port"]) -- 8080
-- Add new key-value pairs
config.timeout = 30
config["max_connections"] = 100
Dot notation and bracket notation read the same underlying key, but they serve different purposes. Stick with table.key when the key name is a valid identifier known at write time — it’s shorter and the parser catches typos. Bracket notation becomes essential the moment the key lives inside a variable, comes from user input, or contains spaces and special characters. A subtle trap: config[key] evaluates key at runtime, so if key holds nil you silently read or write the nil key, which Lua treats as removing the entry. This code demonstrates a variable-driven key lookup where dot syntax cannot reach the value:
local key = "username"
local user = {}
user[key] = "john_doe"
print(user.username) -- john_doe
Tables as arrays
Dictionaries give you named access, but many problems call for ordered, positional data — queues, stacks, and ordered lists. Lua handles this without a second data type. Drop the key names and the constructor assigns integer indices 1, 2, 3, … automatically, producing what walks and talks like an array. Under the hood nothing changes: numbers[2] is still a key-value lookup, just with integer 2 as the key rather than a string. Because the integer keys run contiguously from 1, the # operator and table.insert/table.remove work predictably on this shape — a contract that breaks the moment gaps or non-integer keys creep in.
local numbers = {10, 20, 30, 40, 50}
-- Get the length using #
print(#numbers) -- 5
-- Loop through all elements
for i = 1, #numbers do
print(numbers[i])
end
-- Add elements to the end
table.insert(numbers, 60)
print(#numbers) -- 6
The # operator returns the length only when integer keys form an unbroken run from 1 , introduce a nil in the middle and the result becomes undefined, returning either the index before the gap or the last non-nil index depending on internal representation. This is not a bug but a consequence of tables being general-purpose key-value stores rather than dedicated array types. For reliable iteration you need strategies that don’t lean on #, which brings us to Lua’s three built-in traversal mechanisms.
Iterating over tables
Lua provides three ways to iterate over tables, each with different guarantees about order and coverage:
Using pairs() for key-value iteration
local data = {a = 1, b = 2, c = 3}
for key, value in pairs(data) do
print(key, value)
end
pairs() visits every key-value pair in the table, regardless of key type, but makes no promise about the sequence. Run it twice on the same table and you may get keys in a different order , the internal hash layout determines traversal. This makes pairs() the right choice for dictionary-style tables where you genuinely need everything, and the wrong choice when output order must be deterministic. For ordered integer-indexed data you need a stricter iterator.
Using ipairs() for array-style iteration
local items = {"first", "second", "third"}
for index, value in ipairs(items) do
print(index, value)
end
ipairs() walks integer keys in ascending order , 1, 2, 3 , and halts the instant it hits a nil value. Put nil at index 2 in a four-element table and ipairs() processes only the first element, silently skipping everything after the gap. This makes ipairs() safe for dense arrays but treacherous when nil gaps are possible. For mixed-key tables or cases where you need total control over the iteration loop, Lua exposes the raw primitive underneath both of these convenience iterators.
Using next() for manual iteration
local t = {x = 10, y = 20}
local key, value = next(t)
while key do
print(key, value)
key, value = next(t, key)
end
next(t, key) returns the next key-value pair after the given key , call it with nil to get the first pair, then feed the returned key back to advance. This is the low-level mechanism that pairs() wraps in a for-loop-friendly iterator factory. Writing the loop by hand with next() gives you complete control: you can break early, skip keys based on runtime conditions, or inspect state between iterations in ways that pairs() hides. The trade-off is verbosity , for most everyday tasks the higher-level iterators are clearer and less error-prone.
Common table operations
Lua’s table library provides helpful functions for working with tables. These operate exclusively on the array portion (integer keys 1..n), reinforcing why keeping arrays dense matters:
local list = {3, 1, 4, 1, 5, 9, 2, 6}
-- Sort the table
table.sort(list)
-- Find an element (linear search)
-- Note: table.find is available in Lua 5.4+
local found = false
for i, v in ipairs(list) do
if v == 4 then
found = true
break
end
end
-- Remove the last element
local last = table.remove(list)
-- Concatenate into a string
local str = table.concat(list, ", ")
Practical example: a simple contact list
Sorting, concatenating, and splicing cover the most common array chores, but real programs rarely stop at flat lists of numbers. A contact manager needs structured records , each contact is a dictionary , assembled into an ordered collection you can search and extend. This example pulls together named-key tables, integer-indexed arrays, table.insert, and ipairs() into one cohesive piece. Notice how create_contact returns a freshly constructed table each call, a pattern worth internalizing: factory functions prevent accidental sharing of table references between records.
-- Define a contact
local function create_contact(name, email, phone)
return {
name = name,
email = email,
phone = phone,
tags = {}
}
end
-- Our contact list (array of contacts)
local contacts = {}
-- Add a contact
table.insert(contacts, create_contact("Alice", "alice@example.com", "123-456"))
table.insert(contacts, create_contact("Bob", "bob@example.com", "789-012"))
-- Find contacts by name
local function find_by_name(name)
for _, contact in ipairs(contacts) do
if contact.name == name then
return contact
end
end
return nil
end
local found = find_by_name("Alice")
if found then
print(found.email) -- alice@example.com
end
Table gotchas to remember
The contact list example works because each create_contact call produces a distinct table. Skip that factory and reuse a single table literal across multiple table.insert calls and every entry in your list silently points to the same chunk of memory , mutate one, and all “copies” reflect the change. This reference-sharing behaviour is the most common source of hard-to-spot bugs in Lua programs, especially when tables nest inside other tables.
- Tables are references: When you assign a table to a new variable, you’re copying the reference, not the table itself.
local a = {x = 1}
local b = a
b.x = 2
print(a.x) -- 2 (both point to the same table)
- nil values create gaps: Setting a table element to
nilremoves it, which can affect#length. Lua does not shift remaining elements to close the hole , the gap stays, and#no longer produces a predictable answer because the implementation may count up to the gap or beyond it depending on internal capacity and allocation. This side-effect is whytable.remove()exists: it shifts elements and updates the length correctly.
local t = {1, 2, 3, 4, 5}
t[3] = nil
print(#t) -- May be 2 or 5 (undefined behavior)
- Tables are dynamic: You don’t need to declare table size upfront. Just add keys as needed.
Summary
Tables in Lua are deceptively simple but incredibly powerful:
- Arrays are tables with sequential numeric keys (starting at 1)
- Dictionaries use string or other keys for named access
- Objects combine data with functions in a single structure
- Modules use tables to organize related functions and values
Once you master tables, you can express almost any data structure in Lua. The next articles in this series will explore specific patterns like using tables for object-oriented programming and metatables for custom behavior.
Recap
Lua’s tables are deceptively simple: one data structure that doubles as array, dictionary, and object. The price of that simplicity is that you carry the responsibility for choosing which role a table plays. Arrays use sequential integer keys starting at 1; dictionaries use strings, references, or mixed types; objects bolt on methods via metatables.
A few habits keep you out of trouble. Pick a key style early and keep it consistent within a single table. Use # only on dense arrays and remember it stops at the first nil. Prefer ipairs when order matters and pairs when it does not. When a table grows past a handful of fields, pull it into its own constructor function so the shape lives in one place.
Next steps
Continue to Strings and Pattern Matching Basics — the next tutorial builds directly on table indexing and iteration patterns. When you’re ready to go deeper, the Lua closures guide shows how tables and functions combine to create object-like behavior with encapsulated state.
See also
- table.insert reference — Insert elements into arrays
- Functions, closures, and varargs — The previous tutorial in this series
- Lua strings and pattern matching — Next in the series, working with text
- Lua closures guide — How tables and closures combine for object-like behavior