Skip to content

Strings and Patterns

Lua strings are immutable byte arrays, and the string library is one of the few parts of the standard library that is genuinely rich. Its centerpiece is Lua patterns — a compact matching notation that looks like regular expressions and is not regular expressions.

Every string shares a metatable whose __index is the string table, so s:upper() and string.upper(s) are the same call. All indices are 1-based and inclusive, and negative indices count from the end.

local s = "Hello, Lua"
print(#s) -- => 10 (bytes, not characters)
print(s:len()) -- => 10
print(s:upper()) -- => HELLO, LUA
print(s:lower()) -- => hello, lua
print(s:sub(1, 5)) -- => Hello
print(s:sub(8)) -- => Lua
print(s:sub(-3)) -- => Lua (last 3 bytes)
print(s:sub(-3, -2)) -- => Lu
print(s:reverse()) -- => auL ,olleH
print(("ab"):rep(3)) -- => ababab
print(("ab"):rep(3, "-")) -- => ab-ab-ab (separator, 5.2+)
print(s:byte(1)) -- => 72
print(s:byte(1, 3)) -- => 72 101 108
print(string.char(76, 117, 97)) -- => Lua

Because strings are immutable, every one of these returns a new string. There is no in-place mutation and no StringBuilder; build large strings with a table plus table.concat.

local buf = {}
for i = 1, 5 do buf[#buf + 1] = ("line %d"):format(i) end
print(table.concat(buf, "\n"))

string.format(fmt, ...) follows C’s sprintf with a few Lua-specific additions.

print(string.format("%d items", 42)) -- => 42 items
print(string.format("%5.2f", math.pi)) -- => 3.14
print(string.format("%-10s|", "left")) -- => left |
print(string.format("%10s|", "right")) -- => right|
print(string.format("%x / %X / %#x", 255, 255, 255)) -- => ff / FF / 0xff
print(string.format("%05d", 42)) -- => 00042
print(string.format("%e", 12345.678)) -- => 1.234568e+04
print(string.format("%g", 0.00001)) -- => 1e-05
print(string.format("%%")) -- => %
print(string.format("%c", 65)) -- => A
Specifier Argument Notes
%d, %i integer A float with an exact integer value is accepted (3.0 works, 3.5 errors)
%x, %X, %o integer Hex lower/upper, octal
%c integer The byte with that code
%f, %e, %E, %g, %G float Fixed, scientific, shortest
%a, %A float Hexadecimal float — exact round-trip (5.2+)
%s any Uses tostring, so __tostring is honored
%q string/number Quotes and escapes so the result can be read back by Lua
%% A literal %

Width, precision, -, 0, +, # and space flags all work. The * (dynamic width), h, l, L and n modifiers do not.

%q is the tool for emitting Lua source or safely embedding arbitrary bytes:

print(string.format("%q", 'he said "hi"\n'))
"he said \"hi\"\
"

The trailing backslash-newline is intentional: it is valid Lua source that reads back as a newline.

Lua ships its own matcher in a few hundred lines of C. It is deliberately small, and the differences from PCRE/POSIX regular expressions are not cosmetic:

Regex Lua patterns
\d, \w, \s %d, %w, %s% is the escape character, not \
a|b alternation Not supported. No | at all.
{2,5} repetition counts Not supported. Repeat the class by hand.
(?:...), (?=...), (?<=...) Not supported. No non-capturing groups, no lookaround.
*? lazy quantifier - (a distinct quantifier meaning “shortest”)
\1 backreference %1
(...) groups nest freely Captures work, but a quantifier can only follow a single character class, never a group
%bxy balanced match, %f[set] frontier — no regex equivalent

The upside is speed and simplicity. Because a quantifier can only ever apply to a single character class, the (a+)+ family of catastrophic-backtracking blowups cannot even be written down, there is no pattern compiler to warm up, and the whole implementation fits in your head.

Class Matches
. any character (including \0 and newline)
%a letters
%d digits
%l lowercase letters
%u uppercase letters
%s whitespace
%w alphanumerics (letters + digits, no underscore)
%p punctuation
%c control characters
%x hexadecimal digits
%g printable characters except space (5.2+)

Uppercasing the letter complements the class: %A is “not a letter”, %D is “not a digit”, %S is “not whitespace”.

Character sets use square brackets, and may contain ranges and classes:

"[aeiou]" -- any vowel
"[^aeiou]" -- anything but a vowel
"[a-z0-9_]" -- range plus range plus literal
"[%a_]" -- a letter or an underscore (classes work inside sets)
"[%]]" -- a literal ']' (escape it with %)

These are magic: ^ $ ( ) % . [ ] * + - ?

Escape any of them with %. Note - is magic (it is a quantifier), and %% is a literal percent sign.

print(("3.14"):match("%d%.%d+")) -- => 3.14 ('.' escaped: a literal dot)
print(("3x14"):match("%d.%d+")) -- => 3x14 ('.' unescaped: any character)
print(("50%"):match("%d+%%")) -- => 50% ('%%' is a literal percent)
print(("a-b"):match("%a-b")) -- => b ('-' here is the lazy quantifier)
print(("a-b"):match("%a%-b")) -- => a-b ('-' escaped: a literal hyphen)

- is only a quantifier when it directly follows a character class; elsewhere it is literal. That ambiguity is exactly why you should escape it whenever you mean a hyphen.

To match a user-supplied literal string, escape it first:

local function escape(s)
return (s:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"))
end
print(escape("a.b*c")) -- => a%.b%*c

Or, when you only need a plain substring search, pass true as the fourth argument to find, which skips the pattern engine entirely:

print(("a.b"):find(".", 1, true)) -- => 2 2 (plain find)

A quantifier applies to the immediately preceding single character class — never to a group.

Quantifier Meaning
* 0 or more, longest match (greedy)
+ 1 or more, longest match (greedy)
- 0 or more, shortest match (lazy)
? 0 or 1
local html = "<b>bold</b> and <i>it</i>"
print(html:match("<(.*)>")) -- => b>bold</b> and <i>it</i (greedy)
print(html:match("<(.-)>")) -- => b (lazy)

There is no {n,m}. For “exactly three digits” write %d%d%d; for “two to four” you need either several patterns or a post-check on the length.

^ at the start of the pattern anchors to the start of the subject; $ at the end anchors to the end. Anywhere else they are literal characters.

print(("hello"):match("^he")) -- => he
print(("hello"):match("^ello")) -- => nil
print(("hello"):match("llo$")) -- => llo
print(("hello"):match("^hello$")) -- => hello (full-string match)

^ also changes behavior of the iterating functions: gsub with an anchored pattern only ever tries position 1, and gmatch explicitly does not treat a leading ^ as an anchor (it would make iteration impossible).

Parentheses capture. If a pattern has captures, match/find/gmatch return the captures instead of the whole match.

local date = "2026-08-09"
local y, m, d = date:match("^(%d%d%d%d)-(%d%d)-(%d%d)$")
print(y, m, d) -- => 2026 08 09 (strings, not numbers)

An empty capture () is a position capture: it returns the byte position at that point as an integer.

print(("hello world"):match("()world()")) -- => 7 12

Back references %1%9 match whatever capture n matched — something regex has, but here it is the only way to express “the same again”:

print(("abcabc"):match("(abc)%1")) -- => abc
print(('say "hi" ok'):match([[(["'])(.-)%1]])) -- => " hi

%bxy matches a balanced run starting with x and ending with the matching y, counting nesting:

print(("f(g(1), 2) tail"):match("%b()")) -- => (g(1), 2)
print(("{a{b}c}"):match("%b{}")) -- => {a{b}c}

%f[set] is a frontier: it matches the empty string at a transition from a character not in set to a character in set (the position before the subject counts as \0). It is Lua’s word-boundary.

-- Replace the whole word "in", but not the "in" inside "inside" or "pin".
local s, n = ("in inside pin"):gsub("%f[%w]in%f[%W]", "IN")
print(s, n) -- => IN inside pin 1

%f[%w] matches only where a non-alphanumeric (or the start of the string) is followed by an alphanumeric; %f[%W] matches at the opposite transition. Together they bracket a word.

s:find(pattern [, init [, plain]]) returns the start and end indices, then any captures. Returns nil on failure.

print(("hello world"):find("wor")) -- => 7 9
print(("hello world"):find("o", 6)) -- => 8 8 (start searching at 6)
print(("hello"):find("z")) -- => nil
print(("k=v"):find("(%w+)=(%w+)")) -- => 1 3 k v

Use find when you want positions; use match when you want text.

s:match(pattern [, init]) returns the captures, or the whole match if there are none.

print((" 42 "):match("%d+")) -- => 42
print(("GET /api/x HTTP/1.1"):match("^(%u+)%s")) -- => GET
print(tonumber(("port: 8080"):match("port:%s*(%d+)"))) -- => 8080

s:gmatch(pattern) returns an iterator over every non-overlapping match. This is the workhorse for tokenizing.

for word in ("the quick brown fox"):gmatch("%a+") do
io.write(word, "|")
end
-- => the|quick|brown|fox|
for k, v in ("a=1&b=2&c=3"):gmatch("(%w+)=(%w+)") do
print(k, v)
end
-- => a 1
-- => b 2
-- => c 3

Iterating lines, tolerating a missing final newline:

for line in ("one\ntwo\nthree"):gmatch("[^\n]+") do print(line) end

s:gsub(pattern, repl [, n]) returns the new string and the number of substitutions. n caps the number of replacements.

repl can be three things:

A string, where %1%9 insert captures and %0 inserts the whole match:

print(("hello world"):gsub("(%w+) (%w+)", "%2 %1")) -- => world hello 1
print(("abc"):gsub("%a", "[%0]")) -- => [a][b][c] 3

A table, indexed by the first capture (or the whole match). A nil/false result leaves the match untouched:

local vars = { name = "ada", city = "london" }
print(("Hi ${name} from ${city}, ${missing}"):gsub("%${(%w+)}", vars))
-- => Hi ada from london, ${missing} 3

A function, called with the captures. Again, nil/false keeps the original text:

print(("a1b2"):gsub("%d", function(d) return tonumber(d) * 2 end))
-- => a2b4 2
-- Percent-decoding a URL component:
local function urldecode(s)
s = s:gsub("%+", " ")
return (s:gsub("%%(%x%x)", function(hex)
return string.char(tonumber(hex, 16))
end))
end
print(urldecode("hello+world%21")) -- => hello world!
-- Trim whitespace. The '-' makes the middle lazy so trailing space is dropped.
local function trim(s) return (s:gsub("^%s*(.-)%s*$", "%1")) end
-- Split on a single-character separator.
local function split(s, sep)
local out = {}
for field in s:gmatch("([^" .. sep .. "]+)") do out[#out + 1] = field end
return out
end
-- Split that keeps empty fields.
local function split_keep_empty(s, sep)
local out = {}
for field in (s .. sep):gmatch("(.-)" .. sep) do out[#out + 1] = field end
return out
end
-- Count occurrences (gsub's second return value).
local function count(s, pat) return select(2, s:gsub(pat, "")) end
print(count("banana", "a")) -- => 3
-- startsWith / endsWith without allocating.
local function starts_with(s, prefix) return s:sub(1, #prefix) == prefix end
local function ends_with(s, suffix) return suffix == "" or s:sub(-#suffix) == suffix end
-- Title-case each word.
print(("hello wide world"):gsub("(%a)(%w*)", function(first, rest)
return first:upper() .. rest:lower()
end))
-- => Hello Wide World 3
-- Parse simple key: value config lines.
local conf = [[
host: localhost
port: 8080
]]
for k, v in conf:gmatch("(%w+):%s*(%S+)") do print(k, v) end
-- Template interpolation.
local function render(tpl, ctx)
return (tpl:gsub("{{(%w+)}}", function(k) return tostring(ctx[k] or "") end))
end
print(render("Hello {{who}}!", { who = "Lua" })) -- => Hello Lua!

Patterns are a lexer, not a parser. Reach for something else when you need:

  • Alternation. (cat|dog) has no equivalent. Try each pattern in turn, or match a broader class and post-filter.
  • Nested or recursive structure. HTML, JSON, s-expressions, balanced quotes with escapes. %b handles one bracket pair; it cannot handle a grammar.
  • Repetition counts. %d{3,5} does not exist.
  • Lookaround. %f[set] covers the word-boundary case and nothing else.
  • Unicode. %a, %l, %u are byte-wise and depend on the C locale. They classify ASCII correctly and multi-byte UTF-8 not at all. ("é"):len() is 2, and ("é"):upper() is unchanged.

The options, in rough order of preference:

  • LPeg — a Parsing Expression Grammar library by Lua’s own author. The right tool for anything with a grammar; it handles alternation, recursion and captures properly, and compiles patterns to a small VM. Install with luarocks install lpeg.

  • The utf8 library (Lua 5.3+) for codepoint-aware work:

    print(utf8.len("héllo")) -- => 5 (codepoints, not bytes)
    print(#"héllo") -- => 6 (bytes)
    for _, c in utf8.codes("héllo") do io.write(c, " ") end
    -- => 104 233 108 108 111
    print(("héllo"):match(utf8.charpattern)) -- => h

    utf8.charpattern is a plain Lua pattern that matches exactly one UTF-8 sequence, which makes it usable inside gmatch. There is still no case folding or normalization.

  • lrexlib if you genuinely need PCRE or POSIX regex semantics.

  • A real parser. For JSON, YAML, HTML — use a library. Pattern-based HTML parsing is a well-known way to ship bugs.

  • s:method() works on every string; indices are 1-based and inclusive, negatives count from the end.
  • Strings are immutable — build with a table and table.concat.
  • Lua patterns use % to escape, not \, and have no alternation, no {n,m}, no lookaround, and no groups under quantifiers.
  • - is the lazy quantifier; * and + are greedy; ? is optional.
  • ^ and $ only anchor at the pattern’s start/end; elsewhere they are literal.
  • find gives positions, match gives text, gmatch iterates, gsub replaces and returns a count as a second value.
  • %b() matches balanced pairs and %f[%w] is the word boundary — neither has a regex equivalent.
  • Patterns are byte-oriented. For grammars use LPeg; for Unicode use the utf8 library.