Pattern matching
Lua does not have algebraic-data-type-style pattern matching; pattern matching in Lua refers to string pattern matching — a simpler-than-regex syntax in the string library. The principal functions: string.find (returns positions), string.match (returns captures), string.gmatch (iterator over matches), string.gsub (substitute). For value-driven dispatch, the conventional substitute is if/elseif chains or table-based dispatch. For type-driven dispatch, type() checks combined with if/elseif. The combination — Lua patterns for substantial string parsing, table-based dispatch for value matching, the absence of regex but substantial pattern flexibility — is the substance of Lua’s pattern-dispatch surface.
Lua patterns vs regex
Lua patterns are not regular expressions — they admit a smaller syntax:
| Lua | Regex equivalent | Description |
|---|---|---|
%a | [a-zA-Z] | letters |
%A | [^a-zA-Z] | non-letters |
%d | [0-9] | digits |
%D | [^0-9] | non-digits |
%s | \s | whitespace |
%S | \S | non-whitespace |
%w | \w | alphanumeric |
%W | \W | non-alphanumeric |
%p | [[:punct:]] | punctuation |
%l | [a-z] | lowercase |
%u | [A-Z] | uppercase |
%c | [\x00-\x1F\x7F] | control characters |
%x | [0-9a-fA-F] | hex digits |
. | . | any character |
* | * | 0 or more (greedy) |
+ | + | 1 or more (greedy) |
- | *? | 0 or more (lazy/shortest) |
? | ? | 0 or 1 |
^ | ^ | start of string (or class negation in [^...]) |
$ | $ | end of string |
[...] | [...] | character class |
[^...] | [^...] | negated class |
(...) | (...) | capture |
%1, %2, … | \1, \2, … | backreference |
%% | \\ | literal % |
%. | \. | literal . |
The principal differences from regex:
- No alternation (
|). - No quantifier ranges (
{n,m}). - No lookbehind/lookahead.
- Different escape character (
%instead of\). -is lazy (whereas in regex it’s a literal).
For substantial pattern matching beyond Lua’s, LPeg (a third-party library) admits PEG-style parsing.
string.find
Returns the start and end indices of the first match, or nil:
string.find("hello world", "world") -- 7, 11
string.find("hello", "no match") -- nil
string.find("abc123", "%d+") -- 4, 6 (123)
-- Plain text search (no patterns):
string.find("a+b", "+", 1, true) -- 2, 2 (literal +)
-- Skip ahead:
string.find("aXbXc", "X", 3) -- 4, 4 (start at 3)
string.match
Returns the matched (or captured) text:
string.match("Date: 2026-01-15", "%d+-%d+-%d+") -- "2026-01-15"
-- Captures:
local year, month, day = string.match("2026-01-15", "(%d+)-(%d+)-(%d+)")
-- year="2026", month="01", day="15"
local name, age = string.match("Alice 30", "(%w+) (%d+)")
-- name="Alice", age="30"
-- No match:
string.match("hello", "%d+") -- nil
string.gmatch
Iterator over all matches:
for word in string.gmatch("hello world foo bar", "%w+") do
print(word) -- hello, world, foo, bar
end
-- With captures:
for name, age in string.gmatch("Alice 30, Bob 25, Charlie 35", "(%w+) (%d+)") do
print(name, age)
end
-- Pre-store as table:
local words = {}
for w in string.gmatch(text, "%w+") do
words[#words + 1] = w
end
string.gsub
Global substitute:
string.gsub("hello world", "o", "O") -- "hellO wOrld", 2
-- Limit replacements:
string.gsub("hello", "l", "L", 1) -- "heLlo", 1
-- With capture and backreference:
string.gsub("hello world", "(%w+)", "<%1>") -- "<hello> <world>"
-- With function replacement:
string.gsub("hello", "%w", function(c) return c:upper() end)
-- "HELLO", 5
-- With table replacement:
local replacements = {hello = "hi", world = "earth"}
string.gsub("hello world", "%w+", replacements)
-- "hi earth"
The form admits substantial flexibility:
- String replacement — substitution with backreferences.
- Function replacement — called per match.
- Table replacement — looked up per match.
Patterns
Character classes
-- Built-in:
string.match("abc123", "%d+") -- "123"
string.match("hello", "%a+") -- "hello"
string.match(" hello", "%s+") -- " "
-- Custom:
string.match("hello", "[aeiou]") -- "e" (first vowel)
string.match("hello", "[^aeiou]") -- "h" (first consonant)
string.match("a1b2", "[a-z][0-9]") -- "a1"
Quantifiers
string.match("aaa", "a*") -- "aaa" (0 or more)
string.match("", "a*") -- "" (0 admitted)
string.match("aaa", "a+") -- "aaa" (1 or more)
string.match("aab", "a+") -- "aa"
string.match("ab", "a?") -- "a" (optional)
-- Lazy:
string.match("<a><b>", "<.->") -- "<a>" (shortest match)
string.match("<a><b>", "<.*>") -- "<a><b>" (greedy)
Anchors
string.match("hello", "^hello$") -- "hello" (whole string match)
string.match("hello world", "^hello") -- "hello" (start)
string.match("hello world", "world$") -- "world" (end)
Captures
-- Numbered captures:
local first, second = string.match("hello world", "(%w+) (%w+)")
-- first="hello", second="world"
-- Backreference (within pattern):
string.match("aabb", "(%a)%1") -- "aa" (matches doubled letter)
string.match("abba", "(%a)(%a)%2%1") -- "abba" (palindrome)
-- Empty capture (position):
local pos = string.match("hello world", "()world")
-- pos = 7 (position of "world")
Special characters
To match %, escape with %%:
string.match("100%", "(%d+)%%") -- "100"
The other special characters that need escaping: ( ) . % + - * ? [ ] ^ $:
string.match("a.b", "%.") -- "."
string.match("a*b", "%*") -- "*"
Type-based dispatch
Lua does not admit a switch on type natively; the conventional pattern uses type():
local function describe(value)
local t = type(value)
if t == "number" then
return "number: " .. value
elseif t == "string" then
return "string: " .. value
elseif t == "table" then
return "table of " .. #value
elseif t == "function" then
return "a function"
elseif t == "boolean" then
return value and "true" or "false"
elseif t == "nil" then
return "nil"
else
return "unknown: " .. t
end
end
For substantial dispatch, a table-based approach:
local handlers = {
number = function(v) return "number: " .. v end,
string = function(v) return "string: " .. v end,
table = function(v) return "table of " .. #v end,
["function"] = function() return "a function" end
}
local function describe(value)
local handler = handlers[type(value)]
return handler and handler(value) or "unknown"
end
Value-based dispatch
Lua does not admit a switch statement; the conventional patterns:
if/elseif/else
local function http_status(code)
if code == 200 then
return "OK"
elseif code == 201 then
return "Created"
elseif code == 404 then
return "Not Found"
elseif code == 500 then
return "Server Error"
else
return "Unknown"
end
end
Range-based dispatch
local function classify(score)
if score >= 90 then return "A"
elseif score >= 80 then return "B"
elseif score >= 70 then return "C"
elseif score >= 60 then return "D"
else return "F"
end
end
Table-based dispatch
local status_messages = {
[200] = "OK",
[201] = "Created",
[404] = "Not Found",
[500] = "Server Error"
}
local function http_status(code)
return status_messages[code] or "Unknown"
end
The table-based form admits substantial conciseness for exact-match dispatch.
Function-table dispatch
local commands = {
help = function() print("usage: ...") end,
version = function() print("v1.0") end,
run = function(args) execute(args) end,
quit = function() os.exit() end
}
local function handle(cmd, args)
local fn = commands[cmd]
if fn then
fn(args)
else
print("unknown command: " .. cmd)
end
end
Common patterns
Parsing date
local function parse_date(s)
local year, month, day = s:match("(%d%d%d%d)-(%d%d)-(%d%d)")
if year then
return {
year = tonumber(year),
month = tonumber(month),
day = tonumber(day)
}
end
return nil
end
print(parse_date("2026-01-15").year) -- 2026
Splitting
local function split(s, sep)
sep = sep or "%s"
local parts = {}
for part in s:gmatch("([^" .. sep .. "]+)") do
parts[#parts + 1] = part
end
return parts
end
local words = split("hello world foo") -- {"hello", "world", "foo"}
Validating email
local function is_email(s)
return s:match("^[^@%s]+@[^@%s]+%.[^@%s]+$") ~= nil
end
Trimming
local function trim(s)
return (s:gsub("^%s*(.-)%s*$", "%1"))
end
trim(" hello ") -- "hello"
Tokenising
local function tokenize(s)
local tokens = {}
for token, kind in s:gmatch("(%S+)") do
if token:match("^%d+$") then
tokens[#tokens + 1] = {type = "number", value = tonumber(token)}
elseif token:match("^[%a_][%w_]*$") then
tokens[#tokens + 1] = {type = "identifier", value = token}
else
tokens[#tokens + 1] = {type = "operator", value = token}
end
end
return tokens
end
Replacing variables in template
local function template(s, vars)
return (s:gsub("${(%w+)}", vars))
end
local result = template("Hello, ${name}! You are ${age}.", {
name = "Alice",
age = "30"
})
-- "Hello, Alice! You are 30."
The gsub admits a table as third argument — looked up per capture.
Counting occurrences
local function count(s, pattern)
local _, n = s:gsub(pattern, "")
return n
end
count("hello world", "o") -- 2
count("hello world", "%w+") -- 2 (words)
Replacing with function
local function escape_html(s)
local replacements = {
["<"] = "<",
[">"] = ">",
["&"] = "&",
['"'] = """
}
return (s:gsub("[<>&\"]", replacements))
end
escape_html("<p>Hello & 'world'</p>") -- "<p>Hello & 'world'</p>"
Range pattern dispatch
local function category(age)
if age < 0 then return "invalid"
elseif age < 13 then return "child"
elseif age < 20 then return "teen"
elseif age < 65 then return "adult"
else return "senior"
end
end
Multiple matches
local input = "name=Alice; age=30; email=alice@b.c"
local config = {}
for key, value in input:gmatch("(%w+)=([^;]+)") do
config[key] = value:match("^%s*(.-)%s*$") -- trim
end
print(config.name) -- "Alice"
print(config.age) -- "30"
CSV parsing (simple)
local function parse_csv_line(line)
local fields = {}
for field in line:gmatch("([^,]+)") do
fields[#fields + 1] = field
end
return fields
end
For substantial CSV (with quoted fields, escapes), a library like lua-csv is conventional.
Stripping HTML tags
local function strip_tags(html)
return html:gsub("<[^>]+>", "")
end
strip_tags("<p>Hello <b>world</b></p>") -- "Hello world"
Lookahead-style (simulated)
Lua patterns do not admit lookahead; substantial patterns may simulate via captures:
-- Match number not followed by a digit:
local s = "abc 123 def 456"
for n in s:gmatch("(%d+)%D") do
print(n) -- "12" (matches before "3", then "45" before "6")
end
-- (substantial care needed; lookahead is unsupported)
A note on LPeg
For substantial pattern parsing, the third-party LPeg library admits PEG (parsing expression grammars):
local lpeg = require("lpeg")
local P, R, S, V = lpeg.P, lpeg.R, lpeg.S, lpeg.V
local C, Cs, Ct = lpeg.C, lpeg.Cs, lpeg.Ct
-- Match an integer:
local digit = R("09")
local integer = digit ^ 1
local capture = C(integer)
print(capture:match("123")) -- "123"
LPeg admits substantial expressiveness — composable parsers, captures, semantic actions. Conventional in substantial parser construction.
A note on the conventional discipline
The contemporary Lua pattern-matching advice:
- Use Lua patterns for substantial string manipulation.
- Use raw-string patterns
[[...]]to avoid escape backslashes. - Use
findfor position;matchfor value;gmatchfor iteration;gsubfor substitution. - Use captures
()for substantial parsing. - Use table-based dispatch for value-driven branching.
- Use
if/elseifchains for range-based dispatch. - Use
type()for type-based dispatch. - Reach for LPeg when patterns are insufficient.
- Avoid regex-like assumptions — Lua patterns are simpler.
The combination — Lua patterns for substantial string matching (find/match/gmatch/gsub), the type-based and value-based dispatch via if/elseif or table lookup, the LPeg library for substantial parsing — is the substance of Lua’s pattern-dispatch surface. The discipline trades regex flexibility for substantial simplicity; the mechanism admits substantial routine string processing with a small built-in surface.