NULL values
Working with NULL in queries
In the previous chapter, you saw that NULL represents missing or unknown information.
You also saw that ordinary comparisons involving NULL can produce an unknown result.
Now let's look at how NULL affects calculations and common PostgreSQL queries.
Arithmetic with NULL
Consider:
1SELECT 10 + NULL;The result is:
1NULLPostgreSQL knows the first value:
110but it does not know the second value.
It therefore cannot determine the sum.
The same happens with ordinary arithmetic such as:
1234510 - NULL → NULL10 * NULL → NULL10 / NULL → NULLThis does not mean that every PostgreSQL function must always return NULL when given a NULL.
Different functions can define their own behavior.
The important point is that ordinary arithmetic cannot determine a result when one of the required values is unknown.
Providing a fallback with COALESCE
Sometimes an application wants to substitute another value when a column is NULL.
PostgreSQL provides:
1COALESCE()COALESCE returns the first argument that is not NULL.
For example:
1SELECT COALESCE(NULL, 10);returns:
110And:
1SELECT COALESCE(NULL, NULL, 25);returns:
125Conceptually:
123456COALESCE(NULL, NULL, 25) │ │ │ ✗ ✗ ✓ │ ▼ 25If every argument is NULL:
1SELECT COALESCE(NULL, NULL);the result is:
1NULLUsing COALESCE in application queries
Suppose an application displays a shipping date:
12345678910SELECT id, status, COALESCE( shipped_at::text, 'Not shipped' ) AS shipping_statusFROM ordersWHERE id BETWEEN 83 AND 88ORDER BY id;You should see:
12345678 id | status | shipping_status----+------------+------------------------ 83 | shipped | 2021-03-29 00:00:00+00 84 | shipped | 2021-03-31 00:00:00+00 85 | processing | Not shipped 86 | processing | Not shipped 87 | processing | Not shipped 88 | processing | Not shippedIf shipped_at contains a timestamp, PostgreSQL returns that value as text.
If it is NULL, PostgreSQL returns:
1Not shippedThis id range was chosen because it spans the point where orders stop having a shipped_at value.
COALESCE is also common with aggregates.
Consider:
123SELECT SUM(total_amount)FROM ordersWHERE id = -1;No rows match.
SUM() therefore returns:
1NULLIf an API needs:
10instead, we can write:
1234567SELECT COALESCE( SUM(total_amount), 0 ) AS total_amountFROM ordersWHERE id = -1;Now the result is:
123 total_amount-------------- 0NULLIF
PostgreSQL also provides:
1NULLIF()NULLIF(a, b) returns NULL when the two values compare equal.
Otherwise, it returns the first value.
For example:
1SELECT NULLIF(10, 10);returns:
1NULLwhile:
1SELECT NULLIF(10, 20);returns:
110A useful mental model is:
1234567NULLIF(a, b)if a = b→ NULLotherwise→ aAvoiding division by zero
One useful application of NULLIF is avoiding division by zero.
This would fail:
1SELECT 100 / 0;with:
1ERROR: division by zeroBut:
1SELECT 100 / NULLIF(0, 0);turns the denominator into:
1NULLso the result becomes:
1NULLinstead of a division-by-zero error.
A more realistic shape is an average calculated by dividing a total by a count, where the count can be zero:
1234567891011121314SELECT region, revenue, order_count, ROUND( revenue / NULLIF(order_count, 0), 2 ) AS average_order_valueFROM ( VALUES ('north', 1200.00, 4), ('south', 900.00, 3), ('west', 0.00, 0)) AS summary(region, revenue, order_count);You should see:
12345 region | revenue | order_count | average_order_value--------+---------+-------------+--------------------- north | 1200.00 | 4 | 300.00 south | 900.00 | 3 | 300.00 west | 0.00 | 0 | NULLThe west region has no orders. Without NULLIF, that row would divide by zero and the whole query would fail.
Whether returning NULL is the correct application behavior depends on what that calculation represents, but the pattern is useful to recognize.
Aggregate functions and NULL
Aggregate functions such as SUM(), AVG(), MIN(), MAX(), and COUNT() reduce many rows to a single summary value. The Aggregation section covers them in full, but their treatment of NULL belongs here.
Most common aggregates ignore NULL input values.
Consider:
1231020NULLThen:
1234SUM → 30AVG → 15MIN → 10MAX → 20The NULL value does not participate in those calculations.
COUNT(*) versus COUNT(column)
COUNT() has an especially important distinction.
Our orders table contains 100000 rows.
Run:
1234SELECT COUNT(*) AS total_orders, COUNT(shipped_at) AS orders_with_shipped_atFROM orders;You should see:
123 total_orders | orders_with_shipped_at--------------+------------------------ 100000 | 85000COUNT(*) counts rows.
So it returns:
1100000COUNT(shipped_at) counts only non-NULL values in shipped_at.
So it returns:
185000The missing 15000 are the processing, cancelled, and pending orders, which have not shipped yet.
A useful mental model is:
12345COUNT(*)→ count every rowCOUNT(column)→ count rows where column IS NOT NULLNULL and empty aggregate results
There is another important aggregate behavior.
Run:
123SELECT COUNT(*)FROM ordersWHERE id = -1;The result is:
10Now run:
123SELECT SUM(total_amount)FROM ordersWHERE id = -1;The result is:
1NULLWhen no rows are available:
123456COUNT(*) → 0SUM() → NULLAVG() → NULLMIN() → NULLMAX() → NULLThis is one reason COALESCE is often used around aggregate results.
The NOT IN and NULL problem
NOT IN checks that a value differs from every entry in a list. That check runs into three-valued logic as soon as the list can contain NULL.
Consider:
12SELECT 2WHERE 2 NOT IN (1, NULL);You might expect 2 to be returned because:
12 is not 1But PostgreSQL also has to consider:
12 compared with NULLand that comparison is unknown.
The expression therefore does not become definitively TRUE.
The query returns no row.
Conceptually:
12345672 <> 1→ TRUE2 <> NULL→ UNKNOWNNOT IN cannot establish TRUERemove the NULL from the list and the row comes back:
12SELECT 2WHERE 2 NOT IN (1, 3);This is why NOT IN can produce surprising results when its values may contain NULL.
For relationship queries such as finding customers that have no matching orders, NOT EXISTS often expresses the requirement more directly:
1234567891011SELECT c.id, c.full_nameFROM customers cWHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id)ORDER BY c.idLIMIT 10;You should see:
123456789101112 id | full_name------+--------------- 501 | Customer 501 1001 | Customer 1001 1501 | Customer 1501 2001 | Customer 2001 2501 | Customer 2501 3001 | Customer 3001 3501 | Customer 3501 4001 | Customer 4001 4501 | Customer 4501 5001 | Customer 5001This does not mean NOT IN should never be used.
The important point is to know whether its input can contain NULL.
NULL and UNIQUE constraints
You also saw NULL while learning constraints.
By default, PostgreSQL considers NULL values distinct when enforcing a UNIQUE constraint.
For example:
1234CREATE TEMP TABLE demo_users ( id INTEGER PRIMARY KEY, phone TEXT UNIQUE);Now insert:
1234INSERT INTO demo_users (id, phone)VALUES (1, NULL), (2, NULL);Both rows are allowed:
1234 id | phone----+------- 1 | NULL 2 | NULLThe unique constraint does not treat the two NULL values as duplicates by default.
But two equal non-NULL values are rejected:
1234INSERT INTO demo_users (id, phone)VALUES (3, '9999999999'), (4, '9999999999');PostgreSQL answers with:
1ERROR: duplicate key value violates unique constraint "demo_users_phone_key"Both rows are rejected, because a single statement either succeeds completely or has no effect.
Treating NULL as equal for uniqueness
PostgreSQL also supports:
1NULLS NOT DISTINCTFor example:
12345CREATE TEMP TABLE demo_accounts ( id INTEGER PRIMARY KEY, username TEXT, UNIQUE NULLS NOT DISTINCT (username));Now the unique constraint treats multiple NULL values as duplicates.
The first row is accepted:
12INSERT INTO demo_accounts (id, username)VALUES (1, NULL);But a second one is not:
12INSERT INTO demo_accounts (id, username)VALUES (2, NULL);1ERROR: duplicate key value violates unique constraint "demo_accounts_username_key"Only one row can have:
1username = NULLunder that constraint.
This gives you control over what NULL should mean for a particular uniqueness rule.
Remove the demonstration tables:
123DROP TABLE demo_users;
DROP TABLE demo_accounts;PostgreSQL indexes NULL values
PostgreSQL B-tree indexes can contain NULL values.
That means an index on a nullable column can potentially help PostgreSQL find rows for queries such as:
1WHERE shipped_at IS NULLWhether PostgreSQL actually chooses the index still depends on factors such as:
1234how many rows matchtable sizeavailable indexesestimated costAs with any index, the presence of an index does not guarantee that PostgreSQL will use it.
NULL and index ordering
B-tree indexes can also order NULL values.
By default, for an ascending B-tree ordering PostgreSQL places NULL values after non-NULL values:
123456123...NULLNULLThis corresponds to:
1ORDER BY column ASC NULLS LASTThe opposite ordering can place NULLs first.
PostgreSQL also allows explicit control with:
1NULLS FIRSTand:
1NULLS LASTFor example:
123456SELECT id, shipped_atFROM ordersWHERE id BETWEEN 83 AND 88ORDER BY shipped_at ASC NULLS LAST, id;You should see:
12345678 id | shipped_at----+------------------------ 83 | 2021-03-29 00:00:00+00 84 | 2021-03-31 00:00:00+00 85 | NULL 86 | NULL 87 | NULL 88 | NULLChange the clause to:
1ORDER BY shipped_at ASC NULLS FIRST, idand the four NULL rows move to the top:
12345678 id | shipped_at----+------------------------ 85 | NULL 86 | NULL 87 | NULL 88 | NULL 83 | 2021-03-29 00:00:00+00 84 | 2021-03-31 00:00:00+00So nullable columns do not automatically prevent B-tree indexes from participating in ordering.
The core mental model
When working with NULL, remember the different tools solve different problems:
1234567891011121314151617IS NULL→ test whether a value is missingIS NOT NULL→ test whether a value existsCOALESCE→ use the first available non-NULL valueNULLIF→ turn a particular matching value into NULLCOUNT(column)→ count non-NULL valuesCOUNT(*)→ count rowsAnd remember that NULL also affects:
123456comparisonsarithmeticaggregatesNOT INUNIQUE constraintsindexesThe important point is:
`NULL` is not simply another value. Its meaning as missing or unknown information affects how queries, calculations, constraints, and comparisons behave.