Hash Maps and Dictionaries in Lua
In most languages, arrays and dictionaries are separate data structures. Lua uses a single structure for both: the table. A Lua table splits its storage into two parts — an array part for sequential integer keys and a hash part for everything else. Understanding this duality is fundamental to writing idiomatic Lua.
This tutorial walks through how tables store key-value pairs, how integer and string keys behave differently, when to reach for pairs() versus ipairs(), and how metatables unlock powerful hash-map patterns.
How Tables Store Key-Value Pairs
When you assign a value to a key, Lua decides where to store it based on the key’s type:
- Positive sequential integers (1, 2, 3, …) → array part
- Everything else (strings, floats, booleans, tables, functions, userdata) → hash part
Any Lua value can serve as a key except nil. Setting a key to nil effectively deletes that entry.
local person = {}
person["name"] = "Alice"
person["email"] = "alice@example.com"
person[1] = "first key"
person[2] = "second key"
person[true] = "boolean key"
print(person["name"]) -- Alice
print(person[1]) -- first key
print(person[true]) -- boolean key
The hash part uses open addressing internally — Lua resolves collisions automatically, so you don’t interact with this mechanism directly. Hash lookups are fast and work for any hashable key.
String Keys and Dot Notation
String keys are the most common pattern in Lua hash maps, and Lua provides syntactic sugar to make them concise.
local config = {}
config["host"] = "localhost"
config.port = 8080 -- identical to config["port"] = 8080
print(config.host) -- localhost
print(config["port"]) -- 8080
Dot notation (config.port) works only for string keys that are valid Lua identifiers — no spaces, must start with a letter or underscore. Bracket notation handles any value:
local t = {}
t["my key"] = "value" -- works with bracket notation
t[1] = "one"
t[true] = "yes"
t[{k = "nested"}] = "table key" -- valid; hashed by reference
Bracket notation is necessary when keys contain spaces, start with digits, or are computed values.
Integer Keys and the Array Part
Integer keys get special treatment when they form a sequential chain starting at 1. Lua places these in the array part, which is more memory-efficient and faster for sequential iteration.
local scores = {}
scores[1] = 100
scores[2] = 200
scores[3] = 300
print(#scores) -- 3
The array part is optimized for sequential access, but this comes with a catch: gaps break the sequence, and the # operator becomes unreliable.
local a = {10, 20, nil, 40, 50}
print(#a) -- 2 (undefined; Lua stops at the first nil)
local b = {[1] = "a", [2] = "b", name = "alice", age = 30}
print(#b) -- 2 (only counts the array part, ignores name and age)
Only use # on tables you know are pure, gap-free arrays. For mixed tables, use pairs() to count entries:
local function count_entries(t)
local n = 0
for _ in pairs(t) do n = n + 1 end
return n
end
local mixed = {[1] = "a", [2] = "b", name = "alice"}
print(count_entries(mixed)) -- 3
Iterating with pairs() and ipairs()
Choosing the right iterator depends on what you’re working with.
| Iterator | Best for | Order |
|---|---|---|
pairs() | Hash maps, mixed tables | Undefined |
ipairs() | Pure arrays (1-based, sequential) | Guaranteed sequential |
local data = {[1] = "a", [2] = "b", [3] = "c", name = "alice"}
print("--- pairs() ---")
for k, v in pairs(data) do
print(k, v)
-- Output order is not guaranteed
end
print("--- ipairs() ---")
for i, v in ipairs(data) do
print(i, v)
-- Prints: 1 a, 2 b, 3 c — stops at first nil
end
ipairs() stops iteration when it encounters a nil value, making it ideal for arrays. pairs() iterates every key-value pair regardless of type, but the traversal order is implementation-defined and non-deterministic.
Metatable Tricks for Hash Maps
Metatables let you intercept table operations. Three metamethods are useful for hash-map patterns.
Default Values with __index
__index fires when accessing a key that does not exist in the table. This enables default value lookups:
local defaults = {quiet = true, verbose = false, timeout = 30}
local config = {verbose = true}
setmetatable(config, {__index = defaults})
print(config.quiet) -- true (from defaults)
print(config.verbose) -- true (from config itself)
print(config.timeout) -- 30 (from defaults)
Lua checks config first, then defaults — the __index table’s position matters.
Read-Only Tables with __newindex
__newindex fires when assigning to a key that doesn’t exist. Intercept it to enforce immutability:
local readonly = {}
setmetatable(readonly, {
__newindex = function(t, k, v)
error("Cannot assign to readonly table", 2)
end
})
readonly.name = "Alice" -- error: Cannot assign to readonly table
readonly.host = "localhost" -- same error
Custom Length with __len
The # operator normally returns only the array-part length. Override it with __len to count hash entries:
local counted = {a = true, b = true, c = true, d = true}
setmetatable(counted, {
__len = function(t)
local n = 0
for _ in pairs(t) do n = n + 1 end
return n
end
})
print(#counted) -- 4
Without the metatable, #counted would return 0 since all keys are strings.
Combining Tricks: Defaults and Immutability
You can layer __index and __newindex to build a table that provides defaults but refuses to accept new keys:
local defaults = {theme = "dark", lang = "en", debug = false}
local settings = {}
setmetatable(settings, {
__index = defaults,
__newindex = function(t, k, v)
error("Cannot add new keys to settings", 2)
end
})
settings.theme = "light" -- overwrites an existing key: fine
print(settings.debug) -- false (from defaults)
settings.newkey = "value" -- error: Cannot add new keys
This pattern is useful for configuration objects where you want to prevent accidental key typos from creating new entries silently.
Common Hash Map Patterns
Sets for Membership Testing
Using true as a value turns a table into a set — fast membership checks in O(1) time:
local valid_tags = {
lua = true,
programming = true,
tutorial = true,
}
local function has_tag(tag)
return valid_tags[tag] == true
end
print(has_tag("lua")) -- true
print(has_tag("javascript")) -- false
Counting Occurrences
Build a frequency table by incrementing counts on each encounter:
local words = {"apple", "banana", "apple", "cherry", "banana", "apple"}
local counts = {}
for _, word in ipairs(words) do
counts[word] = (counts[word] or 0) + 1
end
for word, count in pairs(counts) do
print(word, count)
end
-- banana 2
-- apple 3
-- cherry 1
Grouping by a Key
Aggregate records into buckets keyed by a common field:
local records = {
{name = "Alice", dept = "Engineering"},
{name = "Bob", dept = "Marketing"},
{name = "Carol", dept = "Engineering"},
}
local by_dept = {}
for _, rec in ipairs(records) do
by_dept[rec.dept] = by_dept[rec.dept] or {}
table.insert(by_dept[rec.dept], rec)
end
for dept, people in pairs(by_dept) do
print(dept)
for _, p in ipairs(people) do
print(" -", p.name)
end
end
Caching and Memoization
Store expensive computation results to avoid recomputation:
local fib_cache = {0, 1}
local function fib(n)
if fib_cache[n] ~= nil then
return fib_cache[n]
end
local result = fib(n - 1) + fib(n - 2)
fib_cache[n] = result
return result
end
print(fib(10)) -- 55
print(fib(20)) -- 6765
See Also
- Tables and Arrays — the foundation Lua data structure
- Metatables and Metamethods — extend table behaviour with custom operators and lookups
- Functions in Lua — first-class functions and closures
What Comes Next
Tables are the foundation of nearly everything in Lua — arrays, objects, modules, and records all use the same underlying structure. Once you’re comfortable with hash-map patterns, the next step is Operator Overloading with Metamethods to see how metatables extend tables further with custom behavior for arithmetic, comparison, and string conversion.
In this tutorial you covered:
- How tables split storage between an array part and a hash part
- Storing and retrieving values with string and integer keys
- The limitations of the
#operator on mixed tables - Choosing between
pairs()andipairs()based on your data - Using
__index,__newindex, and__lenfor metatable-powered hash maps - Common patterns: sets, counting, grouping, and memoization