Tutorial

Understanding Null Semantics In Sql: 2026 Guide & Examples

Master three-valued logic and traps by understanding null semantics in sql. Learn NOT IN pitfalls, WHERE vs HAVING filtering, and safe COALESCE math.

Anuj SainiAug 23, 2026Updated Sep 20, 202612 min read

Mastering relational databases requires understanding null semantics in sql, where missing data is governed by three-valued logic rather than binary booleans. In production environments, unhandled NULL values silently invalidate financial summaries, corrupt aggregate metrics, and cause take-home interview queries to fail without throwing a single syntax error. When a query returns zero rows instead of hundreds, or when an executive dashboard displays an inflated average, NULL semantics are almost always the hidden culprit.

This understanding null semantics in sql practical guide breaks down the theoretical foundations and daily engineering challenges of SQL NULL handling. We explore three-valued logic (3VL), demystify how WHERE and HAVING treat missing data differently across execution phases, expose the deadly NOT IN subquery trap, and demonstrate safe handling strategies using verified datasets from our live PostgreSQL environment. For complementary database optimization patterns, review our SQL JOIN Fan-Out Guide, SQL CTE Guide, and execution order breakdown in WHERE vs HAVING in SQL.


1. The Core Philosophy: NULL Represents Missing Information, Not Empty State

In E.F. Codd's relational database model, NULL is not a data value; it is a placeholder or marker signaling that a value is missing, unknown, or not applicable. Treating NULL as an ordinary value is the root cause of the most common SQL bugs.

To write reliable analytical queries, you must distinguish between four distinct states of "emptiness":

StateData TypeMeaningHow to Test
NULLMarker (Any Type)Information does not exist or is unknowncol IS NULL
Empty String ('')String (VARCHAR/TEXT)Known, recorded string with zero characterscol = ''
Zero (0)Numeric (INT/NUMERIC)Known numeric quantity representing nonecol = 0
Whitespace (' ')String (VARCHAR/TEXT)Known string containing space charactersTRIM(col) = ''

An empty string is a value you have that happens to contain zero characters. A NULL is a value you do not have at all. While both render as empty cells in most database GUI clients (like DBeaver, pgAdmin, or DataGrip), they respond to completely different comparison logic.

Spotting Missing Values in Practice

In our verified e-commerce dataset containing 500 customer records, 443 customers provided an email address during onboarding, while 57 records have no email recorded. You can float missing records to the top of your inspection results using boolean evaluation in ORDER BY:

sql
-- Surface missing customer contact data first
SELECT customer_id, first_name, email, phone, city
FROM customers
ORDER BY (email IS NULL) DESC, (phone IS NULL) DESC, customer_id ASC
LIMIT 10;

In PostgreSQL, the boolean expression (email IS NULL) evaluates to TRUE (1) for missing rows and FALSE (0) for complete rows. Sorting in descending order guarantees that missing contact records appear immediately for data auditing.


2. Three-Valued Logic (3VL): Truth Tables for AND, OR, and NOT

Standard computer logic is two-valued (Boolean): an expression is either TRUE or FALSE. Relational databases, however, operate on Three-Valued Logic (3VL) comprising TRUE, FALSE, and UNKNOWN.

When an attribute is missing, the database cannot definitively assert whether a comparison is true or false. For example, if employee Sarah's salary is NULL, the statement salary > 50000 cannot be confirmed as TRUE, nor can it be proven FALSE. The only logically sound answer is UNKNOWN.

How Comparisons Yield UNKNOWN

Any standard comparison operator (=, !=, <>, >, <, <=, >=) applied to a NULL operand evaluates to UNKNOWN:

sql
-- Every single one of these expressions evaluates to UNKNOWN:
NULL = 500
NULL != 500
NULL > 500
NULL < 500
NULL = NULL
NULL <> NULL

The NULL = NULL Interview Trap

One of the most frequent technical interview questions asks: "What does SELECT * FROM table WHERE NULL = NULL; return?" It returns zero rows. Even two unknown values cannot be considered equal, because two unknown quantities in the real world might represent completely different unknown numbers.

The 3VL Truth Tables

Mastering understanding null semantics in sql examples requires understanding how UNKNOWN interacts with logical operators (AND, OR, NOT). SQL evaluates composite predicates using the following formal truth tables:

Logical AND Truth Table

OperandsTRUEFALSEUNKNOWN
TRUETRUEFALSEUNKNOWN
FALSEFALSEFALSEFALSE
UNKNOWNUNKNOWNFALSEUNKNOWN

Notice that FALSE AND UNKNOWN resolves to FALSE. Why? Because regardless of what the unknown value actually turns out to be, an AND condition with one FALSE branch can never be true. However, TRUE AND UNKNOWN must evaluate to UNKNOWN.

Logical OR Truth Table

OperandsTRUEFALSEUNKNOWN
TRUETRUETRUETRUE
FALSETRUEFALSEUNKNOWN
UNKNOWNTRUEUNKNOWNUNKNOWN

Notice that TRUE OR UNKNOWN resolves to TRUE. Because one operand is already proven true, the unknown branch cannot change the satisfied outcome. Conversely, FALSE OR UNKNOWN remains UNKNOWN.

Logical NOT Truth Table

OperandResult of NOT
TRUEFALSE
FALSETRUE
UNKNOWNUNKNOWN

Inverting an unknown proposition does not produce certainty; NOT (UNKNOWN) remains UNKNOWN.

The Law of Excluded Middle Trap

In classical Boolean logic, the law of the excluded middle dictates that for any proposition P, P OR (NOT P) is always true. In SQL 3VL, this law does not hold. Consider this innocent query intended to audit all employees:

sql
-- Trap: Does NOT return all employees!
SELECT employee_id, salary
FROM employees
WHERE salary > 50000 OR salary <= 50000;

If an employee has a NULL salary:

  1. salary > 50000 evaluates to UNKNOWN.
  2. salary <= 50000 evaluates to UNKNOWN.
  3. UNKNOWN OR UNKNOWN evaluates to UNKNOWN.

Because the WHERE clause retains rows only if the final condition evaluates strictly to TRUE, every employee with a missing salary is silently omitted from the results. To include them, you must explicitly handle nullability:

sql
-- Safe: Explicitly accounts for NULL values
SELECT employee_id, salary
FROM employees
WHERE salary > 50000 OR salary <= 50000 OR salary IS NULL;

Alternatively, modern SQL dialects supporting PostgreSQL and SQLite offer the null-safe comparison operator IS DISTINCT FROM:

sql
-- Evaluates to TRUE when one side is NULL and the other is not
WHERE status IS DISTINCT FROM 'archived';

3. The NOT IN With NULL Trap: The Deadliest Subquery Pitfall

Among all SQL pitfalls, none causes more severe production incidents than evaluating NOT IN against a dataset or subquery containing NULLs.

The Business Scenario

Imagine your marketing team wants a list of all customers who have never placed an order, so they can send a promotional onboarding voucher. Our database has 500 customers and 2,000 orders. An analyst writes the following query:

sql
-- THE TRAP QUERY: Returns 0 rows if any order has a NULL customer_id
SELECT c.customer_id, c.first_name, c.email
FROM customers AS c
WHERE c.customer_id NOT IN (
    SELECT o.customer_id
    FROM orders AS o
);

If even a single row in the orders table has a NULL customer_id (for example, guest checkouts, abandoned cart syncs, or legacy migrated transactions), this query returns exactly 0 rows. The entire marketing campaign is blocked, yet no database error is raised.

Deconstructing the Mathematical Failure

To understand why this happens, apply De Morgan's laws to how SQL compiles the IN and NOT IN operators.

The expression x IN (1, 2, NULL) expands to:

sql
(x = 1) OR (x = 2) OR (x = NULL)

The negated expression x NOT IN (1, 2, NULL) expands to:

sql
NOT ((x = 1) OR (x = 2) OR (x = NULL))

Distributing the negation across the disjunction yields a conjunction of inequalities:

sql
(x != 1) AND (x != 2) AND (x != NULL)

Now evaluate this expression for customer ID 5 (who has never ordered):

  1. 5 != 1 evaluates to TRUE.
  2. 5 != 2 evaluates to TRUE.
  3. 5 != NULL evaluates to UNKNOWN.
  4. TRUE AND TRUE AND UNKNOWN resolves to UNKNOWN.

Because the final predicate is UNKNOWN, the row is rejected. If you test a customer ID that has ordered (e.g. 1), the expression evaluates to FALSE AND ... which is FALSE.

Thus, for every row in the table, the condition evaluates to either FALSE or UNKNOWN. It can never evaluate to TRUE. The query returns an empty set every single time.

The Three Production-Grade Solutions

Here is how senior data engineers write robust anti-joins that remain immune to NULL values:

EXISTS checks whether the subquery produces one or more rows. It operates on existence rather than scalar value comparison:

sql
-- Fix 1: Bulletproof against NULLs and highly optimized by query planners
SELECT c.customer_id, c.first_name, c.email
FROM customers AS c
WHERE NOT EXISTS (
    SELECT 1
    FROM orders AS o
    WHERE o.customer_id = c.customer_id
);

When an order row has customer_id IS NULL, the join predicate o.customer_id = c.customer_id evaluates to UNKNOWN. The subquery discards that row and returns nothing for that comparison, allowing NOT EXISTS to evaluate cleanly to TRUE for unpurchased customers.

Fix 2: Explicit NULL Filtering in Subquery

If your team insists on using NOT IN, you must explicitly strip NULLs from the subquery candidate list:

sql
-- Fix 2: Safe NOT IN with explicit NULL exclusion
SELECT c.customer_id, c.first_name, c.email
FROM customers AS c
WHERE c.customer_id NOT IN (
    SELECT o.customer_id
    FROM orders AS o
    WHERE o.customer_id IS NOT NULL
);

Fix 3: LEFT JOIN Anti-Join Pattern

Join the child table and test for the absence of the primary key:

sql
-- Fix 3: Traditional outer join anti-pattern
SELECT c.customer_id, c.first_name, c.email
FROM customers AS c
LEFT JOIN orders AS o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;

Notice that we filter on o.order_id IS NULL (the primary key of orders), never on the joined foreign key.

Feature / Criteria

Master Advanced SQL Joins & Subqueries

Level up from basic queries to enterprise-grade analytics engineering with 100+ hands-on challenges in our Data Analyst Career Track.

Explore Career Track

4. NULL Filtering in WHERE vs HAVING: Execution Order & Aggregation Mechanics

A foundational concept in SQL query architecture is how NULLs are filtered before versus after aggregation. Understanding the SQL logical execution pipeline prevents catastrophic performance regressions and inaccurate analytical metrics:

1. FROM & JOIN  -->  Build virtual Cartesian product & join tables
2. WHERE        -->  Filter raw base rows (discards FALSE and UNKNOWN)
3. GROUP BY     -->  Form summary groups (NULLs grouped into one bucket)
4. HAVING       -->  Filter aggregated group summaries
5. SELECT       -->  Compute projected columns, scalar expressions & aliases
6. ORDER BY     -->  Sort final presentation rows

Row-Level Filtering: The WHERE Clause

WHERE filters individual rows from the source tables before any grouping occurs. If a row's predicate evaluates to FALSE or UNKNOWN, that single row is eliminated immediately.

sql
-- Filters out customers with missing cities BEFORE grouping
SELECT city, COUNT(*) AS customer_count
FROM customers
WHERE city IS NOT NULL
GROUP BY city;

Because this occurs in step 2 of execution:

  • The database engine can utilize B-tree indexes on city to skip NULL rows without reading table heap blocks.
  • Memory consumption during the subsequent GROUP BY hash or sort operation is minimized because unneeded rows were already discarded.

Group-Level Filtering: How GROUP BY & HAVING Treat NULLs

In the ANSI SQL standard, all NULL values in a grouping column are treated as mutually equivalent for the purpose of aggregation. They are clustered into a single summary group:

sql
-- Groups all 57 missing emails into a single NULL bucket
SELECT email, COUNT(*) AS row_count
FROM customers
GROUP BY email;

The resulting table contains one row where email is NULL, with a row_count of 57.

Once groups are formed, HAVING filters group summaries based on aggregate metrics. If you evaluate a condition against an aggregate that returns NULL, HAVING drops that entire group because the condition evaluates to UNKNOWN:

sql
-- Compare how WHERE and HAVING evaluate missing values
SELECT 
    p.category,
    COUNT(*) AS total_items,
    AVG(oi.unit_price) AS avg_item_price
FROM order_items AS oi
JOIN products AS p ON oi.product_id = p.product_id
GROUP BY p.category
HAVING AVG(oi.unit_price) > 50.00;

If a newly created product category has zero priced items (all unit prices are NULL), AVG(oi.unit_price) returns NULL. The comparison NULL > 50.00 evaluates to UNKNOWN, and the entire product category is pruned from the report.

The WHERE vs HAVING Performance Trap

A frequent mistake in analytical queries is placing row-level column filters into the HAVING clause instead of WHERE:

sql
-- WRONG & SLOW: Groups all rows before filtering
SELECT department_id, AVG(salary) AS avg_sal
FROM employees
GROUP BY department_id, status
HAVING status = 'active';
 
-- CORRECT & FAST: Filters base rows before grouping
SELECT department_id, AVG(salary) AS avg_sal
FROM employees
WHERE status = 'active'
GROUP BY department_id;

Filtering in WHERE prunes inactive employee rows prior to hashing, preserving memory and CPU bandwidth. Filtering in HAVING forces the engine to aggregate every historical record before discarding the computed summaries.


5. The COUNT Trap: Counting Rows vs. Counting Values

One of the most frequently asked interview questions tests candidate understanding of aggregate counting semantics.

The Experiment

Consider our 500-customer dataset where 57 customers lack an email address:

sql
-- Demonstrating the aggregate counting discrepancy
SELECT 
    COUNT(*)              AS count_star,
    COUNT(1)              AS count_one,
    COUNT(email)          AS count_column,
    COUNT(DISTINCT email) AS count_distinct_email
FROM customers;

The Output

count_starcount_onecount_columncount_distinct_email
500500443443

Why the Counts Differ

  1. COUNT(*) and COUNT(1) count rows. They evaluate whether a row exists in the relation. Even if every single column in a row contains NULL, COUNT(*) counts that row. Both return 500.
  2. COUNT(column) counts non-NULL values. It inspects the specific column expression and skips every row where that column is NULL. It returns 443.
  3. The arithmetic gap (500 - 443 = 57) represents the number of uncollected email addresses.

Aggregate Function Behavior Matrix

Every standard aggregate function in SQL follows strict ANSI null-handling rules:

FunctionIgnores NULLs?Return Value if All Values are NULLExample in 800-Review Dataset
COUNT(*)No0 (on empty set)Returns 800
COUNT(rating)Yes0Returns 714 (86 NULL ratings skipped)
SUM(rating)YesNULLSums 714 active ratings
AVG(rating)YesNULLComputes average of 714 active ratings (2.941)
MIN(rating)YesNULLMinimum of 714 active ratings
MAX(rating)YesNULLMaximum of 714 active ratings

The Dangerous Average Skew

Notice how AVG handles missing ratings. If 86 customers purchased an item but never left a rating:

  • AVG(rating) evaluates to 2.941. This represents the average rating among customers who actually rated the item.
  • AVG(COALESCE(rating, 0)) evaluates to 2.625. This assumes every non-rater intended to submit a score of zero.

Unless business specifications explicitly dictate that unsubmitted feedback is equivalent to zero stars, using COALESCE(rating, 0) inside an average calculation distorts product satisfaction metrics and misinforms business stakeholders.


6. NULL Poisons Arithmetic: Safe Calculations with COALESCE and NULLIF

In SQL arithmetic expressions, NULL acts as a logical poison pill. Any standard mathematical operation involving a NULL operand yields NULL:

sql
-- All of the following yield NULL:
100 + NULL
100 - NULL
100 * NULL
100 / NULL

Arithmetic Contamination in Financial Reporting

In our verified order_items table containing 5,000 line items, 169 records have a missing quantity due to unfulfilled backorders. Calculating line-item revenue directly results in catastrophic data loss:

sql
-- Broken Query: Yields NULL for 169 line items
SELECT 
    item_id, 
    quantity, 
    unit_price,
    quantity * unit_price AS raw_line_amount
FROM order_items
WHERE quantity IS NULL
LIMIT 5;
item_idquantityunit_priceraw_line_amount
3NULL89.99NULL
17NULL45.00NULL
42NULL129.50NULL
68NULL14.95NULL
91NULL210.00NULL

Even though unit_price contains valid numeric data, the missing quantity forces raw_line_amount to evaluate to NULL. When passed to an outer SUM(), those missing amounts contribute $0.00 to ledger totals without notifying the reporting system.

The Safety Net: COALESCE

The ANSI SQL COALESCE(val1, val2, ...) function accepts a variable list of arguments and returns the first non-NULL expression:

sql
-- Clean Query: Restores financial integrity
SELECT 
    item_id,
    COALESCE(quantity, 0) AS clean_quantity,
    COALESCE(quantity, 0) * unit_price AS clean_line_amount
FROM order_items
WHERE quantity IS NULL
LIMIT 5;

Now clean_quantity defaults to 0, and clean_line_amount computes safely to 0.00 instead of NULL.

Preventing Division by Zero with NULLIF

The counterpart to COALESCE is NULLIF(arg1, arg2). It compares two arguments and returns NULL if they are equal; otherwise, it returns arg1.

Data analysts frequently combine COALESCE and NULLIF to prevent division-by-zero crashes when calculating conversion rates or profit margins:

sql
-- Safe division pattern: returns NULL instead of raising a DB exception
SELECT 
    campaign_id,
    conversions,
    clicks,
    ROUND(conversions::numeric / NULLIF(clicks, 0) * 100, 2) AS conversion_rate
FROM marketing_campaigns;

If clicks is 0, NULLIF(clicks, 0) returns NULL. Dividing by NULL returns NULL rather than crashing the scheduled ETL job with a division by zero database error.


7. Interactive Practice Sandbox: Solve Anti-Joins Live

Mastering three-valued logic and subquery null mechanics requires executing queries against realistic relational databases. You can test both the broken subquery trap and the verified NOT EXISTS anti-join solution directly in our interactive PostgreSQL sandbox on Tracks That Have Never Been Purchased (Anti-Join).

This sandbox question runs against a production-grade digital media store database containing 3,503 catalog tracks and 2,240 invoice line items. Exactly 1,984 tracks have been purchased at least once, leaving 1,519 unpurchased tracks that the catalog merchandising team needs to identify.

Sandbox Query 1: The NOT IN Trap With an Injected NULL

Execute this query in the sandbox editor to observe how a single NULL destroys an anti-join:

sql
-- Sandbox Query 1: The NOT IN NULL collapse
SELECT COUNT(*) AS unpurchased_track_count
FROM track
WHERE trackid NOT IN (
    SELECT trackid FROM invoiceline
    UNION ALL
    SELECT NULL::int -- Simulating an unlinked transaction or guest line item
);

Sandbox Output: 0 rows

Because the subquery returns an injected NULL, every track row evaluates to UNKNOWN in the WHERE clause. Despite 1,519 unpurchased items existing in the table, the query reports zero results.

Sandbox Query 2: The Clean Correlated NOT EXISTS Anti-Join

Now execute the industry-standard NOT EXISTS solution:

sql
-- Sandbox Query 2: Verified NULL-safe correlated anti-join
SELECT t.trackid, t.name
FROM track AS t
WHERE NOT EXISTS (
    SELECT 1
    FROM invoiceline AS il
    WHERE il.trackid = t.trackid
)
ORDER BY t.trackid ASC;

Sandbox Output: 1,519 rows

trackidname
7Let's Get It Up
11C.O.D.
17Let There Be Rock
18Bad Boy Boogie
22Whole Lotta Rosie

The correlated subquery checks for the existence of matching rows. Even if invoiceline contains rows with NULL foreign keys, the join condition il.trackid = t.trackid evaluates to UNKNOWN, returning no rows to EXISTS. Consequently, NOT EXISTS correctly evaluates to TRUE, accurately surfacing all 1,519 unpurchased tracks.

To master relational data modeling and end-to-end analytics engineering, explore our comprehensive Data Analyst Career Track.


8. Syntax & Behavior Cheatsheet for SQL NULLs

Use this quick-reference table to audit queries for potential NULL evaluation bugs:

Scenario / OperationRecommended SyntaxRisky / Incorrect SyntaxFailure Mode
Test for Missing DataWHERE col IS NULLWHERE col = NULLEvaluates to UNKNOWN; returns 0 rows
Test for Existing DataWHERE col IS NOT NULLWHERE col <> NULLEvaluates to UNKNOWN; returns 0 rows
Test for Empty StringWHERE col = ''WHERE col IS NULLMissing strings and empty text are distinct types
Unmatched Records (Anti-Join)WHERE NOT EXISTS (...)WHERE col NOT IN (...)Subquery with single NULL returns 0 rows
Aggregate Row CountsCOUNT(*)COUNT(nullable_col)Column count skips missing values
Compute Additive SumsSUM(COALESCE(qty, 0))SUM(qty)Safe if missing quantities represent zero additions
Compute Metric AveragesAVG(rating)AVG(COALESCE(rating, 0))Coalescing to zero artificially depresses user averages
Prevent Division by Zeroval / NULLIF(denom, 0)val / denomCrashes query with zero-division error
Row-Level Pre-FilteringPut in WHERE clausePut in HAVING clauseSevere memory bloat and hash aggregation overhead

9. Next Steps & Interactive Practice

How to use understanding null semantics in sql effectively comes down to disciplined defensive coding: always verify whether missing values mean "empty" or "unknown," prefer NOT EXISTS over NOT IN, and guard arithmetic with COALESCE and NULLIF.

Practice SQL NULL Handling & Anti-Joins Live

Master correlated subqueries, avoid NOT IN traps, and solve realistic company interview problems in our PostgreSQL browser sandbox.

Open Practice Sandbox Free

Frequently Asked Questions

What is three-valued logic in SQL?

SQL three-valued logic (3VL) evaluates logical expressions to TRUE, FALSE, or UNKNOWN. Because NULL represents missing data rather than a fixed value, direct comparisons like NULL = NULL or column = 100 evaluate to UNKNOWN. Conditions in WHERE and HAVING clauses only retain rows that evaluate strictly to TRUE.

Why does NOT IN return zero rows when a subquery contains NULL?

The expression col NOT IN (SELECT other_col) expands logically to a chain of inequality checks joined by AND. If the subquery contains even one NULL, the comparison `col <> NULL` evaluates to UNKNOWN. Because TRUE AND UNKNOWN yields UNKNOWN, the entire condition can never be TRUE, causing SQL to return zero rows.

How does NULL filtering differ between WHERE and HAVING?

The WHERE clause filters individual base rows before grouping and aggregation, discarding any row where conditions evaluate to FALSE or UNKNOWN. The HAVING clause filters grouped summary rows after aggregation functions have already processed and skipped row-level NULLs, evaluating conditions on the resulting group aggregates.

Why does COUNT(*) return more rows than COUNT(column)?

COUNT(*) counts every row in the dataset regardless of whether individual columns contain NULL values. In contrast, COUNT(column) evaluates the specific column and skips every row where that column is NULL. The difference between the two counts reveals the exact number of NULL values in that column.

What is the difference between NULL and an empty string in SQL?

An empty string ('') is a known, zero-length string value present in memory, whereas NULL represents an absence of value or unknown data. Equality checks with empty strings (= '') match empty text but return UNKNOWN for NULLs. NULL rows can only be matched using IS NULL or IS NOT NULL operators.

What is the safest way to handle NULL in SQL arithmetic?

Use COALESCE(column, default_value) to replace NULLs before arithmetic calculations. Because any standard mathematical operation with NULL yields NULL (e.g. price * NULL = NULL), wrapping nullable columns in COALESCE(column, 0) prevents missing numbers from poisoning the calculation.

Anuj Saini

Written by

Anuj SainiFounder & Lead Instructor

Founder at Topfolio with 6+ years in data & analytics across JPMC, Ultrahuman, and high-growth startups. Sat on hiring panels, reviewed 500+ resumes, and writes practical SQL & data guides.