luaguides

Hash Maps and Dictionaries in Lua

Lua takes an unusual approach to hash maps: instead of providing separate array and dictionary types, it uses a single table structure for both. A Lua table splits its storage into two parts — an array part for sequential integer keys and a hash part for everything else. This dual nature makes hash maps in Lua both elegant and full of subtle behaviors worth understanding.

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 enable powerful hash-map patterns.

Prerequisites

You’ll need a working Lua 5.1+ interpreter — the table behavior described here is consistent across PUC Lua, LuaJIT, and Luau. You should understand basic table syntax: creating tables with {}, assigning values with bracket and dot notation, and the concept of keys and values. If tables are new to you, start with the tables introduction before continuing.

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. Dot notation is a convenience that only compiles when the key is a literal identifier known at parse time. For dynamic keys, bracket notation is the only option — it evaluates the expression inside the brackets and uses the result as the key. This distinction becomes important when building tables from user input or when key names collide with Lua reserved words like and or end.

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. The # operator on a table returns any integer index n where t[n] is not nil but t[n+1] is nil — this boundary depends on the internal array-part layout and is not guaranteed to be the highest integer key. The Lua manual explicitly calls # undefined when a table has nil holes in its integer-key sequence.

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. Since pairs() traverses every key regardless of type or position in the table, a counting loop over pairs() gives you the total number of entries across both the array and hash parts. This approach ignores the # operator entirely and works correctly even when integer keys are sparse or interleaved with string keys.

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.

IteratorBest forOrder
pairs()Hash maps, mixed tablesUndefined
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. If the key is found in the original table, __index never fires — the fallback only activates on a genuine miss. This means overriding a key in the child table hides the default entirely, and deleting the child’s key by setting it to nil restores visibility of the default from the __index chain.

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. The standard # operator walks the array part looking for a nil boundary, so a table with only string keys returns 0 from # regardless of how many entries it holds. __len replaces that behavior entirely — when the metatable defines it, # becomes a call to your custom function, letting you define what length means for hash-heavy tables.

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. This behavior surprises developers coming from languages where the length operator counts both array and dictionary entries because the array part and hash part are length-counted separately by the default # operator.

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. Combining default values with write-protection gives you a configuration object that behaves like a frozen dictionary — readers see sensible defaults while writers are blocked from introducing misspelled or unexpected keys.

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. The (counts[word] or 0) + 1 idiom is idiomatic Lua — it exploits the fact that accessing a missing key returns nil, and nil or 0 evaluates to 0 so the count starts cleanly. Without this short-circuit, the first encounter of a word would attempt arithmetic on nil and raise an error.

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. The by_dept[rec.dept] = by_dept[rec.dept] or {} line ensures the bucket exists before table.insert pushes into it. On the first encounter of a department, by_dept[rec.dept] is nil, so the or {} fallback creates a fresh table. Subsequent hits reuse the same bucket, building the group incrementally.

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. Memoization with tables takes advantage of Lua’s O(1) hash-part lookups — the result of fib(n) is cached at index n so recursive branches that hit the same subproblem return instantly. The two-element initializer {0, 1} seeds the array part with the base cases so the recursion bottoms out cleanly when n reaches 0 or 1.

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

Next steps

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() and ipairs() based on your data
  • Using __index, __newindex, and __len for metatable-powered hash maps
  • Common patterns: sets, counting, grouping, and memoization

See Also