Chapter 05 · SQL Querying: Joins, Subqueries, CTEs, Set Operations, and LATERAL

UNION / INTERSECT / EXCEPT, DISTINCT ON, Row Constructors, and Query Composition

Compose PostgreSQL result sets with duplicate-aware set operations, deterministic DISTINCT ON, row constructors, type alignment, and layered query boundaries that remain correct and maintainable.

Intermediate125–155 minutesSet composition + DISTINCT ON labCurrent patched PostgreSQL 18.xCore SQL; no extensions requiredLast reviewed: August 2026

Learning outcomes

ServiceHub's reporting layer now needs to combine several independently correct relations: merge work queues, find overlap between tagged categories, subtract already-closed items, choose the latest event per work order, and compare compound keys for keyset pagination. PostgreSQL provides direct operators for each job, but duplicate semantics, type alignment, precedence, and ordering rules are part of the contract.

01

Use UNION/UNION ALL, INTERSECT/INTERSECT ALL, and EXCEPT/EXCEPT ALL with deliberate duplicate semantics.

02

Resolve compatible output types and understand where ORDER BY applies in set-operation queries.

03

Use PostgreSQL-specific DISTINCT ON only with a deterministic leftmost ordering contract.

04

Compare row constructors lexicographically while accounting for NULL behavior.

05

Build layered queries whose intermediate relations have explicit grain and meaning.

1. Chapter 05 lab bootstrap

All five lessons use the same small ServiceHub dataset so row counts and edge cases remain explainable. Run this as servicehub_owner (or another role that owns the disposable app schema) in servicehub_lab. The objects are deliberately prefixed ch05_. Before the drops, verify the current database and inspect the matching objects; do not paste this into a production database.

Safety boundary

The DROP statements below target only chapter-scoped objects. If your database contains valuable objects with these names, stop and use another disposable database. No CASCADE is used.

sql · reset and seed the deterministic ServiceHub query lab
\echo 'Verify target before recreating chapter-scoped lab objects'SELECT current_database(), current_user;\dt app.ch05_*DROP TABLE IF EXISTS app.ch05_work_order_event;DROP TABLE IF EXISTS app.ch05_work_order_tag;DROP TABLE IF EXISTS app.ch05_work_order;DROP TABLE IF EXISTS app.ch05_technician;DROP TABLE IF EXISTS app.ch05_customer;DROP TABLE IF EXISTS app.ch05_team;CREATE TABLE app.ch05_customer (    customer_id integer PRIMARY KEY,    customer_name text NOT NULL,    region text,    active boolean NOT NULL DEFAULT true);CREATE TABLE app.ch05_technician (    technician_id integer PRIMARY KEY,    technician_name text NOT NULL,    specialty text NOT NULL,    team_code text NOT NULL);CREATE TABLE app.ch05_work_order (    work_order_id integer PRIMARY KEY,    customer_id integer NOT NULL REFERENCES app.ch05_customer(customer_id),    assigned_technician_id integer REFERENCES app.ch05_technician(technician_id),    status text NOT NULL CHECK (status IN ('queued','open','closed','cancelled')),    priority smallint NOT NULL CHECK (priority BETWEEN 1 AND 4),    city text NOT NULL,    opened_at timestamptz NOT NULL,    closed_at timestamptz,    estimated_minutes integer NOT NULL CHECK (estimated_minutes >= 0),    actual_minutes integer CHECK (actual_minutes >= 0),    cost numeric(10,2) NOT NULL CHECK (cost >= 0));CREATE TABLE app.ch05_work_order_tag (    work_order_id integer NOT NULL REFERENCES app.ch05_work_order(work_order_id),    tag text NOT NULL,    PRIMARY KEY (work_order_id, tag));CREATE TABLE app.ch05_work_order_event (    event_id integer PRIMARY KEY,    work_order_id integer NOT NULL REFERENCES app.ch05_work_order(work_order_id),    event_at timestamptz NOT NULL,    event_type text NOT NULL,    detail text);CREATE TABLE app.ch05_team (    team_id integer PRIMARY KEY,    team_name text NOT NULL UNIQUE,    parent_team_id integer REFERENCES app.ch05_team(team_id));INSERT INTO app.ch05_customer VALUES(1,'Northwind Health','North',true),(2,'Alpine Manufacturing','North',true),(3,'City Library','Central',true),(4,'Delta Foods','South',true),(5,'Evergreen School','South',false);INSERT INTO app.ch05_technician VALUES(10,'Ada','electrical','A'),(11,'Linus','network','A'),(12,'Grace','mechanical','B'),(13,'Ken','network','B');INSERT INTO app.ch05_work_order VALUES(1001,1,10,'closed',1,'Baku','2026-08-01 08:00+00','2026-08-01 10:00+00',90,110,240.00),(1002,1,11,'open',2,'Baku','2026-08-02 09:00+00',NULL,60,NULL,80.00),(1003,2,10,'closed',2,'Sumqayit','2026-08-02 07:30+00','2026-08-02 09:00+00',120,85,150.00),(1004,2,NULL,'queued',3,'Sumqayit','2026-08-03 12:00+00',NULL,45,NULL,50.00),(1005,3,12,'closed',1,'Baku','2026-08-01 06:00+00','2026-08-01 08:30+00',120,150,300.00),(1006,4,13,'cancelled',4,'Ganja','2026-08-04 14:00+00','2026-08-04 14:10+00',30,10,0.00),(1007,4,13,'open',2,'Ganja','2026-08-05 11:00+00',NULL,90,NULL,120.00),(1008,1,10,'closed',1,'Baku','2026-08-06 05:00+00','2026-08-06 06:00+00',60,55,90.00),(1009,3,12,'closed',2,'Baku','2026-08-06 09:00+00','2026-08-06 12:00+00',90,180,400.00),(1010,2,11,'open',1,'Baku','2026-08-07 10:00+00',NULL,0,NULL,75.00);INSERT INTO app.ch05_work_order_tag VALUES(1001,'electrical'),(1001,'urgent'),(1002,'network'),(1003,'electrical'),(1005,'mechanical'),(1005,'safety'),(1007,'network'),(1007,'urgent'),(1008,'electrical'),(1009,'mechanical'),(1009,'urgent'),(1010,'network');INSERT INTO app.ch05_work_order_event VALUES(1,1001,'2026-08-01 08:00+00','created','portal'),(2,1001,'2026-08-01 09:30+00','arrived','Ada on site'),(3,1001,'2026-08-01 10:00+00','closed','restored'),(4,1002,'2026-08-02 09:00+00','created','portal'),(5,1002,'2026-08-02 09:10+00','assigned','Linus'),(6,1003,'2026-08-02 07:30+00','created','monitoring'),(7,1003,'2026-08-02 09:00+00','closed','breaker replaced'),(8,1007,'2026-08-05 11:00+00','created','phone'),(9,1007,'2026-08-05 11:20+00','assigned','Ken'),(10,1008,'2026-08-06 05:00+00','created','monitoring'),(11,1008,'2026-08-06 06:00+00','closed','reset'),(12,1009,'2026-08-06 09:00+00','created','portal'),(13,1009,'2026-08-06 10:00+00','diagnosed','pump wear'),(14,1009,'2026-08-06 12:00+00','closed','pump replaced'),(15,1010,'2026-08-07 10:00+00','created','portal');INSERT INTO app.ch05_team VALUES(1,'Field Operations',NULL),(2,'Electrical',1),(3,'Network',1),(4,'Mechanical',1),(5,'Night Shift',3);

After setup, app.ch05_work_order contains 10 work orders, including one unassigned row, several NULL completion values, and one zero-minute estimate. Those details are intentional: they make outer joins, three-valued logic, NOT IN, scalar errors, and evaluation-order mistakes observable.

2. Set operations combine whole result relations

All branches of a set operation must produce the same number of columns, and corresponding columns must resolve to compatible types. UNION, INTERSECT, and EXCEPT remove duplicates by default. Their ALL variants preserve multiplicity according to the operation.

Operator Without ALL With ALL
UNION one copy of each distinct row concatenate all copies
INTERSECT rows present on both sides, one copy min(left_count,right_count) copies
EXCEPT distinct rows from left not present on right max(left_count-right_count,0) copies
sql · UNION versus UNION ALL makes duplicate policy visible
SELECT city FROM app.ch05_work_order WHERE priority = 1UNIONSELECT city FROM app.ch05_work_order WHERE status = 'open'ORDER BY city;SELECT city FROM app.ch05_work_order WHERE priority = 1UNION ALLSELECT city FROM app.ch05_work_order WHERE status = 'open'ORDER BY city;

The first query returns each city once. The second keeps every occurrence from both branches, including a work order that satisfies both branch predicates. Use ALL when multiplicity is part of the relation or when you know branches are disjoint; do not pay for deduplication accidentally.

3. Precedence and parentheses matter

INTERSECT binds more tightly than UNION and EXCEPT. Multiple UNION or EXCEPT operations otherwise associate left-to-right unless parentheses change the grouping. Complex reporting SQL should use parentheses when human readers might otherwise need to remember precedence rules.

sql · make grouping explicit
(SELECT work_order_id FROM app.ch05_work_order WHERE city='Baku')UNION(  (SELECT work_order_id FROM app.ch05_work_order WHERE priority=1)  INTERSECT  (SELECT work_order_id FROM app.ch05_work_order WHERE status='closed'))ORDER BY work_order_id;

An ORDER BY attached to the combined result can normally reference output column names or ordinal positions, not an arbitrary expression. To order a branch before LIMIT, parenthesize that branch and make the branch-local ORDER BY part of the subquery.

4. Type alignment is resolved across branches

PostgreSQL must choose one output type for each set-operation column. Unknown literals can be coerced to match a typed branch, but incompatible content can then fail during conversion.

sql · observe and then make type intent explicit
SELECT 1 AS codeUNION ALLSELECT '2';-- This attempts to resolve the unknown literal as integer and fails:SELECT 1 AS codeUNION ALLSELECT 'external';-- Explicit text contract:SELECT 1::text AS codeUNION ALLSELECT 'external'::text;

In interfaces, ETL, and migration SQL, explicit casts make the union contract reviewable instead of letting an added branch silently change type resolution.

5. DISTINCT ON is PostgreSQL-specific “first row per group”

DISTINCT ON (key) keeps the first row for each equal key according to the incoming sort order. Without a sufficient ORDER BY, “first” is unpredictable. PostgreSQL also requires the DISTINCT ON expressions to match the leftmost ORDER BY expressions.

sql · deterministic latest event per work order
SELECT DISTINCT ON (e.work_order_id)       e.work_order_id,       e.event_type,       e.event_at,       e.event_idFROM app.ch05_work_order_event eORDER BY e.work_order_id,         e.event_at DESC,         e.event_id DESC;

The unique event_id DESC final key resolves ties in event_at. If the presentation needs “latest events globally newest first,” wrap this query and order the outer result by event time; the inner ORDER BY exists to choose the winner per work order.

sql · two ordering contracts in two layers
SELECT *FROM (  SELECT DISTINCT ON (e.work_order_id)         e.work_order_id, e.event_type, e.event_at, e.event_id  FROM app.ch05_work_order_event e  ORDER BY e.work_order_id, e.event_at DESC, e.event_id DESC) latestORDER BY latest.event_at DESC, latest.work_order_id DESC;

6. Deliberately wrong DISTINCT ON ordering

sql · invalid leftmost ordering
SELECT DISTINCT ON (work_order_id)       work_order_id, event_type, event_atFROM app.ch05_work_order_eventORDER BY event_at DESC;
text · representative error
ERROR:  SELECT DISTINCT ON expressions must match initial ORDER BY expressions

Repair the query by beginning ORDER BY with work_order_id, then append the row-preference keys. This rule is not cosmetic: it makes the grouping key and winning-row order coherent.

7. Row constructors compare compound values lexicographically

A row constructor groups expressions into one row value. For <, <=, >, and >=, PostgreSQL compares fields left-to-right and stops at the first unequal or null pair. If that deciding pair contains NULL, the comparison is UNKNOWN.

sql · compound comparison and NULL behavior
SELECT ROW(2,10) > ROW(1,999) AS first_field_decides,       ROW(1,20) > ROW(1,10) AS second_field_decides,       ROW(1,NULL) > ROW(1,10) AS null_decision,       ROW(1,NULL) IS DISTINCT FROM ROW(1,NULL) AS null_safe_difference;
text · expected result
first_field_decides | second_field_decides | null_decision | null_safe_difference--------------------+----------------------+---------------+---------------------t                   | t                    | NULL          | f

This is useful for keyset pagination with a deterministic compound order. If a pagination key can be NULL, define a null policy explicitly; do not assume row comparison magically normalizes it.

sql · keyset-style continuation on a compound order
SELECT work_order_id, opened_atFROM app.ch05_work_orderWHERE (opened_at, work_order_id) <      (TIMESTAMPTZ '2026-08-06 09:00+00', 1009)ORDER BY opened_at DESC, work_order_id DESCLIMIT 3;

8. INTERSECT and EXCEPT can express set questions directly

sql · overlap and subtraction
-- Work orders tagged urgent AND currently open.SELECT work_order_idFROM app.ch05_work_order_tagWHERE tag='urgent'INTERSECTSELECT work_order_idFROM app.ch05_work_orderWHERE status='open'ORDER BY work_order_id;-- Customers with any work order EXCEPT customers with closed work.SELECT customer_id FROM app.ch05_work_orderEXCEPTSELECT customer_id FROM app.ch05_work_order WHERE status='closed'ORDER BY customer_id;
text · expected results
urgent + open: 1007customers with work but no closed work: 4

An EXISTS/NOT EXISTS query could express the same business idea. Choose the form that makes the relation easiest to verify and maintain; then measure if performance matters.

9. Composition pattern: establish grain at every layer

Large queries become maintainable when each layer has an explicit grain: one row per event, one latest event per work order, one work-order row enriched with customer, then one final presentation ordering. CTEs, subqueries, and set operations are tools for enforcing those boundaries, not goals by themselves.

sql · layer latest-event and work-order state
WITH latest_event AS (  SELECT DISTINCT ON (work_order_id)         work_order_id, event_type, event_at  FROM app.ch05_work_order_event  ORDER BY work_order_id, event_at DESC, event_id DESC),active_work AS (  SELECT w.work_order_id, w.customer_id, w.priority, w.opened_at  FROM app.ch05_work_order w  WHERE w.status IN ('queued','open'))SELECT a.work_order_id,       c.customer_name,       a.priority,       l.event_type AS latest_event,       l.event_at AS latest_event_atFROM active_work aJOIN app.ch05_customer c USING (customer_id)LEFT JOIN latest_event l USING (work_order_id)ORDER BY a.priority, a.opened_at, a.work_order_id;

10. Hands-on lab: compose without hiding semantics

sql · final chapter exercises
-- A. UNION ALL a queue from two explicit sources and preserve source label.SELECT 'open'::text AS source, work_order_idFROM app.ch05_work_order WHERE status='open'UNION ALLSELECT 'queued'::text AS source, work_order_idFROM app.ch05_work_order WHERE status='queued'ORDER BY source, work_order_id;-- B. Latest event per work order, deterministic.SELECT DISTINCT ON (work_order_id)       work_order_id, event_type, event_at, event_idFROM app.ch05_work_order_eventORDER BY work_order_id, event_at DESC, event_id DESC;-- C. Compound row comparison.SELECT work_order_id, opened_atFROM app.ch05_work_orderWHERE (opened_at, work_order_id) >      (TIMESTAMPTZ '2026-08-05 11:00+00', 1007)ORDER BY opened_at, work_order_id;

Check your understanding

  1. What duplicate behavior differs between UNION and UNION ALL?
  2. Why can INTERSECT bind differently from UNION in an unparenthesized expression?
  3. What ordering rule makes DISTINCT ON deterministic?
  4. Why does a row comparison involving a NULL deciding field return UNKNOWN?
  5. Why is explicit type casting valuable in long-lived set-operation queries?
Review the answers

UNION removes duplicates while UNION ALL preserves every copy. INTERSECT has higher precedence than UNION/EXCEPT, so parentheses improve clarity. DISTINCT ON needs ORDER BY beginning with the DISTINCT ON keys plus deterministic winner keys. Lexicographic row comparison becomes UNKNOWN if the first deciding pair contains NULL. Explicit casts document and stabilize the common output type across branches.

11. Production judgment and chapter bridge

PostgreSQL query fluency is the ability to state relational intent precisely before asking the optimizer to make it fast. Across this chapter you used NULL-aware predicates, deterministic ordering, cardinality-aware joins, subquery shape contracts, LATERAL dependencies, recursive CTE termination, set-operation multiplicity, and DISTINCT ON winner ordering. Chapter 06 builds on these foundations with aggregates, window functions, grouping sets, INSERT ... ON CONFLICT, MERGE, and RETURNING.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.