In this post we are going to cover getting values out of a VARIANT with a type: the double colon, TO_VARCHAR, TRY_TO_NUMBER, and why the string "1" is not the number 1.
In the last post we learned how to reach a value inside a document. A path on its own gives you back a VARIANT, not a number and not a string. Casting is how you say what type you want, and the double colon is how you write it.
We will start with the three ways to get text out and build it up step by step. On the way we will meet a column whose MAX is not its largest value, a filter that agrees with the cast version half the time, and a total that changed without anything failing.
Every query below was run on a real Snowflake account, and every result you see is the output it gave back.
Episode 89 of the Snowflake SQL: Zero to Hero series on TechBrothersIT.
- Let's create some sample data
- Let's start with the three ways to get text out
- Now let's sort that column without a cast
- Now let's sort the same rows with a cast
- So what does that do to MIN and MAX?
- And what does it do to a threshold filter?
- Let's shrink that down to the smallest possible example
- So before you write a cast, find out what you are casting
- Now let's try a cast that cannot possibly work
- Now let's see what the safe form costs
- While we are here: does a cast round or chop?
- Too few decimals is quiet. Too few digits is not
- What about dates out of a VARIANT?
- Now booleans, and these are backwards
- So what does the number 1 do?
- How do we get round that?
- One last thing: the other casts, in both directions
Let's create some sample data
Everything on this page runs against the same four documents we have been using, plus one table of amounts written as plain text.
-- Both tables come from the setup script this course uses throughout.
-- Run it once and every post in this level works against the same data.
USE DATABASE DEMO_DB;
USE SCHEMA PUBLIC;
-- EVENTS_JSON - four JSON documents in a VARIANT column.
-- SALES_RAW - the same kind of amounts, but written as TEXT by hand,
-- with currency symbols and thousands separators in them.Let's start by looking at one path on all four rows, with TYPEOF beside it so we can see what we are dealing with.
SELECT EVENT_ID,
PAYLOAD:amount AS AMOUNT_RAW,
TYPEOF(PAYLOAD:amount) AS ITS_TYPE
FROM EVENTS_JSON
ORDER BY EVENT_ID;| EVENT_ID | AMOUNT_RAW | ITS_TYPE |
|---|---|---|
| 1 | 4200 | DECIMAL |
| 2 | 890.5 | DECIMAL |
| 3 | "1500.00" | VARCHAR |
| 4 | 9250 | INTEGER |
One path, three different types. Two rows are DECIMAL, one is INTEGER, and one is VARCHAR.
Look at the third row. The value prints with double quotes around it, because whoever wrote that document wrote the amount as a string. That is not a mistake in our sample data. It is the single most common thing you will find in a real JSON column, and the whole post comes out of it.
Let's start with the three ways to get text out
To get a value out with a type you cast it: two colons and a type name. Text is the interesting one, because there are three ways to ask for it and they do not all agree.
Let's run ::VARCHAR, TO_VARCHAR and TO_JSON side by side, with the length of two of the answers.
SELECT EVENT_ID,
PAYLOAD:amount AS AS_VARIANT,
PAYLOAD:amount::VARCHAR AS CAST_TO_VARCHAR,
TO_VARCHAR(PAYLOAD:amount) AS TO_VARCHAR_,
TO_JSON(PAYLOAD:amount) AS TO_JSON_,
LENGTH(PAYLOAD:amount::VARCHAR) AS CAST_LENGTH,
LENGTH(TO_JSON(PAYLOAD:amount)) AS JSON_LENGTH
FROM EVENTS_JSON
ORDER BY EVENT_ID;| EVENT_ID | AS_VARIANT | CAST_TO_VARCHAR | TO_VARCHAR_ | TO_JSON_ | CAST_LENGTH | JSON_LENGTH |
|---|---|---|---|---|---|---|
| 1 | 4200 | 4200 | 4200 | 4200 | 4 | 4 |
| 2 | 890.5 | 890.5 | 890.5 | 890.5 | 5 | 5 |
| 3 | "1500.00" | 1500.00 | 1500.00 | "1500.00" | 7 | 9 |
| 4 | 9250 | 9250 | 9250 | 9250 | 4 | 4 |
On the plain numbers all three gave the same text and the two lengths match. Now look at the third row, where the value was already a string. The cast gave us seven characters and TO_JSON gave us nine.
Those two extra characters are the double quotes. In JSON the quotes are what make something a string, so TO_JSON keeps them.
Let's see the same thing on a value that really is text, and compare the two answers directly.
SELECT PAYLOAD:user.name AS AS_VARIANT,
PAYLOAD:user.name::VARCHAR AS CAST_TO_VARCHAR,
TO_VARCHAR(PAYLOAD:user.name) AS TO_VARCHAR_,
TO_JSON(PAYLOAD:user.name) AS TO_JSON_,
PAYLOAD:user.name::VARCHAR = TO_JSON(PAYLOAD:user.name) AS CAST_EQUALS_JSON
FROM EVENTS_JSON
WHERE EVENT_ID = 1;| AS_VARIANT | CAST_TO_VARCHAR | TO_VARCHAR_ | TO_JSON_ | CAST_EQUALS_JSON |
|---|---|---|---|---|
| "Northwind Traders" | Northwind Traders | Northwind Traders | "Northwind Traders" | FALSE |
The cast drops the quotes. TO_VARCHAR drops the quotes. TO_JSON keeps them, and the last column confirms the two results are not equal.
Cast to VARCHAR when you want the value inside. Use TO_JSON when you want JSON text. They are two different jobs and the difference only shows up on strings, which is why it is so easy to get away with the wrong one for a while.
Now let's sort that column without a cast
Here is the part I want you to leave this post with. Let's order those four rows by the path itself, with no cast on it at all, and read the values down the page.
SELECT EVENT_ID,
PAYLOAD:amount AS AMOUNT_RAW,
TYPEOF(PAYLOAD:amount) AS ITS_TYPE
FROM EVENTS_JSON
ORDER BY PAYLOAD:amount;| EVENT_ID | AMOUNT_RAW | ITS_TYPE |
|---|---|---|
| 2 | 890.5 | DECIMAL |
| 1 | 4200 | DECIMAL |
| 4 | 9250 | INTEGER |
| 3 | "1500.00" | VARCHAR |
That is not a number sort. The smallest value came first and then it climbs, and then the last row is smaller than the one above it.
Now read the type column instead of the value column and it stops being mysterious. The two DECIMAL rows come first, then the INTEGER, then the VARCHAR.
An uncast VARIANT compares by type first, and only uses the value to break ties inside a type. That one sentence explains the sort, the MAX and the filter in the next three sections. It is the single most useful thing to know about a VARIANT.
Now let's sort the same rows with a cast
Same four rows, same order by, and this time we cast the path to a number first.
SELECT EVENT_ID,
PAYLOAD:amount::NUMBER(12,2) AS AMOUNT_CAST,
TYPEOF(PAYLOAD:amount) AS ITS_TYPE
FROM EVENTS_JSON
ORDER BY PAYLOAD:amount::NUMBER(12,2);| EVENT_ID | AMOUNT_CAST | ITS_TYPE |
|---|---|---|
| 2 | 890.50 | DECIMAL |
| 3 | 1500.00 | VARCHAR |
| 1 | 4200.00 | DECIMAL |
| 4 | 9250.00 | INTEGER |
Now they behave. The values climb from smallest to largest and the type column is irrelevant, because every value being compared is a number.
Let's count how much actually moved between those two orderings, rather than eyeballing it.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT_IF(UNCAST_POS = CAST_POS) AS ROWS_IN_THE_SAME_PLACE,
COUNT_IF(UNCAST_POS <> CAST_POS) AS ROWS_THAT_MOVED
FROM (SELECT EVENT_ID,
ROW_NUMBER() OVER (ORDER BY PAYLOAD:amount) AS UNCAST_POS,
ROW_NUMBER() OVER (ORDER BY PAYLOAD:amount::NUMBER(12,2)) AS CAST_POS
FROM EVENTS_JSON);| ROWS_TOTAL | ROWS_IN_THE_SAME_PLACE | ROWS_THAT_MOVED |
|---|---|---|
| 4 | 1 | 3 |
Four rows. One of them stayed where it was and three changed position. On a table this small that is easy to spot. On a report with a thousand rows and a LIMIT on the end, it is not.
So what does that do to MIN and MAX?
This is where type-first ordering stops being an oddity and starts being a bug in your numbers. Let's take the minimum and maximum of that column both ways.
SELECT MIN(PAYLOAD:amount) AS MIN_UNCAST,
MAX(PAYLOAD:amount) AS MAX_UNCAST,
MIN(PAYLOAD:amount::NUMBER(12,2)) AS MIN_CAST,
MAX(PAYLOAD:amount::NUMBER(12,2)) AS MAX_CAST,
COUNT(*) AS ROWS_TOTAL
FROM EVENTS_JSON;| MIN_UNCAST | MAX_UNCAST | MIN_CAST | MAX_CAST | ROWS_TOTAL |
|---|---|---|---|---|
| 890.5 | "1500.00" | 890.50 | 9250.00 | 4 |
The uncast minimum is right. It is right by luck, because the smallest number happens to also be in the type that sorts first.
The uncast maximum is "1500.00". Quotes and all. And the cast maximum, from the same four rows, is 9250.00.
So the MAX of that column is not the largest value in it. The string outranks every number, so it wins the comparison no matter what it says.
And what does it do to a threshold filter?
Let's count the rows over a thousand and the rows under two thousand, with and without a cast, in one query.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT_IF(PAYLOAD:amount > 1000) AS UNCAST_OVER_1000,
COUNT_IF(PAYLOAD:amount::NUMBER(12,2) > 1000) AS CAST_OVER_1000,
COUNT_IF(PAYLOAD:amount < 2000) AS UNCAST_UNDER_2000,
COUNT_IF(PAYLOAD:amount::NUMBER(12,2) < 2000) AS CAST_UNDER_2000
FROM EVENTS_JSON;| ROWS_TOTAL | UNCAST_OVER_1000 | CAST_OVER_1000 | UNCAST_UNDER_2000 | CAST_UNDER_2000 |
|---|---|---|---|---|
| 4 | 3 | 3 | 1 | 2 |
The two > 1000 counts agree. The two < 2000 counts do not.
Both of those follow from the same rule. A VARCHAR outranks every number, so the string row counts as greater than a thousand and the uncast answer happens to come out right. It also does not count as less than two thousand, because it is not being compared as a number at all, and there the uncast answer is one row short.
Half of your predicates keep working. That is worse than all of them breaking, because a query that is wrong some of the time passes review, passes testing, and then disagrees with another query that nobody can explain.
Let's shrink that down to the smallest possible example
Two characters of difference is all it takes. Let's parse the text 1 and the text "1" and compare them.
SELECT PARSE_JSON('1') AS JSON_NUMBER,
PARSE_JSON('"1"') AS JSON_STRING,
TYPEOF(PARSE_JSON('1')) AS NUMBER_TYPE,
TYPEOF(PARSE_JSON('"1"')) AS STRING_TYPE,
PARSE_JSON('1') = PARSE_JSON('"1"') AS ARE_THEY_EQUAL,
PARSE_JSON('1')::INT = PARSE_JSON('"1"')::INT AS EQUAL_ONCE_CAST;| JSON_NUMBER | JSON_STRING | NUMBER_TYPE | STRING_TYPE | ARE_THEY_EQUAL | EQUAL_ONCE_CAST |
|---|---|---|---|---|---|
| 1 | "1" | INTEGER | VARCHAR | FALSE | TRUE |
One is an INTEGER and the other is a VARCHAR. Are they equal? FALSE. So the string one is not the number one.
Cast them both to an integer and ask again, which is the last column. TRUE.
The cast is not decoration on the end of a path. It is what makes two values comparable in the first place.
Now the version you will actually meet: the same key written two ways by two source systems.
SELECT PARSE_JSON('{"qty": 8}'):qty AS NUMBER_DOC,
PARSE_JSON('{"qty": "8"}'):qty AS STRING_DOC,
PARSE_JSON('{"qty": 8}'):qty = PARSE_JSON('{"qty": "8"}'):qty
AS DOCS_AGREE,
PARSE_JSON('{"qty": 8}'):qty::INT
= PARSE_JSON('{"qty": "8"}'):qty::INT AS AGREE_ONCE_CAST,
PARSE_JSON('{"qty": 8}'):qty > 5 AS NUMBER_OVER_FIVE,
PARSE_JSON('{"qty": "8"}'):qty > 5 AS STRING_OVER_FIVE;| NUMBER_DOC | STRING_DOC | DOCS_AGREE | AGREE_ONCE_CAST | NUMBER_OVER_FIVE | STRING_OVER_FIVE |
|---|---|---|---|---|---|
| 8 | "8" | FALSE | TRUE | TRUE | TRUE |
One document says the quantity is a number and the other says it is a string. The documents do not agree. Cast both sides and they do.
Then look at the last two columns. Both of them are greater than five. So a > 5 filter would find both rows while an equality test would find neither, and that is the trap in one grid.
So before you write a cast, find out what you are casting
All of this is avoidable, and the way to avoid it is one query. Group by TYPEOF and let the column tell you what it contains.
SELECT TYPEOF(PAYLOAD:amount) AS ITS_TYPE,
COUNT(*) AS HOW_MANY,
MIN(PAYLOAD:amount) AS SMALLEST_SEEN,
MAX(PAYLOAD:amount) AS LARGEST_SEEN
FROM EVENTS_JSON
GROUP BY TYPEOF(PAYLOAD:amount)
ORDER BY HOW_MANY DESC;| ITS_TYPE | HOW_MANY | SMALLEST_SEEN | LARGEST_SEEN |
|---|---|---|---|
| DECIMAL | 2 | 890.5 | 4200 |
| INTEGER | 1 | 9250 | 9250 |
| VARCHAR | 1 | "1500.00" | "1500.00" |
Two DECIMAL rows, one INTEGER and one VARCHAR. Run that before you write anything, and the string row stops being a surprise.
The smallest and largest columns are uncast on purpose, so the VARCHAR row shows its value with the quotes on. Within one type those two numbers are trustworthy, because everything being compared is the same type.
Now let's try a cast that cannot possibly work
Casting can fail. Let's take a document whose amount is the text N/A and cast it to a number.
SELECT PARSE_JSON('{"amount":"N/A"}'):amount::NUMBER(12,2) AS SHOULD_NOT_GET_THIS_FAR;100071 (22000): Failed to cast variant value "N/A" to FIXED
It raises. The message names the value it could not convert, which is helpful, and the statement returns nothing, which is not.
So you reach for TRY_CAST, the way you would anywhere else in SQL. Let's try that on a variant holding a perfectly good number.
SELECT TRY_CAST(PARSE_JSON('{"amount":1500}'):amount AS NUMBER(12,2))
AS SHOULD_NOT_GET_THIS_FAR;001065 (22023): SQL compilation error: Function TRY_CAST cannot be used with arguments of types VARIANT and NUMBER(12,2)
That is a compilation error, not a row error. TRY_CAST will not accept a VARIANT argument at all, and notice that the value in that query was 1500, a number that would convert without any trouble. So this has nothing to do with the data. The function does not take the type.
The way through is to turn the value into text first and then try the conversion on the text.
SELECT PARSE_JSON('{"amount":"N/A"}'):amount AS RAW_,
TRY_CAST(PARSE_JSON('{"amount":"N/A"}'):amount::VARCHAR AS NUMBER(12,2))
AS TRY_CAST_GIVES,
TRY_TO_NUMBER(PARSE_JSON('{"amount":"N/A"}'):amount::VARCHAR)
AS TRY_TO_NUMBER_GIVES,
TRY_TO_NUMBER(PARSE_JSON('{"amount":"1500.00"}'):amount::VARCHAR)
AS ON_A_GOOD_ONE;| RAW_ | TRY_CAST_GIVES | TRY_TO_NUMBER_GIVES | ON_A_GOOD_ONE |
|---|---|---|---|
| "N/A" | NULL | NULL | 1500 |
Now it works. TRY_CAST on the VARCHAR gave NULL instead of raising, and so did TRY_TO_NUMBER. On a value that does convert, the last column gave us a number back.
TRY_CAST is not useless here, it is just fussy about its argument. Hand it a VARIANT and you get a compile error. Hand it something::VARCHAR and it behaves exactly as you expected. Same for TRY_TO_NUMBER.
Now let's see what the safe form costs
TRY_TO_NUMBER is the safe form and it is not free. Let's look at it on a table of amounts that were written as text by hand, which is the messiest input in this course.
SELECT ROW_ID, AMOUNT_TXT,
TRY_TO_NUMBER(AMOUNT_TXT) AS TRY_GIVES,
TRY_TO_NUMBER(AMOUNT_TXT) IS NULL AS TRY_GAVE_NULL
FROM SALES_RAW
ORDER BY ROW_ID;| ROW_ID | AMOUNT_TXT | TRY_GIVES | TRY_GAVE_NULL |
|---|---|---|---|
| 1 | $4,200.00 | NULL | TRUE |
| 2 | 890.50 | 891 | FALSE |
| 3 | $6,150 | NULL | TRUE |
| 4 | 2,310.75 | NULL | TRUE |
| 5 | N/A | NULL | TRUE |
| 6 | $640.00 | NULL | TRUE |
| 7 | 9,250.00 | NULL | TRUE |
| 8 | $1,080 | NULL | TRUE |
Eight rows, and seven of them came back NULL. A currency symbol stops it. A thousands separator stops it. The text N/A stops it, which is the one you would expect.
Now read the row that did convert. The text was 890.50 and the number that came back is 891.
That is the cost. TRY_TO_NUMBER with no precision and scale returns an integer, so the decimals are rounded away without anything failing. Let's measure what that does to a total.
SELECT COUNT(*) AS ROWS_TOTAL,
COUNT(TRY_TO_NUMBER(PAYLOAD:amount::VARCHAR)) AS CONVERTED,
COUNT(*) - COUNT(TRY_TO_NUMBER(PAYLOAD:amount::VARCHAR)) AS LOST,
SUM(TRY_TO_NUMBER(PAYLOAD:amount::VARCHAR)) AS SAFE_TOTAL,
SUM(PAYLOAD:amount::NUMBER(12,2)) AS DIRECT_TOTAL,
SUM(TRY_TO_NUMBER(PAYLOAD:amount::VARCHAR))
= SUM(PAYLOAD:amount::NUMBER(12,2)) AS TOTALS_AGREE
FROM EVENTS_JSON;| ROWS_TOTAL | CONVERTED | LOST | SAFE_TOTAL | DIRECT_TOTAL | TOTALS_AGREE |
|---|---|---|---|---|---|
| 4 | 4 | 0 | 15841 | 15840.50 | FALSE |
Four rows in, four rows converted, nothing lost. And the two totals disagree. The safe one is a whole number and the direct one has the pence on it.
Nothing failed and nothing was dropped, and the answer still changed. If you use TRY_TO_NUMBER, give it a precision and a scale: TRY_TO_NUMBER(x::VARCHAR, 12, 2). Always, not when you remember.
While we are here: does a cast round or chop?
Worth knowing, because the two give different money. Let's cast the same value into two decimals, one decimal and none.
SELECT PARSE_JSON('296.83'):: NUMBER(10,2) AS TWO_DECIMALS,
PARSE_JSON('296.83')::NUMBER(10,1) AS ONE_DECIMAL,
PARSE_JSON('296.83')::NUMBER(10,0) AS NO_DECIMALS,
PARSE_JSON('296.83')::NUMBER(10,1) = 296.8 AS ONE_DECIMAL_TRUNCATED,
PARSE_JSON('296.83')::NUMBER(10,1) = 296.9 AS ONE_DECIMAL_ROUNDED;| TWO_DECIMALS | ONE_DECIMAL | NO_DECIMALS | ONE_DECIMAL_TRUNCATED | ONE_DECIMAL_ROUNDED |
|---|---|---|---|---|
| 296.83 | 296.8 | 297 | TRUE | FALSE |
Have a look at the fourth column. It is called ONE_DECIMAL_TRUNCATED and it came back TRUE, and it proves nothing at all. At one decimal, 296.83 gives 296.8 whether you round it or chop it, so that comparison cannot tell the two apart.
The column that settles it is the third one. To no decimals the answer is 297. It rounds.
Let's check that the same thing happens with ::INT, including on a negative number.
SELECT PARSE_JSON('12.5'):: INT AS TWELVE_POINT_FIVE,
PARSE_JSON('12.4')::INT AS TWELVE_POINT_FOUR,
PARSE_JSON('12.6')::INT AS TWELVE_POINT_SIX,
PARSE_JSON('-12.5')::INT AS MINUS_TWELVE_POINT_FIVE,
PARSE_JSON('12.5')::INT = 13 AS MATCHES_ROUND,
PARSE_JSON('12.5')::INT = 12 AS MATCHES_TRUNC;| TWELVE_POINT_FIVE | TWELVE_POINT_FOUR | TWELVE_POINT_SIX | MINUS_TWELVE_POINT_FIVE | MATCHES_ROUND | MATCHES_TRUNC |
|---|---|---|---|---|---|
| 13 | 12 | 13 | -13 | TRUE | FALSE |
12.5 gives 13 and -12.5 gives -13. So a half goes away from zero in both directions, and the last two columns say that outright: it matches rounding and it does not match truncation.
And it does not matter whether the number arrived as a number or as a string.
SELECT PARSE_JSON('"12.5"')::INT AS STRING_TWELVE_FIVE,
PARSE_JSON('"12.6"')::INT AS STRING_TWELVE_SIX,
PARSE_JSON('"12.5"')::INT = PARSE_JSON('12.5')::INT AS STRING_AGREES_WITH_NUMBER;| STRING_TWELVE_FIVE | STRING_TWELVE_SIX | STRING_AGREES_WITH_NUMBER |
|---|---|---|
| 13 | 13 | TRUE |
Same answers, and the last column confirms the string version agrees with the number version.
Too few decimals is quiet. Too few digits is not
That is the asymmetry worth remembering. Losing decimals happened silently above. Let's try losing whole digits instead, by casting a five-figure number into a type with room for three.
SELECT PARSE_JSON('13100')::NUMBER(3,0) AS SHOULD_NOT_GET_THIS_FAR;100046 (22003): Number out of representable range: type FIXED, value 13100
Out of representable range, and the statement stops. So a scale that is too small changes your answer without telling you, and a precision that is too small refuses to run. Only one of those two is a problem you will find out about on your own.
What about dates out of a VARIANT?
JSON has no date type, so dates arrive as strings. Let's cast one directly, cast one through VARCHAR, and then try two that will not work.
SELECT PARSE_JSON('{"d":"2024-01-15"}'):d AS AS_VARIANT,
TYPEOF(PARSE_JSON('{"d":"2024-01-15"}'):d) AS ITS_TYPE,
PARSE_JSON('{"d":"2024-01-15"}'):d::DATE AS CAST_TO_DATE,
PARSE_JSON('{"d":"2024-01-15"}'):d::VARCHAR::DATE AS VIA_VARCHAR,
TRY_TO_DATE(PARSE_JSON('{"d":"15/01/2024"}'):d::VARCHAR) AS AMBIGUOUS_FORMAT,
TRY_TO_DATE(PARSE_JSON('{"d":"not a date"}'):d::VARCHAR) AS NOT_A_DATE;| AS_VARIANT | ITS_TYPE | CAST_TO_DATE | VIA_VARCHAR | AMBIGUOUS_FORMAT | NOT_A_DATE |
|---|---|---|---|---|---|
| "2024-01-15" | VARCHAR | 2024-01-15 | 2024-01-15 | NULL | NULL |
The type is VARCHAR, as expected. Casting straight to DATE works, and going through VARCHAR first gives the same answer.
The last two columns are the interesting ones. TRY_TO_DATE returned NULL for text that is clearly meant to be a date in day-first order, and NULL for text that is not a date at all. Two very different problems, one answer again.
Now booleans, and these are backwards
This is the part of the episode that genuinely surprised me. Let's start with the easy case: a JSON true.
SELECT PARSE_JSON('true') AS JSON_TRUE,
TYPEOF(PARSE_JSON('true')) AS ITS_TYPE,
PARSE_JSON('true')::BOOLEAN AS CAST_TO_BOOLEAN,
PARSE_JSON('false')::BOOLEAN AS FALSE_CAST,
PARSE_JSON('true')::BOOLEAN = TRUE AS IT_IS_REALLY_TRUE;| JSON_TRUE | ITS_TYPE | CAST_TO_BOOLEAN | FALSE_CAST | IT_IS_REALLY_TRUE |
|---|---|---|---|---|
| true | BOOLEAN | TRUE | FALSE | TRUE |
Its type is BOOLEAN and it casts to TRUE. No surprises there.
Now the word true in quotes, which is a string and not a boolean.
SELECT PARSE_JSON('"true"') AS JSON_STRING_TRUE,
TYPEOF(PARSE_JSON('"true"')) AS ITS_TYPE,
PARSE_JSON('"true"')::BOOLEAN AS CAST_TO_BOOLEAN;| JSON_STRING_TRUE | ITS_TYPE | CAST_TO_BOOLEAN |
|---|---|---|
| "true" | VARCHAR | TRUE |
Its type is VARCHAR and it still casts to TRUE. So Snowflake reads the word. That is reasonable enough.
How far does that go? Let's try the string yes.
SELECT PARSE_JSON('"yes"')::BOOLEAN AS YES_AS_BOOLEAN;| YES_AS_BOOLEAN |
|---|
| TRUE |
Also true. And it keeps going.
SELECT PARSE_JSON('"t"')::BOOLEAN AS T_,
PARSE_JSON('"y"')::BOOLEAN AS Y_,
PARSE_JSON('"on"')::BOOLEAN AS ON_,
PARSE_JSON('"1"')::BOOLEAN AS STRING_ONE;| T_ | Y_ | ON_ | STRING_ONE |
|---|---|---|---|
| TRUE | TRUE | TRUE | TRUE |
t, y, on and the string "1" all cast to TRUE. A lot of spellings are accepted, which makes the next result very hard to predict.
So what does the number 1 do?
The string "1" casts to TRUE. Let's try the number 1.
SELECT PARSE_JSON('1')::BOOLEAN AS SHOULD_NOT_GET_THIS_FAR;100071 (22000): Failed to cast variant value 1 to BOOLEAN
It raises. The string casts and the number does not.
And zero behaves the same way, so it is not about the value.
SELECT PARSE_JSON('0')::BOOLEAN AS SHOULD_NOT_GET_THIS_FAR;100071 (22000): Failed to cast variant value 0 to BOOLEAN
Now let's do the same casts in plain SQL, outside a VARIANT altogether.
SELECT 1::BOOLEAN AS SQL_ONE_AS_BOOLEAN,
0::BOOLEAN AS SQL_ZERO_AS_BOOLEAN,
TO_BOOLEAN(1) AS TO_BOOLEAN_OF_ONE,
TO_BOOLEAN(0) AS TO_BOOLEAN_OF_ZERO,
'1'::BOOLEAN AS SQL_STRING_ONE,
'true'::BOOLEAN AS SQL_STRING_TRUE;| SQL_ONE_AS_BOOLEAN | SQL_ZERO_AS_BOOLEAN | TO_BOOLEAN_OF_ONE | TO_BOOLEAN_OF_ZERO | SQL_STRING_ONE | SQL_STRING_TRUE |
|---|---|---|---|---|---|
| TRUE | FALSE | TRUE | FALSE | TRUE | TRUE |
1::BOOLEAN is TRUE. 0::BOOLEAN is FALSE. TO_BOOLEAN of both is fine, and so are the strings.
So this is a VARIANT rule and not a SQL rule. The same cast, written against a plain number, works perfectly.
How do we get round that?
One extra step. Cast to a number or to text first, and then to boolean.
SELECT PARSE_JSON('1')::INT AS AS_INT_FIRST,
PARSE_JSON('1')::INT::BOOLEAN AS THEN_BOOLEAN,
PARSE_JSON('1')::VARCHAR AS AS_VARCHAR_FIRST,
PARSE_JSON('1')::VARCHAR::BOOLEAN AS VARCHAR_THEN_BOOLEAN,
TRY_CAST(PARSE_JSON('1')::VARCHAR AS BOOLEAN) AS THE_SAFE_FORM;| AS_INT_FIRST | THEN_BOOLEAN | AS_VARCHAR_FIRST | VARCHAR_THEN_BOOLEAN | THE_SAFE_FORM |
|---|---|---|---|---|
| 1 | TRUE | 1 | TRUE | TRUE |
Through ::INT it works. Through ::VARCHAR it works. And TRY_CAST on the VARCHAR works too, which is the same lesson as the number cast earlier: go through text and TRY_CAST stops complaining.
And if the text might be anything, TRY_TO_BOOLEAN will tell you rather than raise.
SELECT TRY_TO_BOOLEAN(PARSE_JSON('1')::VARCHAR) AS FROM_NUMBER_ONE,
TRY_TO_BOOLEAN(PARSE_JSON('"true"')::VARCHAR) AS FROM_STRING_TRUE,
TRY_TO_BOOLEAN(PARSE_JSON('"maybe"')::VARCHAR) AS FROM_NONSENSE,
TRY_TO_BOOLEAN(PARSE_JSON('2')::VARCHAR) AS FROM_TWO,
TRY_TO_BOOLEAN(PARSE_JSON('-1')::VARCHAR) AS FROM_MINUS_ONE;| FROM_NUMBER_ONE | FROM_STRING_TRUE | FROM_NONSENSE | FROM_TWO | FROM_MINUS_ONE |
|---|---|---|---|---|
| TRUE | TRUE | NULL | NULL | NULL |
The number one and the string true both came back TRUE. The word maybe, the number two and minus one all came back NULL. So 1 and 0 are accepted through text and other numbers are not.
One last thing: the other casts, in both directions
We have gone from a variant to text, to a number, to a date and to a boolean. Let's finish with the handful that are left, starting with a boolean turned into a number.
SELECT PARSE_JSON('true')::INT AS SHOULD_NOT_GET_THIS_FAR;| SHOULD_NOT_GET_THIS_FAR |
|---|
| 1 |
The column name there is left over from an expectation that this would fail. It did not. A JSON true casts to 1 quite happily, which is the exact opposite of the number 1 refusing to become a boolean a moment ago.
In plain SQL that cast does the same thing.
SELECT TRUE::INT AS SQL_TRUE_AS_INT, FALSE::INT AS SQL_FALSE_AS_INT;| SQL_TRUE_AS_INT | SQL_FALSE_AS_INT |
|---|---|
| 1 | 0 |
Now a JSON string that holds digits, cast to a number.
SELECT PARSE_JSON('"42"')::INT AS STRING_42_AS_INT,
TYPEOF(PARSE_JSON('"42"')) AS ITS_TYPE;| STRING_42_AS_INT | ITS_TYPE |
|---|---|
| 42 | VARCHAR |
That works, and the type column reminds us it was a VARCHAR on the way in.
And the other way, a JSON number cast to text.
SELECT PARSE_JSON('42')::VARCHAR AS NUMBER_42_AS_VARCHAR,
TO_JSON(PARSE_JSON('42')) AS AS_JSON_TEXT,
LENGTH(PARSE_JSON('42')::VARCHAR) AS ITS_LENGTH;| NUMBER_42_AS_VARCHAR | AS_JSON_TEXT | ITS_LENGTH |
|---|---|---|
| 42 | 42 | 2 |
Both forms give the same text here, because a number has no quotes to keep. On a string they would differ, which is where this post started.
An array casts to text too.
SELECT PARSE_JSON('[1,2]')::VARCHAR AS ARRAY_AS_VARCHAR,
TO_JSON(PARSE_JSON('[1,2]')) AS ARRAY_AS_JSON_TEXT;| ARRAY_AS_VARCHAR | ARRAY_AS_JSON_TEXT |
|---|---|
| [1,2] | [1,2] |
One line of text, the same from both functions.
But an object will not become a number, and there is no sensible answer it could give.
SELECT PARSE_JSON('{"a":1}')::INT AS SHOULD_NOT_GET_THIS_FAR;100071 (22000): Failed to cast variant value {"a":1} to FIXEDIt raises, and the message quotes the document back at you. That is the right behaviour, and it is a good note to end on: the casts that refuse are the ones you can fix. It is the quiet ones that cost you.
Key points
- An uncast
VARIANTcompares by type first and only by value second. That one sentence explains the sort, theMAXand the threshold filter on this page. ORDER BYon an uncast path put a numeric string after every number, and three of our four rows changed position once the cast was added.- The
MAXof our amount column was a quoted string while the largest number in it was a bigger value. Cast at the path and that goes away. - A
>filter agreed with the cast version while a<filter did not. Half-working predicates are what make this hard to catch. - Three ways to get text out:
::VARCHAR,TO_VARCHARandTO_JSON. OnlyTO_JSONkeeps the JSON quotes, which on our string value was a difference of two characters. - The string
"1"is not the number1until you cast them both.GROUP BY TYPEOFtells you in one query what the column really holds. ::raises when the text will not convert, andTRY_CASTwill not take aVARIANTargument at all — that is a compile error, not a row error.- The working safe form is to go through text:
TRY_TO_NUMBER(x::VARCHAR). With no precision it returns an integer, so give it a precision and a scale. - Casting to fewer decimals rounds quietly, away from zero. Casting to fewer digits raises. Only one of those tells you.
- Booleans are backwards out of a
VARIANT. The stringstrue,yes,t,y,onand"1"all cast. The number1raises, and so does0. - That is a
VARIANTrule, not a SQL rule.1::BOOLEANin plain SQL is fine, and::INT::BOOLEANor::VARCHAR::BOOLEANmakes it go away.
Episode 90 — FLATTEN. Arrays turned into rows, the parent that repeats on every child row, and the total that more than doubles without an error.
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 EVENTS_JSON and SALES_RAW data is invented demo data created by that script. Every count in the text above was read off the grid beside it rather than worked out here. Nothing on this page is timed and no performance or benchmark claims are made anywhere in this series. Conversion behaviour follows the Snowflake conversion functions documentation.



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