Part of Learn Lua with NeoVim

Lua with Neovim: Tables as Dictionaries — Key-Value Pairs, Dot & Bracket Notation | Episode 19

Sandy LaneSandy Lane

Video: Lua with Neovim: Tables as Dictionaries — Key-Value Pairs, Dot & Bracket Notation | Episode 19 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Lua Tables as Dictionaries: Key-Value Pairs

Same {} syntax with key = value instead of just value. Access with t.key (dot) or t["key"] (bracket). Add, modify, and delete keys. The data shape behind every Lua "object."

Tables in Lua are unified — the same construct works as both an array (numeric keys) and a dictionary (string keys). Today we use the dictionary side, the foundation of records, configs, and (later) objects.

A person record

local person = {
  name = "Alice",
  age = 30,
  city = "Portland",
}

print("Name: " .. person.name)
print("Age: " .. person.age)
print("City: " .. person["city"])

Output:

Name: Alice
Age: 30
City: Portland

Inside {}:

  • name = "Alice" is shorthand for ["name"] = "Alice" — a string-keyed entry.
  • The literal name (no quotes) becomes the key "name" (a string).

Two ways to read a value:

  • Dot notation: person.name — concise, but only works for string keys that are valid identifiers.
  • Bracket notation: person["name"] — the general form. Works for any key.

Adding new keys

person.email = "alice@example.com"
print("Email: " .. person.email)

Just assign. The key is created on the spot. No "schema" to define, no class to declare.

Modifying values

person.age = 31
print("Updated age: " .. person.age)

Reassign the same key. The old value is replaced.

Deleting a key

person.city = nil
print("City after delete: " .. tostring(person.city))   -- "nil"

Setting a key to nil removes it from the table. Reading it later returns nil.

(Same as for arrays — but for dictionaries, this is the correct way to remove a key. There's no table.remove for string keys.)

Dynamic key access

local key = "language"
print(person[key])

Bracket notation lets the key come from a variable or expression:

local fields = {"name", "age", "city"}
for _, f in ipairs(fields) do
  print(f .. ": " .. tostring(person[f]))
end

Iterates a list of field names and reads each from the person.

Dot notation can't do this — person.f would look for the literal key "f", not the value of variable f.

Keys with weird characters

local config = {
  ["max-width"] = 1200,
  ["api-key"] = "abc123",
}

print(config["max-width"])
-- print(config.max-width)  -- error! parsed as config.max - width

Hyphens, spaces, special chars require bracket notation with a string. Same for keys that start with a digit.

A config table

local config = {
  theme = "dark",
  font_size = 14,
  show_line_numbers = true,
  language = "en",
}

print("Theme: " .. config.theme)
print("Line numbers: " .. tostring(config.show_line_numbers))

Configs are the canonical use case — a flat key-value structure where every "field" has a sensible name. Many Lua libraries pass configs as tables: setup({ ... }).

Non-string keys

Tables accept any non-nil value as a key:

local t = {}
t[1] = "one"          -- integer key (array-style)
t["two"] = 2          -- string key (dict-style)
t[true] = "yes"       -- boolean key
t[function() end] = "fn"   -- function key (rare!)

In practice, you use integer keys for arrays, string keys for dicts/objects. Other keys are rare and usually a smell.

Iterating

for key, value in pairs(person) do
  print(key .. " = " .. tostring(value))
end

pairs(t) walks every key-value pair, in unspecified order. We cover this in detail next episode.

For arrays specifically (integer keys 1..n), use ipairs(t) — guaranteed in-order, stops at first nil.

Existence check

if person.email then
  print("Has email")
else
  print("No email")
end

if person.email works because nil is falsy and nil is what missing keys return. But:

person.flag = false
if person.flag then        -- false! treats "exists but false" as missing
  print("flag set")
end

To distinguish "exists with falsy value" from "doesn't exist," compare to nil:

if person.flag ~= nil then
  print("flag exists")
end

Deep dive: table is one type

local mixed = {
  "first",          -- key 1 (array-style)
  "second",         -- key 2
  name = "thing",   -- key "name" (dict-style)
  [42] = "deep",    -- key 42
}

print(mixed[1])      -- "first"
print(mixed.name)    -- "thing"
print(mixed[42])     -- "deep"
print(#mixed)        -- 2 (only counts contiguous integer keys from 1)

Lua doesn't distinguish "array tables" from "dict tables" — they're all just tables. Use whichever keys make sense.

The internal representation has an array part (for integer keys 1..n) and a hash part (for everything else), but that's invisible at the language level.

Common stumbles

Trying dot notation with a variable. t.key looks up the literal key "key". Use t[key].

Hyphen in a key. t.max-width is (t.max) - width. Use t["max-width"].

Treating nil as "exists but empty." A nil value means the key isn't there at all. To "track existence with a value," store false or some sentinel, and check with ~= nil.

Iteration order in pairs. Not specified. Don't rely on insertion order. For predictable order, sort the keys explicitly.

Setting a key while iterating. Adding new keys during pairs is undefined. Build a list of pending changes and apply after.

What's next

Episode 20: iterating tables. pairs for any table, ipairs for arrays, the difference, and the gap behaviour. Build a word-frequency counter.

Recap

Tables as dictionaries: { name = "Alice", age = 30 }. Read with dot (t.name) or bracket (t["name"]). Bracket is required for variable keys, special characters, or non-string keys. Add by assigning a new key. Delete by setting to nil. Tables don't distinguish array vs dict; use the same construct with integer keys (array-style) or string keys (dict-style) as fits.

Next episode: pairs and ipairs.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.