In this post we are going to cover AFTER MATCH SKIP in Snowflake.
AFTER MATCH SKIP is one line inside MATCH_RECOGNIZE. It decides where the engine starts looking again once it has found a match. If we never write it, we get one particular answer, and most people never find out there were others.
We will run one pattern over one series three times, changing nothing but that line, and get three different match counts. Then we will ask the pattern what it missed, put RUNNING next to FINAL, do more with MEASURES, and finish with a worked funnel where MATCH_RECOGNIZE and CONDITIONAL_TRUE_EVENT disagree and both are right.
Every query below was run on a real Snowflake account, and every result you see is the output it gave back.
Episode 63 of the Snowflake SQL: Zero to Hero series on TechBrothersIT.
- Let's create some sample data
- Let's start with the default: SKIP PAST LAST ROW
- Now let's change one line: SKIP TO NEXT ROW
- Now let's skip to a named symbol with SKIP TO LAST B
- Now let's ask what the pattern missed
- Now let's put RUNNING and FINAL side by side
- Now let's do more with MEASURES
- Now let's try alternation and optional
- Let's build a funnel: a run of shipped orders, ended
- Can two answers disagree and both be right?
Let's create some sample data
This post picks up exactly where Episode 62 left off, on the same monthly revenue series: ORDERS grouped into months, in a CTE called M. Run the setup once.
-- ORDERS comes from the setup script this course uses throughout.
-- Run it once; every episode reuses the same tables.
USE DATABASE DEMO_DB;
USE SCHEMA PUBLIC;
-- Every pattern on this page reads one monthly series, built in a CTE called M
-- so you can paste any query below straight into a worksheet.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START,
COUNT(*) AS N_ORDERS,
SUM(AMOUNT) AS TOTAL
FROM ORDERS
GROUP BY 1
)
SELECT * FROM M ORDER BY MONTH_START;Let's look at the series every pattern on this page reads.
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START,
COUNT(*) AS N_ORDERS, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1 ORDER BY 1;| MONTH_START | N_ORDERS | TOTAL |
|---|---|---|
| 2023-01-01 | 2 | 5090.50 |
| 2023-02-01 | 2 | 7650.00 |
| 2023-03-01 | 2 | 7330.75 |
| 2023-04-01 | 2 | 9010.00 |
| 2023-05-01 | 2 | 3980.25 |
| 2023-06-01 | 2 | 10075.50 |
| 2023-07-01 | 2 | 7170.00 |
| 2023-08-01 | 2 | 12110.00 |
| 2023-09-01 | 1 | 5540.00 |
| 2023-10-01 | 2 | 8445.00 |
| 2023-11-01 | 2 | 11380.00 |
| 2023-12-01 | 2 | 8980.00 |
| 2024-01-01 | 2 | 15110.00 |
| 2024-02-01 | 1 | 8340.00 |
| 2024-03-01 | 2 | 2930.00 |
| 2024-04-01 | 2 | 12640.00 |
| 2024-05-01 | 2 | 16875.00 |
| 2024-06-01 | 2 | 11660.00 |
| 2024-07-01 | 1 | 8820.00 |
| 2024-08-01 | 2 | 15640.00 |
| 2024-09-01 | 2 | 7010.00 |
| 2024-10-01 | 1 | 9640.00 |
| 2024-11-01 | 1 | NULL |
23 months. The pattern we will use for most of this page is the one Episode 62 finished on, PATTERN (A B+): any row, then one or more rows higher than the one before. A rising run.
Nothing about that pattern changes. The only thing that changes is one line, and the answer changes with it.
Let's start with the default: SKIP PAST LAST ROW
When MATCH_RECOGNIZE finds a match it has to decide where to carry on looking. AFTER MATCH SKIP PAST LAST ROW is the answer we get if we never write the clause at all. It resumes from the row after the match ended.
Let's write it out explicitly. It is worth doing once, so that the comparison coming up is honest. The three queries differ in that line and nowhere else.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT * FROM M
MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO,
FIRST(MONTH_START) AS FROM_MONTH,
LAST(MONTH_START) AS TO_MONTH,
COUNT(*) AS MONTHS_IN_RUN
ONE ROW PER MATCH
AFTER MATCH SKIP PAST LAST ROW
PATTERN (A B+)
DEFINE B AS TOTAL > LAG(TOTAL)
)
ORDER BY MATCH_NO;| MATCH_NO | FROM_MONTH | TO_MONTH | MONTHS_IN_RUN |
|---|---|---|---|
| 1 | 2023-01-01 | 2023-02-01 | 2 |
| 2 | 2023-03-01 | 2023-04-01 | 2 |
| 3 | 2023-05-01 | 2023-06-01 | 2 |
| 4 | 2023-07-01 | 2023-08-01 | 2 |
| 5 | 2023-09-01 | 2023-11-01 | 3 |
| 6 | 2023-12-01 | 2024-01-01 | 2 |
| 7 | 2024-03-01 | 2024-05-01 | 3 |
| 8 | 2024-07-01 | 2024-08-01 | 2 |
| 9 | 2024-09-01 | 2024-10-01 | 2 |
9 matches. Because the engine always resumes past the end of what it just found, no row can be in two matches and the matches cannot overlap.
Read FROM_MONTH and TO_MONTH down the grid and they march forward without ever doubling back.
Two of the nine runs are three months long rather than two: match 5 and match 7. Hold on to those two. They are the only reason the next thing we try gives a different number.
Now let's change one line: SKIP TO NEXT ROW
TO NEXT ROW means resume from the row after the match started, not after it ended. The engine goes back and tries again from inside the match it has just reported.
Let's change that one line and nothing else.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT * FROM M
MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO,
FIRST(MONTH_START) AS FROM_MONTH,
LAST(MONTH_START) AS TO_MONTH,
COUNT(*) AS MONTHS_IN_RUN
ONE ROW PER MATCH
AFTER MATCH SKIP TO NEXT ROW
PATTERN (A B+)
DEFINE B AS TOTAL > LAG(TOTAL)
)
ORDER BY MATCH_NO;| MATCH_NO | FROM_MONTH | TO_MONTH | MONTHS_IN_RUN |
|---|---|---|---|
| 1 | 2023-01-01 | 2023-02-01 | 2 |
| 2 | 2023-03-01 | 2023-04-01 | 2 |
| 3 | 2023-05-01 | 2023-06-01 | 2 |
| 4 | 2023-07-01 | 2023-08-01 | 2 |
| 5 | 2023-09-01 | 2023-11-01 | 3 |
| 6 | 2023-10-01 | 2023-11-01 | 2 |
| 7 | 2023-12-01 | 2024-01-01 | 2 |
| 8 | 2024-03-01 | 2024-05-01 | 3 |
| 9 | 2024-04-01 | 2024-05-01 | 2 |
| 10 | 2024-07-01 | 2024-08-01 | 2 |
| 11 | 2024-09-01 | 2024-10-01 | 2 |
11 matches from the same pattern over the same months. And we can see exactly where the two extra ones came from. Line this grid up against the default one and every row is the same except two.
| New match | Its span | Sits inside | That parent's span |
|---|---|---|---|
| match 6 | 2023-10-01 → 2023-11-01 | match 5 | 2023-09-01 → 2023-11-01 |
| match 9 | 2024-04-01 → 2024-05-01 | match 8 | 2024-03-01 → 2024-05-01 |
Match 6 is the tail end of match 5. Match 9 is the tail end of match 8. That is what overlapping means. The engine went back one row and found a shorter rising run inside a longer one.
Those two parents are precisely the two three-month runs we were told to hold on to in the default grid. That is not a coincidence: a two-month match has nothing inside it to find, so only a run longer than the pattern's minimum can contain a shorter one. The flatter your data, the less difference this mode makes — which is exactly why it is easy to change it and not notice.
Now let's skip to a named symbol with SKIP TO LAST B
The third family of modes resumes at a named symbol's row rather than at a fixed offset. There is SKIP TO LAST <symbol> and SKIP TO FIRST <symbol>.
Our pattern has a B, so SKIP TO LAST B means “carry on from the last row that matched B”. Let's run it.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT * FROM M
MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO,
FIRST(MONTH_START) AS FROM_MONTH,
LAST(MONTH_START) AS TO_MONTH,
COUNT(*) AS MONTHS_IN_RUN
ONE ROW PER MATCH
AFTER MATCH SKIP TO LAST B
PATTERN (A B+)
DEFINE B AS TOTAL > LAG(TOTAL)
)
ORDER BY MATCH_NO;| MATCH_NO | FROM_MONTH | TO_MONTH | MONTHS_IN_RUN |
|---|---|---|---|
| 1 | 2023-01-01 | 2023-02-01 | 2 |
| 2 | 2023-03-01 | 2023-04-01 | 2 |
| 3 | 2023-05-01 | 2023-06-01 | 2 |
| 4 | 2023-07-01 | 2023-08-01 | 2 |
| 5 | 2023-09-01 | 2023-11-01 | 3 |
| 6 | 2023-12-01 | 2024-01-01 | 2 |
| 7 | 2024-03-01 | 2024-05-01 | 3 |
| 8 | 2024-07-01 | 2024-08-01 | 2 |
| 9 | 2024-09-01 | 2024-10-01 | 2 |
9 matches, which is the same answer as the default, row for row.
That tie is a fact about this pattern, not a rule about these two modes. B is the last symbol in PATTERN (A B+), so “the last B” is the last row of the match and the two modes land in the same place. Put a symbol after B+ in the pattern and they come apart immediately. Never read “same count” as “same behaviour”.
Let's put all three modes in one statement, so there is nothing to take on trust.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT
(SELECT COUNT(*) FROM M MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO
ONE ROW PER MATCH
AFTER MATCH SKIP PAST LAST ROW
PATTERN (A B+) DEFINE B AS TOTAL > LAG(TOTAL))) AS SKIP_PAST_LAST_ROW,
(SELECT COUNT(*) FROM M MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO
ONE ROW PER MATCH
AFTER MATCH SKIP TO NEXT ROW
PATTERN (A B+) DEFINE B AS TOTAL > LAG(TOTAL))) AS SKIP_TO_NEXT_ROW,
(SELECT COUNT(*) FROM M MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO
ONE ROW PER MATCH
AFTER MATCH SKIP TO LAST B
PATTERN (A B+) DEFINE B AS TOTAL > LAG(TOTAL))) AS SKIP_TO_LAST_B,
(SELECT COUNT(*) FROM M) AS MONTHS;| SKIP_PAST_LAST_ROW | SKIP_TO_NEXT_ROW | SKIP_TO_LAST_B | MONTHS |
|---|---|---|---|
| 9 | 11 | 9 | 23 |
9, 11, 9 out of 23 months. One pattern, one series, three answers. That row is the whole lesson of the first half of this post.
| Skip mode | Resumes from | Can matches overlap? |
|---|---|---|
PAST LAST ROW (default) | the row after the match ended | No |
TO NEXT ROW | the row after the match started | Yes |
TO LAST <symbol> | the last row that matched that symbol | Yes, unless that symbol ends the match |
TO FIRST <symbol> | the first row that matched that symbol | Yes |
Now let's ask what the pattern missed
Every grid so far shows what matched. The more useful question when we are writing a pattern is what did not.
ALL ROWS PER MATCH WITH UNMATCHED ROWS returns every input row, matched or not.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT * FROM M
MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO, CLASSIFIER() AS SYMBOL
ALL ROWS PER MATCH WITH UNMATCHED ROWS
AFTER MATCH SKIP PAST LAST ROW
PATTERN (A B+)
DEFINE B AS TOTAL > LAG(TOTAL)
)
ORDER BY MONTH_START;| MONTH_START | TOTAL | MATCH_NO | SYMBOL |
|---|---|---|---|
| 2023-01-01 | 5090.50 | 1 | A |
| 2023-02-01 | 7650.00 | 1 | B |
| 2023-03-01 | 7330.75 | 2 | A |
| 2023-04-01 | 9010.00 | 2 | B |
| 2023-05-01 | 3980.25 | 3 | A |
| 2023-06-01 | 10075.50 | 3 | B |
| 2023-07-01 | 7170.00 | 4 | A |
| 2023-08-01 | 12110.00 | 4 | B |
| 2023-09-01 | 5540.00 | 5 | A |
| 2023-10-01 | 8445.00 | 5 | B |
| 2023-11-01 | 11380.00 | 5 | B |
| 2023-12-01 | 8980.00 | 6 | A |
| 2024-01-01 | 15110.00 | 6 | B |
| 2024-02-01 | 8340.00 | NULL | NULL |
| 2024-03-01 | 2930.00 | 7 | A |
| 2024-04-01 | 12640.00 | 7 | B |
| 2024-05-01 | 16875.00 | 7 | B |
| 2024-06-01 | 11660.00 | NULL | NULL |
| 2024-07-01 | 8820.00 | 8 | A |
| 2024-08-01 | 15640.00 | 8 | B |
| 2024-09-01 | 7010.00 | 9 | A |
| 2024-10-01 | 9640.00 | 9 | B |
| 2024-11-01 | NULL | NULL | NULL |
All 23 months are back, in order. The rows that took part in a match carry a MATCH_NO and a SYMBOL. The rows that matched nothing carry NULL in both.
Let's count that.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
), R AS (
SELECT * FROM M
MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO
ALL ROWS PER MATCH WITH UNMATCHED ROWS
AFTER MATCH SKIP PAST LAST ROW
PATTERN (A B+)
DEFINE B AS TOTAL > LAG(TOTAL))
)
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT(MATCH_NO) AS ROWS_IN_A_MATCH,
COUNT(*) - COUNT(MATCH_NO) AS ROWS_IN_NO_MATCH
FROM R;| ROWS_TOTAL | ROWS_IN_A_MATCH | ROWS_IN_NO_MATCH |
|---|---|---|
| 23 | 20 | 3 |
23 rows in, 20 of them inside some match, 3 in no match at all. That is the same 20 and 3 Episode 62 arrived at from the opposite direction, by counting member rows.
The three are the months carrying NULL in the WITH UNMATCHED ROWS grid, and that grid also shows why each one is out.
- February 2024 and June 2024 — each is followed by a month that fell, so neither can start a climb. An
Aneeds at least oneBafter it to completePATTERN (A B+). - November 2024 — the last month in the series, and its
TOTALisNULL, so it can neither rise nor be risen past.
This is how you check a pattern — by asking what it missed, not by reading it. A pattern that quietly skips a tenth of your rows looks exactly like a pattern that works, right up until someone reconciles a total. WITH UNMATCHED ROWS costs you one clause and tells you straight away.
Now let's put RUNNING and FINAL side by side
A measure like COUNT(*) inside MEASURES has two possible meanings, and there are two keywords that choose between them.
| Keyword | Means | Default for |
|---|---|---|
RUNNING | so far in this match, up to and including the current row | ALL ROWS PER MATCH |
FINAL | the value for the whole match, on every row of it | — |
With ONE ROW PER MATCH the distinction is invisible, because the only row we get is the last one. With ALL ROWS PER MATCH we can put both on the same grid and watch them diverge.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT * FROM M
MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO,
CLASSIFIER() AS SYMBOL,
RUNNING COUNT(*) AS RUNNING_ROWS,
FINAL COUNT(*) AS FINAL_ROWS,
RUNNING LAST(TOTAL) AS RUNNING_LAST_TOTAL,
FINAL LAST(TOTAL) AS FINAL_LAST_TOTAL
ALL ROWS PER MATCH
AFTER MATCH SKIP PAST LAST ROW
PATTERN (A B+)
DEFINE B AS TOTAL > LAG(TOTAL)
)
ORDER BY MATCH_NO, MONTH_START;| MONTH_START | TOTAL | MATCH_NO | SYMBOL | RUNNING_ROWS | FINAL_ROWS | RUNNING_LAST_TOTAL | FINAL_LAST_TOTAL |
|---|---|---|---|---|---|---|---|
| 2023-01-01 | 5090.50 | 1 | A | 1 | 2 | 5090.50 | 7650.00 |
| 2023-02-01 | 7650.00 | 1 | B | 2 | 2 | 7650.00 | 7650.00 |
| 2023-03-01 | 7330.75 | 2 | A | 1 | 2 | 7330.75 | 9010.00 |
| 2023-04-01 | 9010.00 | 2 | B | 2 | 2 | 9010.00 | 9010.00 |
| 2023-05-01 | 3980.25 | 3 | A | 1 | 2 | 3980.25 | 10075.50 |
| 2023-06-01 | 10075.50 | 3 | B | 2 | 2 | 10075.50 | 10075.50 |
| 2023-07-01 | 7170.00 | 4 | A | 1 | 2 | 7170.00 | 12110.00 |
| 2023-08-01 | 12110.00 | 4 | B | 2 | 2 | 12110.00 | 12110.00 |
| 2023-09-01 | 5540.00 | 5 | A | 1 | 3 | 5540.00 | 11380.00 |
| 2023-10-01 | 8445.00 | 5 | B | 2 | 3 | 8445.00 | 11380.00 |
| 2023-11-01 | 11380.00 | 5 | B | 3 | 3 | 11380.00 | 11380.00 |
| 2023-12-01 | 8980.00 | 6 | A | 1 | 2 | 8980.00 | 15110.00 |
| 2024-01-01 | 15110.00 | 6 | B | 2 | 2 | 15110.00 | 15110.00 |
| 2024-03-01 | 2930.00 | 7 | A | 1 | 3 | 2930.00 | 16875.00 |
| 2024-04-01 | 12640.00 | 7 | B | 2 | 3 | 12640.00 | 16875.00 |
| 2024-05-01 | 16875.00 | 7 | B | 3 | 3 | 16875.00 | 16875.00 |
| 2024-07-01 | 8820.00 | 8 | A | 1 | 2 | 8820.00 | 15640.00 |
| 2024-08-01 | 15640.00 | 8 | B | 2 | 2 | 15640.00 | 15640.00 |
| 2024-09-01 | 7010.00 | 9 | A | 1 | 2 | 7010.00 | 9640.00 |
| 2024-10-01 | 9640.00 | 9 | B | 2 | 2 | 9640.00 | 9640.00 |
Look at match 1, which is two months long. RUNNING_ROWS goes 1 then 2. FINAL_ROWS says 2 on both of its rows, including the one where the second month has not been seen yet.
Now look at match 5, which is three months long. RUNNING_ROWS climbs 1, 2, 3 while FINAL_ROWS reads 3 all the way down.
The same split runs through RUNNING LAST(TOTAL) and FINAL LAST(TOTAL). The running one is whatever the latest row of the match is. The final one is the total the run ended on.
If we want a running number, we say RUNNING. If we want the answer for the whole match on every row, we say FINAL.
This grid has 20 rows, the same 20 that were in a match when we asked WITH UNMATCHED ROWS.
Now let's do more with MEASURES
MEASURES is not limited to MATCH_NUMBER() and CLASSIFIER(). Ordinary aggregates work across the rows of a match, and FIRST and LAST let us describe the shape of a run in one row.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT * FROM M
MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO,
COUNT(*) AS MONTHS,
SUM(TOTAL) AS REVENUE_IN_RUN,
AVG(TOTAL) AS AVG_MONTH,
MIN(TOTAL) AS SMALLEST_MONTH,
MAX(TOTAL) AS BIGGEST_MONTH,
LAST(TOTAL) - FIRST(TOTAL) AS CLIMB
ONE ROW PER MATCH
PATTERN (A B+)
DEFINE B AS TOTAL > LAG(TOTAL)
)
ORDER BY MATCH_NO;| MATCH_NO | MONTHS | REVENUE_IN_RUN | AVG_MONTH | SMALLEST_MONTH | BIGGEST_MONTH | CLIMB |
|---|---|---|---|---|---|---|
| 1 | 2 | 12740.50 | 6370.25000 | 5090.50 | 7650.00 | 2559.50 |
| 2 | 2 | 16340.75 | 8170.37500 | 7330.75 | 9010.00 | 1679.25 |
| 3 | 2 | 14055.75 | 7027.87500 | 3980.25 | 10075.50 | 6095.25 |
| 4 | 2 | 19280.00 | 9640.00000 | 7170.00 | 12110.00 | 4940.00 |
| 5 | 3 | 25365.00 | 8455.00000 | 5540.00 | 11380.00 | 5840.00 |
| 6 | 2 | 24090.00 | 12045.00000 | 8980.00 | 15110.00 | 6130.00 |
| 7 | 3 | 32445.00 | 10815.00000 | 2930.00 | 16875.00 | 13945.00 |
| 8 | 2 | 24460.00 | 12230.00000 | 8820.00 | 15640.00 | 6820.00 |
| 9 | 2 | 16650.00 | 8325.00000 | 7010.00 | 9640.00 | 2630.00 |
One row per rising run, with the revenue inside it, the average month, the smallest and biggest months, and LAST(TOTAL) - FIRST(TOTAL) as a CLIMB column. That last one is how much the series gained over the run.
Match 7 has the biggest CLIMB of the nine, and it is the three-month run starting in March 2024.
Counting rows per symbol
COUNT(<symbol>.*) counts only the rows of the match that matched that symbol. On a V-shape pattern that tells us how much of each match was the fall and how much was the recovery, without looking at the member rows at all.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT * FROM M
MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO,
COUNT(*) AS ROWS_IN_MATCH,
COUNT(DOWN.*) AS DOWN_ROWS,
COUNT(UP.*) AS UP_ROWS,
FIRST(MONTH_START) AS FROM_MONTH,
LAST(MONTH_START) AS TO_MONTH
ONE ROW PER MATCH
PATTERN (S DOWN+ UP+)
DEFINE DOWN AS TOTAL < LAG(TOTAL),
UP AS TOTAL > LAG(TOTAL)
)
ORDER BY MATCH_NO;| MATCH_NO | ROWS_IN_MATCH | DOWN_ROWS | UP_ROWS | FROM_MONTH | TO_MONTH |
|---|---|---|---|---|---|
| 1 | 3 | 1 | 1 | 2023-02-01 | 2023-04-01 |
| 2 | 3 | 1 | 1 | 2023-06-01 | 2023-08-01 |
| 3 | 3 | 1 | 1 | 2023-11-01 | 2024-01-01 |
| 4 | 4 | 1 | 2 | 2024-02-01 | 2024-05-01 |
| 5 | 3 | 1 | 1 | 2024-06-01 | 2024-08-01 |
5 V-shapes. DOWN_ROWS is 1 in every one of them, so each match fell for exactly one month. Match 4 is the outlier on the other side, with UP_ROWS 2 and ROWS_IN_MATCH 4, because it recovered over two months instead of one.
S is the starting row and is defined by nothing, so it matches anything — the ROWS_IN_MATCH figure counts it along with the DOWN and UP rows. That is why DOWN_ROWS plus UP_ROWS is one short of ROWS_IN_MATCH on every row of this grid.
Now let's try alternation and optional
Two more pieces of pattern syntax are coming up. But first a word about picking thresholds, because this is where demos go wrong. A pattern that matches nothing teaches nothing. Ask for month-on-month moves above 50% on a series that barely reaches that and we get an empty grid and no lesson.
So let's look at the actual month-on-month ratios first.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT MONTH_START, TOTAL,
LAG(TOTAL) OVER (ORDER BY MONTH_START) AS PREV_TOTAL,
ROUND(TOTAL / NULLIF(LAG(TOTAL) OVER (ORDER BY MONTH_START), 0), 3)
AS RATIO_TO_PREV
FROM M
ORDER BY MONTH_START;| MONTH_START | TOTAL | PREV_TOTAL | RATIO_TO_PREV |
|---|---|---|---|
| 2023-01-01 | 5090.50 | NULL | NULL |
| 2023-02-01 | 7650.00 | 5090.50 | 1.503 |
| 2023-03-01 | 7330.75 | 7650.00 | 0.958 |
| 2023-04-01 | 9010.00 | 7330.75 | 1.229 |
| 2023-05-01 | 3980.25 | 9010.00 | 0.442 |
| 2023-06-01 | 10075.50 | 3980.25 | 2.531 |
| 2023-07-01 | 7170.00 | 10075.50 | 0.712 |
| 2023-08-01 | 12110.00 | 7170.00 | 1.689 |
| 2023-09-01 | 5540.00 | 12110.00 | 0.457 |
| 2023-10-01 | 8445.00 | 5540.00 | 1.524 |
| 2023-11-01 | 11380.00 | 8445.00 | 1.348 |
| 2023-12-01 | 8980.00 | 11380.00 | 0.789 |
| 2024-01-01 | 15110.00 | 8980.00 | 1.683 |
| 2024-02-01 | 8340.00 | 15110.00 | 0.552 |
| 2024-03-01 | 2930.00 | 8340.00 | 0.351 |
| 2024-04-01 | 12640.00 | 2930.00 | 4.314 |
| 2024-05-01 | 16875.00 | 12640.00 | 1.335 |
| 2024-06-01 | 11660.00 | 16875.00 | 0.691 |
| 2024-07-01 | 8820.00 | 11660.00 | 0.756 |
| 2024-08-01 | 15640.00 | 8820.00 | 1.773 |
| 2024-09-01 | 7010.00 | 15640.00 | 0.448 |
| 2024-10-01 | 9640.00 | 7010.00 | 1.375 |
| 2024-11-01 | NULL | 9640.00 | NULL |
Now let's pull the extremes out and count how many months two candidate thresholds would catch.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
), R AS (
SELECT TOTAL / NULLIF(LAG(TOTAL) OVER (ORDER BY MONTH_START), 0) AS RATIO
FROM M
)
SELECT ROUND(MIN(RATIO), 3) AS SMALLEST_RATIO,
ROUND(MAX(RATIO), 3) AS BIGGEST_RATIO,
COUNT_IF(RATIO > 1.25) AS MONTHS_UP_OVER_25_PCT,
COUNT_IF(RATIO < 0.80) AS MONTHS_DOWN_OVER_20_PCT
FROM R;| SMALLEST_RATIO | BIGGEST_RATIO | MONTHS_UP_OVER_25_PCT | MONTHS_DOWN_OVER_20_PCT |
|---|---|---|---|
| 0.351 | 4.314 | 10 | 9 |
The smallest ratio in the series is 0.351 and the biggest is 4.314. At a threshold of 1.25, 10 months qualify as a big rise. At 0.80, 9 qualify as a big fall.
Those are thresholds this data can actually show, which is why the patterns below use them.
Alternation: this symbol or that one
A pipe inside PATTERN means “either”, exactly as in a text regular expression. PATTERN (S (BIGUP | BIGDOWN)) is “any row, then either a big rise or a big fall”.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT * FROM M
MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO, CLASSIFIER() AS SYMBOL,
FIRST(MONTH_START) AS FROM_MONTH, LAST(MONTH_START) AS TO_MONTH
ONE ROW PER MATCH
AFTER MATCH SKIP PAST LAST ROW
PATTERN (S (BIGUP | BIGDOWN))
DEFINE BIGUP AS TOTAL > LAG(TOTAL) * 1.25,
BIGDOWN AS TOTAL < LAG(TOTAL) * 0.80
)
ORDER BY MATCH_NO;| MATCH_NO | SYMBOL | FROM_MONTH | TO_MONTH |
|---|---|---|---|
| 1 | BIGUP | 2023-01-01 | 2023-02-01 |
| 2 | BIGDOWN | 2023-04-01 | 2023-05-01 |
| 3 | BIGDOWN | 2023-06-01 | 2023-07-01 |
| 4 | BIGDOWN | 2023-08-01 | 2023-09-01 |
| 5 | BIGUP | 2023-10-01 | 2023-11-01 |
| 6 | BIGUP | 2023-12-01 | 2024-01-01 |
| 7 | BIGDOWN | 2024-02-01 | 2024-03-01 |
| 8 | BIGUP | 2024-04-01 | 2024-05-01 |
| 9 | BIGDOWN | 2024-06-01 | 2024-07-01 |
| 10 | BIGDOWN | 2024-08-01 | 2024-09-01 |
10 matches, and CLASSIFIER() earns its keep here. It names which branch of the alternation each match took. Count the SYMBOL column and four of the ten are BIGUP and six are BIGDOWN.
That is fewer matches than the thresholds counted, and the reason is the skip mode. This pattern uses the default, so each match consumes the rows it used and the search starts again after them. A qualifying month whose predecessor has already been taken never gets to start a match of its own.
Optional: a step that may or may not be there
A question mark makes a symbol optional. PATTERN (A B? C) is “a row, then possibly a rise, then a fall”. The rise is allowed to be missing.
WITH M AS (
SELECT DATE_TRUNC('MONTH', ORDER_DATE) AS MONTH_START, SUM(AMOUNT) AS TOTAL
FROM ORDERS GROUP BY 1
)
SELECT COUNT(*) AS MATCHES FROM M
MATCH_RECOGNIZE (
ORDER BY MONTH_START
MEASURES MATCH_NUMBER() AS MATCH_NO
ONE ROW PER MATCH
AFTER MATCH SKIP PAST LAST ROW
PATTERN (A B? C)
DEFINE B AS TOTAL > LAG(TOTAL),
C AS TOTAL < LAG(TOTAL)
);| MATCHES |
|---|
| 8 |
8 matches. This is the quantifier that makes patterns tolerant of real data, where the middle step of a sequence is often simply absent.
Let's build a funnel: a run of shipped orders, ended
Now something we would actually be asked for. Take each customer's orders in date order and find every run of SHIPPED orders that was ended by an order that was not shipped.
That is PATTERN (SHIPPED+ BROKE), with BROKE defined as any status other than SHIPPED. We add PARTITION BY CUSTOMER_ID so a run never crosses from one customer to another.
SELECT * FROM ORDERS
MATCH_RECOGNIZE (
PARTITION BY CUSTOMER_ID
ORDER BY ORDER_DATE, ORDER_ID
MEASURES MATCH_NUMBER() AS MATCH_NO,
COUNT(SHIPPED.*) AS SHIPPED_IN_A_ROW,
FIRST(ORDER_DATE) AS RUN_STARTED,
LAST(ORDER_DATE) AS BROKE_ON,
LAST(STATUS) AS BROKE_WITH
ONE ROW PER MATCH
AFTER MATCH SKIP PAST LAST ROW
PATTERN (SHIPPED+ BROKE)
DEFINE SHIPPED AS STATUS = 'SHIPPED',
BROKE AS STATUS <> 'SHIPPED'
)
ORDER BY CUSTOMER_ID, MATCH_NO;| CUSTOMER_ID | MATCH_NO | SHIPPED_IN_A_ROW | RUN_STARTED | BROKE_ON | BROKE_WITH |
|---|---|---|---|---|---|
| 102 | 1 | 2 | 2023-01-28 | 2023-07-29 | RETURNED |
| 102 | 2 | 2 | 2023-11-24 | 2024-09-29 | RETURNED |
| 104 | 1 | 2 | 2023-03-09 | 2023-12-09 | CANCELLED |
| 105 | 1 | 4 | 2023-04-05 | 2024-06-13 | PENDING |
| 106 | 1 | 2 | 2023-10-03 | 2024-11-05 | PENDING |
5 runs. Customer 105 is the clearest one: four shipped orders in a row, then a PENDING order stopped it.
LAST(STATUS) as BROKE_WITH tells us what broke each run, which is usually the column the question was really about.
The symbol is called BROKE and not BREAK on purpose — BREAK is a keyword in Snowflake, and a pattern symbol that collides with a keyword is not a problem worth discovering under time pressure. Episode 62 made the same point about START.
COUNT(<symbol>.*) has a sibling, so we can total only the rows that matched one symbol. Let's add up the money.
SELECT * FROM ORDERS
MATCH_RECOGNIZE (
PARTITION BY CUSTOMER_ID
ORDER BY ORDER_DATE, ORDER_ID
MEASURES MATCH_NUMBER() AS MATCH_NO,
COUNT(SHIPPED.*) AS SHIPPED_IN_A_ROW,
SUM(SHIPPED.AMOUNT) AS REVENUE_IN_RUN
ONE ROW PER MATCH
AFTER MATCH SKIP PAST LAST ROW
PATTERN (SHIPPED+ BROKE)
DEFINE SHIPPED AS STATUS = 'SHIPPED',
BROKE AS STATUS <> 'SHIPPED'
)
ORDER BY CUSTOMER_ID, MATCH_NO;| CUSTOMER_ID | MATCH_NO | SHIPPED_IN_A_ROW | REVENUE_IN_RUN |
|---|---|---|---|
| 102 | 1 | 2 | 2010.50 |
| 102 | 2 | 2 | 2220.00 |
| 104 | 1 | 2 | 4286.25 |
| 105 | 1 | 4 | 29665.00 |
| 106 | 1 | 2 | 3120.00 |
SUM(SHIPPED.AMOUNT) adds up the amounts of the SHIPPED rows only and leaves the BROKE row out of the figure.
Symbol-qualified aggregates are the part of MEASURES that makes this clause worth learning. The alternative is a CASE expression inside a SUM and a grouping key we have to build ourselves.
Can two answers disagree and both be right?
Episode 57 answered a very similar question with CONDITIONAL_TRUE_EVENT, which numbers runs by counting how many times a condition has been true so far. Same data, same intent, no pattern matching at all.
Let's run it and compare.
WITH R AS (
SELECT CUSTOMER_ID, ORDER_DATE, STATUS, AMOUNT,
CONDITIONAL_TRUE_EVENT(STATUS <> 'SHIPPED')
OVER (PARTITION BY CUSTOMER_ID ORDER BY ORDER_DATE, ORDER_ID) AS RUN_ID
FROM ORDERS
)
SELECT CUSTOMER_ID, RUN_ID,
COUNT_IF(STATUS = 'SHIPPED') AS SHIPPED_IN_A_ROW,
SUM(CASE WHEN STATUS = 'SHIPPED' THEN AMOUNT END) AS REVENUE_IN_RUN
FROM R
GROUP BY CUSTOMER_ID, RUN_ID
HAVING COUNT_IF(STATUS = 'SHIPPED') > 0
ORDER BY CUSTOMER_ID, RUN_ID;| CUSTOMER_ID | RUN_ID | SHIPPED_IN_A_ROW | REVENUE_IN_RUN |
|---|---|---|---|
| 101 | 0 | 9 | 61660.00 |
| 102 | 0 | 2 | 2010.50 |
| 102 | 1 | 2 | 2220.00 |
| 103 | 1 | 4 | 14135.25 |
| 104 | 0 | 2 | 4286.25 |
| 104 | 1 | 1 | 2540.00 |
| 105 | 0 | 4 | 29665.00 |
| 106 | 1 | 2 | 3120.00 |
| 107 | 0 | 5 | 56800.00 |
| 109 | 0 | 3 | 15870.00 |
10 rows. Not 5. And this is the most useful thing on the page, because neither query is wrong.
Look at what the two grids share. All five MATCH_RECOGNIZE runs are in this grid too, with the same customers, the same shipped counts and the same amounts: customer 102 twice, then 104, 105 and 106.
The five rows that are only here are runs that never ended.
Customer 101 is the plainest example. It has nine shipped orders and nothing after them. PATTERN (SHIPPED+ BROKE) requires a BROKE row to complete the match, so there is no match. CONDITIONAL_TRUE_EVENT counts the run anyway, because all it is doing is numbering.
MATCH_RECOGNIZE | CONDITIONAL_TRUE_EVENT | |
|---|---|---|
| What it answers | Runs that were ended by a non-shipped order | Every run of shipped orders, ended or not |
| A run still open at the end of the data | Not reported | Reported |
| Rows returned here | 5 | 10 |
Which one we want depends entirely on whether a run that has not finished yet counts. For “how often does a customer's streak get broken, and by what?” the pattern is the right tool. For “how long are our shipped streaks?” the window function is, because the open ones are the interesting ones.
One last check on the funnel
Let's count the statuses in ORDERS, and then the orders, the matches and the orders that could have broken a run.
SELECT STATUS, COUNT(*) AS N FROM ORDERS GROUP BY STATUS ORDER BY N DESC;| STATUS | N |
|---|---|
| SHIPPED | 34 |
| PENDING | 3 |
| CANCELLED | 2 |
| RETURNED | 2 |
SELECT
(SELECT COUNT(*) FROM ORDERS) AS ORDERS_TOTAL,
(SELECT COUNT(*) FROM ORDERS MATCH_RECOGNIZE (
PARTITION BY CUSTOMER_ID ORDER BY ORDER_DATE, ORDER_ID
ONE ROW PER MATCH AFTER MATCH SKIP PAST LAST ROW
PATTERN (SHIPPED+ BROKE)
DEFINE SHIPPED AS STATUS = 'SHIPPED',
BROKE AS STATUS <> 'SHIPPED')) AS FUNNEL_MATCHES,
(SELECT COUNT(*) FROM ORDERS WHERE STATUS <> 'SHIPPED') AS NON_SHIPPED_ORDERS;| ORDERS_TOTAL | FUNNEL_MATCHES | NON_SHIPPED_ORDERS |
|---|---|---|
| 41 | 5 | 7 |
41 orders in all, 5 funnel matches, and 7 orders whose status is not SHIPPED. The three non-shipped statuses in the status grid add up to exactly that.
So seven orders could have broken a run and only five did. The other two had no shipped order in front of them in their customer's sequence, and a BROKE row with no SHIPPED+ before it completes nothing.
That is the check worth copying. Count the rows that could have matched, count the ones that did, and make yourself explain the gap. It is either a fact about your data or a bug in your pattern, and you want to know which.
And that closes Level 6
Level 6 was about reshaping and sampling: PIVOT and UNPIVOT for turning rows into columns and back, SAMPLE and TABLESAMPLE for working on part of a table, and MATCH_RECOGNIZE for finding shapes in ordered rows.
MATCH_RECOGNIZE is the one to go back to. Most of what it does can be reproduced with window functions and enough patience. What it gives us instead is a single clause that names the shape we are looking for, and, with WITH UNMATCHED ROWS, tells us honestly what it did not find.
Key points
AFTER MATCH SKIPdecides where the engine resumes after a match. It is not cosmetic: the same pattern over the same 23 months returned 9, 11 and 9 matches under three different modes.SKIP PAST LAST ROWis the default and resumes after the match ended, so matches can never overlap.SKIP TO NEXT ROWresumes after the match started, so the engine can find a shorter run inside a longer one — which is exactly where the two extra matches here came from.SKIP TO LAST Bgave the same count as the default on this pattern becauseBis its last symbol. That is a fact about the pattern, not a rule about the modes.ALL ROWS PER MATCH WITH UNMATCHED ROWSreturns every input row with aNULLmatch number where nothing matched. Check a pattern by asking what it missed.RUNNINGis “so far in this match”;FINALis the whole match's answer repeated on every row. The difference only shows up underALL ROWS PER MATCH.- Aggregates work inside
MEASURES, and they can be qualified by symbol:COUNT(DOWN.*)andSUM(SHIPPED.AMOUNT)read only the rows that matched that symbol. |is alternation and?makes a symbol optional, andCLASSIFIER()tells you which branch of an alternation a match took.- Pick thresholds your data can demonstrate. Looking at the ratios first is why this episode's pattern uses
1.25and0.80rather than a round number that matches nothing. MATCH_RECOGNIZEfound 5 shipped runs andCONDITIONAL_TRUE_EVENTfound 10 on the same data. The pattern requires a run to be ended; the window function counts open runs too. Decide which question you are asking.
Episode 64 — Level 7 begins: functions. Strings, numbers, dates and conversion, starting with CONCAT, the || operator and LENGTH.
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. The ORDERS data is invented demo data created by that script. No performance or benchmark claims are made anywhere in this series — the comparison between MATCH_RECOGNIZE and CONDITIONAL_TRUE_EVENT here is about which question each one answers, not about speed. Clause behaviour follows the Snowflake MATCH_RECOGNIZE documentation.



No comments:
Post a Comment
Note: Only a member of this blog may post a comment.