In this post we are going to cover DATE_TRUNC, DATE_PART, EXTRACT, LAST_DAY and NEXT_DAY in Snowflake.
These are the functions that take a date apart. DATE_TRUNC flattens a date down to the start of a period. DATE_PART and EXTRACT pull one number out of it. LAST_DAY and NEXT_DAY find you another date relative to the one you handed them.
We will start with DATE_TRUNC and build it up step by step. On the way we will also cover the week, which Snowflake disagrees with itself about, and a monthly report that runs without an error, looks completely reasonable, and adds two years together.
Every query below was run on a real Snowflake account, and every result you see is the output it gave back.
Episode 73 of the Snowflake SQL: Zero to Hero series on TechBrothersIT.
- Let's create some sample data
- Let's start with DATE_TRUNC
- Now let's try DATE_PART
- Zero for which day? Let's number a whole week
- What happens at the end of a year?
- So what is the real difference between the two?
- Now let's group some orders by month
- Let's try LAST_DAY
- Now let's try NEXT_DAY and PREVIOUS_DAY
- One last thing: one row of a date dimension
Let's create some sample data
This is the second of five posts on dates, and it runs against the same two tables as the rest of the course. The script below sets up the anchor date that every calculation on this page is measured from.
Copy it into a Snowflake worksheet and run it once.
-- ORDERS and CUSTOMERS come 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 calculation on this page is anchored to one fixed date instead of
-- CURRENT_DATE, so nothing here reads the clock and nothing goes stale.
SELECT DATE '2024-06-30' AS ANCHOR;Here are the orders we will be pulling apart.
SELECT ORDER_ID, CUSTOMER_ID, ORDER_DATE, AMOUNT, STATUS
FROM ORDERS ORDER BY ORDER_DATE LIMIT 10;| ORDER_ID | CUSTOMER_ID | ORDER_DATE | AMOUNT | STATUS |
|---|---|---|---|---|
| 1001 | 101 | 2023-01-12 | 4200.00 | SHIPPED |
| 1002 | 102 | 2023-01-28 | 890.50 | SHIPPED |
| 1003 | 101 | 2023-02-14 | 6150.00 | SHIPPED |
| 1004 | 103 | 2023-02-27 | 1500.00 | CANCELLED |
| 1005 | 104 | 2023-03-09 | 2310.75 | SHIPPED |
| 1006 | 101 | 2023-03-22 | 5020.00 | SHIPPED |
| 1007 | 105 | 2023-04-05 | 7890.00 | SHIPPED |
| 1008 | 102 | 2023-04-19 | 1120.00 | SHIPPED |
| 1009 | 103 | 2023-05-02 | 3340.25 | SHIPPED |
| 1010 | 106 | 2023-05-21 | 640.00 | PENDING |
And the customers, which we will use once or twice for a second date column.
SELECT CUSTOMER_ID, CUSTOMER_NAME, COUNTRY, SIGNUP_DATE
FROM CUSTOMERS ORDER BY SIGNUP_DATE;| CUSTOMER_ID | CUSTOMER_NAME | COUNTRY | SIGNUP_DATE |
|---|---|---|---|
| 101 | Northwind Traders | USA | 2022-01-15 |
| 102 | Blue Ridge Supply | USA | 2022-04-02 |
| 103 | Kestrel Analytics | Canada | 2022-06-21 |
| 104 | Harbour Foods | Canada | 2023-02-08 |
| 105 | Alpine Logistics | NULL | 2023-05-30 |
| 106 | Delta Print Co | USA | 2023-09-12 |
| 107 | Meridian Health | USA | 2024-01-07 |
| 108 | Copper Kettle Ltd | UK | 2024-03-25 |
| 109 | Vantage Media | UK | 2024-08-14 |
| 110 | Sable Interiors | USA | 2024-11-03 |
That is everything we need. Let's start taking a date apart.
Let's start with DATE_TRUNC
DATE_TRUNC takes a part and a date, and gives us back the start of the period that date falls in.
Let's ask for every useful part on the anchor at once.
SELECT DATE '2024-06-30' AS ANCHOR,
DATE_TRUNC('year', DATE '2024-06-30') AS YEAR_,
DATE_TRUNC('quarter', DATE '2024-06-30') AS QUARTER_,
DATE_TRUNC('month', DATE '2024-06-30') AS MONTH_,
DATE_TRUNC('week', DATE '2024-06-30') AS WEEK_,
DATE_TRUNC('day', DATE '2024-06-30') AS DAY_;| ANCHOR | YEAR_ | QUARTER_ | MONTH_ | WEEK_ | DAY_ |
|---|---|---|---|---|---|
| 2024-06-30 | 2024-01-01 | 2024-04-01 | 2024-06-01 | 2024-06-24 | 2024-06-30 |
Year gave us the 1st of January. Quarter gave us the 1st of April. Month gave us the 1st of June. Day gave us the date back untouched. And all of those answers are still dates in 2024, so nothing was turned into a number.
Now look at the week column. It landed on 2024-06-24, which is not the 1st of anything.
So what day does that week start on?
Let's ask directly rather than work it out. We want the weekday of the anchor, the start of its week, and the weekday of that.
SELECT DATE '2024-06-30' AS ANCHOR,
DAYNAME(DATE '2024-06-30') AS ANCHOR_DAY,
DATE_TRUNC('week', DATE '2024-06-30') AS WEEK_START,
DAYNAME(DATE_TRUNC('week', DATE '2024-06-30')) AS WEEK_START_DAY,
DATE_TRUNC('week', DATE '2024-07-01') AS NEXT_MONDAYS_WEEK;| ANCHOR | ANCHOR_DAY | WEEK_START | WEEK_START_DAY | NEXT_MONDAYS_WEEK |
|---|---|---|---|---|
| 2024-06-30 | Sun | 2024-06-24 | Mon | 2024-07-01 |
The anchor 2024-06-30 is a Sun, and DATE_TRUNC('week', ...) put it in the week beginning 2024-06-24, which is a Mon.
The last column confirms the rule from the other side. 2024-07-01 truncates to itself, so that date is the first day of its own week.
So DATE_TRUNC runs the week from Monday to Sunday. Hold on to that, because we are going to find something later that disagrees with it.
Now let's try the same function on a timestamp
On a TIMESTAMP the smaller parts become available.
SELECT '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ AS ANCHOR,
DATE_TRUNC('day', '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ) AS DAY_,
DATE_TRUNC('hour', '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ) AS HOUR_,
DATE_TRUNC('minute', '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ) AS MINUTE_,
DATE_TRUNC('second', '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ) AS SECOND_;| ANCHOR | DAY_ | HOUR_ | MINUTE_ | SECOND_ |
|---|---|---|---|---|
| 2024-06-30 14:37:52.123000 | 2024-06-30 00:00:00 | 2024-06-30 14:00:00 | 2024-06-30 14:37:00 | 2024-06-30 14:37:52 |
Each one zeroed everything below it. DATE_TRUNC('day', ...) on a timestamp is the usual way to get a day's worth of rows out of a timestamp column, and notice that it gave us midnight on that day, still a timestamp, rather than a DATE.
Now let's try DATE_PART
DATE_PART does the other job. Give it a part and a date and it pulls one number out.
Let's ask for every part of the anchor.
SELECT DATE '2024-06-30' AS ANCHOR,
DATE_PART('year', DATE '2024-06-30') AS YEAR_,
DATE_PART('quarter', DATE '2024-06-30') AS QUARTER_,
DATE_PART('month', DATE '2024-06-30') AS MONTH_,
DATE_PART('day', DATE '2024-06-30') AS DAY_,
DATE_PART('dayofweek', DATE '2024-06-30') AS DAY_OF_WEEK,
DATE_PART('dayofyear', DATE '2024-06-30') AS DAY_OF_YEAR;| ANCHOR | YEAR_ | QUARTER_ | MONTH_ | DAY_ | DAY_OF_WEEK | DAY_OF_YEAR |
|---|---|---|---|---|---|---|
| 2024-06-30 | 2024 | 2 | 6 | 30 | 0 | 182 |
Year 2024, quarter 2, month 6, day 30 and day-of-year 182. All numbers, exactly as advertised.
And day-of-week 0. Zero for which day? That one needs its own grid, and it gets one in the next section.
EXTRACT is the standard SQL spelling
EXTRACT(part FROM date) is the ANSI SQL form of the same operation. Let's run it beside DATE_PART and compare the two in the query itself rather than by eye.
SELECT EXTRACT(year FROM DATE '2024-06-30') AS YEAR_,
EXTRACT(month FROM DATE '2024-06-30') AS MONTH_,
EXTRACT(day FROM DATE '2024-06-30') AS DAY_,
DATE_PART('year', DATE '2024-06-30') = EXTRACT(year FROM DATE '2024-06-30')
AS SAME;| YEAR_ | MONTH_ | DAY_ | SAME |
|---|---|---|---|
| 2024 | 6 | 30 | TRUE |
The last column is the comparison, and it came back TRUE. Use whichever you prefer. EXTRACT ports to other databases more cleanly, and DATE_PART reads more like the rest of Snowflake's date functions.
The same thing on the time parts
On a timestamp we can ask for the hour, the minute and the second too.
SELECT '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ AS ANCHOR,
DATE_PART('hour', '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ) AS HOUR_,
DATE_PART('minute', '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ) AS MINUTE_,
DATE_PART('second', '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ) AS SECOND_,
DATE_PART('epoch_second', '2024-06-30 14:37:52'::TIMESTAMP_NTZ) AS EPOCH_SECOND;| ANCHOR | HOUR_ | MINUTE_ | SECOND_ | EPOCH_SECOND |
|---|---|---|---|---|
| 2024-06-30 14:37:52.123000 | 14 | 37 | 52 | 1719758272 |
Those three came straight out. 'epoch_second' is the odd one in that list. It is not a part of the timestamp at all. It is the whole timestamp expressed as seconds since the Unix epoch, which is what you want when something downstream expects an integer.
And there are parts below the second as well.
SELECT DATE_PART('nanosecond', '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ) AS NANOSECOND_,
DATE_PART('epoch_millisecond', '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ)
AS EPOCH_MILLISECOND,
DATE_TRUNC('second', '2024-06-30 14:37:52.123'::TIMESTAMP_NTZ)
AS TRUNCATED_TO_THE_SECOND;| NANOSECOND_ | EPOCH_MILLISECOND | TRUNCATED_TO_THE_SECOND |
|---|---|---|
| 123000000 | 1719758272123 | 2024-06-30 14:37:52 |
'nanosecond' gave us the fractional part scaled to nanoseconds, and 'epoch_millisecond' gave us the epoch value with that fraction folded in.
The third column is the contrast worth noticing. DATE_TRUNC('second', ...) kept the timestamp and threw the fraction away, rather than turning it into a number.
Zero for which day? Let's number a whole week
We left a question open in the last grid. Let's generate seven consecutive days from the anchor and put both of Snowflake's day numberings beside their names.
SELECT D.THE_DATE,
DAYNAME(D.THE_DATE) AS NAME_,
DATE_PART('dayofweek', D.THE_DATE) AS DOW,
DATE_PART('dayofweekiso', D.THE_DATE) AS DOW_ISO
FROM (SELECT DATEADD('day', SEQ4(), DATE '2024-06-30') AS THE_DATE
FROM TABLE(GENERATOR(ROWCOUNT => 7))) D
ORDER BY D.THE_DATE;| THE_DATE | NAME_ | DOW | DOW_ISO |
|---|---|---|---|
| 2024-06-30 | Sun | 0 | 7 |
| 2024-07-01 | Mon | 1 | 1 |
| 2024-07-02 | Tue | 2 | 2 |
| 2024-07-03 | Wed | 3 | 3 |
| 2024-07-04 | Thu | 4 | 4 |
| 2024-07-05 | Fri | 5 | 5 |
| 2024-07-06 | Sat | 6 | 6 |
'dayofweek' numbers Sun as 0 and runs up to Sat as 6. That is the 0 from the previous section explained.
'dayofweekiso' does it differently. It numbers Mon as 1 and Sun as 7.
Which leaves us with two statements that are both true in the same session at the same moment. The truncation functions treat the week as starting on Monday, and 'dayofweek' numbers it as if it started on Sunday.
Rather than guess at the history, let's ask the database.
SHOW PARAMETERS LIKE 'WEEK_START' IN SESSION;| key | value | default | level | description | type |
|---|---|---|---|---|---|
| WEEK_START | 0 | 0 | Defines the first day of the week: 0: legacy Snowflake behavior; 1: Monday .. 7: Sunday. | NUMBER |
The parameter's own description is right there in the grid. The value is 0, and 0 is documented as legacy behaviour, which is exactly the split we just watched. Set it to a value between 1 and 7 and you pin the week to a real day, after which both halves agree with each other.
WEEK_START is a session parameter, so the same query can return different week boundaries for two different users. If a week definition matters to a report, set it explicitly in the session or do not depend on it. The ISO parts and 'dayofweekiso' do not move.
What happens at the end of a year?
There is one more week problem and it only shows up once a year. Let's take eight consecutive days across the end of 2024 and put every week-related part beside them.
SELECT D.THE_DATE,
DAYNAME(D.THE_DATE) AS NAME_,
DATE_PART('weekofyear', D.THE_DATE) AS WEEK_,
DATE_PART('weekiso', D.THE_DATE) AS WEEK_ISO,
DATE_PART('yearofweek', D.THE_DATE) AS YEAR_OF_WEEK,
DATE_PART('yearofweekiso', D.THE_DATE) AS YEAR_OF_WEEK_ISO
FROM (SELECT DATEADD('day', SEQ4(), DATE '2024-12-28') AS THE_DATE
FROM TABLE(GENERATOR(ROWCOUNT => 8))) D
ORDER BY D.THE_DATE;| THE_DATE | NAME_ | WEEK_ | WEEK_ISO | YEAR_OF_WEEK | YEAR_OF_WEEK_ISO |
|---|---|---|---|---|---|
| 2024-12-28 | Sat | 52 | 52 | 2024 | 2024 |
| 2024-12-29 | Sun | 52 | 52 | 2024 | 2024 |
| 2024-12-30 | Mon | 1 | 1 | 2025 | 2025 |
| 2024-12-31 | Tue | 1 | 1 | 2025 | 2025 |
| 2025-01-01 | Wed | 1 | 1 | 2025 | 2025 |
| 2025-01-02 | Thu | 1 | 1 | 2025 | 2025 |
| 2025-01-03 | Fri | 1 | 1 | 2025 | 2025 |
| 2025-01-04 | Sat | 1 | 1 | 2025 | 2025 |
2024-12-29 is in week 52. 2024-12-30 is in week 1. Still December, and already week 1, because the grid says that date is a Mon and a new week has started.
Now read the YEAR_OF_WEEK column on that same row. It says 2025. That week belongs to 2025 even though the date is in 2024, which is the whole reason 'yearofweek' exists.
Label that week with DATE_PART('year', ...) and you get “week 1 of 2024”, which is a week that exists, sits at the beginning of 2024, and is not the week you are looking at. Two rows then collide in a report and the totals are quietly wrong. Pair 'weekofyear' with 'yearofweek', always, or 'weekiso' with 'yearofweekiso'.
On this data the ISO columns agree with the non-ISO ones row for row, because WEEK_START is at its default. They are not guaranteed to agree once that parameter is changed, which is the argument for picking the ISO pair and staying with it.
So what is the real difference between the two?
We have now used both functions on the same date. Let's put them in one row with their types, because this is the line the next section depends on.
SELECT DATE_TRUNC('month', DATE '2024-06-30') AS TRUNC_SAYS,
TYPEOF(DATE_TRUNC('month', DATE '2024-06-30')::VARIANT) AS TRUNC_TYPE,
DATE_PART('month', DATE '2024-06-30') AS PART_SAYS,
TYPEOF(DATE_PART('month', DATE '2024-06-30')::VARIANT) AS PART_TYPE;| TRUNC_SAYS | TRUNC_TYPE | PART_SAYS | PART_TYPE |
|---|---|---|---|
| 2024-06-01 | DATE | 6 | INTEGER |
DATE_TRUNC('month', d) | DATE_PART('month', d) | |
|---|---|---|
| Returns | 2024-06-01 | 6 |
| Type | DATE | INTEGER |
| Keeps the year? | Yes | No, it is gone |
| Good for | Grouping, joining to a calendar, sorting | Labelling, filtering on a single part, maths |
One of them keeps the year. The other throws it away.
Both are correct functions doing exactly what they say. The problem is that a month number looks like a month.
Now let's group some orders by month
Let's write the report. Orders per month, grouped with DATE_TRUNC.
SELECT DATE_TRUNC('month', ORDER_DATE) AS MONTH_,
COUNT(*) AS ORDERS_, ROUND(SUM(AMOUNT), 2) AS TOTAL
FROM ORDERS
GROUP BY DATE_TRUNC('month', ORDER_DATE)
ORDER BY MONTH_;| MONTH_ | 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 rows, one per month that actually has orders in it, running from January 2023 to November 2024. That is what the data is.
Have a look at the bottom row while it is on screen, because it is the only thing in this section that is not about date functions. November 2024 has one order and a NULL total. The group has a single row, that row's amount is NULL, and SUM of nothing but NULL is NULL. That is the behaviour from Episode 71 turning up in a real report.
Now let's write the same report with DATE_PART instead. This is the version people actually write, because the output column is a tidy little month number.
SELECT DATE_PART('month', ORDER_DATE) AS MONTH_NUMBER,
COUNT(*) AS ORDERS_, ROUND(SUM(AMOUNT), 2) AS TOTAL
FROM ORDERS
GROUP BY DATE_PART('month', ORDER_DATE)
ORDER BY MONTH_NUMBER;| MONTH_NUMBER | ORDERS_ | TOTAL |
|---|---|---|
| 1 | 4 | 20200.50 |
| 2 | 3 | 15990.00 |
| 3 | 4 | 10260.75 |
| 4 | 4 | 21650.00 |
| 5 | 4 | 20855.25 |
| 6 | 4 | 21735.50 |
| 7 | 3 | 15990.00 |
| 8 | 4 | 27750.00 |
| 9 | 3 | 12550.00 |
| 10 | 3 | 18085.00 |
| 11 | 3 | 11380.00 |
| 12 | 2 | 8980.00 |
12 rows, and every one of them looks reasonable. There is a month number, an order count and a total. Nothing is NULL any more. No error, no warning, no hint.
And there is no year anywhere in that grid. January 2023 and January 2024 have been added together and labelled 1. Add up the two January rows of the previous grid and you land exactly on that row.
The previous grid is the proof, because it has two separate January rows in it. But rather than leave that as something to compare by eye, let's count it.
SELECT COUNT(DISTINCT DATE_TRUNC('month', ORDER_DATE)) AS REAL_MONTHS,
COUNT(DISTINCT DATE_PART('month', ORDER_DATE)) AS MONTH_NUMBERS,
COUNT(DISTINCT DATE_PART('year', ORDER_DATE)) AS YEARS_PRESENT
FROM ORDERS;| REAL_MONTHS | MONTH_NUMBERS | YEARS_PRESENT |
|---|---|---|
| 23 | 12 | 2 |
23 real months, 12 month numbers, 2 years present. 23 months arrived as 12 rows and nothing said so.
The NULL total went missing too. It was absorbed into a group that had other rows in it, so the sum of that group is a number again.
If you group by a part, put the year in the GROUP BY as well, or group by DATE_TRUNC and format the label afterwards. This is the failure mode that produces a chart with impossible seasonality in it, and it survives review because the SQL is correct. Nothing is wrong with DATE_PART. It answered the question it was asked.
Here is a quick way to catch it on any report. Count the distinct values of your grouping expression and compare that with the number of rows you got back. If grouping collapsed something you did not intend, the two numbers disagree.
Let's try LAST_DAY
LAST_DAY is the other end of DATE_TRUNC. Give it a date and get the last day of its month.
SELECT LAST_DAY(DATE '2024-06-15') AS END_OF_JUNE,
LAST_DAY(DATE '2024-02-15') AS END_OF_FEB_LEAP,
LAST_DAY(DATE '2023-02-15') AS END_OF_FEB_NON_LEAP,
LAST_DAY(DATE '2024-06-30') AS ALREADY_THE_LAST_DAY;| END_OF_JUNE | END_OF_FEB_LEAP | END_OF_FEB_NON_LEAP | ALREADY_THE_LAST_DAY |
|---|---|---|---|
| 2024-06-30 | 2024-02-29 | 2023-02-28 | 2024-06-30 |
June gave us the 30th. February 2024 gave us the 29th and February 2023 gave us the 28th, so it knows about leap years without being told.
And handed a date that is already the last day of its month, it gave that date straight back. The function is idempotent, which is what you want in a pipeline that might run twice.
It takes a second argument too
Let's try all four parts it accepts.
SELECT LAST_DAY(DATE '2024-06-15', 'month') AS MONTH_,
LAST_DAY(DATE '2024-06-15', 'quarter') AS QUARTER_,
LAST_DAY(DATE '2024-06-15', 'year') AS YEAR_,
LAST_DAY(DATE '2024-06-15', 'week') AS WEEK_;| MONTH_ | QUARTER_ | YEAR_ | WEEK_ |
|---|---|---|---|
| 2024-06-30 | 2024-06-30 | 2024-12-31 | 2024-06-16 |
On a June date the month and quarter answers are the same date, because June is the last month of its quarter. The 'week' answer is 2024-06-16, a date in the middle of the month, because weeks do not line up with months and LAST_DAY is not pretending otherwise.
Now let's write the pattern you will actually use
First and last day of the month for every order, plus the number of days in that month worked out from the two.
SELECT ORDER_ID, ORDER_DATE,
DATE_TRUNC('month', ORDER_DATE) AS MONTH_START,
LAST_DAY(ORDER_DATE) AS MONTH_END,
DATEDIFF('day', DATE_TRUNC('month', ORDER_DATE), LAST_DAY(ORDER_DATE)) + 1
AS DAYS_IN_MONTH
FROM ORDERS
ORDER BY ORDER_ID
LIMIT 10;| ORDER_ID | ORDER_DATE | MONTH_START | MONTH_END | DAYS_IN_MONTH |
|---|---|---|---|---|
| 1001 | 2023-01-12 | 2023-01-01 | 2023-01-31 | 31 |
| 1002 | 2023-01-28 | 2023-01-01 | 2023-01-31 | 31 |
| 1003 | 2023-02-14 | 2023-02-01 | 2023-02-28 | 28 |
| 1004 | 2023-02-27 | 2023-02-01 | 2023-02-28 | 28 |
| 1005 | 2023-03-09 | 2023-03-01 | 2023-03-31 | 31 |
| 1006 | 2023-03-22 | 2023-03-01 | 2023-03-31 | 31 |
| 1007 | 2023-04-05 | 2023-04-01 | 2023-04-30 | 30 |
| 1008 | 2023-04-19 | 2023-04-01 | 2023-04-30 | 30 |
| 1009 | 2023-05-02 | 2023-05-01 | 2023-05-31 | 31 |
| 1010 | 2023-05-21 | 2023-05-01 | 2023-05-31 | 31 |
DATE_TRUNC for the start, LAST_DAY for the end, and DATEDIFF between them plus one for the length. The plus one is there because DATEDIFF counts the gaps between the days and not the days themselves.
The DAYS_IN_MONTH column is correct on every row, February included. But that is worth checking rather than eyeballing, so let's run it over the whole table.
SELECT COUNT(*) AS ROWS_TOTAL,
MIN(DATEDIFF('day', DATE_TRUNC('month', ORDER_DATE), LAST_DAY(ORDER_DATE)) + 1)
AS SHORTEST_MONTH,
MAX(DATEDIFF('day', DATE_TRUNC('month', ORDER_DATE), LAST_DAY(ORDER_DATE)) + 1)
AS LONGEST_MONTH,
COUNT_IF(DATEDIFF('day', DATE_TRUNC('month', ORDER_DATE), LAST_DAY(ORDER_DATE)) + 1
NOT BETWEEN 28 AND 31) AS OUT_OF_RANGE
FROM ORDERS;| ROWS_TOTAL | SHORTEST_MONTH | LONGEST_MONTH | OUT_OF_RANGE |
|---|---|---|---|
| 41 | 28 | 31 | 0 |
41 rows, shortest month 28, longest 31, and 0 rows outside that range. The expression holds everywhere in this data, which is a better thing to know than that it held on the ten rows above.
Now let's try NEXT_DAY and PREVIOUS_DAY
NEXT_DAY takes a date and a day name and gives you the next occurrence of that weekday.
SELECT DATE '2024-06-30' AS ANCHOR,
DAYNAME(DATE '2024-06-30') AS ANCHOR_DAY,
NEXT_DAY(DATE '2024-06-30', 'Monday') AS NEXT_MONDAY,
NEXT_DAY(DATE '2024-06-30', 'Friday') AS NEXT_FRIDAY,
PREVIOUS_DAY(DATE '2024-06-30', 'Monday') AS PREVIOUS_MONDAY;| ANCHOR | ANCHOR_DAY | NEXT_MONDAY | NEXT_FRIDAY | PREVIOUS_MONDAY |
|---|---|---|---|---|
| 2024-06-30 | Sun | 2024-07-01 | 2024-07-05 | 2024-06-24 |
From the anchor, which this grid again confirms is a Sun, the next Monday is 2024-07-01 and the next Friday is 2024-07-05. PREVIOUS_DAY goes the other way, to 2024-06-24.
What happens when we are already on that weekday?
This is the edge that catches people, and it is worth knowing before it costs you a scheduling bug. Let's start from 2024-07-01, which the previous grid showed is the Monday in question, and ask for the next Monday.
SELECT DATE '2024-07-01' AS ANCHOR,
DAYNAME(DATE '2024-07-01') AS ANCHOR_DAY,
NEXT_DAY(DATE '2024-07-01', 'Monday') AS NEXT_MONDAY,
PREVIOUS_DAY(DATE '2024-07-01', 'Monday') AS PREVIOUS_MONDAY,
DATEDIFF('day', DATE '2024-07-01', NEXT_DAY(DATE '2024-07-01', 'Monday'))
AS DAYS_FORWARD;| ANCHOR | ANCHOR_DAY | NEXT_MONDAY | PREVIOUS_MONDAY | DAYS_FORWARD |
|---|---|---|---|---|
| 2024-07-01 | Mon | 2024-07-08 | 2024-06-24 | 7 |
We got 2024-07-08, and the last column measures the jump: 7 days. PREVIOUS_DAY skipped straight back past the date as well, to the Monday before.
So NEXT_DAY means strictly next and PREVIOUS_DAY means strictly previous. Neither one ever gives you back the date you handed it, which means “the Monday of this date's week” is not what either function computes. Episode 74 builds that one out of the ISO parts instead.
How fussy is the day-name argument?
Let's try four spellings of the same day.
SELECT NEXT_DAY(DATE '2024-06-30', 'mo') AS MO,
NEXT_DAY(DATE '2024-06-30', 'mon') AS MON,
NEXT_DAY(DATE '2024-06-30', 'monday') AS MONDAY_LOWER,
NEXT_DAY(DATE '2024-06-30', 'MONDAY') AS MONDAY_UPPER;| MO | MON | MONDAY_LOWER | MONDAY_UPPER |
|---|---|---|---|
| 2024-07-01 | 2024-07-01 | 2024-07-01 | 2024-07-01 |
'mo', 'mon', 'monday' and 'MONDAY' all gave the same answer. Case does not matter and two letters is enough.
That is handy, and it is also a reason to write the full name anyway. 'mo' is not obviously a day to the next person reading your query.
One last thing: one row of a date dimension
Let's put everything on this page into one query over real orders. The month each one belongs to, the end of that month, its quarter, its day name, its position in the year, and the Monday that follows it.
SELECT ORDER_ID, ORDER_DATE,
DATE_TRUNC('month', ORDER_DATE) AS MONTH_START,
LAST_DAY(ORDER_DATE) AS MONTH_END,
DATE_PART('quarter', ORDER_DATE) AS QUARTER_,
DAYNAME(ORDER_DATE) AS DAY_NAME,
DATE_PART('dayofyear', ORDER_DATE) AS DAY_OF_YEAR,
NEXT_DAY(ORDER_DATE, 'Monday') AS NEXT_MONDAY
FROM ORDERS
ORDER BY ORDER_ID
LIMIT 5;| ORDER_ID | ORDER_DATE | MONTH_START | MONTH_END | QUARTER_ | DAY_NAME | DAY_OF_YEAR | NEXT_MONDAY |
|---|---|---|---|---|---|---|---|
| 1001 | 2023-01-12 | 2023-01-01 | 2023-01-31 | 1 | Thu | 12 | 2023-01-16 |
| 1002 | 2023-01-28 | 2023-01-01 | 2023-01-31 | 1 | Sat | 28 | 2023-01-30 |
| 1003 | 2023-02-14 | 2023-02-01 | 2023-02-28 | 1 | Tue | 45 | 2023-02-20 |
| 1004 | 2023-02-27 | 2023-02-01 | 2023-02-28 | 1 | Mon | 58 | 2023-03-06 |
| 1005 | 2023-03-09 | 2023-03-01 | 2023-03-31 | 1 | Thu | 68 | 2023-03-13 |
That is a row of a date dimension, and this is genuinely how one gets built. A generated series of dates with a column per part, materialised once, so that reports join to it instead of recomputing all of this on every query.
Every column in that grid comes from a function we have already run on this page, which is the point. Nothing new was needed to build it.
One of those columns does have more to it than we have used here. DAYNAME is the function behind the weekday names we have been reading off every grid in this post, and it, its month equivalent, and what they will and will not give you are the whole of the next post.
Key points
DATE_TRUNC(part, date)returns the start of the period as aDATE.DATE_PART(part, date)returns one number as anINTEGER.- Group by the truncation, not the part. Grouping these orders by
DATE_PART('month', ...)turned 23 real months into 12 rows and merged two years together without an error or a warning. - A quick guard against that: compare the distinct count of your grouping expression with the number of rows the report returned.
EXTRACT(part FROM date)is the standard SQL spelling ofDATE_PART, and the captured comparison column says the two agree.'dayofweek'numbersSunas0.'dayofweekiso'numbersMonas1andSunas7.- Meanwhile
DATE_TRUNC('week', ...)starts the week on Monday. Both are true at once becauseWEEK_STARTis0, which the parameter's own description calls legacy behaviour. And it is a session setting, so it can differ between users. - At a year boundary,
'weekofyear'alone is not enough.2024-12-30is week1of'yearofweek'2025. Pair the week part with the year-of-week part, always. LAST_DAYknows about leap years, leaves an existing month-end unchanged, and takes an optional part:'month','quarter','year'or'week'.DATE_TRUNCfor the month start,LAST_DAYfor the month end, andDATEDIFFplus one between them gives you the days in the month. Checked here across all 41 orders, with nothing outside 28 to 31.NEXT_DAYandPREVIOUS_DAYare strict. From a Monday, the next Monday is 7 days away. Day names are case-insensitive and two letters is enough.
Episode 74 — day names, month names and buckets. DAYNAME and MONTHNAME, and why there is no full-name function. A weekday-versus-weekend test and how to check its magic numbers. And TIME_SLICE, which makes buckets that are not calendar periods at all.
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 and CUSTOMERS data is invented demo data created by that script. Every calculation is anchored to DATE '2024-06-30' rather than the clock, and every weekday named in the text above is taken from a DAYNAME column in one of the grids rather than worked out here. No performance or benchmark claims are made anywhere in this series. Function behaviour and the WEEK_START description follow the Snowflake date & time functions documentation.



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