Polyglot
Languages Lua pattern matching
Lua § pattern-matching

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:

LuaRegex equivalentDescription
%a[a-zA-Z]letters
%A[^a-zA-Z]non-letters
%d[0-9]digits
%D[^0-9]non-digits
%s\swhitespace
%S\Snon-whitespace
%w\walphanumeric
%W\Wnon-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 = {
        ["<"] = "&lt;",
        [">"] = "&gt;",
        ["&"] = "&amp;",
        ['"'] = "&quot;"
    }
    return (s:gsub("[<>&\"]", replacements))
end

escape_html("<p>Hello & 'world'</p>")              -- "&lt;p&gt;Hello &amp; 'world'&lt;/p&gt;"

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 find for position; match for value; gmatch for iteration; gsub for substitution.
  • Use captures () for substantial parsing.
  • Use table-based dispatch for value-driven branching.
  • Use if/elseif chains 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.