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.
The string library
Section titled “The string library”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()) -- => 10print(s:upper()) -- => HELLO, LUAprint(s:lower()) -- => hello, luaprint(s:sub(1, 5)) -- => Helloprint(s:sub(8)) -- => Luaprint(s:sub(-3)) -- => Lua (last 3 bytes)print(s:sub(-3, -2)) -- => Luprint(s:reverse()) -- => auL ,olleHprint(("ab"):rep(3)) -- => abababprint(("ab"):rep(3, "-")) -- => ab-ab-ab (separator, 5.2+)print(s:byte(1)) -- => 72print(s:byte(1, 3)) -- => 72 101 108print(string.char(76, 117, 97)) -- => LuaBecause 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) endprint(table.concat(buf, "\n"))string.format
Section titled “string.format”string.format(fmt, ...) follows C’s sprintf with a few Lua-specific additions.
print(string.format("%d items", 42)) -- => 42 itemsprint(string.format("%5.2f", math.pi)) -- => 3.14print(string.format("%-10s|", "left")) -- => left |print(string.format("%10s|", "right")) -- => right|print(string.format("%x / %X / %#x", 255, 255, 255)) -- => ff / FF / 0xffprint(string.format("%05d", 42)) -- => 00042print(string.format("%e", 12345.678)) -- => 1.234568e+04print(string.format("%g", 0.00001)) -- => 1e-05print(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.
Patterns are not regular expressions
Section titled “Patterns are not regular expressions”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.
Character classes
Section titled “Character classes”| 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 %)Magic characters and escaping
Section titled “Magic characters and escaping”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"))endprint(escape("a.b*c")) -- => a%.b%*cOr, 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)Quantifiers
Section titled “Quantifiers”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.
Anchors
Section titled “Anchors”^ 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")) -- => heprint(("hello"):match("^ello")) -- => nilprint(("hello"):match("llo$")) -- => lloprint(("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).
Captures
Section titled “Captures”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 12Back 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")) -- => abcprint(('say "hi" ok'):match([[(["'])(.-)%1]])) -- => " hiTwo patterns with no regex equivalent
Section titled “Two patterns with no regex equivalent”%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.
find, match, gmatch, gsub
Section titled “find, match, gmatch, gsub”string.find
Section titled “string.find”s:find(pattern [, init [, plain]]) returns the start and end indices, then any captures. Returns nil on failure.
print(("hello world"):find("wor")) -- => 7 9print(("hello world"):find("o", 6)) -- => 8 8 (start searching at 6)print(("hello"):find("z")) -- => nilprint(("k=v"):find("(%w+)=(%w+)")) -- => 1 3 k vUse find when you want positions; use match when you want text.
string.match
Section titled “string.match”s:match(pattern [, init]) returns the captures, or the whole match if there are none.
print((" 42 "):match("%d+")) -- => 42print(("GET /api/x HTTP/1.1"):match("^(%u+)%s")) -- => GETprint(tonumber(("port: 8080"):match("port:%s*(%d+)"))) -- => 8080string.gmatch
Section titled “string.gmatch”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 3Iterating lines, tolerating a missing final newline:
for line in ("one\ntwo\nthree"):gmatch("[^\n]+") do print(line) endstring.gsub
Section titled “string.gsub”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 1print(("abc"):gsub("%a", "[%0]")) -- => [a][b][c] 3A 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} 3A 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))endprint(urldecode("hello+world%21")) -- => hello world!Recipes
Section titled “Recipes”-- 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 outend
-- 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 outend
-- Count occurrences (gsub's second return value).local function count(s, pat) return select(2, s:gsub(pat, "")) endprint(count("banana", "a")) -- => 3
-- startsWith / endsWith without allocating.local function starts_with(s, prefix) return s:sub(1, #prefix) == prefix endlocal 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: localhostport: 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))endprint(render("Hello {{who}}!", { who = "Lua" })) -- => Hello Lua!Where patterns run out
Section titled “Where patterns run out”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.
%bhandles 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,%uare 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
utf8library (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 111print(("héllo"):match(utf8.charpattern)) -- => hutf8.charpatternis a plain Lua pattern that matches exactly one UTF-8 sequence, which makes it usable insidegmatch. 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.
Key points
Section titled “Key points”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.findgives positions,matchgives text,gmatchiterates,gsubreplaces 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
utf8library.