luaguides

Lua arrays and lists: a complete table indexing guide

Lua arrays are not a separate type. The language leans on a single data structure for almost everything: the table. Tables are associative arrays: they map keys to values, and when those keys are consecutive integers starting at 1, they behave exactly like the arrays and lists you find in Python, JavaScript, or Java.

This tutorial shows you how to use Lua tables as arrays. You’ll learn how 1-based indexing works, why the # operator can surprise you, how to use table.insert, table.remove, and table.concat, and how to tell a sequential table from a sparse one. You’ll finish by building a stack, a queue, and a deque, all from plain tables.

How Lua arrays work as tables

A table in Lua is a dictionary. You can store any value under any key:

local person = { name = "Maya", age = 31 }
print(person["name"])  -- Maya

For an array, you drop the string keys and use consecutive integers instead. When Lua encounters { 2, 3, 5, 7, 11 } without explicit keys, it automatically assigns integer indices starting at 1. This implicit key assignment is what gives tables their array-like behavior; the table stores values under keys 1, 2, 3, and so on, just like a conventional list:

local primes = { 2, 3, 5, 7, 11 }
print(primes[1])  -- 2
print(primes[3])  -- 5

That is an array. There is no separate type; it is just a table with integer keys. Lua does not distinguish between an “array table” and a “dictionary table” at the language level; both use the same underlying hash map. You can mix string keys and integer keys in the same table, and the integer-keyed entries still respond to # and ipairs. However, mixing styles makes the table harder to reason about and blurs the line between array and record:

local hybrid = { "first", "second", third = "III" }
print(hybrid[1])        -- first
print(hybrid["third"]) -- III

For the rest of this tutorial, the focus stays on the array-like pattern.

1-Based Indexing

Most mainstream languages start array indices at 0. Lua does not. It starts at 1. The first element is always at index 1, the second at index 2, and so on.

local months = { "January", "February", "March", "April" }

for i = 1, #months do
    print(i, months[i])
end

When you run this loop, each month name prints alongside its numeric index. Notice that months[1] yields “January”; there is no element at index 0, and attempting months[0] would return nil. This output confirms that the first call to table.insert or any manual assignment places data at position 1, not 0.

Output:

1    January
2    February
3    March
4    April

This choice comes from Lua’s origins in scientific and numerical computing, where 1-based indexing is the norm (MATLAB, Fortran, Mathematica all start at 1). It also makes range loops read cleanly: for i = 1, #arr iterates from the first element to the last with no awkward offset. The trade-off is that code ported from C, Python, or Java needs adjustment when accessing the first element.

The length operator

The unary # operator returns the length of a table. For a sequential table with no gaps, it behaves as expected, returning the highest integer key that maintains a contiguous sequence from 1:

local colours = { "red", "green", "blue" }
print(#colours)  -- 3

The gap problem

The # operator is defined only for sequential tables: those where every integer key from 1 to n is present and non-nil. When every slot is occupied, # walks the internal array portion of the table and returns n directly, a fast and deterministic operation. If a table has a hole, the result is unpredictable:

local broken = { "a", "b", nil, "d", "e" }
print(#broken)  -- implementation-defined: may print 2, 4, or something else

The same applies to tables that have non-integer keys mixed in. String keys like "label" live in the hash portion of the table and do not affect the array portion at all; #messy still returns 3 because the integer-keyed entries at positions 1 through 3 remain contiguous. The real problem is a missing integer key (a nil value at a numbered slot), which creates a boundary that # cannot reliably cross:

local messy = { "x", "y", "z" }
messy["label"] = "end"
print(#messy)  -- still 3, but be cautious with mixed tables

If you are unsure whether a table has gaps, count manually by iterating with pairs. Unlike #, which only examines the array portion of the table, pairs visits every key-value pair in both the array and hash portions. This gives you an accurate element count regardless of nil gaps or mixed key types:

local function table_size(t)
    local count = 0
    for _ in pairs(t) do
        count = count + 1
    end
    return count
end

local sparser = {}
sparser[1] = "one"
sparser[3] = "three"  -- index 2 is nil
print(table_size(sparser))  -- 2, regardless of the gap

The practical rule: never set an array element to nil to “remove” it. Use table.remove instead, which shifts subsequent elements down and closes the gap.

Core table functions

Lua’s standard library provides three functions that make array-style tables practical: table.insert, table.remove, and table.concat. All three assume sequential, gap-free tables and operate exclusively on the integer-keyed array portion of the table:

table.insert

table.insert(t, value) appends value to the end of t. table.insert(t, pos, value) inserts value at a specific index, shifting everything from pos onward one step to the right:

local list = { 1, 2, 3 }

table.insert(list, 4)        -- { 1, 2, 3, 4 }
table.insert(list, 2, 99)     -- { 1, 99, 2, 3, 4 }

table.remove

table.remove(t) removes and returns the last element. table.remove(t, pos) removes and returns the element at pos, shifting everything after it one step to the left. When you use a positional table.insert, every element from the insertion point onward moves right to make room, an O(n) cost. In contrast, appending with table.insert(t, value) is amortized O(1) because no shifting is needed. The same cost asymmetry shows up in table.remove:

local list = { 10, 20, 30, 40 }

local last = table.remove(list)
print(last)  -- 40, list is now { 10, 20, 30 }

local mid = table.remove(list, 2)
print(mid)   -- 20, list is now { 10, 30 }

table.concat

table.concat(t) joins all elements of t into a string. Pass a separator as the second argument, and optionally a start and end index. After the remove operation on the previous example, the list { 10, 20, 30, 40 } became { 10, 30 }; the element at position 2 was extracted, and the former position-3 element shifted left into position 2, eliminating the gap automatically:

local words = { "Hello", "world", "from", "Lua" }

print(table.concat(words, " "))           -- Hello world from Lua
print(table.concat(words, ", ", 2, 3))    -- world, from

Sequential tables vs sparse tables

A sequential table (sometimes called a dense array) has integer keys forming an unbroken chain from 1 to n. Every position is filled with a non-nil value, and there are no missing integers in the key sequence. This contiguous structure is what makes #, ipairs, and the table library functions work correctly and predictably:

local sequential = { "first", "second", "third" }
-- Keys: 1, 2, 3 — no gaps

A sparse table has gaps or uses unusual keys. Index 2 and indices 4 through 9 are nil, which means #sparse returns an implementation-defined value; you cannot rely on it for any computation that depends on table length. The ipairs iterator stops dead at the first nil slot, so a for i, v in ipairs(sparse) loop would only visit index 1, silently skipping everything beyond the gap. These gaps also break table.sort and table.concat, both of which require a contiguous integer-key sequence. The following example creates a sparse table by assigning values only to indices 1, 3, and 10:

local sparse = {}
sparse[1] = "one"
sparse[3] = "three"   -- index 2 is absent
sparse[10] = "ten"    -- large gap between 3 and 10

Sparse tables are valid Lua, but the # operator gives undefined results for them, and ipairs() stops at the first nil gap. Only use pairs() when iterating over sparse tables. It visits every key-value pair regardless of gaps. Unlike ipairs, the pairs iterator does not stop at nil values, which means you can loop over every element in a table with holes:

-- Using pairs for sparse tables
for k, v in pairs(sparse) do
    print(k, v)
end
-- May print: 1 one, 3 three, 10 ten (order not guaranteed)

Most of the time, you want sequential tables for array-style work. Every classic data structure (stacks, queues, deques) relies on predictable, gap-free integer indices so that insertion and removal operations produce consistent results. The next three sections build these structures from scratch, starting with the simplest LIFO container.

Building a Stack

A stack follows last-in, first-out (LIFO) order. table.insert pushes onto the top, and table.remove pops from it:

local function new_stack()
    return {}
end

local function push(stack, item)
    table.insert(stack, item)
end

local function pop(stack)
    return table.remove(stack)
end

local stack = new_stack()
push(stack, "draft")
push(stack, "review")
push(stack, "publish")

while #stack > 0 do
    print(pop(stack))
end

Running this script confirms the stack’s LIFO order visually. Items are pushed in the sequence “draft” → “review” → “publish” and popped in the reverse order. Each pop call removes and returns the most recently added item, so the last push appears first in the output.

Output:

publish
review
draft

Because table.remove without an index targets the last element, popping from a stack is a single operation. There is no need to track a top-of-stack pointer manually. The output shows items coming off in reverse order; “publish” was pushed last but popped first, confirming the LIFO behavior. While stacks process the newest items first, many real-world workflows need the opposite ordering: the first item added should be the first one processed.

Building a Queue

A queue follows first-in, first-out (FIFO) order. Items enter at the back via table.insert and leave from the front via table.remove(queue, 1). The catch is that table.remove(t, 1) shifts every element down by one position, making it O(n) for the dequeue operation. For moderate workloads this is fine; for performance-critical queue-heavy code, a different approach using numeric indices is needed. The code below wraps these operations in enqueue and dequeue functions that mirror the stack API from the previous section, except dequeue removes from position 1 instead of the end:

local function new_queue()
    return {}
end

local function enqueue(queue, item)
    table.insert(queue, item)
end

local function dequeue(queue)
    return table.remove(queue, 1)
end

local q = new_queue()
enqueue(q, "first")
enqueue(q, "second")
enqueue(q, "third")

while #q > 0 do
    print(dequeue(q))
end

Running this queue confirms the FIFO contract: “first” entered first and exits first, followed by “second” then “third.” Unlike the stack where items returned in reverse, the queue preserves the original insertion order. Each dequeue call removes from position 1, which triggers an O(n) shift of every remaining element one slot left.

Output:

first
second
third

Building a Deque

A deque (double-ended queue) lets you add and remove from both ends. Lua’s table.insert and table.remove handle both cases: table.insert(t, 1, value) inserts at the front, and table.remove(t, 1) removes from the front. Front operations carry an O(n) cost because every element must shift to accommodate the new or removed item at position 1; back operations are O(1) since no shifting occurs:

local function new_deque()
    return {}
end

local function push_right(deque, item)
    table.insert(deque, item)
end

local function push_left(deque, item)
    table.insert(deque, 1, item)
end

local function pop_right(deque)
    return table.remove(deque)
end

local function pop_left(deque)
    return table.remove(deque, 1)
end

local deque = new_deque()
push_right(deque, "A")
push_right(deque, "B")
push_left(deque, "Z")
print(pop_right(deque))  -- B
print(pop_left(deque))   -- Z
print(pop_right(deque))  -- A

A complete example: filtering scores

To tie the pieces together, here is a runnable script that builds an array, filters it, and computes a summary using table.insert, table.remove, and table.concat. The example stores exam scores in a table, removes failing grades, rebuilds as a sequential table, sorts the results, and calculates an average. Every function and technique covered so far appears in this single working script:

local raw_scores = { 72, 95, 88, 61, 99, 74, 85, 91 }

-- Remove scores below 75 (replace with nil, then compact)
for i = 1, #raw_scores do
    if raw_scores[i] < 75 then
        raw_scores[i] = nil
    end
end

-- Compact: rebuild as a sequential table
local passing = {}
for _, score in pairs(raw_scores) do
    table.insert(passing, score)
end

-- Sort and format
table.sort(passing, function(a, b) return a > b end)

print("Passing scores (sorted):", table.concat(passing, ", "))

local sum = 0
for i = 1, #passing do
    sum = sum + passing[i]
end
print("Average:", string.format("%.1f", sum / #passing))

The output confirms that setting failing scores to nil and then rebuilding with pairs and table.insert produces a clean sequential table. Notice that 74 survived because it is ≥75, and the rebuilt table has no gaps, so table.sort and #passing both work correctly. The average computation uses #passing for the divisor, which only works because the table is sequential.

Output:

Passing scores (sorted): 99, 95, 91, 88, 85, 74
Average: 88.7

Notice the pattern: removing elements by setting them to nil creates gaps, so table.sort would behave unpredictably. Rebuilding into a fresh sequential table with pairs sidesteps that problem entirely.

Summary

Lua’s tables are deceptively simple. On the surface Lua arrays look like ordinary arrays, but underneath they are full hash maps that can hold any mix of keys and values. For array-style work, stick to sequential tables (integer keys from 1 to n with no gaps) and let table.insert, table.remove, and table.concat do the heavy lifting. Reserve pairs() for sparse or mixed tables, and never trust # on a table that might have holes.

See also