In this post we are going to cover TO_CHAR, TO_DATE and TRY_TO_DATE in Snowflake.
These three move between text and dates. TO_CHAR takes a date and gives you back text in whatever shape you ask for. TO_DATE goes the other way: you hand it text and the format that text is in, and you get a real date. TRY_TO_DATE is the same thing, except that when the text does not fit it hands you a NULL instead of stopping the query.
We will start with one date and six format strings, and build it up step by step. On the way we will meet a one-character mistake that prints a perfectly plausible time and is wrong on every row, and a date format that can be read two ways without anything telling you which one you got.
Every query below was run on a real Snowflake account, and every result you see is the output it gave back.
Episode 75 of the Snowflake SQL: Zero to Hero series on TechBrothersIT.
- Let's create some sample data
- Let's start with TO_CHAR on a date
- Now let's format a timestamp, and meet the quiet one
- Now let's do the twelve-hour clock
- Careful: TO_CHAR on a number is a different format language
- Now let's format some real data
- Now let's go the other way with TO_DATE
- What happens when the text does not match the format?
- Let's use TRY_TO_DATE instead
- Now the dangerous one: a date that can be read two ways
- How bad is that on our own rows?
- Can we just let Snowflake work the format out?
- Now let's convert the whole column for real
- Why bother? Because sorting text is not sorting dates
- Now let's parse a timestamp, and an epoch
- One last thing: does a round trip survive?
Let's create some sample data
This post works on two tables. SALES_RAW is the deliberately messy one from Level 5, where every column is text. ORDERS is the clean table the whole course uses, and we will come to it near the end.
Copy the script below into a Snowflake worksheet and run it once.
-- Sample data for this post.
-- Run this once. SALES_RAW is deliberately dirty: every column is text, and
-- the date column holds three different date formats on purpose.
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 clean ORDERS table further down this page is the one the whole course
-- uses. You already have it if you have been following along.Now let's look at the messy table.
SELECT ROW_ID, CUSTOMER_TXT, AMOUNT_TXT, DATE_TXT, REGION_TXT
FROM SALES_RAW ORDER BY ROW_ID;| ROW_ID | CUSTOMER_TXT | AMOUNT_TXT | DATE_TXT | REGION_TXT |
|---|---|---|---|---|
| 1 | Northwind Traders | $4,200.00 | 2023-01-12 | us-east |
| 2 | blue ridge supply | 890.50 | 01/28/2023 | US-EAST |
| 3 | KESTREL ANALYTICS | $6,150 | 14-Feb-2023 | ca-west |
| 4 | Harbour Foods | 2,310.75 | 2023-03-09 | CA-West |
| 5 | Alpine Logistics | N/A | 2023-04-05 | eu-central |
| 6 | Delta Print Co | $640.00 | 05/21/2023 | us-east |
| 7 | Meridian Health | 9,250.00 | 14-Aug-2023 | US-East |
| 8 | copper kettle ltd | $1,080 | 2024-09-29 | uk-north |
Eight rows and five columns. The column we care about is DATE_TXT, and it is worth looking at slowly.
Row 1 says 2023-01-12. Row 2 says 01/28/2023. Row 3 says 14-Feb-2023. Three different date shapes, in one column, in a column that is text.
Let's count them properly instead of eyeballing them. One regular expression per shape.
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,
MIN(DATE_TXT) AS AN_EXAMPLE
FROM SALES_RAW
GROUP BY 1
ORDER BY N DESC;| FORMAT | N | AN_EXAMPLE |
|---|---|---|
| ISO yyyy-mm-dd | 4 | 2023-01-12 |
| US mm/dd/yyyy | 2 | 01/28/2023 |
| dd-Mon-yyyy | 2 | 14-Aug-2023 |
Four rows are ISO, two use slashes and two use a month name. That adds up to eight, and the ELSE 'unrecognised' branch never fired, so there is no fourth shape hiding in there.
Have a look at the AN_EXAMPLE column. It is a MIN() over the text, so the month-name row shows 14-Aug-2023 rather than 14-Feb-2023. As text, A sorts before F.
Let's start with TO_CHAR on a date
TO_CHAR takes two things: a date, and a format string that says what the text should look like.
Let's take one date and ask for it six different ways.
SELECT TO_CHAR(DATE '2024-06-30', 'YYYY-MM-DD') AS ISO,
TO_CHAR(DATE '2024-06-30', 'DD/MM/YYYY') AS UK,
TO_CHAR(DATE '2024-06-30', 'MM/DD/YYYY') AS US,
TO_CHAR(DATE '2024-06-30', 'DD-MON-YYYY') AS DD_MON_YYYY,
TO_CHAR(DATE '2024-06-30', 'DY') AS DAY_ABBREV,
TO_CHAR(DATE '2024-06-30', 'MON') AS MONTH_ABBREV;| ISO | UK | US | DD_MON_YYYY | DAY_ABBREV | MONTH_ABBREV |
|---|---|---|---|---|---|
| 2024-06-30 | 30/06/2024 | 06/30/2024 | 30-Jun-2024 | Sun | Jun |
One row, six columns, same date in all of them. YYYY-MM-DD gives the ISO form. DD/MM/YYYY puts the day first and MM/DD/YYYY puts the month first. DD-MON-YYYY spells the month out short.
The last two are the short ones. DY gives the day of the week and MON gives the month, both abbreviated to three letters.
Those are the building blocks. Everything else in this post is made out of them.
Now let's format a timestamp, and meet the quiet one
Dates have no time in them, so for the next bit we need a timestamp. This is where the one mistake I want you to remember lives.
Let's take one timestamp and format the time part two ways, side by side. One uses MM in the middle position and one uses MI.
SELECT '2024-06-30 14:37:52'::TIMESTAMP_NTZ AS ANCHOR,
TO_CHAR('2024-06-30 14:37:52'::TIMESTAMP_NTZ, 'HH24:MM:SS') AS WRONG_MM,
TO_CHAR('2024-06-30 14:37:52'::TIMESTAMP_NTZ, 'HH24:MI:SS') AS RIGHT_MI,
TO_CHAR('2024-06-30 14:37:52'::TIMESTAMP_NTZ, 'YYYY-MM-DD HH24:MI:SS') AS FULL_;| ANCHOR | WRONG_MM | RIGHT_MI | FULL_ |
|---|---|---|---|
| 2024-06-30 14:37:52 | 14:06:52 | 14:37:52 | 2024-06-30 14:37:52 |
Read those two columns against the anchor. The anchor is 14:37:52. The MI version gives 14:37:52, which is right. The MM version gives 14:06:52.
MM is the month. The anchor is in June, so the month is 06, and 06 went straight into the minutes position.
There was no error and no warning. 14:06:52 is a perfectly ordinary time of day. Nothing on the screen tells you it is the month. In a time format, MM is the month and MI is the minutes, and that one character is the difference between a right answer and a wrong one on every single row.
The last column shows the whole thing written out with MM in the date position and MI in the time position, which is where each of them belongs.
Now let's do the twelve-hour clock
HH24 gives the twenty-four hour clock. For the twelve-hour clock we want HH12, and we need AM in the format as well so we can tell morning from afternoon.
Let's run three different times through it, including one just after midnight.
SELECT TO_CHAR('2024-06-30 14:37:52'::TIMESTAMP_NTZ, 'HH24:MI') AS H24,
TO_CHAR('2024-06-30 14:37:52'::TIMESTAMP_NTZ, 'HH12:MI AM') AS H12,
TO_CHAR('2024-06-30 09:05:00'::TIMESTAMP_NTZ, 'HH12:MI AM') AS MORNING,
TO_CHAR('2024-06-30 00:30:00'::TIMESTAMP_NTZ, 'HH12:MI AM') AS AFTER_MIDNIGHT;| H24 | H12 | MORNING | AFTER_MIDNIGHT |
|---|---|---|---|
| 14:37 | 02:37 PM | 09:05 AM | 12:30 AM |
The afternoon time comes out as 02:37 PM and the morning one as 09:05 AM. Both read the way you would say them.
The last column is the one worth knowing. Half past midnight comes back as 12:30 AM, not 00:30. On a twelve-hour clock the hour after midnight is twelve, and AM is what tells you which twelve it is.
Careful: TO_CHAR on a number is a different format language
TO_CHAR also works on numbers, and the format string means something completely different there. This catches people who have just learned the date elements.
Let's put one number through three number masks, and put a date format beside them for comparison.
SELECT TO_CHAR(1234.5678, '9999.99') AS NUMBER_FORMAT,
TO_CHAR(1234.5678, '0000.0000') AS ZERO_PADDED,
TO_CHAR(1234.5678, '9,999.99') AS WITH_SEPARATOR,
TO_CHAR(DATE '2024-06-30', 'YYYY') AS DATE_FORMAT_FOR_COMPARISON;| NUMBER_FORMAT | ZERO_PADDED | WITH_SEPARATOR | DATE_FORMAT_FOR_COMPARISON |
|---|---|---|---|
| 1234.57 | 1234.5678 | 1,234.57 | 2024 |
9999.99 gave us 1234.57, rounded to two decimal places. 0000.0000 gave us 1234.5678, which keeps four. So the thing that changed between those two columns is how many decimals the mask asked for.
Nothing got padded in this grid, and it is worth saying so. 1234 already fills the four digits in front of the point, so the zeroes in 0000.0000 had nothing left to do. You would need a shorter number to see padding happen.
The third mask, 9,999.99, puts the thousands separator in. And the last column is a reminder: YYYY on a date gives the year, while in a number mask the only characters that mean anything are the digit characters and the punctuation.
Do not carry your date format knowledge across to numbers. MM, DD and YYYY mean nothing in a number mask, and 9 and 0 mean nothing in a date mask. They are two separate format models that happen to share a function name.
Now let's format some real data
That is enough on format strings in the abstract. Let's put them on the ORDERS table and make a column you could actually put in a report.
SELECT ORDER_ID, AMOUNT,
TO_CHAR(AMOUNT, '9,999.00') AS WITH_COMMAS,
TO_VARCHAR(AMOUNT) AS PLAIN,
TO_CHAR(ORDER_DATE, 'DY DD MON YYYY') AS READABLE_DATE
FROM ORDERS
ORDER BY ORDER_ID
LIMIT 10;| ORDER_ID | AMOUNT | WITH_COMMAS | PLAIN | READABLE_DATE |
|---|---|---|---|---|
| 1001 | 4200.00 | 4,200.00 | 4200.00 | Thu 12 Jan 2023 |
| 1002 | 890.50 | 890.50 | 890.50 | Sat 28 Jan 2023 |
| 1003 | 6150.00 | 6,150.00 | 6150.00 | Tue 14 Feb 2023 |
| 1004 | 1500.00 | 1,500.00 | 1500.00 | Mon 27 Feb 2023 |
| 1005 | 2310.75 | 2,310.75 | 2310.75 | Thu 09 Mar 2023 |
| 1006 | 5020.00 | 5,020.00 | 5020.00 | Wed 22 Mar 2023 |
| 1007 | 7890.00 | 7,890.00 | 7890.00 | Wed 05 Apr 2023 |
| 1008 | 1120.00 | 1,120.00 | 1120.00 | Wed 19 Apr 2023 |
| 1009 | 3340.25 | 3,340.25 | 3340.25 | Tue 02 May 2023 |
| 1010 | 640.00 | 640.00 | 640.00 | Sun 21 May 2023 |
Ten rows. WITH_COMMAS is the amount through a number mask and PLAIN is TO_VARCHAR with no mask at all, so you can see what the mask is buying you.
Look at the 890.50 row. The mask has a comma in it, but the number does not need one, so no comma appears. A separator in the mask is permission, not an instruction.
The last column is the date through DY DD MON YYYY. That is a report column. Nobody has to think about it to read it.
Now let's go the other way with TO_DATE
TO_DATE is the reverse. We give it text and the format that text is in, and it gives us a real date back.
Let's start with the easy ones. The ISO rows, read with the ISO format.
SELECT ROW_ID, DATE_TXT,
TO_DATE(DATE_TXT, 'YYYY-MM-DD') AS PARSED
FROM SALES_RAW
WHERE REGEXP_LIKE(DATE_TXT, '[0-9]{4}-[0-9]{2}-[0-9]{2}')
ORDER BY ROW_ID;| ROW_ID | DATE_TXT | PARSED |
|---|---|---|
| 1 | 2023-01-12 | 2023-01-12 |
| 4 | 2023-03-09 | 2023-03-09 |
| 5 | 2023-04-05 | 2023-04-05 |
| 8 | 2024-09-29 | 2024-09-29 |
Four rows, and all four parsed. The PARSED column looks the same as the text column here, but it is not text any more. It is a DATE.
Now the month-name rows, with the format they are actually in.
SELECT ROW_ID, DATE_TXT,
TO_DATE(DATE_TXT, 'DD-MON-YYYY') AS PARSED
FROM SALES_RAW
WHERE REGEXP_LIKE(DATE_TXT, '[0-9]{2}-[A-Za-z]{3}-[0-9]{4}')
ORDER BY ROW_ID;| ROW_ID | DATE_TXT | PARSED |
|---|---|---|
| 3 | 14-Feb-2023 | 2023-02-14 |
| 7 | 14-Aug-2023 | 2023-08-14 |
Two rows, and both parsed. 14-Feb-2023 came back as 2023-02-14. MON reads the three-letter month on the way in just as it writes it on the way out.
So when the format matches the text, this is a dull function. Good. Now let's break it.
What happens when the text does not match the format?
Row 5 of our table has N/A in the amount column, and plenty of real tables have that sort of thing in a date column too. Let's hand TO_DATE something that is not a date at all.
SELECT TO_DATE('N/A', 'YYYY-MM-DD') AS SHOULD_NOT_GET_THIS_FAR;100097 (22007): Can't parse 'N/A' as date with format 'YYYY-MM-DD'
That is a good error. It names the value it could not read and it names the format it was trying to read it with. You do not have to guess at anything.
Now let's try real date text, in the wrong format. The text is a genuine date and the format is a genuine format. They just do not match each other.
SELECT TO_DATE('01/28/2023', 'YYYY-MM-DD') AS SHOULD_NOT_GET_THIS_FAR;100097 (22007): Can't parse '01/28/2023' as date with format 'YYYY-MM-DD'
Same shape of error, same useful detail. And here is the third one, which is the same text read day-first instead.
SELECT TO_DATE('01/28/2023', 'DD/MM/YYYY') AS SHOULD_NOT_GET_THIS_FAR;100097 (22007): Can't parse '01/28/2023' as date with format 'DD/MM/YYYY'
Read day-first, 01/28/2023 asks for month 28, and there is no such month, so Snowflake refuses. Hold on to this one. It is going to matter in a few sections.
All three of those stopped the whole query. One bad value in one row and you get no rows back at all, not even the good ones. On a load, that is almost never what you want.
Let's use TRY_TO_DATE instead
TRY_TO_DATE takes the same arguments as TO_DATE. The difference is what it does when the text does not fit. Instead of throwing, it gives you NULL.
Let's put all four cases through it in one query: rubbish, the wrong format, text that is correct, and a NULL going in.
SELECT TRY_TO_DATE('N/A', 'YYYY-MM-DD') AS RUBBISH,
TRY_TO_DATE('01/28/2023', 'YYYY-MM-DD') AS WRONG_FORMAT,
TRY_TO_DATE('2023-01-12', 'YYYY-MM-DD') AS CORRECT,
TRY_TO_DATE(NULL::VARCHAR, 'YYYY-MM-DD') AS NULL_INPUT;| RUBBISH | WRONG_FORMAT | CORRECT | NULL_INPUT |
|---|---|---|---|
| NULL | NULL | 2023-01-12 | NULL |
Rubbish gives NULL. The wrong format gives NULL. The correct text gives a real date. And NULL in gives NULL out.
The query finished, and we can see which value was the problem instead of losing the whole result to it.
And it checks the calendar, not just the shape
This is the part people do not expect. TRY_TO_DATE is not pattern matching. It knows what a date is.
Let's give it four dates that look perfectly well formed and see which ones it accepts.
SELECT TRY_TO_DATE('2023-02-30', 'YYYY-MM-DD') AS FEB_30,
TRY_TO_DATE('2023-02-29', 'YYYY-MM-DD') AS FEB_29_NON_LEAP,
TRY_TO_DATE('2024-02-29', 'YYYY-MM-DD') AS FEB_29_LEAP,
TRY_TO_DATE('2023-13-01', 'YYYY-MM-DD') AS MONTH_13;| FEB_30 | FEB_29_NON_LEAP | FEB_29_LEAP | MONTH_13 |
|---|---|---|---|
| NULL | NULL | 2024-02-29 | NULL |
The 30th of February is NULL. The 29th of February 2023 is NULL, because 2023 is not a leap year. The 29th of February 2024 comes back as a real date, because that year has one. And month 13 is NULL.
All four of those match YYYY-MM-DD perfectly. They were rejected on the calendar, not on the shape.
Now the dangerous one: a date that can be read two ways
Everything so far has either worked or told us it did not. This section is about the case where it does neither.
Read this text: 01/02/2023. Is that the 2nd of January, or the 1st of February?
It is both. Let's read it both ways in one query and compare the answers.
SELECT '01/02/2023' AS THE_TEXT,
TO_DATE('01/02/2023', 'MM/DD/YYYY') AS READ_AS_US,
TO_DATE('01/02/2023', 'DD/MM/YYYY') AS READ_AS_UK,
TO_DATE('01/02/2023', 'MM/DD/YYYY') = TO_DATE('01/02/2023', 'DD/MM/YYYY')
AS SAME_DAY;| THE_TEXT | READ_AS_US | READ_AS_UK | SAME_DAY |
|---|---|---|---|
| 01/02/2023 | 2023-01-02 | 2023-02-01 | FALSE |
Month-first gives 2023-01-02. Day-first gives 2023-02-01. The comparison column says FALSE.
Two different days, out of one piece of text, and both queries succeeded. Nothing failed, so nothing told us.
Now let's put that next to the one that does fail, so the difference is visible in a single grid.
SELECT TRY_TO_DATE('01/28/2023', 'MM/DD/YYYY') AS UNAMBIGUOUS_US,
TRY_TO_DATE('01/28/2023', 'DD/MM/YYYY') AS UNAMBIGUOUS_UK_IS_NULL,
TRY_TO_DATE('01/02/2023', 'MM/DD/YYYY') AS AMBIGUOUS_US,
TRY_TO_DATE('01/02/2023', 'DD/MM/YYYY') AS AMBIGUOUS_UK,
TRY_TO_DATE('01/02/2023', 'MM/DD/YYYY')
= TRY_TO_DATE('01/02/2023', 'DD/MM/YYYY') AS SAME_DAY;| UNAMBIGUOUS_US | UNAMBIGUOUS_UK_IS_NULL | AMBIGUOUS_US | AMBIGUOUS_UK | SAME_DAY |
|---|---|---|---|---|
| 2023-01-28 | NULL | 2023-01-02 | 2023-02-01 | FALSE |
01/28/2023 read month-first is the 28th of January. Read day-first it is NULL, because there is no 28th month. That one fails loudly.
01/02/2023 parses both ways and quietly hands you a different day, and the SAME_DAY column is FALSE.
A day above 12 protects you, because the wrong reading asks for a month that does not exist and Snowflake refuses it. A day of 12 or under does not protect you at all. It parses either way and gives you another date.
How bad is that on our own rows?
Let's not leave that as a scary story. We have two slash-format rows in the table. Let's read them both ways and look at what happens.
SELECT ROW_ID, DATE_TXT,
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY') AS PARSED_AS_US,
TRY_TO_DATE(DATE_TXT, 'DD/MM/YYYY') AS PARSED_AS_UK
FROM SALES_RAW
WHERE REGEXP_LIKE(DATE_TXT, '[0-9]{2}/[0-9]{2}/[0-9]{4}')
ORDER BY ROW_ID;| ROW_ID | DATE_TXT | PARSED_AS_US | PARSED_AS_UK |
|---|---|---|---|
| 2 | 01/28/2023 | 2023-01-28 | NULL |
| 6 | 05/21/2023 | 2023-05-21 | NULL |
Both of them parse month-first. Neither of them parses day-first, and both day-first columns are NULL.
So let's put a count on it rather than reading two rows and calling it done.
SELECT COUNT(*) AS SLASH_FORMAT_ROWS,
COUNT(TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY')) AS US_PARSED,
COUNT(TRY_TO_DATE(DATE_TXT, 'DD/MM/YYYY')) AS UK_PARSED,
COUNT_IF(TRY_TO_DATE(DATE_TXT, 'DD/MM/YYYY') IS NULL) AS UK_REJECTED_LOUDLY,
COUNT_IF(TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY') IS NOT NULL
AND TRY_TO_DATE(DATE_TXT, 'DD/MM/YYYY') IS NOT NULL
AND TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY')
<> TRY_TO_DATE(DATE_TXT, 'DD/MM/YYYY')) AS SILENTLY_DIFFERENT
FROM SALES_RAW
WHERE REGEXP_LIKE(DATE_TXT, '[0-9]{2}/[0-9]{2}/[0-9]{4}');| SLASH_FORMAT_ROWS | US_PARSED | UK_PARSED | UK_REJECTED_LOUDLY | SILENTLY_DIFFERENT |
|---|---|---|---|---|
| 2 | 2 | 0 | 2 | 0 |
Two slash-format rows. Both parsed month-first. Two were rejected loudly when read day-first, and zero were silently different.
That zero is luck, not safety. Both of those days are 28 and 21, and both are above 12, so the wrong reading had nowhere to go. One row dated on the 5th of something and it would have parsed both ways with nothing to show for it. The count is worth running. It is not worth trusting as a property of your pipeline.
Can we just let Snowflake work the format out?
Yes, and it is better at it than you might expect. There is a session parameter that controls this, so let's look at it before we use it.
SHOW PARAMETERS LIKE 'DATE_INPUT_FORMAT' IN SESSION;| key | value | default | level | description | type |
|---|---|---|---|---|---|
| DATE_INPUT_FORMAT | AUTO | AUTO | input format for date | STRING |
The value is AUTO, and AUTO is also the default. So on a fresh session this is what you already have.
That means we can call TRY_TO_DATE with no format at all. Let's do that on the whole column, all three shapes together.
SELECT ROW_ID, DATE_TXT,
TRY_TO_DATE(DATE_TXT) AS AUTO_PARSED
FROM SALES_RAW
ORDER BY ROW_ID;| ROW_ID | DATE_TXT | AUTO_PARSED |
|---|---|---|
| 1 | 2023-01-12 | 2023-01-12 |
| 2 | 01/28/2023 | 2023-01-28 |
| 3 | 14-Feb-2023 | 2023-02-14 |
| 4 | 2023-03-09 | 2023-03-09 |
| 5 | 2023-04-05 | 2023-04-05 |
| 6 | 05/21/2023 | 2023-05-21 |
| 7 | 14-Aug-2023 | 2023-08-14 |
| 8 | 2024-09-29 | 2024-09-29 |
Every row parsed. ISO, slashes and month names, all handled, with no format string anywhere in the query.
Let's put the comparison in one row, so we can see what AUTO does that a named format does not.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT(TRY_TO_DATE(DATE_TXT)) AS AUTO_SUCCEEDED,
COUNT(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD')) AS ISO_FORMAT_SUCCEEDED,
COUNT(TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY')) AS US_FORMAT_SUCCEEDED,
COUNT(TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY')) AS MON_FORMAT_SUCCEEDED
FROM SALES_RAW;| ROWS_TOTAL | AUTO_SUCCEEDED | ISO_FORMAT_SUCCEEDED | US_FORMAT_SUCCEEDED | MON_FORMAT_SUCCEEDED |
|---|---|---|---|---|
| 8 | 8 | 4 | 2 | 2 |
AUTO got all eight. The ISO format got four, the month-first slash format got two, and the month-name format got two. Each named format handles its own rows and nothing else.
AUTO is convenient and it is also what decides an ambiguous date for you, without writing the decision down anywhere. On anything you care about, name the format. Then the decision is yours and it is visible in the query.
Now let's convert the whole column for real
Here is the pattern that actually ships. COALESCE over three TRY_TO_DATE calls, one per format, in the order we want them tried. The first one that works wins.
SELECT ROW_ID, DATE_TXT,
COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY')) AS THE_DATE
FROM SALES_RAW
ORDER BY ROW_ID;| ROW_ID | DATE_TXT | THE_DATE |
|---|---|---|
| 1 | 2023-01-12 | 2023-01-12 |
| 2 | 01/28/2023 | 2023-01-28 |
| 3 | 14-Feb-2023 | 2023-02-14 |
| 4 | 2023-03-09 | 2023-03-09 |
| 5 | 2023-04-05 | 2023-04-05 |
| 6 | 05/21/2023 | 2023-05-21 |
| 7 | 14-Aug-2023 | 2023-08-14 |
| 8 | 2024-09-29 | 2024-09-29 |
All eight rows converted. And because we chose the order ourselves, we know which rule matched each row rather than trusting AUTO to pick.
Let's count that instead of eyeballing eight rows.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT(COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY'))) AS CONVERTED,
COUNT(*) - COUNT(COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY'))) AS STILL_TEXT
FROM SALES_RAW;| ROWS_TOTAL | CONVERTED | STILL_TEXT |
|---|---|---|
| 8 | 8 | 0 |
Eight rows in, eight converted, nothing left as text.
And now that it is a real date, everything from the last three posts works on it. Let's put a day name and a month start beside it.
SELECT ROW_ID, DATE_TXT,
COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY')) AS THE_DATE,
DAYNAME(COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY'))) AS DAY_NAME,
DATE_TRUNC('month', COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY'))) AS MONTH_START
FROM SALES_RAW
ORDER BY THE_DATE;| ROW_ID | DATE_TXT | THE_DATE | DAY_NAME | MONTH_START |
|---|---|---|---|---|
| 1 | 2023-01-12 | 2023-01-12 | Thu | 2023-01-01 |
| 2 | 01/28/2023 | 2023-01-28 | Sat | 2023-01-01 |
| 3 | 14-Feb-2023 | 2023-02-14 | Tue | 2023-02-01 |
| 4 | 2023-03-09 | 2023-03-09 | Thu | 2023-03-01 |
| 5 | 2023-04-05 | 2023-04-05 | Wed | 2023-04-01 |
| 6 | 05/21/2023 | 2023-05-21 | Sun | 2023-05-01 |
| 7 | 14-Aug-2023 | 2023-08-14 | Mon | 2023-08-01 |
| 8 | 2024-09-29 | 2024-09-29 | Sun | 2024-09-01 |
DAYNAME and DATE_TRUNC on a column that was text a minute ago. Nothing special had to happen. It is a date now, so the date functions work.
Why bother? Because sorting text is not sorting dates
Here is the reason this matters even when nothing fails. Let's number the rows two ways in one query: once ordered by the text, once ordered by the parsed date.
SELECT ROW_ID, DATE_TXT,
ROW_NUMBER() OVER (ORDER BY DATE_TXT) AS TEXT_ORDER,
ROW_NUMBER() OVER (ORDER BY COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY'))) AS DATE_ORDER
FROM SALES_RAW
ORDER BY DATE_ORDER;| ROW_ID | DATE_TXT | TEXT_ORDER | DATE_ORDER |
|---|---|---|---|
| 1 | 2023-01-12 | 5 | 1 |
| 2 | 01/28/2023 | 1 | 2 |
| 3 | 14-Feb-2023 | 4 | 3 |
| 4 | 2023-03-09 | 6 | 4 |
| 5 | 2023-04-05 | 7 | 5 |
| 6 | 05/21/2023 | 2 | 6 |
| 7 | 14-Aug-2023 | 3 | 7 |
| 8 | 2024-09-29 | 8 | 8 |
Look at the first row. Position 5 as text, position 1 as a date. As text, 01/28 sorts before 2023, because 0 comes before 2.
Let's count how many rows moved.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT_IF(T.TEXT_ORDER <> T.DATE_ORDER) AS IN_A_DIFFERENT_POSITION
FROM (SELECT ROW_NUMBER() OVER (ORDER BY DATE_TXT) AS TEXT_ORDER,
ROW_NUMBER() OVER (ORDER BY COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY'))) AS DATE_ORDER
FROM SALES_RAW) T;| ROWS_TOTAL | IN_A_DIFFERENT_POSITION |
|---|---|
| 8 | 7 |
Eight rows, and seven of them sit in a different position. Only the last row happens to land in the same place in both orderings.
That is the whole argument for converting. A date-shaped string sorts alphabetically, and alphabetical is not chronological unless every row in the column happens to be ISO.
Now let's parse a timestamp, and an epoch
TO_TIMESTAMP and TRY_TO_TIMESTAMP work exactly like the date pair, with time elements in the format as well.
SELECT TO_TIMESTAMP('2024-06-30 14:37:52', 'YYYY-MM-DD HH24:MI:SS') AS PARSED,
TO_TIMESTAMP('30/06/2024 02:37 PM', 'DD/MM/YYYY HH12:MI AM') AS TWELVE_HOUR,
TRY_TO_TIMESTAMP('2024-06-30', 'YYYY-MM-DD') AS DATE_ONLY,
TRY_TO_TIMESTAMP('nonsense', 'YYYY-MM-DD HH24:MI:SS') AS RUBBISH;| PARSED | TWELVE_HOUR | DATE_ONLY | RUBBISH |
|---|---|---|---|
| 2024-06-30 14:37:52 | 2024-06-30 14:37:00 | 2024-06-30 00:00:00 | NULL |
The first column parsed the full timestamp. The second used the twelve-hour clock, and because the format has no SS in it the seconds came back as 00. The third gave a timestamp at midnight from date-only text. And the last one is rubbish, so TRY_TO_TIMESTAMP gave NULL.
TO_TIMESTAMP has one more trick. Hand it a number instead of text and it reads it as seconds since the epoch.
SELECT TO_TIMESTAMP(0) AS EPOCH_ZERO,
TO_TIMESTAMP(1719757072) AS EPOCH_SECONDS,
TO_TIMESTAMP(1719757072000, 3) AS EPOCH_MILLIS,
DATE_PART('epoch_second', '2024-06-30 14:37:52'::TIMESTAMP_NTZ) AS BACK_TO_EPOCH;| EPOCH_ZERO | EPOCH_SECONDS | EPOCH_MILLIS | BACK_TO_EPOCH |
|---|---|---|---|
| 1970-01-01 00:00:00 | 2024-06-30 14:17:52 | 2024-06-30 14:17:52 | 1719758272 |
TO_TIMESTAMP(0) is the start of 1970, which is where the counting begins. The second and third columns are the same instant written two ways, once in seconds and once in milliseconds with a scale argument of 3.
The last column goes back the other way, turning a timestamp into an epoch second. Notice it does not match the number in the second column — those are two different instants, twenty minutes apart. The epoch literal in this query is not the epoch of the timestamp beside it.
One last thing: does a round trip survive?
If we format a date to text and then parse that text straight back, do we get the same date? Let's not assume. Let's check it on the clean ORDERS table.
SELECT ORDER_ID, ORDER_DATE,
TO_CHAR(ORDER_DATE, 'DD-MON-YYYY') AS FORMATTED,
TO_DATE(TO_CHAR(ORDER_DATE, 'DD-MON-YYYY'), 'DD-MON-YYYY') AS BACK_AGAIN,
ORDER_DATE = TO_DATE(TO_CHAR(ORDER_DATE, 'DD-MON-YYYY'), 'DD-MON-YYYY')
AS SURVIVED
FROM ORDERS
ORDER BY ORDER_ID
LIMIT 10;| ORDER_ID | ORDER_DATE | FORMATTED | BACK_AGAIN | SURVIVED |
|---|---|---|---|---|
| 1001 | 2023-01-12 | 12-Jan-2023 | 2023-01-12 | TRUE |
| 1002 | 2023-01-28 | 28-Jan-2023 | 2023-01-28 | TRUE |
| 1003 | 2023-02-14 | 14-Feb-2023 | 2023-02-14 | TRUE |
| 1004 | 2023-02-27 | 27-Feb-2023 | 2023-02-27 | TRUE |
| 1005 | 2023-03-09 | 09-Mar-2023 | 2023-03-09 | TRUE |
| 1006 | 2023-03-22 | 22-Mar-2023 | 2023-03-22 | TRUE |
| 1007 | 2023-04-05 | 05-Apr-2023 | 2023-04-05 | TRUE |
| 1008 | 2023-04-19 | 19-Apr-2023 | 2023-04-19 | TRUE |
| 1009 | 2023-05-02 | 02-May-2023 | 2023-05-02 | TRUE |
| 1010 | 2023-05-21 | 21-May-2023 | 2023-05-21 | TRUE |
The SURVIVED column is TRUE on every row of the grid. Out and back through DD-MON-YYYY and nothing moved.
That grid is only the first part of the table though, so let's check all of it with a count.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT_IF(ORDER_DATE = TO_DATE(TO_CHAR(ORDER_DATE, 'DD-MON-YYYY'), 'DD-MON-YYYY'))
AS SURVIVED,
COUNT_IF(ORDER_DATE <> TO_DATE(TO_CHAR(ORDER_DATE, 'YYYY-MM-DD'), 'YYYY-MM-DD'))
AS ISO_ROUND_TRIP_FAILURES
FROM ORDERS;| ROWS_TOTAL | SURVIVED | ISO_ROUND_TRIP_FAILURES |
|---|---|---|
| 41 | 41 | 0 |
Forty-one orders, forty-one survived, and the ISO round trip had no failures either.
So a round trip through a format you named is safe. A round trip through AUTO is a round trip through a guess, and on an ambiguous date the guess is the whole answer.
Let's finish with one grid that does the whole job: raw text in, a real date in the middle, and two formatted columns out.
SELECT ROW_ID, DATE_TXT,
COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY')) AS THE_DATE,
TO_CHAR(COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY')), 'DY DD MON YYYY') AS READABLE,
TO_CHAR(COALESCE(TRY_TO_DATE(DATE_TXT, 'YYYY-MM-DD'),
TRY_TO_DATE(DATE_TXT, 'MM/DD/YYYY'),
TRY_TO_DATE(DATE_TXT, 'DD-MON-YYYY')), 'YYYY-MM-DD') AS NORMALISED
FROM SALES_RAW
ORDER BY ROW_ID;| ROW_ID | DATE_TXT | THE_DATE | READABLE | NORMALISED |
|---|---|---|---|---|
| 1 | 2023-01-12 | 2023-01-12 | Thu 12 Jan 2023 | 2023-01-12 |
| 2 | 01/28/2023 | 2023-01-28 | Sat 28 Jan 2023 | 2023-01-28 |
| 3 | 14-Feb-2023 | 2023-02-14 | Tue 14 Feb 2023 | 2023-02-14 |
| 4 | 2023-03-09 | 2023-03-09 | Thu 09 Mar 2023 | 2023-03-09 |
| 5 | 2023-04-05 | 2023-04-05 | Wed 05 Apr 2023 | 2023-04-05 |
| 6 | 05/21/2023 | 2023-05-21 | Sun 21 May 2023 | 2023-05-21 |
| 7 | 14-Aug-2023 | 2023-08-14 | Mon 14 Aug 2023 | 2023-08-14 |
| 8 | 2024-09-29 | 2024-09-29 | Sun 29 Sep 2024 | 2024-09-29 |
Three date shapes in, one normalised date out, and a readable version beside it. That is the shape of the work: parse once into a real date, then format for whoever is reading.
Key points
TO_CHARturns a date into text.TO_DATEturns text into a date. Both take a format string.- In a time format,
MMis the month andMIis the minutes. Getting that wrong gives you a plausible clock time, no error, and a wrong answer on every row. HH12needsAMin the format with it, and the half hour after midnight reads12:30 AM.TO_CHARon a number uses a completely different format language. Date elements mean nothing there.TO_DATEthrows when the text does not fit, which stops the whole query.TRY_TO_DATEreturnsNULLinstead.TRY_TO_DATEchecks the calendar as well as the shape. The 30th of February isNULL, and so is the 29th of February in a year that does not have one.- A slash date with a day above 12 fails loudly when you read it the wrong way round. A day of 12 or under does not. It parses and gives you a different day.
- On our two slash-format rows, zero were silently misread — but only because both of those days are above 12. That is luck, not a property of the pipeline.
DATE_INPUT_FORMATisAUTOby default, andAUTOparsed all eight of our rows while each named format parsed only its own. Naming the format is how you keep the decision.COALESCEover severalTRY_TO_DATEcalls converts a mixed column and lets you choose the order the rules are tried in.- Sorting date-shaped text is not sorting dates. Seven of our eight rows landed in a different position.
- A round trip through a named format survived on all 41 orders. A round trip through
AUTOis a round trip through a guess.
Episode 76 — time zones. TIMESTAMP_NTZ, TIMESTAMP_LTZ and TIMESTAMP_TZ, what the session time zone actually controls, and one cast that moves your data without saying a word.
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 SALES_RAW and ORDERS data is invented demo data created by the setup script above. Every date, clock time and weekday named in the text is read off a captured column rather than worked out here. Nothing on this page is timed and no performance claims are made anywhere in this series. Function behaviour follows the Snowflake conversion functions documentation.



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