In this post we are going to cover DATEADD, DATEDIFF and CURRENT_DATE in Snowflake.
DATEADD moves a date. We tell it a unit, how many of them, and what to move, and it hands back a new date. DATEDIFF goes the other way: we give it two dates and a unit, and it tells us how far apart they are. CURRENT_DATE and its relatives tell us what the clock says right now.
We will start by asking Snowflake what time it is, because there are several ways to ask and they do not all return the same thing. Then we will add days and months, and find out that adding a month twice is not the same as adding two months. Then we will measure gaps, and find out that DATEDIFF does not measure elapsed time at all. Every calculation here is anchored to DATE '2024-06-30' rather than to today, so your numbers will match these ones.
Every query below was run on a real Snowflake account, and every result you see is the output it gave back.
Episode 72 of the Snowflake SQL: Zero to Hero series on TechBrothersIT.
- Let's create some sample data
- Let's start by asking Snowflake what time it is
- Now let's move a date with DATEADD
- What happens at the end of a month?
- Is adding a month twice the same as adding two months?
- What happens if we add an hour to a DATE?
- Which typed literals can we actually write?
- Now let's measure a gap with DATEDIFF
- Let's run DATEDIFF over the customers table
- Let's finish with the edges, and one query that checks itself
Let's create some sample data
This is the first of five posts on dates. Everything here runs against the two tables the course has been using, ORDERS and CUSTOMERS.
Copy this into a Snowflake worksheet and run it once. The last line is the anchor date every calculation on this page uses.
-- 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 hard-coded date instead of
-- CURRENT_DATE, so the numbers you get are the numbers below. Only one grid on
-- this page reads the clock, and it is labelled where it appears.
SELECT DATE '2024-06-30' AS ANCHOR;Let's look at ten of the orders, oldest first.
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 |
An order ID, a customer, a date, an amount and a status. There is more date range in the table than those ten rows show, so let's measure it.
SELECT COUNT(*) AS ROWS_TOTAL,
MIN(ORDER_DATE) AS EARLIEST,
MAX(ORDER_DATE) AS LATEST,
COUNT(DISTINCT ORDER_DATE) AS DISTINCT_DAYS
FROM ORDERS;| ROWS_TOTAL | EARLIEST | LATEST | DISTINCT_DAYS |
|---|---|---|---|
| 41 | 2023-01-12 | 2024-11-05 | 41 |
41 orders, running from 2023-01-12 to 2024-11-05. DISTINCT_DAYS is 41 as well, so no two orders share a day. That column comes back in Episode 74, where it decides whether a bucketing query groups anything at all.
Now the customers.
SELECT CUSTOMER_ID, CUSTOMER_NAME, COUNTRY, SEGMENT, SIGNUP_DATE
FROM CUSTOMERS ORDER BY SIGNUP_DATE;| CUSTOMER_ID | CUSTOMER_NAME | COUNTRY | SEGMENT | SIGNUP_DATE |
|---|---|---|---|---|
| 101 | Northwind Traders | USA | Enterprise | 2022-01-15 |
| 102 | Blue Ridge Supply | USA | SMB | 2022-04-02 |
| 103 | Kestrel Analytics | Canada | Enterprise | 2022-06-21 |
| 104 | Harbour Foods | Canada | SMB | 2023-02-08 |
| 105 | Alpine Logistics | NULL | Enterprise | 2023-05-30 |
| 106 | Delta Print Co | USA | SMB | 2023-09-12 |
| 107 | Meridian Health | USA | Enterprise | 2024-01-07 |
| 108 | Copper Kettle Ltd | UK | SMB | 2024-03-25 |
| 109 | Vantage Media | UK | Enterprise | 2024-08-14 |
| 110 | Sable Interiors | USA | SMB | 2024-11-03 |
Ten customers, each with a signup date. These are the dates we will measure against when we get to DATEDIFF.
Every piece of arithmetic on this page uses one fixed date: DATE '2024-06-30'. A tutorial that does date maths against CURRENT_DATE is wrong by one day the following morning, and wrong by months by the time you read it. The current-time functions appear exactly once here, clearly labelled, and are never used in a calculation.
Let's start by asking Snowflake what time it is
There are several ways to ask, and they do not all return the same thing. Let's put them side by side.
SELECT CURRENT_DATE() AS TODAY,
CURRENT_TIMESTAMP() AS NOW_,
CURRENT_TIME() AS TIME_OF_DAY,
LOCALTIMESTAMP() AS LOCAL_,
SYSDATE() AS SYSDATE_;| TODAY | NOW_ | TIME_OF_DAY | LOCAL_ | SYSDATE_ |
|---|---|---|---|---|
| 2026-09-25 | 2026-09-25 04:12:30.280000-07:00 | 04:12:30.280000 | 2026-09-25 04:12:30.280000-07:00 | 2026-09-25 11:12:30.280000 |
Your values will differ, and that is the entire point of these functions. What is worth reading is the shape of the row. NOW_ carries a UTC offset and SYSDATE_ does not, even though both were evaluated in the same statement.
Let's ask for the types, which explain why.
SELECT TYPEOF(CURRENT_DATE()::VARIANT) AS CURRENT_DATE_TYPE,
TYPEOF(CURRENT_TIMESTAMP()::VARIANT) AS CURRENT_TIMESTAMP_TYPE,
TYPEOF(SYSDATE()::VARIANT) AS SYSDATE_TYPE,
TYPEOF(LOCALTIMESTAMP()::VARIANT) AS LOCALTIMESTAMP_TYPE;| CURRENT_DATE_TYPE | CURRENT_TIMESTAMP_TYPE | SYSDATE_TYPE | LOCALTIMESTAMP_TYPE |
|---|---|---|---|
| DATE | TIMESTAMP_LTZ | TIMESTAMP_NTZ | TIMESTAMP_LTZ |
| Function | Type it returns | What that means in practice |
|---|---|---|
CURRENT_DATE() | DATE | No time component at all |
CURRENT_TIMESTAMP() | TIMESTAMP_LTZ | Carries a time zone offset; displayed in the session zone |
LOCALTIMESTAMP() | TIMESTAMP_LTZ | Same type as CURRENT_TIMESTAMP() |
SYSDATE() | TIMESTAMP_NTZ | No offset stored; the instant is printed without one |
So we have three ways to ask the same question and two different types, and a column that quietly loses its offset if we pick the wrong one. The difference between TIMESTAMP_LTZ, TIMESTAMP_NTZ and TIMESTAMP_TZ gets a post of its own at Episode 76. For now, notice only that SYSDATE() is not the same type as the other two.
Does the clock move while a query runs?
Worth knowing, because the answer decides whether a long query can contradict itself. Let's generate a thousand rows and count the distinct values.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT(DISTINCT CURRENT_TIMESTAMP()) AS DISTINCT_TIMESTAMPS,
COUNT(DISTINCT CURRENT_DATE()) AS DISTINCT_DATES
FROM TABLE(GENERATOR(ROWCOUNT => 1000));| ROWS_TOTAL | DISTINCT_TIMESTAMPS | DISTINCT_DATES |
|---|---|---|
| 1000 | 1 | 1 |
One. The clock is read once per statement, not once per row. A query that runs for ten minutes cannot see the date change underneath it, so a batch job that stamps rows with CURRENT_TIMESTAMP() stamps them all identically. That is almost always what we want.
Now let's move a date with DATEADD
DATEADD takes three arguments in that order: which unit we are adding, how many of them, and what we are adding them to. A negative amount subtracts.
SELECT DATE '2024-06-30' AS ANCHOR,
DATEADD('day', 1, DATE '2024-06-30') AS PLUS_1_DAY,
DATEADD('day', -1, DATE '2024-06-30') AS MINUS_1_DAY,
DATEADD('month', 1, DATE '2024-06-30') AS PLUS_1_MONTH,
DATEADD('year', 1, DATE '2024-06-30') AS PLUS_1_YEAR;| ANCHOR | PLUS_1_DAY | MINUS_1_DAY | PLUS_1_MONTH | PLUS_1_YEAR |
|---|---|---|---|---|
| 2024-06-30 | 2024-07-01 | 2024-06-29 | 2024-07-30 | 2025-06-30 |
Nothing surprising yet, and that is deliberate. The anchor is the 30th of a 30-day month, which is the easy case. We will move it to the 31st shortly and the behaviour changes.
The part names have short forms
Other people's code will use them, so they are worth recognising. 'day', 'dd', 'd' and 'days' are all the same part. Let's run all four, plus one real difference to compare against.
SELECT DATEADD('day', 1, DATE '2024-06-30') AS DAY_,
DATEADD('dd', 1, DATE '2024-06-30') AS DD,
DATEADD('d', 1, DATE '2024-06-30') AS D,
DATEADD('days', 1, DATE '2024-06-30') AS DAYS_,
DATEADD('week', 1, DATE '2024-06-30') AS WEEK_;| DAY_ | DD | D | DAYS_ | WEEK_ |
|---|---|---|---|---|
| 2024-07-01 | 2024-07-01 | 2024-07-01 | 2024-07-01 | 2024-07-07 |
DAY_, DD, D and DAYS_ are identical. WEEK_ is the only column that lands somewhere else, a week on from the anchor.
Now let's try it on a real date column
The same function over ORDER_DATE: a follow-up date thirty days out, the same date a month later, and the matching day a year earlier.
SELECT ORDER_ID, ORDER_DATE,
DATEADD('day', 30, ORDER_DATE) AS FOLLOW_UP,
DATEADD('month', 1, ORDER_DATE) AS A_MONTH_LATER,
DATEADD('year', -1, ORDER_DATE) AS SAME_DAY_LAST_YEAR
FROM ORDERS
ORDER BY ORDER_ID
LIMIT 10;| ORDER_ID | ORDER_DATE | FOLLOW_UP | A_MONTH_LATER | SAME_DAY_LAST_YEAR |
|---|---|---|---|---|
| 1001 | 2023-01-12 | 2023-02-11 | 2023-02-12 | 2022-01-12 |
| 1002 | 2023-01-28 | 2023-02-27 | 2023-02-28 | 2022-01-28 |
| 1003 | 2023-02-14 | 2023-03-16 | 2023-03-14 | 2022-02-14 |
| 1004 | 2023-02-27 | 2023-03-29 | 2023-03-27 | 2022-02-27 |
| 1005 | 2023-03-09 | 2023-04-08 | 2023-04-09 | 2022-03-09 |
| 1006 | 2023-03-22 | 2023-04-21 | 2023-04-22 | 2022-03-22 |
| 1007 | 2023-04-05 | 2023-05-05 | 2023-05-05 | 2022-04-05 |
| 1008 | 2023-04-19 | 2023-05-19 | 2023-05-19 | 2022-04-19 |
| 1009 | 2023-05-02 | 2023-06-01 | 2023-06-02 | 2022-05-02 |
| 1010 | 2023-05-21 | 2023-06-20 | 2023-06-21 | 2022-05-21 |
Compare FOLLOW_UP with A_MONTH_LATER row by row. They are not the same column, and they do not even disagree in a consistent direction.
- Order
1001— the month version is later than the thirty-day version, because the month it crosses is a long one. - Order
1003— the month version is earlier, because the month it crosses is a short one. - Orders
1007and1008— the two columns are identical, because the month they cross happens to be exactly thirty days long.
“A month” is not a fixed number of days, so DATEADD('day', 30, ...) and DATEADD('month', 1, ...) answer different questions. Pick the one your business rule actually means. A payment term of “net 30” is days; a subscription renewal is months.
What happens at the end of a month?
Here is the first thing that cannot work the way we might hope. Add one month to the 31st of January and there is no 31st of February to land on, so something has to give. Let's see what.
SELECT DATEADD('month', 1, DATE '2024-01-31') AS JAN_31_PLUS_1_MONTH,
DATEADD('month', 1, DATE '2024-03-31') AS MAR_31_PLUS_1_MONTH,
DATEADD('month', 1, DATE '2023-01-31') AS JAN_31_2023_NON_LEAP,
DATEADD('month', -1, DATE '2024-03-31') AS MAR_31_MINUS_1_MONTH;| JAN_31_PLUS_1_MONTH | MAR_31_PLUS_1_MONTH | JAN_31_2023_NON_LEAP | MAR_31_MINUS_1_MONTH |
|---|---|---|---|
| 2024-02-29 | 2024-04-30 | 2023-02-28 | 2024-02-29 |
Read those four columns carefully, because each one is a separate rule.
2024-01-31plus a month gives2024-02-29— it clamps to the last day of the shorter month rather than overflowing into March.2024-03-31plus a month gives2024-04-30— same clamp, different month length.2023-01-31plus a month gives2023-02-28. Same sum, different year, different answer: 2024 is a leap year and 2023 is not.- The clamp applies going backwards too:
2024-03-31minus a month is2024-02-29.
That behaviour is reasonable. It is also the mechanism behind the trap we are about to walk into.
Let's try the leap day
SELECT DATEADD('year', 1, DATE '2024-02-29') AS LEAP_DAY_PLUS_A_YEAR,
DATEADD('year', 4, DATE '2024-02-29') AS PLUS_FOUR_YEARS,
DATEADD('day', 1, DATE '2024-02-28') AS DAY_AFTER_FEB_28_2024,
DATEADD('day', 1, DATE '2023-02-28') AS DAY_AFTER_FEB_28_2023;| LEAP_DAY_PLUS_A_YEAR | PLUS_FOUR_YEARS | DAY_AFTER_FEB_28_2024 | DAY_AFTER_FEB_28_2023 |
|---|---|---|---|
| 2025-02-28 | 2028-02-29 | 2024-02-29 | 2023-03-01 |
2024-02-29 plus a year is 2025-02-28, which is the same clamp applied to a year instead of a month. Plus four years gets us a real 29th of February again.
DAY_AFTER_FEB_28_2024 and DAY_AFTER_FEB_28_2023 show the same fact from the other side. The day after 2024-02-28 is the leap day, and the day after 2023-02-28 is the 1st of March.
If you store an anniversary — a renewal date, a birthday, a contract date — and it falls on the 29th of February, it will be the 28th in three years out of four and you cannot get the 29th back by adding years. Decide which day you mean and store that.
Is adding a month twice the same as adding two months?
This is the result the video says it did not expect before running it, and it follows directly from the clamp. Let's start from the same date, add a month twice, then add two months in one step, and compare.
SELECT DATEADD('month', 1, DATEADD('month', 1, DATE '2024-01-31')) AS TWICE_ONE_MONTH,
DATEADD('month', 2, DATE '2024-01-31') AS ONCE_TWO_MONTHS,
DATEADD('month', 1, DATEADD('month', 1, DATE '2024-01-31'))
= DATEADD('month', 2, DATE '2024-01-31') AS SAME;| TWICE_ONE_MONTH | ONCE_TWO_MONTHS | SAME |
|---|---|---|
| 2024-03-29 | 2024-03-31 | FALSE |
2024-03-29 and 2024-03-31. SAME says FALSE. Month arithmetic in Snowflake is not associative, and the clamp is why.
2024-01-31plus a month clamps to2024-02-29.- The 31st is now gone. It is not remembered as an intention, only as a date.
- A month after
2024-02-29is2024-03-29. - Done in one step, Snowflake works from the original 31st and lands on
2024-03-31.
A monthly schedule written as “add one month each time” drifts off month-end permanently. The first short month pulls it back and nothing ever pushes it forward again. Compute each occurrence from the original date (DATEADD('month', n, start_date)), or derive month-end explicitly. LAST_DAY, which is in Episode 73, is the honest way to say “the end of the month”.
What happens if we add an hour to a DATE?
A DATE has no time component, so there is nowhere to put an hour. Snowflake does not refuse and does not round. Let's see what it does instead.
SELECT DATEADD('hour', 1, DATE '2024-06-30') AS DATE_PLUS_AN_HOUR,
TYPEOF(DATEADD('hour', 1, DATE '2024-06-30')::VARIANT) AS ITS_TYPE,
DATEADD('hour', 25, DATE '2024-06-30') AS PLUS_25_HOURS;| DATE_PLUS_AN_HOUR | ITS_TYPE | PLUS_25_HOURS |
|---|---|---|
| 2024-06-30 01:00:00 | TIMESTAMP_NTZ | 2024-07-01 01:00:00 |
ITS_TYPE says TIMESTAMP_NTZ. The expression went in as a DATE and came out as a timestamp, with the time set to midnight plus the hour we asked for. Add 25 hours and the date rolls as well, which is the behaviour we want.
This is quiet. A column defined as DATE, fed through DATEADD('hour', ...), arrives downstream as a TIMESTAMP_NTZ — and an equality join against a real DATE column then compares midnight-plus-an-hour against midnight. Nothing errors; rows just stop matching.
Now let's try it where the time already exists
SELECT '2024-06-30 23:30:00'::TIMESTAMP_NTZ AS ANCHOR,
DATEADD('hour', 1, '2024-06-30 23:30:00'::TIMESTAMP_NTZ) AS PLUS_1_HOUR,
DATEADD('minute', 45, '2024-06-30 23:30:00'::TIMESTAMP_NTZ) AS PLUS_45_MIN,
DATEADD('second', 1, '2024-06-30 23:59:59'::TIMESTAMP_NTZ) AS PLUS_1_SEC;| ANCHOR | PLUS_1_HOUR | PLUS_45_MIN | PLUS_1_SEC |
|---|---|---|---|
| 2024-06-30 23:30:00 | 2024-07-01 00:30:00 | 2024-07-01 00:15:00 | 2024-07-01 00:00:00 |
PLUS_1_HOUR, PLUS_45_MIN and PLUS_1_SEC all cross midnight, and the date rolls with them. DATEADD on a timestamp is ordinary clock arithmetic. No clamping and no surprises, because hours and minutes are all the same length as each other in a way that months are not.
Which typed literals can we actually write?
A short detour, and a genuinely useful one, because the pattern is not the one we would guess. Writing a type name in front of a string literal works for DATE, which is why every query on this page does it.
SELECT DATE '2024-06-30' AS DATE_LITERAL_WORKS,
'2024-06-30'::DATE AS AND_SO_DOES_THE_CAST,
'2024-06-30 23:30:00'::TIMESTAMP_NTZ AS TIMESTAMP_NEEDS_THE_CAST,
TO_TIMESTAMP_NTZ('2024-06-30 23:30:00') AS OR_THE_FUNCTION;| DATE_LITERAL_WORKS | AND_SO_DOES_THE_CAST | TIMESTAMP_NEEDS_THE_CAST | OR_THE_FUNCTION |
|---|---|---|---|
| 2024-06-30 | 2024-06-30 | 2024-06-30 23:30:00 | 2024-06-30 23:30:00 |
A DATE literal, a cast to DATE, a cast to TIMESTAMP_NTZ and TO_TIMESTAMP_NTZ(). All four parse.
So the obvious next step is to write the type name in front of a timestamp string the same way.
SELECT TIMESTAMP_NTZ '2024-06-30 23:30:00' AS SHOULD_NOT_GET_THIS_FAR;001003 (42000): SQL compilation error: syntax error line 1 at position 21 unexpected ''2024-06-30 23:30:00''.
A compilation error, pointing at the string. TIMESTAMP_NTZ does not take a typed literal. The surprise is not that one spelling fails. It is which spelling works instead.
SELECT TIMESTAMP '2024-06-30 23:30:00' AS PLAIN_TIMESTAMP_LITERAL,
TYPEOF((TIMESTAMP '2024-06-30 23:30:00')::VARIANT) AS ITS_TYPE;| PLAIN_TIMESTAMP_LITERAL | ITS_TYPE |
|---|---|
| 2024-06-30 23:30:00 | TIMESTAMP_NTZ |
The plain, unqualified keyword parses, and ITS_TYPE reports TIMESTAMP_NTZ — the exact type the qualified spelling refused to produce.
| Spelling | Result |
|---|---|
DATE '2024-06-30' | Parses, gives a DATE |
TIMESTAMP '2024-06-30 23:30:00' | Parses, gives a TIMESTAMP_NTZ |
TIMESTAMP_NTZ '2024-06-30 23:30:00' | Syntax error |
'2024-06-30 23:30:00'::TIMESTAMP_NTZ | Parses |
TO_TIMESTAMP_NTZ('2024-06-30 23:30:00') | Parses |
There is no way to reason your way to that table, which is the only reason it is on the page. Use the cast or TO_TIMESTAMP_NTZ() and the question never comes up.
Now let's measure a gap with DATEDIFF
DATEDIFF takes a part, a start and an end, and returns how far apart they are in that unit. The word “apart” is doing a lot of work in that sentence. Let's find out how much.
SELECT DATEDIFF('year', DATE '2023-12-31', DATE '2024-01-01') AS ONE_DAY_ONE_YEAR,
DATEDIFF('month', DATE '2024-01-31', DATE '2024-02-01') AS ONE_DAY_ONE_MONTH,
DATEDIFF('day', DATE '2023-12-31', DATE '2024-01-01') AS ONE_DAY_ONE_DAY,
DATEDIFF('year', DATE '2024-01-01', DATE '2024-12-31') AS ALMOST_A_YEAR_APART;| ONE_DAY_ONE_YEAR | ONE_DAY_ONE_MONTH | ONE_DAY_ONE_DAY | ALMOST_A_YEAR_APART |
|---|---|---|---|
| 1 | 1 | 1 | 0 |
Read ONE_DAY_ONE_YEAR and ALMOST_A_YEAR_APART together. 2023-12-31 to 2024-01-01 is one day apart, which ONE_DAY_ONE_DAY confirms, and DATEDIFF('year', ...) reports 1. Meanwhile 2024-01-01 to 2024-12-31, nearly the whole of one calendar year, reports 0.
DATEDIFF is not measuring elapsed time. It is counting how many boundaries of that unit lie between the two dates. One new-year midnight was crossed in the first case and none in the second. ONE_DAY_ONE_MONTH makes the same point one unit down: the 31st of January to the 1st of February is one month, because one month boundary was crossed.
Which is why it is the wrong way to compute an age
Let's take somebody born on 2000-12-31, measure them at 2024-01-01, and ask two different questions about the same pair of dates.
SELECT DATEDIFF('year', DATE '2000-12-31', DATE '2024-01-01') AS DATEDIFF_SAYS,
FLOOR(DATEDIFF('day', DATE '2000-12-31', DATE '2024-01-01') / 365.25)
AS WHOLE_YEARS_APPROX,
DATEDIFF('day', DATE '2000-12-31', DATE '2024-01-01') AS DAYS_APART;| DATEDIFF_SAYS | WHOLE_YEARS_APPROX | DAYS_APART |
|---|---|---|
| 24 | 23 | 8401 |
DATEDIFF_SAYS is 24. WHOLE_YEARS_APPROX, which is the day count divided by 365.25, is 23. The second one is their age, and DAYS_APART says they are 8401 days old. If you need whole completed years, count days or compare month-and-day. Do not ask DATEDIFF for years.
Now let's check the argument order
SELECT DATEDIFF('day', DATE '2024-01-01', DATE '2024-01-31') AS START_THEN_END,
DATEDIFF('day', DATE '2024-01-31', DATE '2024-01-01') AS END_THEN_START,
DATEDIFF('day', DATE '2024-01-01', DATE '2024-01-01') AS SAME_DAY;| START_THEN_END | END_THEN_START | SAME_DAY |
|---|---|---|
| 30 | -30 | 0 |
Start first, then end. Swap them and we get the negative, not an error, so a reversed argument list produces a report full of negative numbers rather than a failure anybody would notice. Two identical dates give 0.
And here is one pair of dates at five different granularities.
SELECT DATEDIFF('year', DATE '2022-01-15', DATE '2024-06-30') AS YEARS_,
DATEDIFF('quarter', DATE '2022-01-15', DATE '2024-06-30') AS QUARTERS_,
DATEDIFF('month', DATE '2022-01-15', DATE '2024-06-30') AS MONTHS_,
DATEDIFF('week', DATE '2022-01-15', DATE '2024-06-30') AS WEEKS_,
DATEDIFF('day', DATE '2022-01-15', DATE '2024-06-30') AS DAYS_;| YEARS_ | QUARTERS_ | MONTHS_ | WEEKS_ | DAYS_ |
|---|---|---|---|---|
| 2 | 9 | 29 | 128 | 897 |
2 years, 9 quarters, 29 months, 128 weeks, 897 days. Every one of those is a correct answer to a different question, which is exactly why the part argument deserves a moment's thought rather than a habit.
Let's run DATEDIFF over the customers table
Now the real data, measured from each signup date to DATE '2024-06-30', at three granularities at once.
SELECT CUSTOMER_ID, CUSTOMER_NAME, SIGNUP_DATE,
DATEDIFF('day', SIGNUP_DATE, DATE '2024-06-30') AS DAYS_TO_ANCHOR,
DATEDIFF('month', SIGNUP_DATE, DATE '2024-06-30') AS MONTHS_TO_ANCHOR,
DATEDIFF('year', SIGNUP_DATE, DATE '2024-06-30') AS YEARS_TO_ANCHOR
FROM CUSTOMERS
ORDER BY SIGNUP_DATE;| CUSTOMER_ID | CUSTOMER_NAME | SIGNUP_DATE | DAYS_TO_ANCHOR | MONTHS_TO_ANCHOR | YEARS_TO_ANCHOR |
|---|---|---|---|---|---|
| 101 | Northwind Traders | 2022-01-15 | 897 | 29 | 2 |
| 102 | Blue Ridge Supply | 2022-04-02 | 820 | 26 | 2 |
| 103 | Kestrel Analytics | 2022-06-21 | 740 | 24 | 2 |
| 104 | Harbour Foods | 2023-02-08 | 508 | 16 | 1 |
| 105 | Alpine Logistics | 2023-05-30 | 397 | 13 | 1 |
| 106 | Delta Print Co | 2023-09-12 | 292 | 9 | 1 |
| 107 | Meridian Health | 2024-01-07 | 175 | 5 | 0 |
| 108 | Copper Kettle Ltd | 2024-03-25 | 97 | 3 | 0 |
| 109 | Vantage Media | 2024-08-14 | -45 | -2 | 0 |
| 110 | Sable Interiors | 2024-11-03 | -126 | -5 | 0 |
Northwind Traders signed up on 2022-01-15, which is the pair of dates we just measured at every granularity, so 897 and 29 should look familiar.
Vantage Media and Sable Interiors come back negative in DAYS_TO_ANCHOR and MONTHS_TO_ANCHOR, and they are negative honestly: those two customers signed up after the anchor. The data runs into November 2024 and the anchor is June, so from the anchor's point of view they are in the future.
Where does the year count disagree with the elapsed years?
We have been saying that DATEDIFF('year', ...) is not elapsed years. Rather than assert it, let's ask the table for every row where the two methods differ.
SELECT CUSTOMER_ID, SIGNUP_DATE,
DATEDIFF('year', SIGNUP_DATE, DATE '2024-06-30') AS DATEDIFF_YEARS,
FLOOR(DATEDIFF('day', SIGNUP_DATE, DATE '2024-06-30') / 365.25) AS ELAPSED_YEARS
FROM CUSTOMERS
WHERE DATEDIFF('year', SIGNUP_DATE, DATE '2024-06-30')
<> FLOOR(DATEDIFF('day', SIGNUP_DATE, DATE '2024-06-30') / 365.25)
ORDER BY CUSTOMER_ID;| CUSTOMER_ID | SIGNUP_DATE | DATEDIFF_YEARS | ELAPSED_YEARS |
|---|---|---|---|
| 106 | 2023-09-12 | 1 | 0 |
| 109 | 2024-08-14 | 0 | -1 |
| 110 | 2024-11-03 | 0 | -1 |
Three rows. Customer 106 is the real lesson: DATEDIFF says a year has passed since 2023-09-12 and the elapsed count says none has.
Customers 109 and 110 are the two future signups again. -1 elapsed against 0 from DATEDIFF is a disagreement about how to round a negative rather than about the calendar.
Let's ask the same question as a single count.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT_IF(DATEDIFF('year', SIGNUP_DATE, DATE '2024-06-30')
<> FLOOR(DATEDIFF('day', SIGNUP_DATE, DATE '2024-06-30') / 365.25))
AS DISAGREE
FROM CUSTOMERS;| ROWS_TOTAL | DISAGREE |
|---|---|
| 10 | 3 |
3 out of 10. On a ten-row table, nearly a third of the rows would carry the wrong year count, and every one of them would look perfectly plausible in a report.
Those two negative rows could have been filtered out of this page and nobody would have known. They are left in because they are what a fixed anchor means: it is a point in time, and some of your data is later than it. A report with an as-of date has exactly the same property.
Let's finish with the edges, and one query that checks itself
Two edges worth knowing, both in one row.
SELECT DATEDIFF('day', NULL, DATE '2024-06-30') AS START_IS_NULL,
DATEDIFF('day', DATE '2024-06-30', NULL) AS END_IS_NULL,
DATE '2024-06-30' - DATE '2024-01-01' AS MINUS_OPERATOR,
DATEDIFF('day', DATE '2024-01-01', DATE '2024-06-30') AS DATEDIFF_FOR_COMPARISON;| START_IS_NULL | END_IS_NULL | MINUS_OPERATOR | DATEDIFF_FOR_COMPARISON |
|---|---|---|---|
| NULL | NULL | 181 | 181 |
A NULL on either side gives NULL, not zero and not an error. That is right, and it means a DATEDIFF over a nullable date column will produce NULLs we have to handle.
MINUS_OPERATOR and DATEDIFF_FOR_COMPARISON are the other edge. We can subtract one DATE from another with a plain minus sign, and it gives days. Both columns say 181.
The minus operator is shorter and only ever gives you days. DATEDIFF('day', ...) spells out which unit it means and reads the same as the rest of your date code. Prefer it on anything someone else will maintain.
Let's put both functions in one query
DATEADD for a due date, DATEDIFF for an age in two units, and then the two functions composed so that the answer has to come back to the anchor.
SELECT ORDER_ID, ORDER_DATE,
DATEADD('day', 30, ORDER_DATE) AS DUE,
DATEDIFF('day', ORDER_DATE, DATE '2024-06-30') AS AGE_IN_DAYS,
DATEDIFF('month', ORDER_DATE, DATE '2024-06-30') AS AGE_IN_MONTHS,
DATEADD('day', DATEDIFF('day', ORDER_DATE, DATE '2024-06-30'), ORDER_DATE)
AS ROUND_TRIP_SHOULD_BE_THE_ANCHOR
FROM ORDERS
ORDER BY ORDER_ID
LIMIT 5;| ORDER_ID | ORDER_DATE | DUE | AGE_IN_DAYS | AGE_IN_MONTHS | ROUND_TRIP_SHOULD_BE_THE_ANCHOR |
|---|---|---|---|---|---|
| 1001 | 2023-01-12 | 2023-02-11 | 535 | 17 | 2024-06-30 |
| 1002 | 2023-01-28 | 2023-02-27 | 519 | 17 | 2024-06-30 |
| 1003 | 2023-02-14 | 2023-03-16 | 502 | 16 | 2024-06-30 |
| 1004 | 2023-02-27 | 2023-03-29 | 489 | 16 | 2024-06-30 |
| 1005 | 2023-03-09 | 2023-04-08 | 479 | 15 | 2024-06-30 |
ROUND_TRIP_SHOULD_BE_THE_ANCHOR is the check. Add the measured day difference back onto the order date and we land on 2024-06-30 on every row. In days, DATEADD and DATEDIFF are exact inverses. In months they are not, for exactly the reason the clamp showed us, and that is the one thing from this post worth carrying into the next one.
Key points
DATEADD(part, amount, date)adds; a negative amount subtracts. The part names have short forms —'day','dd','d'and'days'are the same part.- Month arithmetic clamps:
2024-01-31plus a month is2024-02-29, and the same sum in 2023 gives2023-02-28. - Adding a month twice is not adding two months. The clamp throws away the original day of month, so the comparison column reads
FALSE— and a schedule written as “+1 month each time” drifts off month-end for good. - Adding an hour to a
DATEpromotes the result toTIMESTAMP_NTZ. The type changes without a warning. DATE 'literal'andTIMESTAMP 'literal'both parse, andTIMESTAMP 'literal'yields aTIMESTAMP_NTZ— butTIMESTAMP_NTZ 'literal'is a syntax error. Use a cast orTO_TIMESTAMP_NTZ().DATEDIFFcounts boundary crossings, not elapsed time. One day either side of new year is “1 year”; nearly all of one calendar year is “0”.- So
DATEDIFF('year', ...)is the wrong way to compute an age or a tenure — it disagreed with elapsed years on 3 of the 10 customers here. - Argument order is start then end. Reversed, you get a negative number rather than an error.
- A
NULLon either side returnsNULL, anddate - datereturns days, the same asDATEDIFF('day', ...). CURRENT_DATE,CURRENT_TIMESTAMP,LOCALTIMESTAMPandSYSDATEdo not all return the same type, and the clock is read once per statement — 1000 rows produced one distinct timestamp.
Episode 73 — pulling dates apart. DATE_TRUNC, DATE_PART, EXTRACT, LAST_DAY and NEXT_DAY — including a monthly report that runs clean, looks entirely reasonable and silently adds two years together.
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. 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, so these values do not go stale; the one grid that does read the clock is labelled as such and is not used in any calculation. No performance or benchmark claims are made anywhere in this series. Function behaviour follows the Snowflake date & time functions documentation.



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