3.3 Querying, Joining & Aggregating Data
Key Takeaways
- PostgreSQL processes SQL statements through a strict logical lifecycle: FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> DISTINCT -> ORDER BY -> LIMIT/OFFSET.
- Equality comparisons against NULL (e.g., col = NULL) evaluate to UNKNOWN and silently discard matching rows; relational queries must use IS NULL, IS NOT NULL, or the null-safe comparison operator IS NOT DISTINCT FROM.
- PostgreSQL's ORDER BY clause sorts NULL values at the very top (NULLS FIRST) for descending sorts and at the very bottom (NULLS LAST) for ascending sorts by default, which can be explicitly controlled using NULLS FIRST or NULLS LAST.
- The HAVING clause filters aggregated groups after the GROUP BY reduction stage, whereas the WHERE clause filters individual base tuples before aggregation occurs.
- The PostgreSQL-specific DISTINCT ON (expressions) construct retains the first row of each group based on a specified sort order, requiring that the leftmost ORDER BY expressions match the DISTINCT ON expressions.
3.3 Querying, Joining & Aggregating Data
[!NOTE] Exam Blueprint Focus: EDB lists "SQL Queries and Data Handling" as a required competency for this certification (it publishes no percentage weight for it), and querying is the largest single body of testable syntax in this guide. Candidates must demonstrate deep fluency in
SELECTprojections, three-valued boolean filtering (WHERE), pattern matching (LIKE,ILIKE, and POSIX regular expressions), join behaviors (INNER,LEFT,RIGHT,FULL,CROSS), aggregate computations (COUNT(*),COUNT(col),SUM,AVG),GROUP BYgrouping rules,HAVINGfilters, and PostgreSQL's uniqueDISTINCT ONfeature.
Executing high-performance relational queries requires an understanding of SQL's logical execution pipeline. While a query is written starting with SELECT, the database engine processes clauses in an entirely different sequence: identifying candidate tables, applying row filters, aggregating partitions, filtering summary groups, projecting columns, eliminating duplicates, sorting, and slicing paginated windows.
Logical Query Execution Lifecycle
To predict query results and write optimal SQL, you must understand the engine's logical processing sequence:
1. FROM & JOINs ──> Identifies candidate tables and builds joined tuple streams
2. WHERE ──> Filters base input rows using boolean predicate expressions
3. GROUP BY ──> Reduces rows into partition buckets sharing group keys
4. HAVING ──> Filters aggregated bucket summaries (cannot use column aliases)
5. SELECT ──> Evaluates column expressions, window functions, and scalar subqueries
6. DISTINCT ──> Deduplicates projected row sets (or evaluates DISTINCT ON)
7. ORDER BY ──> Sorts remaining tuples (supports NULLS FIRST / NULLS LAST)
8. LIMIT / OFFSET ──> Slices and offsets the final result set for client transmission
Filtering & Pattern Matching
Three-Valued Logic and NULL Comparisons
In SQL, boolean logic evaluates to one of three states: TRUE, FALSE, or UNKNOWN. All comparison operators (=, <>, !=, <, >, <=, >=) evaluate to UNKNOWN when either operand is NULL:
-- BROKEN: This query returns ZERO rows, even if rows have status = NULL!
SELECT * FROM orders WHERE status = NULL;
-- CORRECT: Relational null-testing operators
SELECT * FROM orders WHERE status IS NULL;
SELECT * FROM orders WHERE status IS NOT NULL;
-- NULL-Safe Equality (Treats two NULLs as equal, evaluates to TRUE or FALSE)
SELECT * FROM orders WHERE status IS NOT DISTINCT FROM 'shipped';
Pattern Matching: LIKE, ILIKE, and POSIX Regular Expressions
PostgreSQL provides three tiers of text pattern matching:
LIKE: Standard SQL pattern matching using%(matches zero or more characters) and_(matches exactly one character). Case-sensitive.ILIKE: PostgreSQL-specific extension providing case-insensitive pattern matching using%and_.- POSIX Regular Expressions: Powerful regular expression pattern matching supporting character classes, quantifiers, and alternation:
~: Matches regular expression, case-sensitive.~*: Matches regular expression, case-insensitive.!~: Does not match regular expression, case-sensitive.!~*: Does not match regular expression, case-insensitive.
-- Case-insensitive search matching 'PostgreSQL', 'postgresql', 'POSTGRESQL'
SELECT * FROM articles WHERE title ILIKE '%postgres%';
-- POSIX Regex: Find email addresses ending in .org or .edu (case-insensitive)
SELECT email FROM users WHERE email ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.(org|edu)$';
Sorting & Pagination: ORDER BY, LIMIT, and OFFSET
Sorting and Default NULL Positioning
The ORDER BY clause sorts output rows in ascending (ASC) or descending (DESC) order. A common exam trap involves the default positioning of NULL values in PostgreSQL:
- Ascending (
ASC) default:NULLS LAST(NULLs appear at the very bottom of the result set). - Descending (
DESC) default:NULLS FIRST(NULLs appear at the very top of the result set). - Explicit Control: You can override the default using
NULLS FIRSTorNULLS LASTexplicitly:
-- Top earners first, but keep employees with unknown (NULL) salaries at the bottom
SELECT employee_name, salary
FROM employees
ORDER BY salary DESC NULLS LAST;
Pagination Mechanics
PostgreSQL supports result-set windowing using LIMIT and OFFSET or the SQL:2008 standard FETCH FIRST:
-- Page 3 (Rows 21 through 30)
SELECT * FROM audit_logs
ORDER BY event_time DESC
LIMIT 10 OFFSET 20;
-- SQL Standard equivalent
SELECT * FROM audit_logs
ORDER BY event_time DESC
OFFSET 20 ROWS
FETCH FIRST 10 ROWS ONLY;
[!TIP] Performance Consideration for Large Offsets:
OFFSET 1000000 LIMIT 10forces the PostgreSQL engine to scan, sort, and process 1,000,010 rows before discarding the first 1,000,000 and returning 10. For high-volume pagination, use Keyset Pagination (Seek Method) using an indexed column:WHERE event_id < :last_seen_id ORDER BY event_id DESC LIMIT 10.
Relational Join Mechanics
Joins combine tuples from two or more relations based on matching column predicates.
INNER JOIN: LEFT OUTER JOIN: FULL OUTER JOIN:
[ Table A ] [ Table B ] [ Table A ] [ Table B ] [ Table A ] [ Table B ]
├──[ Matched ]──┤ ├──[ Matched ]──┤ ├──[ Matched ]──┤
└──[ Unmatched A ] └──[ Unmatched A ]
└──[ Unmatched B ]
1. INNER JOIN
Returns only the tuples where the join condition evaluates to TRUE across both tables. Unmatched rows from either table are discarded.
SELECT c.name, o.order_id, o.amount
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id;
2. LEFT OUTER JOIN (or LEFT JOIN)
Preserves all rows from the left table. If a left-table row finds matching tuples in the right table, the matched data is combined. If no match exists, all projected columns from the right table are filled with NULL.
-- Find customers who have NEVER placed an order (Anti-Join pattern)
SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.order_id IS NULL;
3. RIGHT OUTER JOIN and FULL OUTER JOIN
RIGHT OUTER JOIN: Preserves all rows from the right table, populating unmatched left columns withNULL.FULL OUTER JOIN: Preserves all rows from both tables. Matched rows are joined together; unmatched rows from either side are returned withNULLfor the opposite table's columns.
4. CROSS JOIN
Produces the Cartesian product of both tables, combining every row in table A with every row in table B ($M \times N$ rows). A CROSS JOIN takes no ON condition:
-- Generates all shirt size and color combinations
SELECT s.size_name, c.color_name
FROM sizes s
CROSS JOIN colors c;
Join Syntax: ON vs. USING
When join columns share the exact same column name across both tables, you can use the shorthand USING (column_name) syntax. Unlike ON a.dept_id = b.dept_id which retains two independent columns in the internal projection, USING (dept_id) combines them into a single column:
SELECT dept_id, e.name, d.name
FROM employees e
INNER JOIN departments d USING (dept_id);
Aggregations, GROUP BY, and HAVING
Aggregations collapse multiple input rows into a single summary tuple.
Standard Aggregate Functions
COUNT(*): Counts the total number of input rows, including rows containingNULLvalues or duplicates.COUNT(column): Counts the number of rows wherecolumnis NOT NULL.SUM(column)/AVG(column): Computes the sum or arithmetic mean of non-null values. If all input values areNULL,SUMreturnsNULL(not zero).MIN(column)/MAX(column): Returns the minimum or maximum non-null value.
The GROUP BY Rule
When using GROUP BY, every column in the SELECT projection list that is not wrapped in an aggregate function must appear in the GROUP BY clause (unless the table's primary key is in the GROUP BY, which functionally determines the remaining columns in PostgreSQL):
-- Invalid: e.department_id is missing from GROUP BY
-- SELECT department_id, employee_title, AVG(salary) FROM employees GROUP BY department_id; -- ERROR!
-- Valid grouping
SELECT department_id, employee_title, AVG(salary)
FROM employees
GROUP BY department_id, employee_title;
WHERE vs. HAVING Filtering
A classic certification topic is distinguishing WHERE from HAVING:
WHERE: Evaluated before grouping and aggregation. It filters individual input rows. It cannot reference aggregate functions (WHERE COUNT(*) > 5is a syntax error).HAVING: Evaluated after grouping and aggregation. It filters aggregated summary groups. It can reference aggregate functions and grouping columns.
SELECT department_id, COUNT(*) AS active_employees, AVG(salary) AS avg_sal
FROM employees
WHERE status = 'ACTIVE' -- Filters base rows BEFORE aggregation
GROUP BY department_id
HAVING COUNT(*) >= 5 AND AVG(salary) > 75000; -- Filters groups AFTER aggregation
Deduplication: DISTINCT vs. DISTINCT ON
Standard SQL DISTINCT
The standard DISTINCT clause evaluates the entire projected SELECT list and removes duplicate rows:
SELECT DISTINCT department_id, job_level FROM employees;
PostgreSQL-Specific DISTINCT ON (expressions)
PostgreSQL provides a proprietary, highly efficient extension: DISTINCT ON (expr1, expr2, ...):
- It retains only the first row of each set of rows where the given expressions evaluate to equal.
- Because "the first row" is unpredictable unless rows are sorted,
DISTINCT ONmust be paired with anORDER BYclause. - The Golden Rule of
DISTINCT ON: The expressions inDISTINCT ON (...)must match the leftmost expressions in theORDER BYclause!
-- Scenario: Retrieve the single highest-paid employee for each department
SELECT DISTINCT ON (department_id)
department_id,
employee_name,
salary,
hire_date
FROM employees
ORDER BY department_id, salary DESC, hire_date ASC;
In this query, PostgreSQL groups rows by department_id, sorts employees within each department by salary DESC, and picks the very first tuple. This replaces complex subqueries, self-joins, or window functions (ROW_NUMBER()) with a single, highly optimized query pass.
A query is executed: SELECT product_name, unit_price FROM products ORDER BY unit_price DESC;. Several products have a unit_price of NULL. Where will these NULL rows appear in the query results, and how can an administrator place them at the very bottom while maintaining descending price order?
An administrator needs to write a query using PostgreSQL's proprietary DISTINCT ON feature to find the most recently placed order for every customer from the orders table (customer_id, order_id, order_date, total_amount). Which query correctly conforms to PostgreSQL syntactic requirements?
A table survey_responses contains 100 rows. In 15 of these rows, the column feedback_score contains a NULL value, while the remaining 85 rows contain integer scores ranging from 1 to 10. What are the respective results of SELECT COUNT(*) FROM survey_responses; and SELECT COUNT(feedback_score) FROM survey_responses;?