Lua's 21 Keywords and One Data Structure: A JS Developer's Tour
Synchronized to personal site: Lua Programming Getting Started: From Basic Syntax to Metatables
A plain Lua introductory tutorial: basic types, operators, control flow, functions, and Lua's most unique table and metatable mechanisms. Example code is based on Lua 5.5.
While organizing old notes recently, I noticed a language I almost never see in daily life—Lua. I thought it was some niche toy, but after looking it up, I discovered it was released in 1993 and is actually quite widely used. It's just that most of the time it's embedded inside other software, so you won't see it unless you write code for those specific scenarios.
What really piqued my interest was its number of keywords. Lua has only 21 keywords, fewer than even Go (25), which is known for its simplicity. A language born in 1993, with syntax this minimal, that has survived to this day and is embedded in all kinds of software—that made me want to learn it seriously.
This article is an introductory tutorial, assuming you've already written code (JavaScript will be used for comparison throughout). Common syntax will be glossed over, with the focus on where Lua differs from mainstream languages—types and variables, operators, control flow, functions, and finally Lua's core: tables and metatables. Example code is based on Lua 5.5.
What is Lua
Lua was born in the Tecgraf lab at the Pontifical Catholic University of Rio de Janeiro (PUC-Rio) in Brazil, with its predecessor being a data-entry language written for Petrobras. Its positioning was clear from the start: a scripting language embedded into host programs.
This positioning determined its shape:
- Small. The entire interpreter (including standard libraries) compiles to a few hundred KB and can be stuffed into any program.
- C-embeddable. It provides a clean C API, allowing host programs to embed the Lua interpreter and write business logic in Lua.
- Portable. It doesn't depend on OS features and can run on almost any platform.
So Lua's usage scenarios are mostly "someone else's software, Lua's configuration": the game industry uses it the most—World of Warcraft addons are written in Lua, Roblox's scripting language Luau is a dialect of Lua; the editor Neovim uses Lua as its configuration language; Redis has built-in Lua scripting; OpenResty uses Lua to write logic for Nginx; the macOS automation tool Hammerspoon also uses Lua. There are also frameworks like LÖVE that let you write games directly in Lua.
A common question: what's the relationship between Lua and LuaJIT? LuaJIT is another implementation with JIT compilation, far faster than the official interpreter, but it fully supports only Lua 5.1 syntax. The latest stable version of the official interpreter is 5.5 (released December 2025). This article's examples are based on Lua 5.5.
Installation and Running
Download from the official site or install via a package manager:
brew install lua # macOS
apt install lua5.5 # Debian / Ubuntu
If your software repository only has lua5.4, installing that works too—this article's content is common to both versions.
After installation, there are two ways to use it. Interactive mode: run lua directly to enter the REPL, executing line by line:
> print("hello, lua")
hello, lua
Script mode: write code into a file and execute it with the lua command:
-- hello.lua
print("hello, lua")
lua hello.lua
Basic Types and Variables
Lua has 8 basic types: nil, boolean, number, string, function, userdata, thread, table. Use type() to inspect them (equivalent to JS's typeof):
print(type(nil)) --> nil
print(type(42)) --> number
print(type(3.14)) --> number
print(type("lua")) --> string
print(type(print)) --> function
print(type({})) --> table
A few points worth noting:
nilmeans "does not exist". An unassigned variable is nil, and accessing a non-existent table field also returns nil. It's equivalent to JS'snullandundefinedmerged into one—Lua has only this single "empty" value.numberhas only one type. Integers and floating-point numbers are unified into a single type (like JS'sNumber), with internal automatic distinction, e.g.,10 // 3is integer division,10 / 3is floating-point division.- Only
falseandnilare falsy.0and the empty string""are both truthy. This differs greatly from JS: in JS,0,"",null,undefined,NaNare all falsy; in Lua, there are only two.
Variables are global by default; use local to declare local variables—similar in role to JS's let. The difference is that omitting a keyword in JS throws an error, while omitting it in Lua makes the variable global:
local x = 10 -- local variable, scoped to the current block
y = 20 -- global variable (try not to write this)
Default globals are an error-prone design: misspelling a variable name doesn't throw an error but silently creates a global variable. So the Lua community convention is: always write local except for intentionally exposed globals. (Lua 5.5 introduced the global declaration, which can disable default globals within a block and enforce variable declaration before use, but that's beyond scope here.)
nil has another use: assigning nil to a table field deletes that field (detailed in the next section).
Operators
Arithmetic operators: +, -, *, /, //, %, ^. Among them, ^ is exponentiation (corresponding to JS's **), and // is floor division, rounding toward negative infinity (JS has no floor division operator; Math.floor is usually used to approximate it). Two that are easy to misread:
//is floor division, not a comment.^is exponentiation, not XOR—Lua's bitwise XOR is~.
print(10 // 3) --> 3
print(10 % 3) --> 1
print(2 ^ 10) --> 1024.0
Strings are concatenated with ... In JS, + does both addition and concatenation; Lua separates the two, with + only doing addition:
print("ab" .. "cd") --> abcd
print("n = " .. 42) --> n = 42 (numbers are auto-converted to strings)
Comparison operators: ==, ~=, <, >, <=, >=. Note that the inequality operator is ~= (corresponding to JS's !=). Also, Lua doesn't have JS's distinction between == and ===; == for reference types like tables and functions compares whether they are the same object, not their contents (behavior equivalent to JS's ===):
local a = {1, 2}
local b = {1, 2}
print(a == b) --> false (two different tables)
Logical operators: and, or, not. Those familiar with JS's short-circuit return of operands won't be surprised—Lua's and and or behave the same way: they return the operand itself, not a boolean (corresponding to JS's &&, ||):
print(1 and 2) --> 2
print(nil and 2) --> nil
print(nil or "default") --> default
print(not nil) --> true
This leads to a common idiom: x = x or default_value, corresponding to JS's x = x || default_value. But be careful about the different truthy/falsy sets: in JS, 0 and "" are treated as falsy by || and fall through to the default; in Lua, they are truthy and won't:
local name = input or "anonymous"
Control Flow
Conditional branching uses if / elseif / else / end. The biggest difference from JS is the delimitation style: no parentheses, no curly braces, relying entirely on the then and end keywords. This style comes from Modula (an academic language in the Pascal family) and is rarely seen in today's mainstream languages—then marks the end of the condition and the start of the code block, end marks the end of the code block. It's a few more keystrokes, but the block boundaries are very explicit.
Two more points: conditions don't need parentheses; Lua has no switch. Note the spelling of elseif—it's one word, not else if:
local score = 85
if score >= 90 then
print("A")
elseif score >= 60 then
print("B")
else
print("C")
end
There are three types of loops. while:
local i = 1
while i <= 3 do
print(i)
i = i + 1
end
repeat / until (equivalent to do-while, the loop body executes at least once):
local i = 1
repeat
print(i)
i = i + 1
until i > 3
Numeric for (JS has no direct equivalent; the closest is C-style for, but Lua puts the start, end, and step all in the loop header):
for i = 1, 5 do
print(i) --> 1 2 3 4 5
end
for i = 10, 1, -2 do
print(i) --> 10 8 6 4 2
end
Generic for is similar to JS's for...of, used to iterate over tables. The difference is that JS's iteration protocol is hidden inside objects, while Lua's iterators are explicit functions—two built-in ones: ipairs iterates over the array part in order (stopping at the first nil), and pairs iterates over all key-value pairs (order unspecified):
local colors = {"red", "green", "blue"}
for i, v in ipairs(colors) do
print(i, v) --> 1 red / 2 green / 3 blue
end
local person = {name = "Ada", year = 1815}
for k, v in pairs(person) do
print(k, v) -- output order is unspecified
end
Functions
Functions are first-class citizens: they can be assigned to variables, passed as arguments, and stored in tables.
local function add(a, b)
return a + b
end
local f = add -- functions are values too
print(f(1, 2)) --> 3
Lua functions can return multiple values—in JS, returning multiple values requires wrapping them in an array and destructuring; in Lua, you just write them after return:
local function divmod(a, b)
return a // b, a % b
end
local q, r = divmod(10, 3)
print(q, r) --> 3 1
Variadic arguments also use ...—the same symbol as JS's rest parameters, also collecting trailing arguments. Use table.pack to gather them into a table (the returned table has an n field recording the argument count):
local function count(...)
local args = table.pack(...)
return args.n
end
print(count(1, 2, 3)) --> 3
Lua implements proper tail calls: tail-recursive calls won't blow the call stack. When writing recursion, put the recursive call at the tail position of the return expression, and you can recurse deeply without worry.
table: The Only Data Structure
Finally, Lua's core. Lua has only one data structure: the table. Arrays, dictionaries, objects, modules—all are tables. In JS, these are separate things (Array, Object, Map, Set); in Lua, they are one thing.
Arrays
Created with {...} literals, indexing starts at 1—this is the most conspicuous difference between Lua and JS, where arrays start at 0.
Why start at 1? Lua author Roberto Ierusalimschy explained in a talk: Lua's first users were Petrobras engineers from a Fortran background, and Fortran arrays are 1-based; more importantly, intuition—"first is 1st, not 0th," and for non-programmers, counting from 1 is more natural. He also criticized the prevalence of 0-based indexing: modern languages' 0-based indexing mostly follows C, and C uses 0-based because of pointer arithmetic (a[i] is *(a+i)), a reason other languages don't have.
In practice, just remember two things: t[1] is the first element, t[#t] is the last:
local colors = {"red", "green", "blue"}
print(colors[1]) --> red
print(#colors) --> 3 (# gets the array length)
# is equivalent to JS's .length, but it's only reliable for arrays without "holes"—if an array has nil in the middle, the length is undefined (JS's sparse arrays at least have a length; Lua simply doesn't guarantee one). Use standard library functions to add or remove elements; don't manually shift positions (table.insert is roughly JS's push):
table.insert(colors, "yellow") -- insert at the end
table.remove(colors, 2) -- remove the 2nd and shift forward
print(colors[1], colors[2]) --> red blue
Dictionaries
Key-value pair syntax:
local person = {
name = "Ada",
year = 1815,
}
print(person["name"]) --> Ada
print(person.name) --> Ada (syntactic sugar, equivalent to the line above)
A practical detail: person.name is syntactic sugar for person["name"], where the key is a string (JS object literals have the same). And colors[1] has the numeric key 1—the same table can serve as both an array and a dictionary simultaneously, which are separate things in JS:
local mix = {10, 20, name = "moon"}
print(mix[1], mix[2], mix.name) --> 10 20 moon
Assigning nil to a field deletes it:
person.year = nil
print(person.year) --> nil
Objects
Lua has no class keyword; an object is just a table containing data and functions. Combined with the colon syntactic sugar, you can write object-oriented style code:
local counter = {
n = 0,
inc = function(self)
self.n = self.n + 1
end,
}
counter:inc() -- colon sugar, equivalent to counter.inc(counter)
counter:inc()
print(counter.n) --> 2
obj:method(...) automatically passes obj as the first argument (conventionally named self) to method. self's role is similar to JS's this, but it's an explicit parameter—no binding rules, no classic confusion of "what is this?". You can also use the colon shorthand when defining methods:
local counter = {
n = 0,
}
function counter:inc()
self.n = self.n + 1
end
The two styles are equivalent.
Metatables: Adding Behavior to Tables
Tables themselves just store data. To get behaviors like "what to do when a method isn't found" or "how to add two tables together", you use metatables—this is Lua's most distinctive mechanism.
Every table can have a metatable attached (setmetatable), and fields in the metatable starting with __ define special behaviors. The closest concept in JS is Proxy: both are metaprogramming mechanisms that intercept "default behavior". The difference is that Proxy is an advanced technique in JS, while metatables are infrastructure in Lua—the language's own "inheritance" and operator overloading are built on top of them.
__index: Where to Look for Missing Keys
When accessing t[k], if k doesn't exist in t, Lua checks the metatable's __index. It can be a function or another table:
local defaults = {color = "green", size = 10}
local t = setmetatable({}, {__index = defaults})
print(t.color) --> green (not in t, so look in defaults)
This is the foundation for implementing "inheritance" in Lua: point __index at a "parent" table, and child objects automatically gain the parent's methods.
Writing a "Class" with Metatables
Combining __index with the colon syntactic sugar is the standard Lua OOP pattern:
local Vec = {}
Vec.__index = Vec
function Vec.new(x, y)
return setmetatable({x = x, y = y}, Vec)
end
function Vec:add(other)
return Vec.new(self.x + other.x, self.y + other.y)
end
local a = Vec.new(1, 2)
local b = Vec.new(3, 4)
local c = a:add(b)
print(c.x, c.y) --> 4 6
a:add(b) first looks for add in a—not finding it, it follows the metatable's __index to Vec.add. It looks just like a real class.
Operator Overloading
Metatables can also define arithmetic behavior, such as __add:
local mt = {
__add = function(a, b)
return {x = a.x + b.x, y = a.y + b.y}
end,
}
local a = setmetatable({x = 1, y = 2}, mt)
local b = setmetatable({x = 3, y = 4}, mt)
local c = a + b
print(c.x, c.y) --> 4 6
Other commonly used ones include __sub (subtraction), __eq (equality), __tostring (string representation for print), __call (making a table callable), etc. Metatables are the main outlet for Lua metaprogramming; understanding them is truly understanding Lua.
Modules and require
A Lua module is just a file that returns a table. Convention: use local variables inside the file for internal private state, and return a table exposing the public interface at the end:
-- math_utils.lua
local M = {}
function M.square(x)
return x * x
end
function M.cube(x)
return x * x * x
end
return M
Use require to load it:
local mu = require("math_utils")
print(mu.square(5)) --> 25
Unlike JS's import, require is not syntax but a runtime function. It searches for files using the templates in package.path (the current directory is in the search path by default) and loads the same module only once—repeated require calls return the cached result directly.
Error Handling
Lua doesn't have try-catch like JS; use pcall (protected call) to wrap calls that might fail—error handling is a function, not a syntactic structure:
local function risky()
error("something went wrong")
end
local ok, err = pcall(risky)
if not ok then
print("Caught error:", err)
end
On success, pcall returns true plus all the function's return values; on error, it returns false plus the error message. Combined with assert, you can skip a lot of checks:
local n = assert(tonumber("42"), "not a number")
print(n) --> 42
A Glance at the Standard Library
Lua's standard library is very small. A few commonly used ones:
- string:
string.format,string.sub,string.match(comes with its own pattern matching, similar to a simplified regex). - table:
table.insert,table.remove,table.sort,table.concat. - math:
math.floor,math.abs,math.random, and other conventional math functions. - io / os: File I/O and system calls.
- coroutine: Coroutines, Lua's native concurrency solution.
- utf8: UTF-8 string handling.
In Practice: Dijkstra's Shortest Path
After learning the syntax, let's wrap up by writing a complete program. We'll choose the classic graph algorithm Dijkstra's shortest path—the data structure it needs to handle (a graph) is naturally nested tables, perfect for testing what we've just learned.
The Problem to Solve
The graph looks like this: six nodes, and the numbers on the edges are the cost (weight) of traversing that edge:
The question is: starting from A, what is the shortest distance to each node? For example, from A to F, visually you could go A → B → D → F (4+5+6=15), or A → C → B → D → E → F (2+1+5+2+3=13); the latter is shorter. A human eye can piece it together, but how does a program calculate it?
Algorithm Idea
Dijkstra's idea is simple, step by step:
- The distance from the start node A to itself is 0; distances from A to other nodes are initially recorded as "infinity" (
math.huge), because no path has been found yet. - From the nodes that are "not yet settled", pick the one with the smallest distance and settle it—its shortest distance is now confirmed; there can be no shorter route (any detour through other nodes would only be longer).
- Look at the neighbors directly reachable from this node: if "going to the current node first, then to the neighbor" is shorter than the neighbor's currently recorded distance, update the neighbor's distance. This step is called relaxation.
- Repeat steps 2 and 3 until all nodes are settled.
Walking through the graph above, the complete process is as follows (green edges are the edges being examined in each round, and green rows in the dist table on the right are values that were updated):
The most noteworthy moment in the animation is in the second round: B changes from 4 to 3—"a detour being shorter than a direct connection" is counterintuitive, and this is precisely the value of relaxation.
Putting the Graph into a Table
How to represent the graph? List each node's neighbors and corresponding weights—this is an adjacency list. The natural way in Lua is a table of tables: the outer table's keys are node names, and the values are inner tables (keys are neighbors, values are weights). For an undirected graph, each edge must be written in both directions; A = {B = 4, C = 2} means A to B has weight 4, A to C has weight 2:
local graph = {
A = {B = 4, C = 2},
B = {A = 4, C = 1, D = 5},
C = {A = 2, B = 1, D = 8, E = 10},
D = {B = 5, C = 8, E = 2, F = 6},
E = {C = 10, D = 2, F = 3},
F = {D = 6, E = 3},
}
The Core Loop
The main loop corresponds to steps 2 and 3 of the idea above. Three auxiliary tables are needed: dist records distances; visited marks settled nodes (value is true); prev records "which node we came from to reach a given node", used later to reconstruct the path:
local function dijkstra(graph, start)
local dist = {}
local prev = {}
local visited = {}
for node in pairs(graph) do
dist[node] = math.huge -- initially infinity
end
dist[start] = 0 -- start to itself is 0
while true do
-- pick the unsettled node with the smallest dist
local current = nil
local best = math.huge
for node, d in pairs(dist) do
if not visited[node] and d < best then
best = d
current = node
end
end
if current == nil then
break -- no unsettled nodes left
end
visited[current] = true -- settle it
-- relax: is going through current to a neighbor shorter?
for neighbor, weight in pairs(graph[current]) do
local new_dist = dist[current] + weight
if new_dist < dist[neighbor] then
dist[neighbor] = new_dist
prev[neighbor] = current
end
end
end
return dist, prev
end
Note if not visited[node]: for unmarked nodes, visited[node] is nil, and nil is falsy—no need for any "set" structure; a single table with truthy/falsy values suffices.
Reconstructing the Path
dist gives the distance; the path is reconstructed from prev: prev[F] = E means "the stop before F is E". Trace back from the target node to the start, then reverse the order:
local function build_path(prev, target)
local path = {target}
local node = target
while prev[node] do
node = prev[node]
table.insert(path, 1, node) -- insert at the front to get the correct order
end
return path
end
Running It
local dist, prev = dijkstra(graph, "A")
for node, d in pairs(dist) do
local path = table.concat(build_path(prev, node), " → ")
print(string.format("%s: %d (%s)", node, d, path))
end
Combine the four code blocks above into dijkstra.lua and run:
lua dijkstra.lua
A: 0 (A)
B: 3 (A → C → B)
C: 2 (A → C)
D: 8 (A → C → B → D)
E: 10 (A → C → B → D → E)
F: 13 (A → C → B → D → E → F)
(pairs iteration order is unspecified, so the line order may vary.)
Checking against the graph, the shortest path from A to F is 13, exactly the green-highlighted A → C → B → D → E → F in the diagram. Everything used in the program was covered earlier: nested tables, pairs, truthy/falsy values, multiple return values, plus table.insert, table.concat, string.format. Writing the same program in JS would require Map or nested objects for the graph structure and a separate Set for visited; Lua handles it all with a single table.
A final note: this implementation scans linearly each round to pick the minimum, giving O(V²) complexity, which is perfectly fine for small graphs. Large graphs would need a binary heap optimization, but Lua has no built-in heap, which is beyond the scope of this article.
Next Steps
That's the syntax—a language with only 21 keywords, and this introduction covers most of it. What's really worth spending time on comes after you start using it: writing configs in Neovim, writing plugins for games, or reading Lua code written by others.
For further learning, official first-party resources are sufficient:
- Lua 5.5 Reference Manual: The definitive source for syntax details.
- Programming in Lua: The official tutorial, free online version available; the chapters on metatables and OOP are worth reading closely.
(End)