Part of Learn Lua with NeoVim

Lua with Neovim: Nested Tables — Tables Inside Tables, Chained Access & Averages | Episode 21

Sandy LaneSandy Lane

Video: Lua with Neovim: Nested Tables — Tables Inside Tables, Chained Access & Averages | Episode 21 by Taught by Celeste AI - AI Coding Coach

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

Lua Nested Tables: Tables Inside Tables

A student with a list of grades and an address. A classroom of students. Chained access (student.address.city) and computing class averages from nested data.

Tables can contain other tables — that's how you build records with sub-records, lists of objects, and tree-structured data. Today we put it all together.

A student record

local student = {
  name = "Alice",
  grades = { 90, 85, 92, 88 },
  address = {
    street = "123 Main St",
    city = "Portland",
  },
}

print("Name: " .. student.name)
print("First grade: " .. student.grades[1])
print("City: " .. student.address.city)

Output:

Name: Alice
First grade: 90
City: Portland

Three fields:

  • name — a string.
  • grades — an array of numbers (table with integer keys).
  • address — a dict with string keys.

Access chains naturally with dots and brackets:

  • student.grades[1] — read grades, then take index 1.
  • student.address.city — read address, then read city.

Modifying nested fields

student.address.city = "Seattle"
print("New city: " .. student.address.city)

Same syntax. The chain works for both reading and writing.

To add a new nested field:

student.address.zip = "98101"

To replace the whole sub-table:

student.address = {
  street = "456 Oak Ave",
  city = "Spokane",
}

A classroom: list of students

local classroom = {
  {
    name = "Alice",
    grades = { 90, 85, 92 },
  },
  {
    name = "Bob",
    grades = { 78, 82, 80 },
  },
  {
    name = "Charlie",
    grades = { 95, 98, 91 },
  },
}

classroom is an array of student records. Index it numerically:

print(classroom[1].name)        -- "Alice"
print(classroom[2].grades[1])   -- 78 (Bob's first grade)

Computing averages

for i = 1, #classroom do
  local s = classroom[i]
  local total = 0
  for j = 1, #s.grades do
    total = total + s.grades[j]
  end
  local avg = total / #s.grades
  print(s.name .. " average: " .. string.format("%.1f", avg))
end

Output:

Alice average: 89.0
Bob average: 80.0
Charlie average: 94.7

Nested loops:

  • Outer: walk students.
  • Inner: walk each student's grades.

Sum the grades, divide by count, format to one decimal place.

string.format("%.1f", avg) produces a clean fixed-decimal output.

Cleaner with ipairs

for _, s in ipairs(classroom) do
  local total = 0
  for _, grade in ipairs(s.grades) do
    total = total + grade
  end
  print(s.name .. " avg: " .. (total / #s.grades))
end

Same logic. ipairs skips the index when you don't need it.

Tree shapes

local org = {
  name = "Acme Corp",
  ceo = {
    name = "Alice",
    reports = {
      { name = "Bob", role = "Engineering" },
      { name = "Carol", role = "Sales" },
    },
  },
}

print(org.ceo.reports[1].name)   -- "Bob"
print(org.ceo.reports[2].role)   -- "Sales"

Arbitrarily deep nesting, accessed by chained dots and brackets. The shape of the data is whatever you build.

Adding to a nested array

table.insert(classroom[1].grades, 100)   -- Alice gets a 100

table.insert works on any array, including one nested deep inside another structure. Same for table.remove.

Iterating into nested tables

To walk every grade across all students:

for _, s in ipairs(classroom) do
  for _, g in ipairs(s.grades) do
    print(s.name .. ": " .. g)
  end
end

Two ipairs loops. Lua doesn't have a "deep iteration" built-in — you write the nested loops explicitly.

JSON-shaped data

Lua tables are essentially JSON's data model. Both have arrays, dictionaries, and arbitrary nesting. Many JSON libraries (e.g. dkjson, cjson) decode JSON straight into Lua tables:

local json = require("dkjson")
local data = json.decode([[
  { "name": "Alice", "grades": [90, 85] }
]])
print(data.name)        -- "Alice"
print(data.grades[1])   -- 90

Same access patterns as before. Tables are flexible enough that JSON's data needs no transformation.

Watch for nil

print(student.address.zip)
-- prints "nil" if zip wasn't set; doesn't crash

print(student.address.zip.country)
-- ERROR: tries to index nil

The first line is fine — reading a missing field returns nil. The second crashes — you can't index nil.

Safe-chain pattern:

local zip = student.address and student.address.zip
local country = zip and zip.country

Or the inline:

local country = (student.address and student.address.zip and student.address.zip.country) or "unknown"

Each and short-circuits if the previous part is nil. Verbose for deep paths.

Pretty-printing nested tables

Lua's built-in print shows table addresses (table: 0x...), not contents. For real inspection, write a small printer or use a library:

local function dump(t, indent)
  indent = indent or ""
  for k, v in pairs(t) do
    if type(v) == "table" then
      print(indent .. tostring(k) .. " = {")
      dump(v, indent .. "  ")
      print(indent .. "}")
    else
      print(indent .. tostring(k) .. " = " .. tostring(v))
    end
  end
end

dump(student)

For production, use require("inspect") or require("dkjson").encode(t, {indent=true}).

Common stumbles

Indexing nil. t.a.b.c crashes if t.a is nil. Defensive: t.a and t.a.b and t.a.b.c.

Forgetting tables are reference types. Two tables with identical contents are not equal — equality is by reference (covered next episode).

Modifying through one variable, surprised the other changed. Two variables can point to the same table; mutations are visible through both. Episode 22 makes this explicit.

Using # on the wrong level. #classroom is the number of students. #classroom[1].grades is the number of grades for student 1. Each level has its own length.

Mixing array and dict in the same table without intent. {a=1, "first"} is two tables in one — t.a and t[1]. Easy to confuse.

What's next

Episode 22: table references vs copies. The "two variables, one table" trap. Shallow copy with pairs. Deep copy via recursion. Why arrays-of-arrays are sneaky.

Recap

Tables can contain other tables — arrays of records, records of records, trees of any shape. Access via chained . and []. Read missing fields returns nil; indexing through a nil crashes. ipairs/pairs only iterate one level — write nested loops for deeper traversal. Lua's table model is JSON-shaped, making JSON interop trivial.

Next episode: references vs copies.

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.