Snowflake SQL Tutorial 80 - IFF, NVL, NVL2, COALESCE, NULLIF, DECODE - Null Handling

Snowflake SQL · Episode 80

In this post we are going to cover the null handling functions in Snowflake: IFF, NVL, NVL2, COALESCE, NULLIF and DECODE.

They all do roughly the same kind of job. Each one looks at a value, decides whether it is there or not, and gives you something back. The differences between them are small, and every one of those differences is a decision about what a missing value means.

We will start with IFF and build it up step by step. On the way we will write a column that makes a false statement about five people, divide by zero and survive it, and find out which one of these functions can match a NULL when the simple CASE form cannot.

Every query below was run on a real Snowflake account, and every result you see is the output it gave back.

Episode 80 of the Snowflake SQL: Zero to Hero series on TechBrothersIT.

Let's create some sample data

This post runs against the EMPLOYEES table that the rest of the course uses, plus CUSTOMERS and a deliberately messy SALES_RAW later on. The script below just points the worksheet at them.

Copy it into a Snowflake worksheet and run it once.

Sample data
-- EMPLOYEES, CUSTOMERS and SALES_RAW 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;

Here is the whole employees table.

Query
SELECT EMP_ID, FIRST_NAME, LAST_NAME, DEPT_ID, MANAGER_ID, SALARY,
       COMMISSION_PCT, HIRE_DATE
FROM EMPLOYEES ORDER BY EMP_ID;
Result8 rows · 8 columns · scroll →
EMP_IDFIRST_NAMELAST_NAMEDEPT_IDMANAGER_IDSALARYCOMMISSION_PCTHIRE_DATE
1AishaKhan106118000.00NULL2019-03-11
2MarcusReid10696500.00NULL2021-07-01
3LenaFischer206104000.000.052018-01-22
4TomasNovak20387250.00NULL2022-09-05
5PriyaRaman30696500.000.102020-11-17
6DanielO'Connor10NULL131000.00NULL2016-05-30
7SofiaMarino30571400.000.082023-02-13
8GraceLinNULL692000.00NULL2021-04-19

Have a look at the COMMISSION_PCT column. It is a rate, so 0.05 means five per cent. Some rows have one and some rows say NULL.

Let's count those properly rather than reading them off the screen.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT(COMMISSION_PCT) AS HAVE_A_RATE,
       COUNT(*) - COUNT(COMMISSION_PCT) AS RATE_IS_NULL,
       COUNT(MANAGER_ID) AS HAVE_A_MANAGER,
       COUNT(*) - COUNT(MANAGER_ID) AS MANAGER_IS_NULL,
       COUNT(DEPT_ID) AS HAVE_A_DEPARTMENT
FROM EMPLOYEES;
Result1 row · 6 columns · scroll →
ROWS_TOTALHAVE_A_RATERATE_IS_NULLHAVE_A_MANAGERMANAGER_IS_NULLHAVE_A_DEPARTMENT
835717

8 rows. 3 of them have a commission rate and 5 do not. 7 have a manager, 1 does not, and 7 have a department.

So the missing rate is not a rare edge case in this table. It is the majority of it, and that is why this post exists.

Let's see what one null does to a calculation

Before any of the functions, here is why the nulls matter immediately. Let's work out each person's commission: salary times the rate.

Query
SELECT EMP_ID, FIRST_NAME, SALARY, COMMISSION_PCT,
       SALARY * COMMISSION_PCT AS COMMISSION_AMOUNT
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 5 columns
EMP_IDFIRST_NAMESALARYCOMMISSION_PCTCOMMISSION_AMOUNT
1Aisha118000.00NULLNULL
2Marcus96500.00NULLNULL
3Lena104000.000.055200.0000
4Tomas87250.00NULLNULL
5Priya96500.000.109650.0000
6Daniel131000.00NULLNULL
7Sofia71400.000.085712.0000
8Grace92000.00NULLNULL

3 numbers and 5 nulls. Any arithmetic with a NULL gives back NULL, so the 5 rows with no rate have no commission amount either.

We have not done anything clever yet and most of the column is already gone.

Let's start with IFF

IFF takes three things: a condition, a value to use if it is true, and a value to use if it is not. That is the whole function.

Let's split the salaries into six figures and below, and put the equivalent CASE next to it so we can see they are the same thing.

Query
SELECT EMP_ID, FIRST_NAME, SALARY,
       IFF(SALARY >= 100000, 'Six figures', 'Below') AS BAND,
       CASE WHEN SALARY >= 100000 THEN 'Six figures' ELSE 'Below' END
         AS CASE_FOR_COMPARISON
FROM EMPLOYEES
ORDER BY SALARY DESC;
Result8 rows · 5 columns
EMP_IDFIRST_NAMESALARYBANDCASE_FOR_COMPARISON
6Daniel131000.00Six figuresSix figures
1Aisha118000.00Six figuresSix figures
3Lena104000.00Six figuresSix figures
2Marcus96500.00BelowBelow
5Priya96500.00BelowBelow
8Grace92000.00BelowBelow
4Tomas87250.00BelowBelow
7Sofia71400.00BelowBelow

The two columns are identical all the way down. Let's count that rather than trust it.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT_IF(IFF(SALARY >= 100000, 'Six figures', 'Below')
                = CASE WHEN SALARY >= 100000 THEN 'Six figures'
                       ELSE 'Below' END) AS AGREE
FROM EMPLOYEES;
Result1 row · 2 columns
ROWS_TOTALAGREE
88

8 rows, and the two expressions agree on 8 of them.

So IFF is a shorter CASE with exactly two branches. Use it when you have two. Reach for CASE the moment you have three.

Now let's see what IFF does with a NULL condition

Here is the query that this whole post is really about. Does this person earn commission? The condition is COMMISSION_PCT > 0, and the two answers are 'Earns commission' and 'Does not'.

That reads like a perfectly sensible column. Let's run it, with the condition on its own beside it, and a properly written version in the last column.

Query
SELECT EMP_ID, COMMISSION_PCT,
       IFF(COMMISSION_PCT > 0, 'Earns commission', 'Does not') AS THE_ANSWER,
       COMMISSION_PCT > 0                                      AS THE_CONDITION_ITSELF,
       IFF(COMMISSION_PCT IS NULL, 'No rate recorded',
           IFF(COMMISSION_PCT > 0, 'Earns commission', 'Zero')) AS WRITTEN_PROPERLY
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 5 columns
EMP_IDCOMMISSION_PCTTHE_ANSWERTHE_CONDITION_ITSELFWRITTEN_PROPERLY
1NULLDoes notNULLNo rate recorded
2NULLDoes notNULLNo rate recorded
30.05Earns commissionTRUEEarns commission
4NULLDoes notNULLNo rate recorded
50.10Earns commissionTRUEEarns commission
6NULLDoes notNULLNo rate recorded
70.08Earns commissionTRUEEarns commission
8NULLDoes notNULLNo rate recorded

Look at THE_ANSWER and THE_CONDITION_ITSELF together. On the rows with no rate, the condition is not FALSE. It is NULL.

NULL is not true, so IFF took the false branch. That is the documented behaviour and it is working exactly as designed.

The problem is what the column now says. 'Does not' is a claim about a person, and the truth is that we do not know. There is no rate recorded for them.

Let's get the number on screen.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT_IF(COMMISSION_PCT IS NULL) AS ACTUALLY_NULL,
       COUNT_IF(IFF(COMMISSION_PCT > 0, 'Earns commission', 'Does not') = 'Does not')
         AS LABELLED_DOES_NOT,
       COUNT_IF(COMMISSION_PCT IS NULL
                AND IFF(COMMISSION_PCT > 0, 'Earns commission', 'Does not') = 'Does not')
         AS NULLS_HIDDEN_IN_DOES_NOT
FROM EMPLOYEES;
Result1 row · 4 columns
ROWS_TOTALACTUALLY_NULLLABELLED_DOES_NOTNULLS_HIDDEN_IN_DOES_NOT
8555

8 rows. 5 are actually NULL. 5 were labelled 'Does not'. And 5 nulls are hidden inside that label.

Every single unknown became a confident no, and nothing in the result says so.

The fix is in the last column of the grid above. Test IS NULL first, in its own branch, and you get 'No rate recorded', 'Earns commission' and 'Zero'. Three states, because there genuinely are three.

Is IFF lazy?

Worth knowing, because if the unused branch is never evaluated you can afford to put something expensive in it.

Let's put a division by zero in the branch that should not run, once on each side.

Query
SELECT IFF(TRUE, 1, 1/0)  AS FALSE_BRANCH_IS_BAD,
       IFF(FALSE, 1/0, 1) AS TRUE_BRANCH_IS_BAD;
Result1 row · 2 columns
FALSE_BRANCH_IS_BADTRUE_BRANCH_IS_BAD
1.0000001.000000

Both columns came back as 1.000000. Neither division by zero ever happened, so the unused branch really is skipped.

Now let's replace a null with NVL

NVL takes two arguments. If the first one is NULL, you get the second one. Otherwise you get the first.

IFNULL is the same function with a different name. Let's put them side by side and compare them on every row.

Query
SELECT EMP_ID, COMMISSION_PCT,
       IFNULL(COMMISSION_PCT, 0) AS VIA_IFNULL,
       NVL(COMMISSION_PCT, 0)    AS VIA_NVL,
       IFNULL(COMMISSION_PCT, 0) = NVL(COMMISSION_PCT, 0) AS SAME
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 5 columns
EMP_IDCOMMISSION_PCTVIA_IFNULLVIA_NVLSAME
1NULL0.000.00TRUE
2NULL0.000.00TRUE
30.050.050.05TRUE
4NULL0.000.00TRUE
50.100.100.10TRUE
6NULL0.000.00TRUE
70.080.080.08TRUE
8NULL0.000.00TRUE

The SAME column is TRUE all the way down. They are interchangeable, so use whichever one your team already uses and stop thinking about it.

NVL2 takes three arguments, and people misread it

NVL2 is the one that catches people out. It does not mean if null. It means: if the first argument is not null give me the second, otherwise give me the third.

So the second argument is the not-null case. Let's run it with an IFF beside it that spells out the same logic.

Query
SELECT EMP_ID, COMMISSION_PCT,
       NVL2(COMMISSION_PCT, 'Has a rate', 'Does not')          AS NVL2_,
       IFF(COMMISSION_PCT IS NOT NULL, 'Has a rate', 'Does not') AS IFF_FOR_COMPARISON,
       NVL2(COMMISSION_PCT, SALARY * COMMISSION_PCT, 0)         AS COMMISSION_OR_ZERO
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 5 columns
EMP_IDCOMMISSION_PCTNVL2_IFF_FOR_COMPARISONCOMMISSION_OR_ZERO
1NULLDoes notDoes not0.0000
2NULLDoes notDoes not0.0000
30.05Has a rateHas a rate5200.0000
4NULLDoes notDoes not0.0000
50.10Has a rateHas a rate9650.0000
6NULLDoes notDoes not0.0000
70.08Has a rateHas a rate5712.0000
8NULLDoes notDoes not0.0000

The two label columns match, and the last column is the useful shape: the commission where there is a rate, and zero where there is not.

Let's count the agreement.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT_IF(NVL2(COMMISSION_PCT, 'Has a rate', 'Does not')
                = IFF(COMMISSION_PCT IS NOT NULL, 'Has a rate', 'Does not')) AS AGREE
FROM EMPLOYEES;
Result1 row · 2 columns
ROWS_TOTALAGREE
88

8 of 8. So NVL2(x, a, b) is IFF(x IS NOT NULL, a, b), and the second argument is the one that goes with a value being present.

It reads nicely on the manager column too.

Query
SELECT EMP_ID, FIRST_NAME, MANAGER_ID,
       NVL2(MANAGER_ID, 'Reports to someone', 'Top of the tree') AS REPORTING
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 4 columns
EMP_IDFIRST_NAMEMANAGER_IDREPORTING
1Aisha6Reports to someone
2Marcus6Reports to someone
3Lena6Reports to someone
4Tomas3Reports to someone
5Priya6Reports to someone
6DanielNULLTop of the tree
7Sofia5Reports to someone
8Grace6Reports to someone

Daniel has no manager, so he comes back as 'Top of the tree'. That is a sentence a report can print, which a NULL is not.

But what did replacing null with zero actually change?

This is the important part of the post, and it is about what we just did rather than how to do it. We replaced NULL with zero. So what changed?

Let's average the rate two ways, and sum it two ways, on the same grid.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT(COMMISSION_PCT)                        AS NON_NULL_ROWS,
       ROUND(AVG(COMMISSION_PCT), 4)                AS AVG_IGNORING_NULLS,
       ROUND(AVG(NVL(COMMISSION_PCT, 0)), 4)        AS AVG_TREATING_NULL_AS_ZERO,
       ROUND(SUM(COMMISSION_PCT), 4)                AS SUM_IGNORING_NULLS,
       ROUND(SUM(NVL(COMMISSION_PCT, 0)), 4)        AS SUM_TREATING_NULL_AS_ZERO
FROM EMPLOYEES;
Result1 row · 6 columns · scroll →
ROWS_TOTALNON_NULL_ROWSAVG_IGNORING_NULLSAVG_TREATING_NULL_AS_ZEROSUM_IGNORING_NULLSSUM_TREATING_NULL_AS_ZERO
830.07670.02880.230.23

The average ignoring the nulls is 0.0767. The average treating NULL as zero is 0.0288. That is less than half.

Now look at the two sums. 0.23 and 0.23. Identical, and of course they are — adding zero to a total changes nothing.

The whole difference is the divisor. The same 0.23 was divided by 3 rows in one case and by 8 rows in the other.

Let's see the same thing in money, which is where somebody notices.

Query
SELECT ROUND(AVG(SALARY * COMMISSION_PCT), 2)               AS AVG_OF_THOSE_WHO_EARN_IT,
       ROUND(AVG(SALARY * NVL(COMMISSION_PCT, 0)), 2)       AS AVG_ACROSS_EVERYONE,
       ROUND(SUM(SALARY * NVL(COMMISSION_PCT, 0)), 2)       AS TOTAL_COMMISSION,
       COUNT(SALARY * COMMISSION_PCT)                       AS ROWS_IN_THE_FIRST,
       COUNT(SALARY * NVL(COMMISSION_PCT, 0))               AS ROWS_IN_THE_SECOND
FROM EMPLOYEES;
Result1 row · 5 columns
AVG_OF_THOSE_WHO_EARN_ITAVG_ACROSS_EVERYONETOTAL_COMMISSIONROWS_IN_THE_FIRSTROWS_IN_THE_SECOND
6854.002570.2520562.0038

Average commission of the people who earn it: 6854.00, over 3 rows. Average commission across everybody: 2570.25, over 8 rows. The total of 20562.00 is the same either way.

Both numbers are correct. They answer different questions.

The point of this section

Writing NVL(x, 0) is not tidying up. It is choosing which question your number answers. Decide which one you meant, and then write the one you meant.

Now let's use COALESCE for as many arguments as we like

COALESCE takes any number of arguments and returns the first one that is not NULL. Let's use the customers table for this, because it has a missing country.

Query
SELECT CUSTOMER_ID, CUSTOMER_NAME, CITY, COUNTRY, SEGMENT
FROM CUSTOMERS ORDER BY CUSTOMER_ID;
Result10 rows · 5 columns
CUSTOMER_IDCUSTOMER_NAMECITYCOUNTRYSEGMENT
101Northwind TradersCharlotteUSAEnterprise
102Blue Ridge SupplyAshevilleUSASMB
103Kestrel AnalyticsTorontoCanadaEnterprise
104Harbour FoodsVancouverCanadaSMB
105Alpine LogisticsZurichNULLEnterprise
106Delta Print CoRaleighUSASMB
107Meridian HealthBostonUSAEnterprise
108Copper Kettle LtdManchesterUKSMB
109Vantage MediaLondonUKEnterprise
110Sable InteriorsAustinUSASMB

Customer 105, Alpine Logistics, has no country. Now let's ask for the country, then the city, then the word Unknown, in that order.

Query
SELECT CUSTOMER_ID, CITY, COUNTRY,
       COALESCE(COUNTRY, CITY, 'Unknown')            AS FIRST_NON_NULL,
       COALESCE(NULL, NULL, NULL, 'last resort')     AS ALL_BUT_THE_LAST_ARE_NULL,
       COALESCE(COUNTRY, 'Unknown')                  AS TWO_ARGUMENTS
FROM CUSTOMERS
ORDER BY CUSTOMER_ID;
Result10 rows · 6 columns · scroll →
CUSTOMER_IDCITYCOUNTRYFIRST_NON_NULLALL_BUT_THE_LAST_ARE_NULLTWO_ARGUMENTS
101CharlotteUSAUSAlast resortUSA
102AshevilleUSAUSAlast resortUSA
103TorontoCanadaCanadalast resortCanada
104VancouverCanadaCanadalast resortCanada
105ZurichNULLZurichlast resortUnknown
106RaleighUSAUSAlast resortUSA
107BostonUSAUSAlast resortUSA
108ManchesterUKUKlast resortUK
109LondonUKUKlast resortUK
110AustinUSAUSAlast resortUSA

That row falls through the missing country and comes back with Zurich. The middle column shows what happens when everything but the last argument is NULL, and the last column is COALESCE with two arguments — which is exactly NVL. It just scales further.

One type note, because mixing types is allowed and does something.

Query
SELECT COALESCE(NULL, 1, 2)          AS ALL_NUMBERS,
       COALESCE(NULL, '1', '2')      AS ALL_TEXT,
       COALESCE(NULL, 1, '2')        AS MIXED_,
       TYPEOF(COALESCE(NULL, 1, '2')::VARIANT) AS MIXED_TYPE;
Result1 row · 4 columns
ALL_NUMBERSALL_TEXTMIXED_MIXED_TYPE
111INTEGER

Mix a number and text and COALESCE picks a type and converts. TYPEOF says INTEGER. So keep your arguments the same type, or cast them yourself and know what you are getting.

Is COALESCE lazy too?

Same question we asked of IFF, same way of answering it. Let's put a division by zero in an argument that should never be needed.

Query
SELECT COALESCE(1, 1/0)          AS FIRST_ARG_IS_FINE,
       COALESCE(NULL, 1, 1/0)    AS SECOND_ARG_IS_FINE;
Result1 row · 2 columns
FIRST_ARG_IS_FINESECOND_ARG_IS_FINE
1.0000001.000000

Both fine. COALESCE stops as soon as it finds something that is not NULL, and it never looks at the rest.

But lazy is not the same as safe. Let's make it reach the bad argument.

Query
SELECT COALESCE(NULL, 1/0) AS SHOULD_NOT_GET_THIS_FAR;
Query failed — Snowflake returned an error
100051 (22012): Division by zero

Division by zero. The laziness protects you only while there is something earlier to return. The moment COALESCE has to evaluate that argument, it evaluates it.

Now the inverse: NULLIF

NULLIF goes the other way. It takes two values, and if they are equal it gives you NULL. Otherwise it gives you the first one.

Query
SELECT NULLIF(5, 5)      AS EQUAL_GIVES_NULL,
       NULLIF(5, 3)      AS DIFFERENT_GIVES_THE_FIRST,
       NULLIF('a', 'a')  AS TEXT_EQUAL,
       NULLIF(NULL, 5)   AS FIRST_IS_NULL,
       NULLIF(5, NULL)   AS SECOND_IS_NULL;
Result1 row · 5 columns
EQUAL_GIVES_NULLDIFFERENT_GIVES_THE_FIRSTTEXT_EQUALFIRST_IS_NULLSECOND_IS_NULL
NULL5NULLNULL5

NULLIF(5, 5) is NULL. NULLIF(5, 3) is 5. NULLIF('a', 'a') is NULL, so it works on text. NULLIF(NULL, 5) is NULL, and NULLIF(5, NULL) is 5.

Turning a value into nothing sounds like a strange thing to want. It is, until you divide.

Let's divide by a computed value and watch it fail

Here is a real calculation. How much salary is there per dollar of commission? That means dividing the salary by the commission amount, and we already made the commission amount safe with NVL earlier.

Query
SELECT EMP_ID, SALARY,
       SALARY / (SALARY * NVL(COMMISSION_PCT, 0)) AS SHOULD_NOT_GET_THIS_FAR
FROM EMPLOYEES;
Query failed — Snowflake returned an error
100051 (22012): Division by zero

Division by zero. The NVL turned the missing rates into zeros, and then we divided by them.

And this is a hard error. Not a NULL in one cell — the whole query is gone, because of rows we were not even interested in.

So let's wrap the divisor in NULLIF against zero and run it again.

Query
SELECT EMP_ID, SALARY, COMMISSION_PCT,
       SALARY * NVL(COMMISSION_PCT, 0) AS COMMISSION_AMOUNT,
       ROUND(SALARY / NULLIF(SALARY * NVL(COMMISSION_PCT, 0), 0), 2)
         AS SALARY_PER_COMMISSION_DOLLAR
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 5 columns
EMP_IDSALARYCOMMISSION_PCTCOMMISSION_AMOUNTSALARY_PER_COMMISSION_DOLLAR
1118000.00NULL0.0000NULL
296500.00NULL0.0000NULL
3104000.000.055200.000020.00
487250.00NULL0.0000NULL
596500.000.109650.000010.00
6131000.00NULL0.0000NULL
771400.000.085712.000012.50
892000.00NULL0.0000NULL

The three people who earn commission get 20.00, 10.00 and 12.50. Everybody else gets NULL, which is honest. You cannot divide by nothing, so the answer is not a number.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT(SALARY / NULLIF(SALARY * NVL(COMMISSION_PCT, 0), 0)) AS RATIO_CALCULATED,
       COUNT(*) - COUNT(SALARY / NULLIF(SALARY * NVL(COMMISSION_PCT, 0), 0))
         AS RATIO_IS_NULL,
       COUNT_IF(NVL(COMMISSION_PCT, 0) = 0) AS RATE_IS_ZERO_OR_NULL
FROM EMPLOYEES;
Result1 row · 4 columns
ROWS_TOTALRATIO_CALCULATEDRATIO_IS_NULLRATE_IS_ZERO_OR_NULL
8355

8 rows, 3 ratios calculated, 5 came back NULL, and 5 rows had a rate that was zero or missing. The same 5 rows, which is not a coincidence.

The idiom worth memorising

NULLIF(divisor, 0) every time you divide by something you calculated. It turns an error that kills the statement into a NULL in the cells where there is genuinely no answer.

Two more things NULLIF is good for

Empty strings and sentinel values. Both of them are nulls that somebody wrote down as text, and NULLIF turns them back into real nulls.

Query
SELECT NULLIF('', '')              AS EMPTY_TO_NULL,
       NULLIF(TRIM('   '), '')     AS SPACES_TO_NULL,
       NULLIF('N/A', 'N/A')        AS SENTINEL_TO_NULL,
       COALESCE(NULLIF('N/A', 'N/A'), 'replaced') AS COMBINED_WITH_COALESCE;
Result1 row · 4 columns
EMPTY_TO_NULLSPACES_TO_NULLSENTINEL_TO_NULLCOMBINED_WITH_COALESCE
NULLNULLNULLreplaced

An empty string becomes NULL. A string of spaces, trimmed first, becomes NULL. And 'N/A' becomes NULL, which means COALESCE can then replace it — the last column shows those two working together.

Let's do that on a real messy table. SALES_RAW holds amounts as text, the way a spreadsheet import leaves them.

Query
SELECT ROW_ID, AMOUNT_TXT,
       NULLIF(AMOUNT_TXT, 'N/A') AS SENTINEL_REMOVED
FROM SALES_RAW
ORDER BY ROW_ID;
Result8 rows · 3 columns
ROW_IDAMOUNT_TXTSENTINEL_REMOVED
1$4,200.00$4,200.00
2890.50890.50
3$6,150$6,150
42,310.752,310.75
5N/ANULL
6$640.00$640.00
79,250.009,250.00
8$1,080$1,080

Row 5 is the one. Its amount is the text N/A, and one line of NULLIF turns it into a proper NULL that the rest of your query can handle.

Can anything match a NULL? DECODE can

DECODE is an old Oracle function and it is still in Snowflake, for one good reason. You give it a column, then pairs of search value and result, then an optional default.

That makes it look exactly like the simple form of CASE, so let's write both and compare them.

Query
SELECT CUSTOMER_ID, COUNTRY,
       DECODE(COUNTRY, 'USA', 'Domestic', NULL, 'Missing', 'International')
         AS VIA_DECODE,
       CASE COUNTRY WHEN 'USA' THEN 'Domestic' WHEN NULL THEN 'Missing'
                    ELSE 'International' END AS VIA_SIMPLE_CASE
FROM CUSTOMERS
ORDER BY CUSTOMER_ID;
Result10 rows · 4 columns
CUSTOMER_IDCOUNTRYVIA_DECODEVIA_SIMPLE_CASE
101USADomesticDomestic
102USADomesticDomestic
103CanadaInternationalInternational
104CanadaInternationalInternational
105NULLMissingInternational
106USADomesticDomestic
107USADomesticDomestic
108UKInternationalInternational
109UKInternationalInternational
110USADomesticDomestic

Find customer 105, the one with no country. DECODE says Missing. The simple CASE says International. Same row, same data, different answer.

The simple CASE form compares with =, and = NULL is never true. DECODE compares with null-safe equality, and that is the one thing it does that CASE cannot.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT(*) - COUNT(COUNTRY) AS ACTUAL_NULLS,
       COUNT_IF(DECODE(COUNTRY, 'USA', 'Domestic', NULL, 'Missing', 'International')
                = 'Missing') AS DECODE_FOUND,
       COUNT_IF(CASE COUNTRY WHEN 'USA' THEN 'Domestic' WHEN NULL THEN 'Missing'
                             ELSE 'International' END = 'Missing') AS CASE_FOUND
FROM CUSTOMERS;
Result1 row · 4 columns
ROWS_TOTALACTUAL_NULLSDECODE_FOUNDCASE_FOUND
10110

10 rows, 1 actual null. DECODE found 1. The simple CASE found 0.

One more thing about DECODE: the default is optional, and leaving it out is not the same as leaving it empty.

Query
SELECT CUSTOMER_ID, COUNTRY,
       DECODE(COUNTRY, 'USA', 'Domestic')                  AS NO_DEFAULT,
       DECODE(COUNTRY, 'USA', 'Domestic', 'Other')         AS WITH_DEFAULT
FROM CUSTOMERS
ORDER BY CUSTOMER_ID;
Result10 rows · 4 columns
CUSTOMER_IDCOUNTRYNO_DEFAULTWITH_DEFAULT
101USADomesticDomestic
102USADomesticDomestic
103CanadaNULLOther
104CanadaNULLOther
105NULLNULLOther
106USADomesticDomestic
107USADomesticDomestic
108UKNULLOther
109UKNULLOther
110USADomesticDomestic

With no default, every unmatched row comes back NULL. With a default, they come back Other. Same choice you make with an ELSE, and the same consequences.

So if you are matching a list of values and one of them might be NULL, DECODE is the short way. Otherwise use the searched CASE with IS NULL in its own branch.

One last thing: how many ways can we write the same fix?

There are six spellings of replace null with zero in this post. Let's put all of them on one column and look at them together.

Query
SELECT EMP_ID, COMMISSION_PCT,
       IFNULL(COMMISSION_PCT, 0)                              AS IFNULL_,
       NVL(COMMISSION_PCT, 0)                                 AS NVL_,
       NVL2(COMMISSION_PCT, COMMISSION_PCT, 0)                AS NVL2_,
       COALESCE(COMMISSION_PCT, 0)                            AS COALESCE_,
       IFF(COMMISSION_PCT IS NULL, 0, COMMISSION_PCT)         AS IFF_,
       CASE WHEN COMMISSION_PCT IS NULL THEN 0
            ELSE COMMISSION_PCT END                           AS CASE_
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 8 columns · scroll →
EMP_IDCOMMISSION_PCTIFNULL_NVL_NVL2_COALESCE_IFF_CASE_
1NULL0.000.000.000.000.000.00
2NULL0.000.000.000.000.000.00
30.050.050.050.050.050.050.05
4NULL0.000.000.000.000.000.00
50.100.100.100.100.100.100.10
6NULL0.000.000.000.000.000.00
70.080.080.080.080.080.080.08
8NULL0.000.000.000.000.000.00

IFNULL, NVL, NVL2, COALESCE, IFF and CASE, and reading across any row they all give the same value.

Now let's check that rather than eyeball it.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT_IF(IFNULL(COMMISSION_PCT, 0) = NVL(COMMISSION_PCT, 0)
                AND NVL(COMMISSION_PCT, 0) = COALESCE(COMMISSION_PCT, 0)
                AND COALESCE(COMMISSION_PCT, 0)
                    = IFF(COMMISSION_PCT IS NULL, 0, COMMISSION_PCT)
                AND IFF(COMMISSION_PCT IS NULL, 0, COMMISSION_PCT)
                    = CASE WHEN COMMISSION_PCT IS NULL THEN 0
                           ELSE COMMISSION_PCT END)
         AS ALL_SIX_AGREE
FROM EMPLOYEES;
Result1 row · 2 columns
ROWS_TOTALALL_SIX_AGREE
88

8 rows, and the chained comparison holds on all 8 of them.

One honest caveat about that check, because it is worth being precise. Read the query above: it chains IFNULL, NVL, COALESCE, IFF and CASE. NVL2 is not in it. So that column proves five of the six spellings identical, and the sixth is something we read off the grid above with our own eyes.

Which is the whole lesson about checks. The grid shows agreement. The check proves it, and only for what is actually written in the check.

Here is the whole family on one grid to finish with.

Query
SELECT EMP_ID, SALARY, COMMISSION_PCT, MANAGER_ID,
       NVL(COMMISSION_PCT, 0)                                  AS RATE_OR_ZERO,
       NVL2(MANAGER_ID, 'Reports to someone', 'Top of tree')    AS REPORTING,
       ROUND(SALARY + SALARY * COALESCE(COMMISSION_PCT, 0), 2)  AS TOTAL_PAY,
       ROUND(SALARY / NULLIF(SALARY * NVL(COMMISSION_PCT, 0), 0), 2) AS SAFE_RATIO,
       IFF(COMMISSION_PCT IS NULL, 'REVIEW', 'OK')              AS FLAG
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 9 columns · scroll →
EMP_IDSALARYCOMMISSION_PCTMANAGER_IDRATE_OR_ZEROREPORTINGTOTAL_PAYSAFE_RATIOFLAG
1118000.00NULL60.00Reports to someone118000.00NULLREVIEW
296500.00NULL60.00Reports to someone96500.00NULLREVIEW
3104000.000.0560.05Reports to someone109200.0020.00OK
487250.00NULL30.00Reports to someone87250.00NULLREVIEW
596500.000.1060.10Reports to someone106150.0010.00OK
6131000.00NULLNULL0.00Top of tree131000.00NULLREVIEW
771400.000.0850.08Reports to someone77112.0012.50OK
892000.00NULL60.00Reports to someone92000.00NULLREVIEW

NVL for the rate, NVL2 for the reporting line, COALESCE inside the total pay, NULLIF keeping the ratio safe, and IFF raising a REVIEW flag on every row where the rate is missing. That last column is the one to copy. It does not guess what the missing value meant, it says that something needs looking at.

Key points

  • IFF(condition, a, b) is a CASE with exactly two branches, and the two agree on every row.
  • A NULL condition is not FALSE, but IFF sends it to the false branch anyway. That is how an unknown becomes a confident no.
  • Test IS NULL first, in its own branch, whenever a label is a claim about something real.
  • IFF never evaluates the branch it does not use.
  • NVL and IFNULL are the same function with two names.
  • NVL2(x, a, b) returns a when x is not null. The second argument is the not-null case.
  • Replacing NULL with zero changes an average and never changes a sum, because the divisor changes and the total does not.
  • COALESCE takes any number of arguments and returns the first one that is not NULL. With two arguments it is NVL.
  • COALESCE short-circuits, but that only protects you while it can skip the argument. Reach it and it will evaluate it.
  • NULLIF(x, y) gives NULL when the two are equal. NULLIF(divisor, 0) is how you survive dividing by a computed value.
  • NULLIF also turns empty strings and 'N/A' sentinels back into real nulls.
  • DECODE matches NULL, which the simple CASE form cannot, because CASE compares with =.
  • There are six ways to write replace null with zero, and they produce the same value. Pick the one that reads best and be consistent.
Next in this series

Episode 81 — ZEROIFNULL, NULLIFZERO, GREATEST, LEAST and EQUAL_NULL. Including a guaranteed minimum bonus that pays nothing to exactly the people it was written for, and a column that does not equal itself.

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 EMPLOYEES, CUSTOMERS and SALES_RAW data is invented demo data created by that script. The agreement check in the last section is described as covering five spellings rather than six because that is what its SQL chains; NVL2 appears in the grid above it and not in the check. No performance or benchmark claims are made anywhere in this series. Function behaviour follows the Snowflake conditional expression functions documentation.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.