Hash Maps and Dictionaries in Lua

· 7 min read · Updated March 26, 2026 · beginner
tables hash-maps key-value metatables pairs ipairs

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.

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.

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

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