Skip to content
Reference / SQL Regular Expressions
Reference

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.

Find, extract, replace
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.

Case-insensitive matching
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:

Named groups
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:

A group in the replacement
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:

Whole-value format check
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 '', not NULL. regexp_extract returns an empty string when nothing matches (only a NULL input gives NULL), so coalesce doesn't fill it in and a cast to a number fails. Wrap it in nullif(..., ''), or filter with regexp_matches first.
  • regexp_replace changes 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, while regexp_matches(email, 'example') matches all 20. PostgreSQL code moved to DuckDB silently returns fewer rows. DuckDB has no ~* at all.
  • SIMILAR TO takes a regular expression. In DuckDB, email similar to '%example%' matches nothing, because % is a literal character there. In PostgreSQL it's a wildcard, as in LIKE.
  • 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 \1 in a pattern with invalid escape sequence: \1. (\1 in a replacement is fine.)
Casting an empty match
select cast(regexp_extract(product_name, '\d+') as integer) as amount
from products
A lookahead
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
Dialect note

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.