Filter concepts by levelShowing all levels.

PostgreSQL · Section 2

SQL Fundamentals

Level
beginner
Read
30 min
Concepts
6

The four statements every later section builds on, the fixed pipeline a query's clauses run through, conditional expressions and type conversion, and the two things that quietly produce wrong results when overlooked: three-valued NULL logic and operator precedence.

PostgreSQL overview

What is true here

  1. SELECT reads; INSERT/UPDATE/DELETE write; RETURNING hands back affected rows in the same statement.
  2. WHERE filters rows before grouping; HAVING filters groups after — only HAVING can reference an aggregate.
  3. COALESCE returns the first non-NULL argument; NULLIF manufactures a NULL when two values are equal.
  4. NULL = NULL evaluates to NULL (unknown), never trueIS NULL is the only correct test.
  5. AND binds tighter than OR — a WHERE clause mixing both needs parentheses to say what it means.

What you will be able to do

  • Use RETURNING to see a write's effect without a follow-up query
  • Predict which clause (WHERE vs HAVING) can filter on a given condition
  • Convert a sentinel value into a real NULL, and supply a default for one
  • Recognize when a WHERE clause is silently dropping rows because of NULL or missing parentheses
What happens to a row, in order

WHERE

filters individual rows

GROUP BY / HAVING

collapses rows, filters groups

SELECT list

expressions, functions, aliases

ORDER BY / LIMIT

sorts, then slices

  • WHERE — filters individual rows
    • leads to GROUP BY / HAVING
  • GROUP BY / HAVING — collapses rows, filters groups
    • leads to SELECT list
  • SELECT list — expressions, functions, aliases
    • leads to ORDER BY / LIMIT
  • ORDER BY / LIMIT — sorts, then slices

Reading and writing

The four statements, and the modifier that closes the loop on a write.

SELECT, INSERT, UPDATE, DELETE and RETURNING

corebeginner

SELECT reads rows, INSERT adds them, UPDATE changes them, DELETE removes them. RETURNING attaches to a write to hand back the affected rows in the same round trip — no second query needed to see what happened.

Think of it as

Four verbs and one modifier. SELECT never changes data — it only looks. INSERT, UPDATE and DELETE all change data and, by default, tell you nothing about what changed beyond a row count. RETURNING is the one word that makes a write also answer "and what did that actually produce?" in the same statement.

sql
SELECT id, title FROM tasks WHERE done = false;

INSERT INTO tasks (title) VALUES ('Write docs') RETURNING id;

UPDATE tasks SET done = true WHERE id = 3 RETURNING *;

DELETE FROM tasks WHERE done = true RETURNING id;

What we're doing: Run all four statements against one table, using RETURNING each time to see the effect without a follow-up SELECT.

crud_demo.sqlsql
CREATE TABLE tasks (id SERIAL PRIMARY KEY, title TEXT, done BOOLEAN DEFAULT false);

INSERT INTO tasks (title) VALUES ('Ship the feature'), ('Write docs')
    RETURNING id, title;

SELECT id, title, done FROM tasks ORDER BY id;

UPDATE tasks SET done = true WHERE title = 'Ship the feature'
    RETURNING id, done;

DELETE FROM tasks WHERE done = true
    RETURNING id, title;
1
id fills itself; done defaults to false for every new row.
3–4
Two rows in one INSERT. RETURNING hands back both, in the order they were inserted.
6
A plain SELECT, just to see the table state — not required by RETURNING, done here to show it.
8–9
UPDATE changes one row; RETURNING confirms exactly what changed, without a second query.
11–12
DELETE removes the row that is now done; RETURNING hands back what was deleted, since the row no longer exists to SELECT afterward.
Output
 id |       title       
----+-------------------
  1 | Ship the feature
  2 | Write docs
(2 rows)

 id |       title       | done 
----+-------------------+------
  1 | Ship the feature   | f
  2 | Write docs         | f
(2 rows)

 id | done 
----+------
  1 | t
(1 row)

 id |       title       
----+-------------------
  1 | Ship the feature
(1 row)

Why this works: RETURNING attaches directly to the write statement, so PostgreSQL hands back the affected rows as part of executing it — there is no window where the client has committed a write but not yet seen its result. This matters most for DELETE: once a row is gone, an ordinary SELECT can never see it again, so RETURNING is the only way to get the deleted row's data back in the same transaction. UPDATE's RETURNING reflects the row AFTER the change, which is why done reads 't', not the pre-update 'f'.

Running UPDATE or DELETE with no WHERE clause

Wrong

sql
UPDATE tasks SET done = true;
-- every row, not just the one meant

Better

sql
UPDATE tasks SET done = true WHERE id = 1;
-- exactly the row meant

What you see: The wrong version reports "UPDATE 2" (or however many rows the table has) when the intent was to change one row — every task is now marked done, silently, with no error raised.

Why: UPDATE and DELETE have no implicit scope — omitting WHERE means "every row in the table," not "no rows" or an error. PostgreSQL has no confirmation prompt for this the way an interactive tool might; the statement simply runs. Always write the WHERE clause first, or run the equivalent SELECT first to see exactly which rows would be affected before switching the SELECT to an UPDATE or DELETE.

A write with RETURNING answers its own question

INSERT/UPDATE/DELETE

changes rows

RETURNING *

hands back the affected rows

result set

no second SELECT needed

  1. INSERT/UPDATE/DELETE — changes rows
  2. RETURNING * — hands back the affected rows
  3. result set — no second SELECT needed

The four statements, and what RETURNING adds to each

The four statements, and what RETURNING adds to each
StatementEffectWith RETURNING
SELECT ... FROM treads rows, changes nothingnot applicable — SELECT already returns rows
INSERT INTO t ... RETURNING *adds row(s)hands back the row(s) just inserted, generated columns included
UPDATE t SET ... RETURNING *changes matching row(s)hands back each row AFTER the update was applied
DELETE FROM t ... RETURNING *removes matching row(s)hands back each row as it existed just before deletion

Together

sql
CREATE TABLE tasks (id SERIAL PRIMARY KEY, title TEXT, done BOOLEAN DEFAULT false);

INSERT INTO tasks (title) VALUES ('Ship the feature') RETURNING id, title;
-- id | title
--  1 | Ship the feature

UPDATE tasks SET done = true WHERE id = 1 RETURNING id, done;
-- id | done
--  1 | t

DELETE FROM tasks WHERE id = 1 RETURNING id, title;
-- id | title
--  1 | Ship the feature

Remember: RETURNING hands back affected rows in the same round trip — the only way to see a deleted row's data, since a later SELECT never can.

See also: filtering and sorting · postgresql as rdbms

Advertisement

Shaping the result

Filtering, grouping and sorting — in the order PostgreSQL actually applies them.

WHERE, ORDER BY, LIMIT/OFFSET, GROUP BY, HAVING, DISTINCT

corebeginner

WHERE filters rows first. GROUP BY collapses rows into groups, HAVING filters those groups. ORDER BY sorts the result, LIMIT/OFFSET slice it. DISTINCT drops duplicates; DISTINCT ON keeps one row per group, chosen by ORDER BY.

Think of it as

A pipeline, not a single filter. Rows go through WHERE first (row by row), then get bucketed by GROUP BY, then buckets get filtered by HAVING (group by group), then what survives gets sorted by ORDER BY, and finally LIMIT/OFFSET cuts a window out of the sorted result. WHERE cannot see an aggregate like count(*) because grouping has not happened yet when WHERE runs; HAVING can, because it runs after.

sql
SELECT customer_id, count(*) AS orders
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
HAVING count(*) > 2
ORDER BY orders DESC
LIMIT 5 OFFSET 0;

SELECT DISTINCT status FROM orders;

What we're doing: Show WHERE filtering rows, GROUP BY/HAVING filtering groups by an aggregate WHERE cannot see, and ORDER BY/LIMIT slicing the result.

filtering_demo.sqlsql
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER,
    status TEXT,
    total NUMERIC
);

INSERT INTO orders (customer_id, status, total) VALUES
    (1, 'completed', 50), (1, 'completed', 30), (1, 'completed', 20),
    (2, 'completed', 100), (2, 'cancelled', 40),
    (3, 'completed', 10);

SELECT customer_id, count(*) AS orders, sum(total) AS total
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
HAVING count(*) > 1
ORDER BY total DESC
LIMIT 1;
8–11
Six rows: customer 1 has three completed orders, customer 2 has one completed and one cancelled, customer 3 has one completed.
15
WHERE removes the cancelled row before any grouping happens.
16
The remaining five rows collapse into three groups, one per customer_id.
17
HAVING drops customer 3's group (count 1) — count(*) is an aggregate, which WHERE could never have referenced.
18
Of the two surviving groups, sort by total descending.
19
LIMIT 1 keeps only the top group after sorting — customer 1, whose three orders sum to 100.
Output
 customer_id | orders | total 
-------------+--------+-------
           1 |      3 |   100
(1 row)

Why this works: WHERE runs first and works row by row, so it can test status but has no concept of "how many rows share this customer_id" yet — that only exists after GROUP BY collapses rows into groups. HAVING runs after grouping, so count(*) and sum(total) are already computed values it can filter on. Customer 2 also totals 100 across all orders, but its cancelled row was removed by WHERE before grouping, leaving only one completed order (total 100) — group count 1, which HAVING excludes. Customer 1 survives with count 3 and total 100, and LIMIT 1 keeps it as the sole top row.

Trying to filter on an aggregate in WHERE

Wrong

sql
SELECT customer_id, count(*)
FROM orders
WHERE count(*) > 1
GROUP BY customer_id;

Better

sql
SELECT customer_id, count(*)
FROM orders
GROUP BY customer_id
HAVING count(*) > 1;

What you see: ERROR: aggregate functions are not allowed in WHERE — raised before the query runs at all, not a wrong result.

Why: WHERE filters individual rows, before GROUP BY has produced any groups to count — there is nothing for count(*) to mean at that point in the pipeline. HAVING exists specifically because a query needs a second filtering step that runs AFTER grouping, once aggregates are actual computed values.

Clause order in one query

WHERE

filters rows

GROUP BY

collapses into groups

HAVING

filters groups

ORDER BY

sorts the result

LIMIT/OFFSET

slices the sorted result

  • WHERE — filters rows
    • leads to GROUP BY
  • GROUP BY — collapses into groups
    • leads to HAVING
  • HAVING — filters groups
    • leads to ORDER BY
  • ORDER BY — sorts the result
    • leads to LIMIT/OFFSET
  • LIMIT/OFFSET — slices the sorted result

What each clause operates on

What each clause operates on
ClauseOperates onRuns relative to grouping
WHEREindividual rowsbefore GROUP BY
GROUP BYrows, collapsing them into groups
HAVINGgroupsafter GROUP BY
ORDER BYthe final result setafter everything else
LIMIT / OFFSETthe sorted final resultafter ORDER BY

Together

sql
SELECT customer_id, count(*) AS orders
FROM orders
WHERE status = 'completed'        -- filters ROWS first
GROUP BY customer_id              -- then collapses into one row per customer
HAVING count(*) > 2               -- then filters GROUPS
ORDER BY orders DESC              -- then sorts what's left
LIMIT 5;                          -- then takes the top 5

DISTINCT vs DISTINCT ON

DISTINCT vs DISTINCT ON
FormKeeps
SELECT DISTINCT *one copy of each fully-identical row
SELECT DISTINCT colone row per distinct value of col — other columns collapsed away
SELECT DISTINCT ON (col) *one whole row per value of col, chosen by ORDER BY

Together

sql
-- one row per customer: the most recent order, in full
SELECT DISTINCT ON (customer_id) *
FROM orders
ORDER BY customer_id, created_at DESC;

Remember: WHERE filters rows before grouping and cannot see an aggregate; HAVING filters groups after and can. Order: WHERE → GROUP BY → HAVING → ORDER BY → LIMIT.

See also: select insert update delete · null semantics

Advertisement

Computing values

Conditional logic, type conversion, naming and functions inside a SELECT list.

CASE, COALESCE, NULLIF, CAST and type conversion

corebeginner

CASE branches like an if/elif chain. COALESCE returns the first non-NULL argument — a default. NULLIF returns NULL if two values are equal, else the first — turning a sentinel into a real NULL. CAST converts a value between types.

Think of it as

Four small tools for one job: producing a value without leaving SQL. CASE is a full conditional. COALESCE is CASE's shortcut for 'use this, or this, or finally this default.' NULLIF is COALESCE's mirror image — it manufactures a NULL instead of avoiding one, for the specific case of 'treat this particular value as if it were missing.' CAST changes a value's type, so a text column can be compared to a number, or a number formatted as text.

sql
SELECT
    CASE WHEN total > 100 THEN 'large' WHEN total > 10 THEN 'medium' ELSE 'small' END AS size,
    COALESCE(nickname, first_name, 'Unknown') AS display_name,
    NULLIF(discount_code, '') AS discount_code,
    price::numeric(10, 2) AS price
FROM orders;

What we're doing: Combine all four tools in one SELECT over a small orders table, showing each one produce a real, checkable value.

conditional_demo.sqlsql
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    total NUMERIC,
    nickname TEXT,
    discount_code TEXT
);

INSERT INTO orders (total, nickname, discount_code) VALUES
    (150, NULL, ''),
    (25, 'Big Sale', 'NONE');

SELECT
    id,
    CASE WHEN total > 100 THEN 'large' WHEN total > 10 THEN 'medium' ELSE 'small' END AS size,
    COALESCE(nickname, 'Unnamed order') AS label,
    NULLIF(discount_code, '') AS code,
    total::text || ' USD' AS display_total
FROM orders
ORDER BY id;
8–10
Row 1 has total 150, no nickname, and an empty-string discount_code — not NULL, an actual empty string.
13–15
Three WHEN branches; the first row matches "large" (>100), the second matches "medium" (>10).
16
Row 1's NULL nickname falls through to the fallback; row 2's real nickname is used as-is.
17
NULLIF('', '') returns NULL for row 1, turning the empty-string sentinel into a real absence. Row 2's 'NONE' does not equal '', so it passes through unchanged.
18
total::text converts NUMERIC to TEXT so it can be concatenated with a literal string using ||.
Output
 id |  size  |     label     | code | display_total 
----+--------+---------------+------+----------------
  1 | large  | Unnamed order |      | 150 USD
  2 | medium | Big Sale      | NONE | 25 USD

Why this works: CASE evaluates WHEN clauses top to bottom and stops at the first true one, so 150 matches "large" before "medium" is even checked. COALESCE treats SQL NULL specifically — row 1's nickname column holds NULL, so COALESCE moves to its fallback; row 2's real value short-circuits it. NULLIF compares the discount_code against the empty string as its second argument — row 1's discount_code IS the empty string, so NULLIF manufactures a NULL, which renders as blank; row 2's "NONE" does not match "", so it is returned unchanged. The ::text cast is what makes || legal at all — PostgreSQL will not implicitly concatenate a NUMERIC with a TEXT literal.

Using COALESCE to catch an empty string, not just NULL

Wrong

sql
SELECT COALESCE(discount_code, 'NONE') FROM orders;
-- row 1's discount_code is '', not NULL — COALESCE never triggers

Better

sql
SELECT COALESCE(NULLIF(discount_code, ''), 'NONE') FROM orders;
-- NULLIF turns '' into NULL first, THEN COALESCE catches it

What you see: The wrong version returns an empty string for row 1, not the intended 'NONE' fallback — COALESCE only ever looks for NULL, and an empty string is not NULL.

Why: COALESCE and empty string are unrelated concepts in SQL — '' is a real, zero-length value, not an absence of a value. NULLIF(discount_code, '') is the standard way to bridge the two: it converts the specific sentinel value ('') into an actual NULL, which COALESCE can then catch. This is exactly the combination NULLIF exists to enable.

Four tools, four different jobs

CASE

branch on any condition

COALESCE

first non-NULL value

NULLIF

turn a sentinel into NULL

CAST / ::

change the type

  1. CASE — branch on any condition
  2. COALESCE — first non-NULL value
  3. NULLIF — turn a sentinel into NULL
  4. CAST / :: — change the type

Four conditional and conversion tools

Four conditional and conversion tools
ExpressionReturns
CASE WHEN cond THEN a ELSE b ENDa if cond is true, else b (or NULL with no ELSE)
COALESCE(a, b, c)the first of a, b, c that is not NULL
NULLIF(a, b)NULL if a equals b, otherwise a
x::integer / CAST(x AS integer)x converted to the target type, or an error if the value cannot convert

Together

sql
SELECT
    CASE WHEN 5 > 3 THEN 'yes' ELSE 'no' END,        -- 'yes'
    COALESCE(NULL, NULL, 'fallback'),                 -- 'fallback'
    NULLIF('N/A', 'N/A'),                              -- NULL
    NULLIF('active', 'N/A'),                           -- 'active'
    '42'::integer + 1;                                 -- 43

Remember: COALESCE returns the first non-NULL argument; NULLIF manufactures a NULL when two values are equal — combine them to treat a sentinel value as absence.

See also: null semantics · aliases and functions

Aliases, expressions, scalar and aggregate functions

standardbeginner

AS renames a column or table. A scalar function returns one value per row (upper, round); an aggregate returns one value per group (count, sum, avg) — the same row-vs-group distinction WHERE and HAVING split on.

Think of it as

An alias renames a column or table for the rest of the query, purely cosmetic. An expression computes a value from columns and literals — it can appear anywhere a column can. A scalar function takes one row's values and returns one value per row (upper(name)); an aggregate function takes many rows and returns one value for the whole group (count(*), sum(total)) — the same distinction WHERE-vs-HAVING draws on.

sql
SELECT
    o.id AS order_id,               -- column alias, AS optional
    c.name customer_name,           -- alias without AS — same effect
    o.total * 1.1 AS total_with_tax -- an expression, aliased
FROM orders o                       -- table alias
JOIN customers c ON c.id = o.customer_id;

What we're doing: Use a table alias, a column alias, an inline expression, a scalar function and an aggregate function together in one query.

aliases_demo.sqlsql
CREATE TABLE orders (id SERIAL PRIMARY KEY, status TEXT, total NUMERIC);

INSERT INTO orders (status, total) VALUES
    ('completed', 50), ('completed', 150), ('cancelled', 30);

SELECT
    upper(o.status) AS status_label,
    count(*) AS order_count,
    sum(o.total) AS total_revenue,
    round(avg(o.total), 2) AS avg_order
FROM orders o
WHERE o.status = 'completed'
GROUP BY o.status;
11
o is a table alias — every later reference to orders in this query can use o instead.
6
upper() is a scalar function — applied to the single, already-grouped status value.
7–9
count, sum and avg are all aggregates — one value each, for the whole (single) group that survives WHERE.
9
round(avg(...), 2) nests a scalar function around an aggregate's result — legal, since avg() already produced one value by the time round() runs.
12
WHERE filters using the alias o, exactly as it would use the full table name orders.
Output
 status_label | order_count | total_revenue | avg_order 
--------------+-------------+---------------+-----------
 COMPLETED    |           2 |           200 |    100.00

Why this works: The cancelled row is excluded by WHERE before grouping, leaving two completed orders (50 and 150). count(*) counts them, sum() adds their totals, and avg() computes their mean — all three are aggregates because they each collapse multiple rows into one value per group. upper() and round() are scalar: upper() runs once on the single grouped status value, and round() runs once on avg()'s already-computed result, formatting it rather than aggregating anything further.

Referencing a SELECT-list alias inside WHERE

Wrong

sql
SELECT total * 1.1 AS total_with_tax
FROM orders
WHERE total_with_tax > 100;

Better

sql
SELECT total * 1.1 AS total_with_tax
FROM orders
WHERE total * 1.1 > 100;

What you see: ERROR: column "total_with_tax" does not exist — raised even though the alias is right there in the SELECT list, a few lines up.

Why: PostgreSQL evaluates WHERE before the SELECT list's aliases exist — conceptually, WHERE filters rows before the output columns (and their names) are even computed. The alias total_with_tax is a label attached to the FINAL result, not a name usable earlier in the same query's evaluation order. The fix is to repeat the expression itself in WHERE, since the alias is not in scope there (GROUP BY, HAVING and ORDER BY are the exceptions — PostgreSQL does allow a SELECT-list alias in those, as a documented extension beyond standard SQL).

Scalar vs aggregate functions

Scalar vs aggregate functions
KindInput → outputExamples
Scalarone row → one value, every rowupper(text), length(text), round(numeric), now()
Aggregatemany rows → one value per groupcount(*), sum(col), avg(col), min(col), max(col)

Together

sql
SELECT upper(status) AS status_label,   -- scalar: one per row
       count(*)  AS total,              -- aggregate: one per group
       round(avg(total), 2) AS avg_total -- aggregate result, then a scalar round() on it
FROM orders
GROUP BY status;

Remember: A scalar function returns one value per row; an aggregate returns one per group. A SELECT-list alias is not visible inside WHERE — repeat the expression.

See also: filtering and sorting · conditional expressions

Advertisement

Two classic gotchas

The pieces of SQL logic that look obvious and quietly are not.

NULL semantics and three-valued logic

corebeginner

NULL means 'unknown,' not zero or empty. Comparing anything to NULL — even NULL = NULL — produces NULL, not true or false, which is why WHERE silently drops rows a reader might expect kept.

Think of it as

SQL logic has three truth values, not two: true, false, and unknown. Ordinary boolean logic only has room for two, so most languages let a missing value default to falsy. SQL refuses to guess — asking "is this unknown value equal to that one?" honestly returns "unknown," not a coin-flip true or false. WHERE then only keeps rows where the condition is true; both false AND unknown are dropped.

sql
SELECT * FROM customers WHERE email IS NULL;
SELECT * FROM customers WHERE email IS NOT NULL;

-- always wrong — matches nothing, ever, on any table:
SELECT * FROM customers WHERE email = NULL;

What we're doing: Show WHERE email = NULL silently matching nothing, next to IS NULL correctly matching the rows with a missing email.

null_demo.sqlsql
CREATE TABLE customers (id SERIAL PRIMARY KEY, email TEXT);

INSERT INTO customers (email) VALUES ('a@example.com'), (NULL), (NULL);

SELECT count(*) FROM customers WHERE email = NULL;

SELECT count(*) FROM customers WHERE email IS NULL;

SELECT count(*) FROM customers WHERE NOT (email = NULL);
3
Three rows: one real email, two rows with a genuinely missing email.
5
email = NULL evaluates to NULL for every single row, including the two that ARE actually NULL — WHERE never keeps a NULL result.
7
IS NULL is the special-cased operator — it correctly reports true for the two missing-email rows.
9
NOT (NULL) is still NULL, not true — so this also matches nothing, for the same underlying reason as line 5.
Output
 count 
-------
     0
(1 row)

 count 
-------
     2
(1 row)

 count 
-------
     0
(1 row)

Why this works: = NULL asks PostgreSQL to compare a column against an unknown value — the honest answer is always 'unknown,' regardless of what the column actually holds, so WHERE (which only keeps true rows) keeps zero rows every time. This is not a bug or an edge case unique to this table; email = NULL matches nothing on ANY table, for ANY column. IS NULL sidesteps the comparison entirely — it directly tests 'is this value the special marker NULL,' which is a real true/false question with a real answer, which is exactly why it exists as separate syntax rather than reusing =.

Writing WHERE col <> 'value' and expecting NULL rows included or excluded predictably

Wrong

sql
-- Intending to find every customer whose email is NOT this one
SELECT * FROM customers WHERE email <> 'a@example.com';
-- silently excludes every row where email IS NULL too

Better

sql
SELECT * FROM customers
WHERE email <> 'a@example.com' OR email IS NULL;

What you see: The wrong version returns only the rows with a real, different email — the two rows whose email is NULL vanish from the result, even though intuitively 'not equal to this address' should describe them too.

Why: email <> 'a@example.com' is still a comparison, and NULL <> anything evaluates to NULL (unknown) — not true. So a row with a NULL email is excluded by <> for the exact same three-valued-logic reason it is excluded by =. Any query meant to include 'missing' as its own category has to test for it explicitly with IS NULL, alongside whatever other comparison is being made.

= NULL vs IS NULL

WHERE email = NULL

  • +Compares against an unknown value
  • +Always evaluates to NULL (unknown)
  • +WHERE excludes it — matches ZERO rows, always, for every table

WHERE email IS NULL

  • A special-cased test, not a comparison
  • Evaluates to true or false, never NULL
  • Correctly matches every row whose email really is absent
  • WHERE email = NULL
    • Compares against an unknown value
    • Always evaluates to NULL (unknown)
    • WHERE excludes it — matches ZERO rows, always, for every table
  • WHERE email IS NULL
    • A special-cased test, not a comparison
    • Evaluates to true or false, never NULL
    • Correctly matches every row whose email really is absent

Three-valued logic — AND/OR truth tables involving NULL

Three-valued logic — AND/OR truth tables involving NULL
aba AND ba OR b
trueNULLNULLtrue
falseNULLfalseNULL
NULLNULLNULLNULL

Together

sql
SELECT true AND NULL,   -- NULL — unknown whether the whole thing holds
       false AND NULL,  -- false — false ALREADY decides the AND, NULL or not
       true OR NULL,    -- true  — true ALREADY decides the OR, NULL or not
       false OR NULL;   -- NULL  — still unknown

Remember: NULL = NULL is NULL, not true — WHERE drops every NULL comparison since it only keeps true rows. Use IS NULL / IS NOT NULL, never = NULL.

See also: conditional expressions · filtering and sorting

Operator precedence and parentheses

standardbeginner

AND binds tighter than OR, so a WHERE clause mixing both groups the AND conditions first by default — not necessarily the grouping intended. Parentheses always override the default and are never wrong to add.

Think of it as

SQL reads an expression with a fixed, unwritten order of operations, the same way 2 + 3 * 4 means 2 + (3 * 4), not (2 + 3) * 4. AND binds tighter than OR, so a WHERE clause mixing both without parentheses groups the AND conditions FIRST — often not the grouping the author had in mind, especially since English 'this and that, or the other' does not map cleanly onto SQL's own grouping. Parentheses always win: an explicit () overrides whatever the default order would have done.

sql
-- ambiguous to a reader, unambiguous to PostgreSQL: AND binds first
SELECT * FROM orders
WHERE status = 'completed' AND total > 100 OR status = 'refunded';

-- the same query, made explicit
SELECT * FROM orders
WHERE (status = 'completed' AND total > 100) OR status = 'refunded';

-- a DIFFERENT query, also legal, with different meaning
SELECT * FROM orders
WHERE status = 'completed' AND (total > 100 OR status = 'refunded');

What we're doing: Show that WHERE ... AND ... OR ... without parentheses groups the AND first, producing rows a reader might not expect from the intended "only completed orders, either large or refunded" logic.

precedence_demo.sqlsql
CREATE TABLE orders (id SERIAL PRIMARY KEY, status TEXT, total NUMERIC);

INSERT INTO orders (status, total) VALUES
    ('completed', 150),   -- large completed order
    ('completed', 20),    -- small completed order
    ('refunded', 20),     -- small refunded order
    ('cancelled', 500);   -- large but cancelled

SELECT id, status, total FROM orders
WHERE status = 'completed' AND total > 100 OR status = 'refunded'
ORDER BY id;

SELECT id, status, total FROM orders
WHERE status = 'completed' AND (total > 100 OR status = 'refunded')
ORDER BY id;
3–7
Four orders: one large completed, one small completed, one small refunded, one large cancelled.
9
Parsed as (status = 'completed' AND total > 100) OR status = 'refunded' — AND binds first.
13
Explicit parentheses change the meaning: now it's completed AND (large OR refunded) — the small completed row is excluded here, unlike above.
Output
 id |  status   | total 
----+-----------+-------
  1 | completed |   150
  3 | refunded  |    20
(2 rows)

 id |  status   | total 
----+-----------+-------
  1 | completed |   150
(1 row)

Why this works: The first query's default precedence groups AND before OR: (status = 'completed' AND total > 100) OR status = 'refunded'. That matches order 1 (completed and large) via the AND branch, and order 3 (refunded) via the OR branch — regardless of order 3's total, since the OR branch never even checks it. The second query's explicit parentheses require status = 'completed' AND (total > 100 OR status = 'refunded') — order 3 fails this immediately because its status is not 'completed', so only order 1 remains. Both queries are completely valid SQL; they simply express different logic, and the only way to tell which one a piece of code means is to look at (or add) the parentheses.

Assuming AND and OR combine left-to-right like plain English

Wrong

sql
-- Intent: "completed orders that are either large or refunded"
-- Actual meaning: "(completed AND large) OR (any refunded order)"
SELECT * FROM orders
WHERE status = 'completed' AND total > 100 OR status = 'refunded';

Better

sql
SELECT * FROM orders
WHERE status = 'completed' AND (total > 100 OR status = 'refunded');

What you see: The wrong version returns every refunded order regardless of amount, even ones that would never have been described as 'completed' — a silent over-match, not an error.

Why: English sentences like "completed orders that are large or refunded" read left to right with an implicit grouping the speaker has in mind, but SQL has no idea what that grouping was — it applies its own fixed rule (AND before OR) regardless of intent. Since that fixed rule silently produces a different, valid result rather than an error, the bug ships without any warning. Parentheses are the only way to make the intended grouping match the actual one.

Remember: AND binds tighter than OR — a AND b OR c means (a AND b) OR c. Add parentheses whenever a WHERE clause mixes both; it costs nothing and removes the ambiguity.

See also: filtering and sorting · null semantics

Advertisement