NULL in SQL Does Not Mean What You Think It Means

by Eric Hanson, Backend Developer at Clean Systems Consulting

The filter that silently excludes rows

Your query is supposed to return all orders that are not cancelled. You write WHERE cancelled = false. In production, orders where cancelled is NULL — representing orders created before the cancelled column was added — are silently excluded. You've lost records from your report without any error or warning.

NULL in SQL is not a value. It is the absence of a value — the database's way of representing "unknown" or "not applicable." This distinction has concrete consequences: NULL behaves differently in comparisons, in aggregations, in joins, and in boolean logic. Getting it wrong produces queries that return wrong results silently.

NULL is not false, and NULL is not zero

The most common NULL misconception: treating it as a falsy value in filter conditions.

-- A column where some rows have NULL
-- (e.g., discount_pct is NULL when no discount applies)

-- Intended: "orders with no discount"
WHERE discount_pct = 0     -- Returns rows where discount_pct is exactly 0
                           -- Does NOT return rows where discount_pct is NULL

WHERE discount_pct = NULL  -- Returns NOTHING. Always. This is always false.
                           -- You cannot compare with = NULL

-- Correct: check for NULL explicitly
WHERE discount_pct IS NULL
WHERE discount_pct IS NOT NULL

-- Correct: "orders with no discount OR discount of 0"
WHERE discount_pct IS NULL OR discount_pct = 0

-- Using COALESCE to normalize NULL to a default value
WHERE COALESCE(discount_pct, 0) = 0

= NULL is never true because NULL means "unknown." An unknown value compared to anything else is unknown, not true or false. This is three-valued logic: true, false, and unknown.

Three-valued logic and WHERE clauses

SQL WHERE clauses return rows only when the condition evaluates to TRUE. Conditions that evaluate to FALSE or UNKNOWN (NULL) are excluded. This is why WHERE column = NULL returns nothing — the comparison evaluates to UNKNOWN, not TRUE.

-- Suppose: column = NULL for some rows, column = 5 for others

WHERE column = 5      -- TRUE for rows where column = 5, UNKNOWN for NULL rows
WHERE column != 5     -- TRUE for rows where column != 5, UNKNOWN for NULL rows
WHERE column IS NULL  -- TRUE for NULL rows, FALSE for others
WHERE column IS NOT NULL -- FALSE for NULL rows, TRUE for others

The implication: WHERE column != 5 does NOT return NULL rows. Both = 5 and != 5 exclude NULLs. If you want "rows where column is not 5, including nulls":

WHERE column != 5 OR column IS NULL
-- Or equivalently:
WHERE NOT (column = 5)  -- Still excludes NULLs
-- This is NOT the same as: WHERE column != 5 OR column IS NULL

The NOT operator also propagates NULL: NOT NULL evaluates to NULL (unknown), not TRUE.

NULL propagation in expressions

Any arithmetic or string operation involving NULL produces NULL:

SELECT
  5 + NULL,          -- NULL
  'hello' || NULL,   -- NULL (string concatenation)
  NULL * 100,        -- NULL
  CASE WHEN NULL THEN 'yes' ELSE 'no' END  -- 'no' (WHEN NULL is UNKNOWN = false)

This means that if any input to a calculation is NULL, the entire expression becomes NULL. In revenue calculations, this is dangerous:

-- If unit_price or quantity is NULL, the total is NULL (not zero)
SELECT unit_price * quantity AS line_total FROM order_items;

-- Safe version: use COALESCE to provide defaults
SELECT COALESCE(unit_price, 0) * COALESCE(quantity, 0) AS line_total FROM order_items;

NULL in aggregations

Aggregate functions (SUM, AVG, COUNT, MIN, MAX) ignore NULLs, except COUNT(*) which counts all rows:

-- A column with values: 10, 20, NULL, 30, NULL
SELECT
  COUNT(*),           -- 5 (counts all rows including NULLs)
  COUNT(amount),      -- 3 (counts only non-NULL rows)
  SUM(amount),        -- 60 (ignores NULLs — equivalent to 10+20+30)
  AVG(amount),        -- 20 (60 / 3, not 60 / 5 — NULLs excluded from denominator)
  MIN(amount),        -- 10
  MAX(amount)         -- 30
FROM payments;

The AVG behavior is worth highlighting: it divides by the count of non-NULL values, not the total row count. If 40% of rows are NULL, the average reflects only the non-NULL population. Whether this is correct depends on your business question — sometimes you want SUM(amount) / COUNT(*) (treating NULL as zero for the average).

NULL in JOINs

NULL values in join columns do not match each other:

-- If user_id is NULL in some orders
SELECT * FROM orders o
JOIN users u ON o.user_id = u.id;
-- Orders with NULL user_id are excluded — NULL != NULL

This is correct behavior for FK columns (NULL user_id means "no user"), but it can surprise developers who expect NULL to match NULL in some other context.

NULL ordering

In ORDER BY, NULL sorts as larger than any other value in PostgreSQL (NULLs sort last in ASC, first in DESC). SQL Server and MySQL sort NULLs first in ASC. This is database-specific and matters for reports that depend on sort order:

-- PostgreSQL: explicit NULL placement
ORDER BY amount DESC NULLS LAST   -- Put NULLs at the end regardless of sort direction
ORDER BY amount ASC NULLS FIRST

NULL in UNIQUE constraints

Most databases allow multiple NULLs in a UNIQUE-constrained column, because NULL != NULL — each NULL is "unknown" and therefore not the same as any other NULL. PostgreSQL, MySQL, and SQL Server all allow this. If you need at-most-one-NULL semantics, use a partial unique index:

-- PostgreSQL: unique where not null
CREATE UNIQUE INDEX idx_users_referral_code
ON users (referral_code)
WHERE referral_code IS NOT NULL;

The practical rule

Treat NULL as a special case to handle explicitly. Use IS NULL and IS NOT NULL for filtering. Use COALESCE(value, default) to provide defaults in calculations. Use NULLIF(value, other) to convert a specific value to NULL (the inverse of COALESCE). Never compare to NULL with = or !=. When designing schemas, be deliberate about which columns allow NULL and document what NULL means for each one — "no value set," "not applicable," or "unknown" each have different query implications.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

What Async-First Backend Development Actually Looks Like in Practice

Async-first is easy to advocate for in the abstract. Here's what it concretely requires from the team running it and the contractors working within it.

Read more

Handling Criticism Without Feeling Defeated

Criticism stings, even when you know it’s supposed to help. Learning to handle it without losing confidence is a superpower for any professional.

Read more

Fixed Price vs Time & Materials — Which Contract Model Works Better for Backend Projects

Fixed price contracts transfer risk to the contractor and invite scope games; time and materials contracts transfer risk to the client and require active oversight — understanding which risk you are better positioned to manage determines which model to use.

Read more

Why Raleigh-Durham Startups Are Looking Beyond the Research Triangle for Backend Help

The Research Triangle has a strong engineering reputation. That reputation has made local backend hiring more competitive, not less.

Read more