Conditional Expressions — CASE, COALESCE, NULLIF and GREATEST

SQL’s conditional expressions let you embed branching logic directly inside queries — transforming, categorising, and cleaning data without a separate Python processing step. CASE WHEN is the SQL equivalent of Python’s if/elif/else, producing different output values based on conditions. COALESCE replaces NULL with a fallback value. NULLIF converts a specific value to NULL (the inverse of COALESCE). Together, these tools let you shape data for API responses entirely in SQL — computing labels like “today”, “this week”, “popular”, or substituting defaults for missing values before data even reaches your FastAPI application.

CASE WHEN — Conditional Values

-- Searched CASE: evaluate conditions in order, return first matching result
SELECT
    id,
    title,
    view_count,
    CASE
        WHEN view_count >= 10000 THEN 'viral'
        WHEN view_count >= 1000  THEN 'popular'
        WHEN view_count >= 100   THEN 'moderate'
        ELSE 'low'
    END AS popularity_label
FROM posts;

-- Simple CASE: compare one expression to many values
SELECT
    id,
    status,
    CASE status
        WHEN 'published' THEN '✓ Live'
        WHEN 'draft'     THEN '✎ Draft'
        WHEN 'archived'  THEN '✗ Hidden'
        ELSE '? Unknown'
    END AS status_label
FROM posts;

-- CASE in WHERE clause
SELECT * FROM posts
WHERE CASE
    WHEN status = 'published' THEN TRUE
    WHEN status = 'draft' AND author_id = 1 THEN TRUE
    ELSE FALSE
END;

-- CASE in ORDER BY — custom sort order
SELECT id, title, status
FROM posts
ORDER BY
    CASE status
        WHEN 'published' THEN 1
        WHEN 'draft'     THEN 2
        WHEN 'archived'  THEN 3
        ELSE 4
    END,
    created_at DESC;

-- CASE in aggregate (conditional counting)
SELECT
    COUNT(CASE WHEN view_count >= 1000 THEN 1 END) AS popular_posts,
    COUNT(CASE WHEN view_count <  1000 THEN 1 END) AS low_traffic_posts
FROM posts;
Note: The CASE expression returns NULL when no WHEN condition matches and no ELSE is specified. Always include an ELSE clause unless you intentionally want NULL for unmatched rows. In aggregate contexts like COUNT(CASE WHEN ... THEN 1 END), the NULLs from unmatched rows are automatically excluded from the count — which is exactly the behaviour you want for conditional counting.
Tip: CASE WHEN in ORDER BY is the cleanest way to implement custom sort orders that do not correspond to alphabetical or numeric ordering. Sorting post statuses as “published first, then draft, then archived” is done by mapping each status to a number and sorting by that number: ORDER BY CASE status WHEN 'published' THEN 1 WHEN 'draft' THEN 2 ELSE 3 END. This is much cleaner than creating a separate sort_order column in the database.
Warning: Do not use CASE WHEN col = NULL THEN ... — as with all NULL comparisons, this is always false. Use CASE WHEN col IS NULL THEN ... for NULL checks inside CASE expressions. Also remember that CASE evaluates conditions from top to bottom and stops at the first match — put the most specific conditions first and the most general last, just like Python’s if/elif chain.

COALESCE — Replace NULL with a Default

-- COALESCE(val1, val2, ...) returns the first non-NULL value in the list
SELECT COALESCE(NULL, NULL, 'first non-null', 'ignored');   -- 'first non-null'
SELECT COALESCE(NULL, 42);                                   -- 42
SELECT COALESCE(42, 99);                                     -- 42 (first is not NULL)

-- Provide fallback for missing data
SELECT
    id,
    COALESCE(excerpt, LEFT(body, 200)) AS display_excerpt,
    COALESCE(avatar_url, '/static/default-avatar.png') AS avatar
FROM posts p
LEFT JOIN user_profiles up ON p.author_id = up.user_id;

-- Default for nullable aggregates
SELECT
    author_id,
    COALESCE(SUM(view_count), 0) AS total_views   -- 0 instead of NULL if no posts
FROM users u
LEFT JOIN posts p ON u.id = p.author_id
GROUP BY u.id;

-- COALESCE in WHERE — treat NULL same as a value
SELECT * FROM posts
WHERE COALESCE(deleted_at, 'infinity'::TIMESTAMPTZ) > NOW();

NULLIF — Convert a Value to NULL

-- NULLIF(val, compare_value): returns NULL if val = compare_value, else val
-- This is the inverse of COALESCE

SELECT NULLIF(0, 0);       -- NULL  (0 equals the comparison value)
SELECT NULLIF(5, 0);       -- 5     (5 does not equal 0)
SELECT NULLIF('', '');     -- NULL  (empty string becomes NULL)
SELECT NULLIF('hello',''); -- 'hello'

-- Practical: prevent division by zero
SELECT
    post_id,
    total_reads,
    total_reads / NULLIF(total_impressions, 0) AS read_rate
    -- NULLIF(0, 0) = NULL, and NULL / anything = NULL
    -- Without NULLIF: division by zero raises an error
FROM post_analytics;

-- Clean empty strings: treat '' same as NULL
SELECT COALESCE(NULLIF(TRIM(bio), ''), 'No bio provided') AS display_bio
FROM user_profiles;
-- TRIM removes whitespace → NULLIF('', '') = NULL → COALESCE returns default

GREATEST and LEAST

-- GREATEST: returns the largest value from a list
-- LEAST: returns the smallest value from a list
-- Both return NULL if ANY argument is NULL (unlike MAX/MIN which ignore NULLs)

SELECT GREATEST(1, 5, 3, 2);       -- 5
SELECT LEAST(1, 5, 3, 2);          -- 1
SELECT GREATEST('apple', 'banana', 'cherry');  -- 'cherry' (alphabetical)

-- Clamp a value to a range: max(min_val, min(max_val, val))
SELECT GREATEST(0, LEAST(100, user_score))   AS clamped_score
FROM users;
-- Ensures score is between 0 and 100 regardless of input

-- Ensure updated_at never goes backwards
UPDATE posts
SET updated_at = GREATEST(updated_at, NOW())
WHERE id = 42;

-- Cap page size between 1 and 100
SELECT GREATEST(1, LEAST(100, :page_size)) AS safe_page_size;

Common Mistakes

Mistake 1 — Forgetting ELSE in CASE (silent NULLs)

❌ Wrong — no ELSE means unmatched rows return NULL:

CASE WHEN status = 'published' THEN 'live'
     WHEN status = 'draft'     THEN 'editing'
END AS label
-- Archived posts return NULL label silently!

✅ Correct — always include ELSE:

CASE WHEN status = 'published' THEN 'live'
     WHEN status = 'draft'     THEN 'editing'
     ELSE 'other'
END AS label   -- ✓ no NULLs

Mistake 2 — Division by zero without NULLIF

❌ Wrong — crashes when denominator is 0:

SELECT total_clicks / total_views AS ctr FROM ads;   -- ERROR if total_views = 0!

✅ Correct:

SELECT total_clicks::FLOAT / NULLIF(total_views, 0) AS ctr FROM ads;   -- NULL instead of error ✓

Mistake 3 — Using COALESCE to check multiple columns when only one is needed

❌ Wrong — evaluates both regardless:

COALESCE(expensive_function(), other_expensive_function())
-- Both functions are called even if the first returns non-NULL

✅ Correct — COALESCE short-circuits in most cases; for truly expensive operations consider CASE WHEN.

Quick Reference

Expression Use For Example
CASE WHEN c THEN v ELSE d END Conditional values Status labels, custom sort
COALESCE(a, b, c) First non-NULL fallback Default avatar, zero for NULL count
NULLIF(a, b) Convert specific value to NULL Avoid divide-by-zero, empty string → NULL
GREATEST(a, b, c) Largest of N values Clamp lower bound
LEAST(a, b, c) Smallest of N values Clamp upper bound

🧠 Test Yourself

A post’s excerpt column is nullable. When it is NULL you want to display the first 150 characters of body. When it is an empty string after trimming, you also want the body fallback. Write the SQL expression for the display excerpt.