Snowflake SQL Tutorial 68 - REGEXP_LIKE and REGEXP_COUNT - Regex 1 (the anchoring rule)

Snowflake SQL · Episode 68

In this post we are going to cover REGEXP_LIKE and REGEXP_COUNT in Snowflake.

These are the two regular-expression functions that only ask questions. REGEXP_LIKE takes a value and a pattern and tells you whether they match. REGEXP_COUNT takes the same two things and tells you how many times the pattern occurs. Neither of them changes anything.

We will start with the simplest test we can write and build it up step by step. Along the way we will cover the one rule in this post that catches everybody who has written regular expressions somewhere else, case sensitivity, the backslash problem, and a real job with three date formats in one column.

Every query below was run on a real Snowflake account, and every result you see is the output it gave back, including the error.

Episode 68 of the Snowflake SQL: Zero to Hero series on TechBrothersIT.

Let's create some sample data

This post uses two tables. EMPLOYEES gives us a column of email addresses, and SALES_RAW gives us the messy text: punctuated phone numbers, mixed-case region codes, and a date column written three different ways.

Copy this into a Snowflake worksheet and run it once.

Sample data
-- Sample data for this post.
-- Run this once. SALES_RAW is deliberately messy: three date formats, four
-- phone formats, currency symbols, mixed-case region codes and one amount
-- that is not a number. Nothing on this page modifies it.
USE DATABASE DEMO_DB;
USE SCHEMA PUBLIC;

CREATE OR REPLACE TABLE SALES_RAW (
    ROW_ID        INT,
    CUSTOMER_TXT  VARCHAR,
    AMOUNT_TXT    VARCHAR,
    DATE_TXT      VARCHAR,
    REGION_TXT    VARCHAR,
    PHONE_TXT     VARCHAR
);

INSERT INTO SALES_RAW VALUES
    (1, '  Northwind Traders ', '$4,200.00', '2023-01-12',  'us-east',    '(704) 555-0142'),
    (2, 'blue ridge supply',    '890.50',    '01/28/2023',  'US-EAST',    '704-555-0198'),
    (3, 'KESTREL ANALYTICS',    '$6,150',    '14-Feb-2023', 'ca-west',    '+1 416 555 0117'),
    (4, 'Harbour Foods  ',      '2,310.75',  '2023-03-09',  'CA-West',    '604.555.0163'),
    (5, 'Alpine Logistics',     'N/A',       '2023-04-05',  'eu-central', '+41 44 555 0188'),
    (6, ' Delta Print Co',      '$640.00',   '05/21/2023',  'us-east',    '919-555-0124'),
    (7, 'Meridian Health',      '9,250.00',  '14-Aug-2023', 'US-East',    '(617) 555-0179'),
    (8, 'copper kettle ltd',    '$1,080',    '2024-09-29',  'uk-north',   '+44 161 555 0133');

-- The EMPLOYEES table is the one from Episode 1. You already have it if you
-- have been following along. Every statement on this page is a SELECT, and
-- one of them is expected to fail.

Now let's look at the employees.

Query
SELECT EMP_ID, FIRST_NAME, LAST_NAME, EMAIL FROM EMPLOYEES ORDER BY EMP_ID;
Result8 rows · 4 columns
EMP_IDFIRST_NAMELAST_NAMEEMAIL
1AishaKhanaisha.khan@example.com
2MarcusReidmarcus.reid@example.com
3LenaFischerlena.fischer@example.com
4TomasNovaktomas.novak@example.com
5PriyaRamanpriya.raman@example.com
6DanielO'Connordaniel.oconnor@example.com
7SofiaMarinosofia.marino@example.com
8GraceLingrace.lin@example.com

Eight rows, and every address ends in @example.com. That turns out to matter later, because it is why one of the demonstrations on this page fails to demonstrate anything.

And now the messy table.

Query
SELECT ROW_ID, CUSTOMER_TXT, AMOUNT_TXT, DATE_TXT, REGION_TXT, PHONE_TXT
FROM SALES_RAW ORDER BY ROW_ID;
Result8 rows · 6 columns · scroll →
ROW_IDCUSTOMER_TXTAMOUNT_TXTDATE_TXTREGION_TXTPHONE_TXT
1 Northwind Traders $4,200.002023-01-12us-east(704) 555-0142
2blue ridge supply890.5001/28/2023US-EAST704-555-0198
3KESTREL ANALYTICS$6,15014-Feb-2023ca-west+1 416 555 0117
4Harbour Foods 2,310.752023-03-09CA-West604.555.0163
5Alpine LogisticsN/A2023-04-05eu-central+41 44 555 0188
6 Delta Print Co$640.0005/21/2023us-east919-555-0124
7Meridian Health9,250.0014-Aug-2023US-East(617) 555-0179
8copper kettle ltd$1,0802024-09-29uk-north+44 161 555 0133

Eight rows again. Have a look at DATE_TXT. It holds 2023-01-12, 01/28/2023 and 14-Feb-2023. Three formats in one column, which is the problem regular expressions are genuinely the right tool for, and the one we finish the page with.

Let's start with a simple REGEXP_LIKE

REGEXP_LIKE takes the value, then the pattern, and gives back TRUE or FALSE.

Let's ask the simplest question we can ask about these addresses. Does the email contain the word example? We will ask it three ways: with the bare word, with .* on both sides of the word, and with ordinary LIKE and % on both sides.

Query
SELECT EMAIL,
       REGEXP_LIKE(EMAIL, 'example')       AS BARE_WORD,
       REGEXP_LIKE(EMAIL, '.*example.*')   AS WITH_DOT_STAR,
       LIKE(EMAIL, '%example%')            AS PLAIN_LIKE
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 4 columns
EMAILBARE_WORDWITH_DOT_STARPLAIN_LIKE
aisha.khan@example.comFALSETRUETRUE
marcus.reid@example.comFALSETRUETRUE
lena.fischer@example.comFALSETRUETRUE
tomas.novak@example.comFALSETRUETRUE
priya.raman@example.comFALSETRUETRUE
daniel.oconnor@example.comFALSETRUETRUE
sofia.marino@example.comFALSETRUETRUE
grace.lin@example.comFALSETRUETRUE

BARE_WORD is FALSE on every row. And every one of those addresses visibly contains the word example.

WITH_DOT_STAR and PLAIN_LIKE are TRUE on every row. Only the bare pattern fails.

A column of FALSE is easy to skim past, so let's run the same three tests again as counts.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT_IF(REGEXP_LIKE(EMAIL, 'example'))     AS MATCHED_BARE,
       COUNT_IF(REGEXP_LIKE(EMAIL, '.*example.*')) AS MATCHED_DOT_STAR,
       COUNT_IF(EMAIL LIKE '%example%')            AS MATCHED_LIKE
FROM EMPLOYEES;
Result1 row · 4 columns
ROWS_TOTALMATCHED_BAREMATCHED_DOT_STARMATCHED_LIKE
8088

Eight rows. Zero matched the bare word. Eight matched the version with .* either side, and eight matched LIKE.

That zero is not a typo in the pattern, and it is not a bug. It is documented behaviour.

So what is going on? REGEXP_LIKE anchors the pattern for us

The Snowflake documentation states it outright: “the function implicitly anchors a pattern at both ends”.

In other words the pattern we wrote is not the pattern that ran. An empty pattern becomes ^$, and ABC becomes ^ABC$.

The one rule to take from this post

REGEXP_LIKE is not a contains test. It is an equals test that understands patterns. Your pattern has to describe the whole value, start to finish, or it returns FALSE. Almost every other engine searches for the pattern anywhere in the string unless you anchor it yourself. Snowflake does the opposite by default.

The documentation's own worked example is five comparisons. Let's run all five in one row.

Query
SELECT REGEXP_LIKE('', '')        AS EMPTY_MATCHES_EMPTY,
       REGEXP_LIKE('ABC', 'ABC')  AS EXACT,
       REGEXP_LIKE('ABCD', 'ABC') AS TRAILING_D_BREAKS_IT,
       REGEXP_LIKE('xABC', 'ABC') AS LEADING_X_BREAKS_IT,
       REGEXP_LIKE('ABCD', 'ABC.') AS PATTERN_COVERS_IT;
Result1 row · 5 columns
EMPTY_MATCHES_EMPTYEXACTTRAILING_D_BREAKS_ITLEADING_X_BREAKS_ITPATTERN_COVERS_IT
TRUETRUEFALSEFALSETRUE

Read it across. The empty pattern matches the empty string. ABC matches ABC exactly.

ABCD against the pattern ABC is FALSE. One extra character at the end is enough. And xABC is FALSE too, with the extra character at the front.

PATTERN_COVERS_IT is the fix. ABC. describes all four characters, so it matches.

Can we just write the anchors ourselves?

If the anchors are the problem, the first thing most of us try is adding them by hand. Let's see what that does.

Query
SELECT EMAIL,
       REGEXP_LIKE(EMAIL, '.*@example\\.com')   AS WITHOUT_ANCHORS,
       REGEXP_LIKE(EMAIL, '^.*@example\\.com$') AS WITH_ANCHORS
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 3 columns
EMAILWITHOUT_ANCHORSWITH_ANCHORS
aisha.khan@example.comTRUETRUE
marcus.reid@example.comTRUETRUE
lena.fischer@example.comTRUETRUE
tomas.novak@example.comTRUETRUE
priya.raman@example.comTRUETRUE
daniel.oconnor@example.comTRUETRUE
sofia.marino@example.comTRUETRUE
grace.lin@example.comTRUETRUE

Identical columns. The anchors were already there, so adding ^ and $ changed nothing. That is worth knowing mainly so that you do not spend time adding them in the belief that it is the fix.

So how do we write a genuine contains test? Here is the whole list.

What you wantHow to write it in Snowflake
Value contains example anywhereREGEXP_LIKE(C, '.*example.*')
Value contains example, no pattern neededC LIKE '%example%'
Value contains it, ignoring caseREGEXP_LIKE(C, '.*example.*', 'i') or C ILIKE '%example%'
Value is something pattern-shapedREGEXP_LIKE(C, pattern), where the anchoring is doing you a favour
How many times it occursREGEXP_COUNT(C, 'example'), which we try next
When to use which

If all you need is contains, LIKE says so in fewer characters and does not need the .* armour. Reach for REGEXP_LIKE when the thing you are describing really is a shape, like a date format or a product code. That is the case where the implicit anchoring is exactly what you wanted anyway.

Now let's try REGEXP_COUNT with the same pattern

This is the part that catches people who have already learned the anchoring rule. Let's hand the same pattern, on the same column, to both functions.

Query
SELECT EMAIL,
       REGEXP_LIKE(EMAIL, 'a')  AS LIKE_SAYS,
       REGEXP_COUNT(EMAIL, 'a') AS COUNT_SAYS
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 3 columns
EMAILLIKE_SAYSCOUNT_SAYS
aisha.khan@example.comFALSE4
marcus.reid@example.comFALSE2
lena.fischer@example.comFALSE2
tomas.novak@example.comFALSE3
priya.raman@example.comFALSE4
daniel.oconnor@example.comFALSE2
sofia.marino@example.comFALSE3
grace.lin@example.comFALSE2

LIKE_SAYS is FALSE on every row, because no address is the single letter a.

COUNT_SAYS finds that letter four times in aisha.khan@example.com. One function says the letter is not there and the other says it is there four times, and both are correct.

REGEXP_LIKEREGEXP_COUNT
Anchored at both endsYes, implicitlyNo
ReturnsBOOLEANA number
'a' against 'aisha.khan@example.com'FALSEFour
Natural readingDoes the value match this shape?How many times does this appear in the value?

That is the pair to remember, and it is the reason the two functions are taught together. Same family, opposite default.

It also gives us a second way to write a contains test. If you do not want to type .* twice, REGEXP_COUNT(C, p) > 0 is a perfectly honest way to spell it.

How many times does aa appear in aaaa?

One question before we leave REGEXP_COUNT, because intuition gets this one wrong. Let's ask it.

Query
SELECT REGEXP_COUNT('aaaa', 'aa')   AS TWO_CHAR_PATTERN,
       REGEXP_COUNT('aaaa', 'a')    AS ONE_CHAR_PATTERN,
       REGEXP_COUNT('aaaa', 'aa', 2) AS STARTING_AT_POSITION_2,
       REGEXP_COUNT('', 'a')        AS EMPTY_SUBJECT,
       REGEXP_COUNT(NULL, 'a')      AS NULL_SUBJECT;
Result1 row · 5 columns
TWO_CHAR_PATTERNONE_CHAR_PATTERNSTARTING_AT_POSITION_2EMPTY_SUBJECTNULL_SUBJECT
2410NULL

Two, not three. Matches do not overlap. The engine consumes a match and resumes after it, so the pair starting at the second character is never considered.

ONE_CHAR_PATTERN is the control. The single letter is found four times, so the letters really are all there.

The rest of the grid is the edges. A third argument says where to start searching, and starting one character in leaves only one non-overlapping pair. An empty subject counts zero, and a NULL subject counts NULL. So a count you are about to do arithmetic on can still be NULL.

Now let's look at case

Regular expressions in Snowflake are case sensitive unless you say otherwise. Both functions take an optional parameters argument, and 'i' is the one you will use most.

Let's look for a customer name in lower case, then turn the flag on, then upper-case both sides by hand instead.

Query
SELECT ROW_ID, CUSTOMER_TXT,
       REGEXP_LIKE(CUSTOMER_TXT, '.*kestrel.*')      AS LOWER_PATTERN,
       REGEXP_LIKE(CUSTOMER_TXT, '.*kestrel.*', 'i') AS WITH_I_FLAG,
       REGEXP_LIKE(UPPER(CUSTOMER_TXT), '.*KESTREL.*') AS UPPER_BOTH_SIDES
FROM SALES_RAW
ORDER BY ROW_ID;
Result8 rows · 5 columns
ROW_IDCUSTOMER_TXTLOWER_PATTERNWITH_I_FLAGUPPER_BOTH_SIDES
1 Northwind Traders FALSEFALSEFALSE
2blue ridge supplyFALSEFALSEFALSE
3KESTREL ANALYTICSFALSETRUETRUE
4Harbour Foods FALSEFALSEFALSE
5Alpine LogisticsFALSEFALSEFALSE
6 Delta Print CoFALSEFALSEFALSE
7Meridian HealthFALSEFALSEFALSE
8copper kettle ltdFALSEFALSEFALSE

KESTREL ANALYTICS is stored in capitals, and LOWER_PATTERN finds nothing anywhere in the column.

Add 'i' as the third argument and WITH_I_FLAG turns TRUE on that row. Force both sides to upper case yourself and UPPER_BOTH_SIDES turns TRUE too. Two routes to the same answer.

Notice that the patterns in that query are already wrapped in .* on both sides. Without that, the anchoring rule applies and all three columns would be FALSE whatever we did about case.

Now let's see what forgetting the flag actually costs, on the region column.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT_IF(REGEXP_LIKE(REGION_TXT, 'us-.*'))      AS SENSITIVE,
       COUNT_IF(REGEXP_LIKE(REGION_TXT, 'us-.*', 'i')) AS INSENSITIVE
FROM SALES_RAW;
Result1 row · 3 columns
ROWS_TOTALSENSITIVEINSENSITIVE
824

Two of the eight rows match with case sensitivity on, and four match with it off. The flag doubled the matches.

So the first version is a report quietly missing half its rows, with no error and no indication that anything was skipped. REGION_TXT in the sample data is why. The same region is written us-east, US-EAST and US-East in one column.

The other way round

UPPER() on both sides works too, and is sometimes clearer. The difference is that the 'i' flag leaves the data alone and only changes the comparison, which matters when you want the original value back in the result.

What happens when the pattern needs a literal dot?

This is where the time actually goes, and it is not really a regular-expression problem. A . in a pattern means any character, so a pattern meant to match a literal full stop matches almost anything in that position.

Let's run the unescaped pattern and the escaped one side by side, on two inputs that differ by one character.

Query
SELECT REGEXP_LIKE('a.c', 'a.c')    AS UNESCAPED_DOT_IS_ANY_CHAR,
       REGEXP_LIKE('abc', 'a.c')    AS SO_THIS_MATCHES_TOO,
       REGEXP_LIKE('a.c', 'a\\.c')  AS ESCAPED_DOT,
       REGEXP_LIKE('abc', 'a\\.c')  AS ESCAPED_DOT_REJECTS_ABC;
Result1 row · 4 columns
UNESCAPED_DOT_IS_ANY_CHARSO_THIS_MATCHES_TOOESCAPED_DOTESCAPED_DOT_REJECTS_ABC
TRUETRUETRUEFALSE

UNESCAPED_DOT_IS_ANY_CHAR and SO_THIS_MATCHES_TOO are the trap. The pattern a.c matches both a.c and abc, because the dot is a wildcard.

ESCAPED_DOT and ESCAPED_DOT_REJECTS_ABC are the fix. The escaped pattern matches the literal dot and correctly rejects abc.

Why the doubling exists

The escaped pattern in that query is written with two backslashes, and the regular-expression engine only ever sees one. A SQL string literal processes escapes before the function is called, so it consumes the first backslash. Every backslash you want the pattern to contain has to be written twice inside ordinary single quotes. That is why patterns like '\\d{4}-\\d{2}' look so unlike the regex you would write in any other language.

Can we stop doubling the backslashes?

Yes. Snowflake has a second string-literal syntax, $$...$$, which passes its contents through untouched. Inside it we write one backslash, exactly as we would anywhere else.

Let's run both spellings side by side on the same two inputs.

Query
SELECT REGEXP_LIKE('a.c', 'a\\.c')    AS DOUBLED_BACKSLASH,
       REGEXP_LIKE('a.c', $$a\.c$$)   AS DOLLAR_QUOTED,
       REGEXP_LIKE('abc', 'a\\.c')    AS DOUBLED_ON_ABC,
       REGEXP_LIKE('abc', $$a\.c$$)   AS DOLLAR_QUOTED_ON_ABC;
Result1 row · 4 columns
DOUBLED_BACKSLASHDOLLAR_QUOTEDDOUBLED_ON_ABCDOLLAR_QUOTED_ON_ABC
TRUETRUEFALSEFALSE

Identical answers. Both forms match the literal dot and both reject abc.

For a pattern with one backslash in it the doubling is survivable. For anything with several, the dollar-quoted form is much easier to read and much harder to get wrong.

Let's try the same thing on the real email column

The escaped and unescaped patterns were also run against the real addresses, expecting a visible difference. They did not produce one, and the grid stays in.

Query
SELECT EMAIL,
       REGEXP_LIKE(EMAIL, '.*\\.com')  AS ESCAPED,
       REGEXP_LIKE(EMAIL, '.*.com')    AS UNESCAPED_MATCHES_MORE_THAN_YOU_THINK
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 3 columns
EMAILESCAPEDUNESCAPED_MATCHES_MORE_THAN_YOU_THINK
aisha.khan@example.comTRUETRUE
marcus.reid@example.comTRUETRUE
lena.fischer@example.comTRUETRUE
tomas.novak@example.comTRUETRUE
priya.raman@example.comTRUETRUE
daniel.oconnor@example.comTRUETRUE
sofia.marino@example.comTRUETRUE
grace.lin@example.comTRUETRUE

TRUE and TRUE on all eight rows. That is correct, not broken.

Every one of these addresses really does have a dot immediately before com. So the escaped pattern matches because the dot is there, and the unescaped one matches because its wildcard lands on that same dot. Both are right, for different reasons, and the column cannot tell them apart.

The made-up grid with a.c and abc in it is the one that proves the difference, because it contains the abc row that this column does not have. A test only distinguishes two patterns if the data contains a case where they disagree. Which is an argument for testing a regular expression against values you invent, not only against the table you happen to have.

Now let's count digits three ways

There are three spellings of “a digit” and they are all affected by the backslash rule, so let's put them in one grid and check they agree. We will add a count of everything that is not a digit at the end.

Query
SELECT PHONE_TXT,
       REGEXP_COUNT(PHONE_TXT, '[0-9]')   AS DIGITS_BRACKET_CLASS,
       REGEXP_COUNT(PHONE_TXT, '\\d')     AS DIGITS_BACKSLASH_D,
       REGEXP_COUNT(PHONE_TXT, $$\d$$)    AS DIGITS_DOLLAR_QUOTED,
       REGEXP_COUNT(PHONE_TXT, '[^0-9]')  AS NON_DIGITS
FROM SALES_RAW
ORDER BY ROW_ID;
Result8 rows · 5 columns
PHONE_TXTDIGITS_BRACKET_CLASSDIGITS_BACKSLASH_DDIGITS_DOLLAR_QUOTEDNON_DIGITS
(704) 555-01421010104
704-555-01981010102
+1 416 555 01171111114
604.555.01631010102
+41 44 555 01881111114
919-555-01241010102
(617) 555-01791010104
+44 161 555 01331212124

[0-9], the doubled \\d and the dollar-quoted \d return the same count on every row. That is the point of putting them in one grid.

Now read the counts down the column and the data describes itself. Five of the numbers hold ten digits, two hold eleven and one holds twelve. The longer ones are the three rows whose raw value starts with a + and carries a country code.

NON_DIGITS inverts the class with a leading ^ inside the brackets. That is the punctuation, and it is a quick way to find out how messy a column is before you decide how to clean it.

Two meanings of ^

^ means two different things in this post. At the start of a pattern it anchors to the beginning of the value. At the start of a character class, inside [ ], it negates the class. Same character, different job, decided by where you put it.

Now let's do some real work with three date formats

DATE_TXT holds three formats. Before writing anything clever, let's count the separators. It is the cheapest way to find out how many shapes a text column actually has.

Query
SELECT ROW_ID, DATE_TXT,
       REGEXP_COUNT(DATE_TXT, '-')   AS DASHES,
       REGEXP_COUNT(DATE_TXT, '/')   AS SLASHES,
       REGEXP_COUNT(DATE_TXT, '[0-9]') AS DIGITS
FROM SALES_RAW
ORDER BY ROW_ID;
Result8 rows · 5 columns
ROW_IDDATE_TXTDASHESSLASHESDIGITS
12023-01-12208
201/28/2023028
314-Feb-2023206
42023-03-09208
52023-04-05208
605/21/2023028
714-Aug-2023206
82024-09-29208

Three distinct signatures fall straight out. Two hyphens with eight digits is ISO. Two slashes is the US format. And two hyphens with only six digits is the one with a month name in the middle, because Feb and Aug contribute no digits at all.

Now let's turn those signatures into a classification, with REGEXP_LIKE inside a CASE expression. Here the implicit anchoring is working for us, because a date format is precisely the kind of claim that should describe the whole value.

Query
SELECT CASE WHEN REGEXP_LIKE(DATE_TXT, '[0-9]{4}-[0-9]{2}-[0-9]{2}') THEN 'ISO yyyy-mm-dd'
            WHEN REGEXP_LIKE(DATE_TXT, '[0-9]{2}/[0-9]{2}/[0-9]{4}') THEN 'US mm/dd/yyyy'
            WHEN REGEXP_LIKE(DATE_TXT, '[0-9]{2}-[A-Za-z]{3}-[0-9]{4}') THEN 'dd-Mon-yyyy'
            ELSE 'unrecognised'
       END AS FORMAT,
       COUNT(*) AS N
FROM SALES_RAW
GROUP BY 1
ORDER BY N DESC;
Result3 rows · 2 columns
FORMATN
ISO yyyy-mm-dd4
US mm/dd/yyyy2
dd-Mon-yyyy2

Four ISO rows, two US and two day-month-year. That accounts for all eight rows of the table, but only if you trust the arithmetic. A classification is exactly the kind of query where a row can fall into the unrecognised bucket without anyone noticing.

So let's prove that none of them did.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT_IF(REGEXP_LIKE(DATE_TXT, '[0-9]{4}-[0-9]{2}-[0-9]{2}')
             OR REGEXP_LIKE(DATE_TXT, '[0-9]{2}/[0-9]{2}/[0-9]{4}')
             OR REGEXP_LIKE(DATE_TXT, '[0-9]{2}-[A-Za-z]{3}-[0-9]{4}')) AS RECOGNISED
FROM SALES_RAW;
Result1 row · 2 columns
ROWS_TOTALRECOGNISED
88

Eight rows, eight recognised. Nothing landed in the ELSE branch. That is the check worth writing every time you classify anything. The GROUP BY above shows you what it found, and this one shows you that it found something for every row.

The next step, elsewhere

Classifying a date format is not the same as converting it. Turning these three shapes into real DATE values is TRY_TO_DATE with a format string, which is Episode 75.

Can one pattern tell us an address is shaped like an address?

Episode 66 took an email address apart with POSITION and SUBSTR, and Episode 67 did it with SPLIT_PART. Neither of them could say whether the address was shaped like an address. One regular expression can.

Query
SELECT EMAIL,
       REGEXP_LIKE(EMAIL, '[a-z.]+@[a-z]+\\.[a-z]{2,}') AS LOOKS_LIKE_AN_EMAIL
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 2 columns
EMAILLOOKS_LIKE_AN_EMAIL
aisha.khan@example.comTRUE
marcus.reid@example.comTRUE
lena.fischer@example.comTRUE
tomas.novak@example.comTRUE
priya.raman@example.comTRUE
daniel.oconnor@example.comTRUE
sofia.marino@example.comTRUE
grace.lin@example.comTRUE

Read the pattern as a sentence: letters and dots, an @, letters, a literal dot, then at least two letters. The {2,} is a quantifier with no upper bound, and the \\. is the escaped dot from earlier. Without the backslashes it would accept any character there.

And as a count, so we are not reading a column of TRUE.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT_IF(REGEXP_LIKE(EMAIL, '[a-z.]+@[a-z]+\\.[a-z]{2,}')) AS VALID
FROM EMPLOYEES;
Result1 row · 2 columns
ROWS_TOTALVALID
88

Eight of eight valid. And this is the case where the anchoring rule is a gift rather than a nuisance. Because the pattern is anchored, it is asserting that the whole value is an address, which is what validation means. The same pattern in an unanchored engine would happily pass nonsense aisha.khan@example.com nonsense.

What this does and does not prove

This pattern is a shape check, not a correctness check. It only accepts lower-case letters, so a real address with a digit or a capital in it would be rejected. That is fine for demo data and wrong for a sign-up form. Validating email addresses properly with one regular expression is a famously bad idea. Validating that a column has the shape you expect is a very good one.

One last thing: what does Snowflake do with an invalid pattern?

Worth knowing before you build a pattern out of user input or a configuration table. Let's hand it something that is not a pattern at all, an unclosed character class, and see whether it errors or quietly matches nothing.

Query — expected to fail
SELECT REGEXP_LIKE('abc', '[a-') AS SHOULD_NOT_GET_THIS_FAR;
Query failed — Snowflake returned an error
100048 (2201B): Invalid regular expression: '[a-', invalid UTF-8

The statement is refused, and the message names the offending pattern. That is the good outcome. A malformed pattern that silently matched nothing would give you an empty result set and no reason for it, and you would be looking at your data instead of at your pattern.

It also means a pattern assembled at run time can fail your statement. So if yours comes from a column or a parameter rather than from the query text, that is a failure mode to plan for rather than discover.

Key points

  • REGEXP_LIKE is implicitly anchored at both ends. The documentation says so: '' becomes ^$ and ABC becomes ^ABC$. Your pattern must describe the whole value.
  • So REGEXP_LIKE is not a contains test. It is an equals test that understands patterns. The bare word example matched none of the eight addresses that contain it.
  • For a genuine contains test, wrap the pattern in .* on both sides, or just use LIKE.
  • REGEXP_COUNT is not anchored. The same pattern on the same column returns FALSE from REGEXP_LIKE and four from REGEXP_COUNT. Same family, opposite default.
  • Matches do not overlap: REGEXP_COUNT('aaaa', 'aa') is two, not three.
  • Patterns are case sensitive by default. 'i' as the parameters argument turns that off, and on this data it doubled the number of matching rows.
  • A backslash has to survive the SQL string literal before the regex engine sees it, so a literal dot is '\\.' in ordinary quotes, or a single \. inside a $$...$$ literal. The dollar-quoted form is the better choice for anything with more than one backslash.
  • ^ anchors at the start of a pattern and negates inside [ ]. Two jobs, one character.
  • REGEXP_LIKE in a CASE expression classifies text shapes well, and the implicit anchoring helps there. Always run the companion query that proves no row fell into the ELSE branch.
  • An invalid pattern raises an error rather than matching nothing, which is the behaviour you want, and a reason to be careful with patterns built at run time.
Next in this series

In the next post we will carry on with regular expressions, but instead of only testing we will extract and rewrite. REGEXP_SUBSTR, REGEXP_REPLACE and REGEXP_INSTR, including capture groups and what the anchoring rule does and does not do to them.

Every query and every result grid on this page was captured from a live Snowflake account (version 10.34.101) running the demo script for this episode, including the error pane and the comparison that failed to show a difference. The EMPLOYEES and SALES_RAW data is invented demo data created by that script. No performance or benchmark claims are made anywhere in this series. The anchoring rule and the quoted sentence come from the Snowflake REGEXP_LIKE documentation.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.