SQL Regular Expressions
A regular expression describes the shape of text. DuckDB's regexp_ functions test for it, pull out the part that matched, replace it, or split on it.
A regular expression is a pattern for the shape of text, such as "one or
more digits, then letters". DuckDB tests a value against one with
regexp_matches, pulls out the matching part with
regexp_extract, and rewrites it with regexp_replace. Reach for one when LIKE's two wildcards can't describe what
you're looking for.
select product_name,
regexp_matches(product_name, '\d') as has_digit,
regexp_extract(product_name, '\d+[a-zA-Z]+') as size,
regexp_replace(product_name, '\s*\(.*\)', '') as without_parens
from products
where product_id in (8, 12, 21)
order by product_id
Tulip Bulbs (10pk) has a digit, a size of 10pk, and loses its
parentheses. Garden Gnome has no digit, and its size is an empty string.
The functions
| Function | Returns |
|---|---|
regexp_matches(s, p) | True if the pattern appears anywhere in s |
regexp_full_match(s, p) | True only if the pattern matches all of s |
regexp_extract(s, p, n) |
Group n of the first match (0, the default, is the
whole match), or '' when nothing matches
|
regexp_extract(s, p, ['a', 'b']) | A struct with one named field per group |
regexp_extract_all(s, p) | Every match, as a list |
regexp_replace(s, p, r, 'g') | s with matches replaced by r; only the
first unless you pass 'g' |
regexp_split_to_array(s, p) | The pieces between matches, as a list |
s ~ p, s !~ p |
Same as regexp_full_match and its negation: the
whole value must match
|
Pattern syntax
DuckDB uses the RE2 library, whose syntax is the familiar Perl style without the features that can make a match slow (see below).
| Pattern | Matches |
|---|---|
. | Any one character |
\d, \w, \s | A digit, a word character, a whitespace character |
[abc], [a-z], [^0-9] | One character from a set, a range, or not in a range |
*, +, ? | Zero or more, one or more, zero or one of the piece before |
{3}, {2,4} | Exactly 3, or 2 to 4, of the piece before |
^, $, \b | Start of text, end of text, a word boundary |
(...), a|b | A group, either alternative |
\., \( | The character itself, not its special meaning |
In DuckDB a backslash inside a string literal is an ordinary character, so
'\d' reaches the regex engine as written. MySQL treats a
backslash in a string as an escape, so the same pattern there is
'\\d'.
Options
The last argument takes option letters. 'i' ignores case,
'g' makes regexp_replace replace every match, and
you can combine them as 'gi'. 's' lets
. match a newline, which it otherwise doesn't. An inline
(?i) at the start of a pattern also ignores case.
select count(*) filter (where regexp_matches(product_name, 'plant')) as exact_case,
count(*) filter (where regexp_matches(product_name, 'plant', 'i')) as any_case
from products
That returns 0 and 3: Snake Plant, ZZ Plant, and Ceramic Planter. An unknown
letter is an error: 'x' gives
Unrecognized Regex option x.
Extracting parts
Name the groups with a list and regexp_extract returns a
struct, one field per group:
select product_name,
regexp_extract(product_name, '(\d+)([a-zA-Z]+)', ['amount', 'unit']) as size
from products
where regexp_matches(product_name, '\d')
order by product_id
Each field is text: '20' and 'L' for Potting Soil
20L. Read one with size.amount, and cast it before you do
arithmetic.
Replacing with a group
In the replacement, \1 stands for what group 1 matched. This
turns quoted variety names into parenthesized ones:
select product_name,
regexp_replace(product_name, '''([^'']+)''', '(\1)') as relabeled
from products
where product_name like '%''%'
order by product_id Pothos 'Golden' becomes Pothos (Golden).
Validating a format
Anchor the pattern with ^ and $ to check a whole
value, not just part of it. 15 of Garden Shop's 20 customers have a phone
number in the 215-555-0142 format. The other 5 are
NULL, and a NULL phone lands in neither the
well-formed nor the malformed count:
select count(*) as customers,
count(*) filter (where regexp_matches(phone, '^\d{3}-\d{3}-\d{4}$')) as well_formed,
count(*) filter (where not regexp_matches(phone, '^\d{3}-\d{3}-\d{4}$')) as malformed,
count(*) filter (where phone is null) as missing
from customers What can go wrong
- No match is
'', notNULL.regexp_extractreturns an empty string when nothing matches (only aNULLinput givesNULL), socoalescedoesn't fill it in and a cast to a number fails. Wrap it innullif(..., ''), or filter withregexp_matchesfirst. -
regexp_replacechanges the first match only. Pass'g'to change them all. See why regexp_replace changes only the first match. -
~means a full match in DuckDB.email ~ 'example'matches none of the 20 customers, whileregexp_matches(email, 'example')matches all 20. PostgreSQL code moved to DuckDB silently returns fewer rows. DuckDB has no~*at all. -
SIMILAR TOtakes a regular expression. In DuckDB,email similar to '%example%'matches nothing, because%is a literal character there. In PostgreSQL it's a wildcard, as inLIKE. - No lookarounds or backreferences in a pattern. RE2 leaves
them out so that every match finishes in time proportional to the text. A
lookahead fails with
invalid perl operator: (?=, and\1in a pattern withinvalid escape sequence: \1. (\1in a replacement is fine.)
select cast(regexp_extract(product_name, '\d+') as integer) as amount
from products select regexp_matches(product_name, 'Pot(?=ting)') as potting
from products
The first fails with Could not convert string '' to INT32, and
the second with invalid perl operator: (?=.
How other databases differ
| Engine | Matches anywhere | Extract | Replace |
|---|---|---|---|
| DuckDB | regexp_matches(s, p) | regexp_extract | First match; 'g' for all |
| PostgreSQL | s ~ p, s ~* p ignores case | substring(s from p); regexp_substr from
version 15
| First match; 'g' for all |
| MySQL 8 | s REGEXP p, REGEXP_LIKE | REGEXP_SUBSTR | All matches by default |
| Oracle | REGEXP_LIKE | REGEXP_SUBSTR | All matches by default |
| SQL Server | REGEXP_LIKE from SQL Server 2025; before that, only
LIKE with [0-9] sets
| REGEXP_SUBSTR (2025) | REGEXP_REPLACE (2025) |
| Snowflake | REGEXP_LIKE and RLIKE need a
full match, like DuckDB's ~ | REGEXP_SUBSTR | All matches by default |
| SQLite | s REGEXP p parses, but the core library has no function
behind it. The sqlite3 shell adds one
| None built in | None built in |
The same regexp_replace(phone, '-', '') returns
215555-0142 in DuckDB and PostgreSQL and
2155550142 in MySQL, Oracle, and Snowflake. When a query has to
run on more than one engine, say how many matches you want instead of
relying on the default.
Related
Pull a size out of a product name with a pattern.
Pass 'g' to replace every match, not just the first.
Escape _ and % to match them literally.