Skip to content

Regular expressions

API reference: .regexp — signatures, return values and examples.

peachq matches with RE2, the same engine DuckDB uses. RE2 guarantees linear-time matching, so no pattern, however hostile or badly written, can hang a query the way a backtracking engine can. It is also what makes a peachq predicate and the equivalent DuckDB SQL predicate mean the same thing, which matters once a query pushes down to DuckDB storage.

Regex is a peachq extension: standard q has no regular expressions, only glob-style like and the ss/ssr search verbs. Nothing here changes those.

Loading

The .regexp namespace is part of the standard library:

q)\l pq

The rlike operator is a language keyword and needs no load.

rlike, the predicate

rlike is an infix operator that answers "does this subject contain a match?".

q)"abc" rlike "b"
1b
q)"abc" rlike "^b"
0b
q)`AAPL`MSFT`IBM rlike "^[AM]"
110b

It reads naturally in a where clause, which is where regex matching mostly lives:

q)trade:([] sym:`AAPL`MSFT`IBM`BP; price:171.4 402.3 189.2 4.6; size:100 250 75 900)
q)select from trade where sym rlike "^[A-C]"
sym  price size
---------------
AAPL 171.4 100
BP   4.6   900

rlike searches anywhere in the subject unless you anchor with ^ or $. That is the universal meaning of the name (MySQL, Hive, Spark and SQLite all use RLIKE/REGEXP this way).

Function reference

Every function takes a fixed number of arguments, and the names below are literally the parameter names, so typing a function's name prints its signature.

function returns
.regexp.matches[subject;pattern] boolean, match anywhere
.regexp.full_match[subject;pattern] boolean, the entire subject must match
.regexp.extract[subject;pattern] the matched text, "" if no match
.regexp.groups[subject;pattern] the first match's capture groups, () if no match
.regexp.groups_all[subject;pattern] one group-list per match
.regexp.extract_all[subject;pattern] every non-overlapping match
.regexp.replace[subject;pattern;replacement] the first match replaced
.regexp.replace_all[subject;pattern;replacement] every match replaced
.regexp.split[subject;pattern] subject split on the pattern
.regexp.escape[text] the pattern that matches text literally
.regexp.version the vendored RE2's version pin
q).regexp.matches
{[subject;pattern] .regexp.i.test[subject;pattern;0b]}

Coming from DuckDB SQL, swap the first _ for a .: regexp_matches is .regexp.matches, regexp_full_match is .regexp.full_match, regexp_extract_all is .regexp.extract_all.

Matching

q).regexp.matches["abc";"b"]
1b
q).regexp.full_match["abc";"b"]
0b
q).regexp.full_match["abc";"a.c"]
1b

.regexp.matches is rlike spelled as a function.

Extracting

.regexp.extract gives the matched text; .regexp.groups gives the capture groups:

q).regexp.extract["2026-08-10";"[0-9]{4}"]
"2026"
q).regexp.groups["2026-08-10";"([0-9]{4})-([0-9]{2})"]
"2026"
"08"

There is no group-number argument, because q can index a result. Take the group you want, or name them all at once with a plain dict construction:

q)first .regexp.groups["2026-08-10";"([0-9]{4})-([0-9]{2})"]
"2026"
q)`year`month!.regexp.groups["2026-08-10";"([0-9]{4})-([0-9]{2})"]
year | "2026"
month| "08"

A capture group that did not participate in the match reads as "". There is no limit on how many groups a pattern may have; the \1-\9 limit below is about the replacement string, not the pattern.

.regexp.extract_all returns every match rather than the first:

q).regexp.extract_all["a1b22c";"[0-9]+"]
,"1"
"22"

A one-character match displays as ,"1" because it is a one-element string, not a character , the same display "," vs "a1b" gives. Every result here is a string, never a char atom.

.regexp.groups_all gives one group-list per match, so selecting a group across every match is ordinary indexing:

q).regexp.groups_all["a1 b22";"([a-z])([0-9]+)"]
,"a" ,"1"
,"b" "22"
q).regexp.groups_all["a1 b22";"([a-z])([0-9]+)"][;1]
,"1"
"22"

That last line is DuckDB's regexp_extract_all(subject, pattern, group), the second group of every match, without needing a group argument.

Replacing and splitting

.regexp.replace replaces the first match, .regexp.replace_all every match. \1 to \9 refer to capture groups:

q).regexp.replace["a-b-c";"-";"+"]
"a+b-c"
q).regexp.replace_all["a-b-c";"-";"+"]
"a+b+c"
q).regexp.replace["John Smith";"(\\w+) (\\w+)";"\\2 \\1"]
"Smith John"

q).regexp.split["a,b,,c";","]
,"a"
,"b"
""
,"c"

A subject containing no match splits into itself: .regexp.split["abc";","] is enlist "abc".

split follows DuckDB's regexp_split_to_array exactly, including its two non-obvious rules for patterns that can match nothing: a zero-width match at the start of the remaining text is not treated as a delimiter (it yields one character and the scan moves on), and once the remaining text is empty no further match is considered.

q).regexp.split["abc";""]
,"a"
,"b"
,"c"
q).regexp.split["ab";"a*"]
""
,"b"

extract_all, by contrast, keeps every zero-width match, which is also DuckDB's answer (regexp_extract_all('ab','a*') is [a, '', '']). The two functions genuinely differ here.

Matching literal text

.regexp.escape turns text into the pattern that matches it exactly:

q).regexp.escape "1.5-2.0?"
"1\\.5\\-2\\.0\\?"
q).regexp.matches["a.c";.regexp.escape "."]
1b
q).regexp.matches["abc";.regexp.escape "."]
0b

It composes with every function, and it is visible at the call site, which is why peachq has this rather than DuckDB's l (literal) option flag.

Subjects, and how many answers you get back

Symbols and character data are treated alike: you can match against either. What decides whether you get one answer or many is whether the subject is one piece of text or a collection of them:

subject example result
symbol `one one answer
character "c" one answer
string "word" one answer
symbol list `a`list one per symbol
list of strings ("words";"other") one per string
dictionary `a`b!("x1";"yy") one per value, keys kept

Every function distributes, not just the predicates, so there is never a reason to write each:

q).regexp.matches[`AAPL`MSFT`IBM;"^[AM]"]
110b
q).regexp.extract[("a1";"b22");"[0-9]+"]
,"1"
"22"
q).regexp.replace_all[`x`y!("a-b";"c-d");"-";"+"]
x| "a+b"
y| "c+d"

Distributing is also the fast path: the pattern is compiled once and the whole collection is handled in a single call, where each would pay a q-level dispatch per element.

An empty collection answers a typed empty, so a predicate over an empty column is still boolean:

q).regexp.matches[0#`;"x"]
`boolean$()

The one thing to watch: a string is a list of characters, so "word" looks like a collection but counts as a single subject. A symbol list counts as many. This is why "word" gives one boolean and `a`list gives two. Mixed lists work elementwise, so ("ab";`c) matches both entries.

An empty string is one empty subject, not zero subjects: "" rlike "^$" is 1b. A null symbol behaves as the empty string, so a predicate always answers with a boolean and never injects a null into a where clause.

The collapse trap

This one is q, not regex, and no amount of checking can catch it: equal-length character atoms collapse into one string when they are written.

q)type ("k";"p")
10h
q)count ("k";"p")
2

("k";"p") is already "kp", a single two-character subject, before any function sees it. Unequal lengths stay two subjects:

q).regexp.matches[("k";"p");"k"]
1b
q).regexp.matches[("k";"pp");"k"]
10b

If a collection of one-character subjects matters, build it with enlist each or as symbols.

List results over a collection

extract_all, groups, groups_all and split already return a list for a single subject, so over a collection they return a list of lists, and the type alone cannot tell you which you have:

q).regexp.extract_all[("a1";"b22c33");"[0-9]+"]
,,"1"
("22";"33")

each has exactly the same ambiguity; distribution just makes it easier to reach. Know the shape of your subject.

What is rejected

Anything not certainly text is 'type: nesting deeper than one level, a collection with a non-text element, a dictionary with non-text values, a table.

q).regexp.matches[(("a";"bb");("c";"dd"));"a"]
'type
q).regexp.matches[("ab";1);"a"]
'type
q).regexp.matches[([] a:1 2);"a"]
'type

Accepting a shape is a one-way door; rejecting one is reversible. If a shape here should work, it can be added.

Patterns

Patterns are ordinary q strings, which means a backslash must be doubled:

q).regexp.matches["a1";"\\d"]
1b

Write "\\d", "\\s", "\\w", "\\.": the q string "\\d" is the two characters \ and d, which is what the regex engine needs to see.

peachq uses RE2 syntax, which covers the usual ground: character classes, + * ? {n,m}, alternation, groups, anchors, Perl classes (\d \s \w), and Unicode classes. RE2 deliberately omits backreferences within a pattern and lookaround assertions, the price of its linear-time guarantee. Full syntax reference: https://github.com/google/re2/wiki/Syntax.

Matching is UTF-8 aware. A pattern that can match nothing ("", "x*") advances by a whole character rather than a byte, so .regexp.split["aéb";""] keeps "é" intact.

Flags

There is no options argument. Flags go inside the pattern, where RE2 reads them itself:

q).regexp.matches["ABC";"b"]
0b
q).regexp.matches["ABC";"(?i)b"]
1b
q)"ABC" rlike "(?i)b"
1b

flag meaning
(?i) case-insensitive
(?s) . matches newline
(?m) ^/$ match at line breaks

They apply from that point on, and (?i:...) scopes to a group. Because a pattern carries its own flags, subject rlike pattern and .regexp.matches[subject;pattern] always agree for the same pattern, and an infix operator, which can only take two arguments, is as capable as the function.

DuckDB spells these as a third string argument (regexp_matches('ABC','b','i')). It accepts the in-pattern form identically, so translation is a no-op: the flags travel inside the pattern string, which a query translator passes through verbatim. Of DuckDB's own 231 regexp calls across its test corpus, 27 use the options argument and only 2, the l (literal) uses, could not be written in-pattern. Those get .regexp.escape instead, and g gets .regexp.replace_all, so nothing is lost and every function keeps a fixed arity.

Errors

'regex means one thing: a pattern RE2 will not compile, or a replacement string that does not fit its pattern.

q).regexp.matches["abc";"("]
'regex
q).regexp.replace["a-b";"-";"\\1"]
'regex

There is no "regex is unavailable" state to distinguish it from. RE2 is compiled in on every platform (Linux, macOS, Windows and the WebAssembly browser build alike), so 'regex always means the pattern, never the build. (./q therefore links a C++ runtime; on Windows it is linked statically, so q.exe still ships as a single file.)

How this relates to like, ss and ssr

They are unchanged and unrelated. like remains glob-style (* and ?) and matches the whole subject; ss and ssr keep their own limited pattern grammar. Reach for like when a glob says what you mean, and .regexp/rlike when it does not. There is deliberately no overlap in behaviour between like and rlike beyond both returning booleans; notably like is whole-subject while rlike searches.

For DuckDB SQL users

PeachQ uses DuckDB-style regular-expression names and RE2 patterns: regexp_matches(s, p) becomes .regexp.matches[s;p], and similarly for full_match, extract and extract_all.

Use q indexing on groups or groups_all to select captures, replace_all for global replacement, and split for regexp_split_to_array. Flags such as (?i) go in the pattern. DuckDB’s ~ matches the whole subject; PeachQ’s rlike searches within it.