The WHERE Clause — Filtering with Operators and Conditions

The WHERE clause is what transforms a full-table operation into a targeted one — it filters which rows a SELECT returns, which rows an UPDATE modifies, and which rows a DELETE removes. Without WHERE, SQL statements operate on every row. With WHERE, they operate on exactly the rows that satisfy the condition. Every FastAPI query parameter that filters results — ?status=published, ?author_id=5, ?created_after=2025-01-01 — translates to a WHERE condition. Understanding all the operators and patterns available in WHERE clauses is the foundation for writing precise, efficient database queries.

Comparison Operators

-- Equality and inequality
SELECT * FROM users WHERE role = 'admin';
SELECT * FROM users WHERE role != 'admin';   -- also: <> is equivalent
SELECT * FROM posts WHERE view_count >= 1000;
SELECT * FROM posts WHERE view_count > 0 AND view_count < 100;

-- BETWEEN (inclusive on both ends)
SELECT * FROM posts
WHERE created_at BETWEEN '2025-01-01' AND '2025-12-31';
-- Equivalent to: created_at >= '2025-01-01' AND created_at <= '2025-12-31'

SELECT * FROM posts WHERE view_count BETWEEN 100 AND 1000;

-- IN — match any value from a list
SELECT * FROM users WHERE role IN ('editor', 'admin');
SELECT * FROM posts WHERE id IN (1, 5, 12, 42);
SELECT * FROM posts WHERE status NOT IN ('archived', 'deleted');

-- IN with a subquery
SELECT * FROM posts
WHERE author_id IN (
    SELECT id FROM users WHERE role = 'admin'
);
Note: BETWEEN is inclusive on both ends — BETWEEN 1 AND 10 includes both 1 and 10. For date ranges, be careful: BETWEEN '2025-01-01' AND '2025-12-31' includes records from 2025-12-31 00:00:00 but misses records from 2025-12-31 23:59:59. For TIMESTAMPTZ ranges, prefer created_at >= '2025-01-01' AND created_at < '2026-01-01' (exclusive upper bound) to capture the full day of December 31.
Tip: Use ILIKE instead of LIKE for case-insensitive string matching in PostgreSQL. LIKE 'alice%' only matches strings starting with lowercase ‘alice’; ILIKE 'alice%' also matches ‘Alice’, ‘ALICE’, etc. For searching user-submitted text (names, titles, tags), always use ILIKE so the search is not case-sensitive. Note that ILIKE is PostgreSQL-specific — it does not exist in standard SQL.
Warning: Never construct SQL WHERE conditions by string interpolation in Python: f"WHERE name = '{user_input}'". This is a SQL injection vulnerability — a user can enter ' OR 1=1 -- and see all rows, or worse. Always use parameterised queries with placeholders: WHERE name = %s (psycopg2) or WHERE name = :name (SQLAlchemy). FastAPI + SQLAlchemy handles this automatically, but raw psycopg2 requires explicit parameterisation.

NULL Handling

-- NULL is not a value — it means "unknown" or "missing"
-- You CANNOT use = or != to compare with NULL

-- Wrong: WHERE col = NULL always returns no rows
-- Wrong: WHERE col != NULL always returns no rows

-- Correct: IS NULL / IS NOT NULL
SELECT * FROM posts WHERE published_at IS NULL;     -- not published yet
SELECT * FROM posts WHERE published_at IS NOT NULL; -- has been published

SELECT * FROM users WHERE deleted_at IS NULL;       -- active users
SELECT * FROM posts WHERE excerpt IS NULL;          -- no excerpt provided

-- NULL in expressions: NULL propagates
-- NULL + 5 = NULL
-- NULL = NULL = NULL (not TRUE!)
-- COALESCE returns first non-NULL value
SELECT
    id,
    COALESCE(excerpt, SUBSTRING(body, 1, 200)) AS display_excerpt
FROM posts;
-- Uses excerpt if available, otherwise first 200 chars of body

String Pattern Matching

-- LIKE: case-sensitive pattern matching
-- % = any sequence of characters
-- _ = exactly one character

SELECT * FROM users WHERE name LIKE 'Alice%';     -- starts with 'Alice'
SELECT * FROM users WHERE name LIKE '%Smith';     -- ends with 'Smith'
SELECT * FROM users WHERE name LIKE '%ali%';      -- contains 'ali'
SELECT * FROM users WHERE email LIKE '_@%.com';   -- 1 char, @, domain, .com

-- ILIKE: case-insensitive (PostgreSQL-specific)
SELECT * FROM users WHERE name ILIKE 'alice%';    -- matches Alice, ALICE, alice

-- SIMILAR TO: SQL standard regex (rarely used — prefer ~ for regex)
SELECT * FROM posts WHERE slug SIMILAR TO '[a-z0-9-]+';

-- Regular expressions (PostgreSQL-specific, very powerful)
SELECT * FROM posts WHERE slug ~ '^[a-z0-9-]+$';   -- ~ means matches regex
SELECT * FROM posts WHERE title ~* 'python';        -- ~* is case-insensitive regex
SELECT * FROM posts WHERE slug !~ '[A-Z]';          -- !~ means does NOT match

Combining Conditions

-- AND — all conditions must be true
SELECT * FROM posts
WHERE status = 'published'
  AND author_id = 1
  AND view_count > 100;

-- OR — at least one condition must be true
SELECT * FROM users
WHERE role = 'admin'
   OR role = 'editor';
-- Better written as: WHERE role IN ('admin', 'editor')

-- NOT — negate a condition
SELECT * FROM posts WHERE NOT (status = 'archived');
SELECT * FROM posts WHERE status != 'archived';   -- equivalent

-- Complex combinations (use parentheses for clarity)
SELECT * FROM posts
WHERE (status = 'published' OR status = 'draft')
  AND author_id IN (
      SELECT id FROM users WHERE role = 'editor'
  )
  AND created_at >= NOW() - INTERVAL '30 days';

-- Dynamic filtering pattern used in FastAPI
-- Build conditions based on query parameters:
-- WHERE (status = :status OR :status IS NULL)
-- This pattern: matches the status if a filter is provided,
-- or matches all rows if the filter is NULL (not provided)

Common Mistakes

Mistake 1 — Using = NULL instead of IS NULL

❌ Wrong — always returns zero rows:

SELECT * FROM posts WHERE published_at = NULL;   -- matches nothing!

✅ Correct:

SELECT * FROM posts WHERE published_at IS NULL;   -- ✓

Mistake 2 — String interpolation (SQL injection risk)

❌ Wrong — vulnerable to SQL injection:

query = f"SELECT * FROM users WHERE email = '{user_email}'"
# If user_email = "' OR 1=1 --", this returns all users!

✅ Correct — always use parameterised queries:

cursor.execute("SELECT * FROM users WHERE email = %s", (user_email,))   # ✓

Mistake 3 — OR without parentheses produces unexpected results

❌ Wrong — AND binds tighter than OR, so this means (A AND B) OR C:

WHERE status = 'published' AND author_id = 1 OR role = 'admin'
-- Matches: (published posts by author 1) OR (any row where role=admin)

✅ Correct — use parentheses to make intent explicit:

WHERE status = 'published'
  AND (author_id = 1 OR author_id = 2)   -- ✓ clear grouping

Quick Reference

Operator Example Meaning
= role = 'admin' Equal
!= / <> role != 'admin' Not equal
BETWEEN a AND b age BETWEEN 18 AND 65 Inclusive range
IN (...) role IN ('user', 'admin') Match any value
NOT IN (...) status NOT IN ('archived') Match none of these
IS NULL deleted_at IS NULL Value is absent
IS NOT NULL published_at IS NOT NULL Value present
LIKE '%x%' name LIKE 'Ali%' Pattern match (case-sensitive)
ILIKE '%x%' name ILIKE 'ali%' Pattern match (case-insensitive)
~ / ~* slug ~ '^[a-z]+' Regex match / case-insensitive

🧠 Test Yourself

Your FastAPI search endpoint accepts an optional ?author_id=5 query parameter. When no parameter is provided, you want all posts. Which SQL pattern handles both cases without building separate queries?