Strings and Pattern Matching
Reviewed & published by Brayan K
By the end of this lesson you'll format and slice text with the string library, search and replace with find, match, gmatch, and gsub, and wield Lua's own lightweight pattern system — character classes, anchors, quantifiers, and captures — to validate and extract data without ever reaching for a regex engine.
Part of the free Lua course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
- Format strings with string.format and printf-style placeholders
- Measure and slice text with #, len, and sub
- Change case and repeat text with upper, lower, and rep
- Search and replace using find, match, gmatch, and gsub
- Write Lua patterns: classes %a %d %s %w, anchors ^ $, quantifiers * + - ?
- Capture substrings with () and escape magic characters with %
💡 Real-World Analogy
Think of a Lua pattern as a cookie cutter pressed into a sheet of dough. The cutter's shape — letters here, digits there, a gap of whitespace — decides exactly which pieces of text pop out. It is a simpler tool than a full regex Swiss-army knife: fewer settings, but lighter, faster, and built right into the language. Once you learn the handful of shapes (%a, %d, %s, %w) and the quantifiers that stretch them, you can stamp structured data out of almost any messy string.
1. The String Library: Length, Slicing & Case
Lua bundles its text tools in the string library, and every string can call them as methods with a colon (name:upper() is the same as string.upper(name)). You measure length with # or string.len, pull out slices with string.sub (1-based and inclusive, with negatives counting from the end), change case with upper/lower, and repeat text with rep. Strings are immutable — every one of these returns a brand-new string. Read and run this example.
-- The 'string' library is full of tools for working with text.
-- Every Lua string also has these as methods via the colon.
local name = "Ada Lovelace"
-- LENGTH: # or string.len give the byte length.
print(#name) -- prints: 12
print(string.len(name)) -- prints: 12
-- SUB: pull out a slice (1-based, inclusive on both ends).
print(string.sub(name, 1, 3)) -- prints: Ada
print(name:sub(5)) -- prints: Lovelace (5 to the end)
print(name:sub(-8)) -- prints: Lovelace (negative counts from end)
-- CASE: upper and lower return new strings (originals never change).
print(name:upper()) -- prints: ADA LOVELACE
print(name:lower()) -- prints: ada lovelace
-- REP: repeat a string, with an optional separator.
print(string.rep("ab", 3)) -- prints: ababab
print(string.rep("hi", 3, "-")) -- prints: hi-hi-hi2. Building Strings with string.format
string.format is the cleanest way to stitch values into text. It works just like C's printf: a format string holds % placeholders that get filled by the arguments — %s for strings, %d for integers, %.2f for a float with two decimals, %x for hex, and %q for a safely quoted string. You can add width and alignment (%5d, %-5d), and write %% for a literal percent sign. It beats long chains of .. concatenation for anything with numbers.
-- string.format builds a string from a format and arguments,
-- exactly like C's printf. Placeholders start with %.
print(string.format("Hello, %s!", "Ada")) -- prints: Hello, Ada!
-- %d = integer, %s = string, %f = float, %.2f = 2 decimal places.
print(string.format("%d items at %.2f each", 3, 1.5))
-- prints: 3 items at 1.50 each
-- Width and padding: %5d right-aligns in 5 columns, %-5d left-aligns.
print(string.format("[%5d]", 42)) -- prints: [ 42]
print(string.format("[%-5d]", 42)) -- prints: [42 ]
-- %x = hex, %q = a safely quoted string (great for serialising).
print(string.format("%x", 255)) -- prints: ff
print(string.format("%q", 'say "hi"')) -- prints: "say \"hi\""
-- Use %% to print a literal percent sign.
print(string.format("%d%% done", 90)) -- prints: 90% doneYour turn. Finish the receipt line with string.format and slice a name with string.sub. Fill in the blanks marked ___ and compare with the expected output.
-- 🎯 YOUR TURN — replace each ___ then run the code.
local price = 4.5
local qty = 3
-- 1) Build a receipt line: "3 x $4.50 = $13.50"
-- %d for the quantity, %.2f for the two money amounts.
local line = string.format("%d x $%.2f = $%.2f", qty, price, ___)
-- 👉 the total is qty * price
print(line)
-- 2) Grab just the first 3 letters of "Lovelace" with sub
local short = string.sub("Lovelace", 1, ___) -- 👉 the ending index
print(short)
-- ✅ Expected output:
-- 3 x $4.50 = $13.50
-- Lov3. Searching & Replacing: find, match, gmatch, gsub
Four functions do the heavy lifting. string.find returns the positions of a match (pass true as a fourth argument for a plain, non-pattern search). string.match returns the matched text or its captures. string.gmatch hands back an iterator so you can loop over every match. And string.gsub performs a global substitution, returning the new string plus a count of how many replacements it made. Learn these four and you can parse almost anything.
-- find returns the START and END positions of a match (and captures).
local s = "user: ada42"
print(string.find(s, "ada")) -- prints: 7 9
-- Pass true as the 4th arg for a PLAIN (non-pattern) search.
print(string.find("a.b.c", ".", 1, true)) -- prints: 2 2
-- match returns the matched text (or its captures), not positions.
print(string.match(s, "%a+")) -- prints: user (first run of letters)
print(string.match(s, "(%a+): (%a+)(%d+)")) -- prints: user ada 42
-- gmatch is an ITERATOR over every match — perfect for a for loop.
for word in string.gmatch("the quick brown fox", "%a+") do
io.write(word, " ") -- prints: the quick brown fox
end
print()
-- gsub substitutes and returns the new string PLUS a replacement count.
local cleaned, n = string.gsub("a-b-c-d", "-", "/")
print(cleaned, n) -- prints: a/b/c/d 34. Lua Patterns (Not Regex)
The magic inside those four functions is the Lua pattern, a small system that is not a full regular expression engine. Classes start with a percent sign: %a letters, %d digits, %s whitespace, %w alphanumerics (uppercase versions negate). Quantifiers follow the item they repeat: * zero-or-more, + one-or-more, - lazy zero-or-more, ? optional. Anchors ^ and $ tie a match to the start or end of the string. Parentheses () create captures, and the magic characters ^ $ ( ) % . [ ] * + - ? must be escaped with %.
Pattern Cheat Sheet
| Pattern | Matches |
|---|---|
| %a / %d / %s / %w | letter / digit / whitespace / alphanumeric |
| %A / %D / %S / %W | the negation of each (NOT a letter, etc.) |
| * / + / - / ? | 0+ greedy / 1+ greedy / 0+ lazy / optional |
| ^ ... $ | anchor to start / end of the subject |
| ( ... ) | capture the matched text to return it |
-- Lua PATTERNS are a small, fast system — NOT full regular expressions.
-- There is no alternation (|) and no backtracking, but they cover most jobs.
-- Character classes (lowercase matches; UPPERCASE negates):
-- %a letters %d digits %s whitespace %w alphanumerics
-- %A not-letter %D not-digit ... and so on.
print(string.match("Room 7B", "%d")) -- prints: 7 (first digit)
print(string.match("Room 7B", "%a+")) -- prints: Room
-- Quantifiers apply to the item before them:
-- * zero or more (greedy) + one or more (greedy)
-- - zero or more (lazy) ? zero or one (optional)
print(string.match("color colour", "colou?r")) -- prints: color
-- Anchors: ^ at the start, $ at the end, of the SUBJECT string.
print(string.match("2026-06-21", "^%d%d%d%d")) -- prints: 2026
print((string.match("file.lua", "%.lua$"))) -- prints: .lua
-- MAGIC characters ^ $ ( ) % . [ ] * + - ? must be escaped with %.
-- To match a literal dot, write %. (a bare . means "any character").
print(string.match("3.14", "%d+%.%d+")) -- prints: 3.14Now you try patterns. Capture the key and value from a key=value line, then count words with gmatch. Fill in the two blanks:
-- 🎯 YOUR TURN — pull the parts out of a key=value line.
local setting = "volume=75"
-- 1) Capture the key (letters) and the value (digits) in one match.
-- %a+ for the key, %d+ for the value, () marks each capture.
local key, value = string.match(setting, "(%a+)=(___)")
-- 👉 the class that matches one or more digits
print(key, value) -- ✅ Expected output: volume 75
-- 2) Count how many words are in a sentence using gmatch + a counter.
local count = 0
for _ in string.gmatch("one two three four", "%a+") do
count = count + ___ -- 👉 add one for each word found
end
print(count) -- ✅ Expected output: 4Pro Tips
- 💡 Reach for string.format over long .. chains whenever numbers are involved — %.2f alone saves a lot of rounding pain.
- 💡 Patterns are not regex. There is no | alternation; build it with multiple match attempts or a character set like [abc].
- 💡 Escape magic characters with % — %. for a dot, %% for a percent — or pass true to find for a plain search.
- 💡 gmatch is your loop tool. To split on a delimiter, match the gaps between it: [^,]+ grabs every non-comma field.
Common Errors (and the fix)
- A dot matches too much: a bare . means "any character", so "3.14" matched by "%d.%d" also matches "3x14". Fix: escape it as %. to match a literal period.
- Expecting regex alternation: "cat|dog" does not work — Lua patterns have no |. Fix: try each pattern separately, or use a character set [cd] for single-character choices.
- Off-by-one with sub: indices are 1-based and inclusive, so s:sub(1, 3) returns three characters, not two. Fix: remember both ends are included.
- Ignoring the gsub count: writing local s = str:gsub(...) as a non-final assignment is fine, but in a list the second return (the count) can sneak in. Fix: wrap in parentheses (str:gsub(...)) to force one value.
📋 Quick Reference
| Task | Code | Result / Note |
|---|---|---|
| Length | #s or string.len(s) | byte count |
| Slice | s:sub(1, 3) | 1-based, inclusive |
| Format | string.format("%.2f", x) | printf-style |
| Find position | s:find("ab") | start, end |
| Extract | s:match("%d+") | matched text |
| Loop matches | for w in s:gmatch("%a+") | iterator |
| Replace | s:gsub("-", "/") | new string, count |
Mini-Challenge: a CSV splitter
No blanks this time — just a brief and an outline. You'll combine gmatch with a "not a comma" pattern and a trimming gsub. Build it, run it, and check your output against the example in the comments.
-- 🎯 MINI-CHALLENGE: a tiny CSV splitter
-- 1. Write split(line) that returns a table of the comma-separated fields.
-- Use a pattern that matches "everything that is NOT a comma": [^,]+
-- Loop with: for field in string.gmatch(line, "[^,]+") do ... end
-- and table.insert each field into a result table.
-- 2. Also write trim(s) that removes leading/trailing spaces using gsub
-- with the pattern "^%s*(.-)%s*$" and capture (.-) for the middle.
--
-- ✅ Example run:
-- local parts = split("ada,42,london")
-- print(parts[1], parts[2], parts[3]) -- ada 42 london
-- print("[" .. trim(" hi ") .. "]") -- [hi]
-- your code here🎉 Lesson Complete!
- ✅ The string library: len/#, sub, upper/lower, rep, and format
- ✅ Search and replace with find (positions), match (text), gmatch (loop), gsub (replace + count)
- ✅ Lua patterns are their own system — classes %a %d %s %w, anchors ^ $, quantifiers * + - ?
- ✅ Parentheses () create captures; escape magic characters with %
- ✅ Strings are immutable — every operation returns a new string
Practice quiz
Which function returns a formatted string using % placeholders?
- string.format
- string.printf
- string.template
- string.fill
Answer: string.format. string.format works like C's printf, returning a new string built from the format and arguments.
What does the # operator return for a string?
- The number of words
- Its memory address
- Its byte length
- Its first character
Answer: Its byte length. On a string, # gives the length in bytes, the same value string.len returns.
Which pattern class matches a single letter (a-z, A-Z)?
- %s
- %a
- %w
- %d
Answer: %a. %a matches any alphabetic character; %d is digits, %s is whitespace, %w is alphanumerics.
What does string.gsub return in addition to the new string?
- The original string
- Nothing else
- The matched captures
- The number of substitutions made
Answer: The number of substitutions made. gsub returns the modified string plus a count of how many replacements it performed.
Which function is designed to iterate over ALL matches in a loop?
- string.gmatch
- string.find
- string.match
- string.sub
Answer: string.gmatch. string.gmatch returns an iterator yielding each successive match, ideal for a for loop.
In Lua patterns, what does the anchor ^ mean at the start of a pattern?
- Repeat zero or more
- Match only at the beginning of the string
- Negate the class
- Match the end of the string
Answer: Match only at the beginning of the string. A leading ^ anchors the match to the start of the subject string.
How do you match a literal dot '.' in a Lua pattern?
- Wrap it in quotes
- Dots are always literal
- Use .. instead
- Write %.
Answer: Write %.. '.' is the magic 'any character' class, so escape it with % to match a literal period: %.
Which quantifier means 'zero or one' (optional) in Lua patterns?
- *
- +
- ?
- -
Answer: ?. ? matches the previous class zero or one time, making it optional.
What do parentheses () do inside a Lua pattern?
- Group for alternation
- Define a capture to return
- Escape magic characters
- Mark an anchor
Answer: Define a capture to return. Parentheses create captures; the captured substrings are returned by find, match, and gmatch.
Are Lua patterns the same as POSIX or PCRE regular expressions?
- No, they are a simpler distinct system
- Only in Lua 5.4
- Only with the regex library
- Yes, fully compatible
Answer: No, they are a simpler distinct system. Lua patterns are their own lightweight system, not regex; there is no alternation | or full regex backtracking.
Continue this course
- Previous: Lua for Game Development
- Next: OOP with Metatables — Build classes, methods and inheritance using __index and setmetatable
Frequently asked questions
Are Lua patterns the same as regular expressions?
No. Lua patterns are a deliberately small, fast system that covers most text jobs without the weight of a full regex engine. They have no alternation operator (|), no backreferences in the regex sense, and no unbounded backtracking. Classes use a percent sign (%a, %d, %s, %w) rather than backslashes, and the magic characters are ^ $ ( ) % . [ ] * + - ?. If you genuinely need PCRE-style regex you reach for a third-party library like lrexlib, but for splitting, validating, and extracting, Lua patterns are usually enough.
How do I match a literal special character like a dot or a dash?
Escape it with a percent sign. A bare . means 'any character', so to match a real period you write %. — for example %d+%.%d+ matches a decimal number. The full set of magic characters that need escaping is ^ $ ( ) % . [ ] * + - ?. If you would rather not think about escaping at all and just want a plain substring search, pass true as the fourth argument to string.find, which turns off pattern matching entirely.
What is the difference between string.find, string.match, and string.gmatch?
string.find returns the start and end positions of the first match (plus any captures), which is handy when you need indices. string.match returns the matched text itself, or the captures if the pattern has parentheses, and nil if there is no match. string.gmatch returns an iterator that yields each successive match, so you put it in a for loop to walk over every occurrence. Use find for positions, match for a single extraction, and gmatch to loop over all matches.
Why does string.gsub return two values?
gsub returns the new, modified string first and the number of substitutions it made second. The count is useful for checking whether anything changed or for reporting how many replacements happened. If you only want the string, capture just the first value: local result = (str:gsub('a', 'b')). Note the parentheses force the call down to one value when you assign it elsewhere as the last item in a list.
Does the # length operator count characters or bytes?
Bytes. Lua strings are sequences of bytes, so #s and string.len(s) both give the number of bytes, which equals the number of characters only for plain ASCII text. For UTF-8 text where some characters take several bytes, use the utf8 library (utf8.len) to count actual characters. For positions, string.sub and pattern offsets are also byte-based, so be careful slicing multibyte text.