thecodex.expert · The Codex Family of Knowledge
SQL

Joins and Aggregates

Combine tables with JOINs, summarise data with aggregates, and compute running totals with window functions.

ISO/IEC 9075 PostgreSQL 16 Last verified:
Canonical Definition

SQL JOINs combine rows from multiple tables by matching a join condition — INNER JOIN returns only matching rows, LEFT JOIN returns all left-table rows — while aggregate functions (COUNT, SUM, AVG, MIN, MAX) with GROUP BY collapse groups of rows into summary values, and HAVING filters those groups.

One sentence

JOINs combine rows from multiple tables by matching a condition; aggregate functions (COUNT, SUM, AVG, MIN, MAX) summarise groups of rows into single values.

JOINs

SQLjoins.sql
-- Sample schema
-- employees: id, name, department_id, salary, manager_id
-- departments: id, name, location, budget

-- INNER JOIN — only rows that match in BOTH tables
SELECT e.name, e.salary, d.name AS dept_name
FROM   employees   e
JOIN   departments d ON e.department_id = d.id;
-- Employees without a department are excluded
-- Departments with no employees are excluded

-- LEFT JOIN (LEFT OUTER JOIN) — all rows from LEFT table
SELECT e.name, d.name AS dept_name
FROM   employees   e
LEFT JOIN departments d ON e.department_id = d.id;
-- Employees without a department: dept_name is NULL
-- All employees are included

-- RIGHT JOIN — all rows from RIGHT table (rarely used; flip tables for LEFT JOIN)
SELECT e.name, d.name AS dept_name
FROM   employees   e
RIGHT JOIN departments d ON e.department_id = d.id;
-- All departments included, even with no employees

-- FULL OUTER JOIN — all rows from both tables
SELECT e.name, d.name AS dept_name
FROM   employees   e
FULL OUTER JOIN departments d ON e.department_id = d.id;

-- CROSS JOIN — every combination (Cartesian product)
SELECT e.name, p.project_name
FROM   employees e CROSS JOIN projects p;  -- n × m rows

-- Self-join — join a table to itself
SELECT e.name AS employee, m.name AS manager
FROM   employees e
LEFT JOIN employees m ON e.manager_id = m.id;

Aggregate functions

SQLaggregates.sql
-- Aggregate functions operate on groups of rows

-- Without GROUP BY: aggregate over entire result set
SELECT
    COUNT(*)                    AS total_employees,  -- count all rows
    COUNT(manager_id)           AS have_manager,     -- count non-null values
    COUNT(DISTINCT department_id) AS num_departments,
    SUM(salary)                 AS payroll,
    AVG(salary)                 AS avg_salary,
    MIN(salary)                 AS lowest,
    MAX(salary)                 AS highest,
    ROUND(AVG(salary), 2)       AS avg_rounded
FROM employees;

-- GROUP BY — one output row per group
SELECT
    department,
    COUNT(*)           AS headcount,
    AVG(salary)        AS avg_salary,
    SUM(salary)        AS dept_payroll,
    MIN(hire_date)     AS earliest_hire
FROM   employees
GROUP  BY department;

-- HAVING — filter groups (runs AFTER GROUP BY)
SELECT
    department,
    COUNT(*)    AS headcount,
    AVG(salary) AS avg_salary
FROM   employees
GROUP  BY department
HAVING COUNT(*) > 5          -- only departments with more than 5 people
AND    AVG(salary) > 70000;  -- and average salary above 70k

-- ORDER in GROUP BY query: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
SELECT department, COUNT(*) AS n
FROM   employees
WHERE  active = TRUE          -- filters rows BEFORE grouping
GROUP  BY department
HAVING COUNT(*) > 3           -- filters groups AFTER grouping
ORDER  BY n DESC;

Window functions

SQLwindow.sql
-- Window function: aggregate over a "window" of rows without collapsing them
-- Syntax: function() OVER (PARTITION BY ... ORDER BY ...)

SELECT
    name,
    department,
    salary,
    -- Rank within department (gaps for ties)
    RANK()        OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank,
    -- Dense rank (no gaps)
    DENSE_RANK()  OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rank,
    -- Row number (arbitrary tiebreaker)
    ROW_NUMBER()  OVER (PARTITION BY department ORDER BY salary DESC) AS row_num,
    -- Average salary in the same department (all rows kept)
    AVG(salary)   OVER (PARTITION BY department)                      AS dept_avg,
    -- Running total across all employees ordered by salary
    SUM(salary)   OVER (ORDER BY salary)                              AS running_total,
    -- Lag and lead: access previous/next row's value
    LAG(salary, 1)  OVER (ORDER BY hire_date) AS prev_salary,
    LEAD(salary, 1) OVER (ORDER BY hire_date) AS next_salary,
    -- NTILE: divide into N groups (percentiles)
    NTILE(4)      OVER (ORDER BY salary)                              AS quartile
FROM employees;

-- Find top earner per department using window function
SELECT name, department, salary
FROM (
    SELECT name, department, salary,
           RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rk
    FROM employees
) ranked
WHERE rk = 1;

Multiple joins and join conditions

SQLmulti_joins.sql
-- Multiple joins in one query
SELECT
    e.name,
    d.name    AS department,
    p.name    AS project,
    r.title   AS role
FROM       employees  e
JOIN       departments d ON e.department_id  = d.id
LEFT JOIN  assignments a ON a.employee_id    = e.id
LEFT JOIN  projects    p ON a.project_id     = p.id
LEFT JOIN  roles       r ON e.role_id        = r.id
WHERE d.location = 'Mumbai'
ORDER BY e.name;

-- Non-equi join (join on inequality)
SELECT e.name, s.grade
FROM   employees e
JOIN   salary_grades s
       ON e.salary BETWEEN s.min_salary AND s.max_salary;

-- USING — shorthand when join columns have the same name
SELECT e.name, d.name
FROM   employees e
JOIN   departments d USING (department_id);  -- instead of ON e.department_id = d.id

-- NATURAL JOIN — auto-joins on all columns with identical names (fragile, avoid in production)
SELECT * FROM employees NATURAL JOIN departments;

GROUP BY extensions: ROLLUP, CUBE, GROUPING SETS

SQLgrouping.sql
-- ROLLUP — subtotals and grand total
SELECT
    department,
    EXTRACT(YEAR FROM hire_date) AS year,
    COUNT(*) AS hires
FROM   employees
GROUP  BY ROLLUP (department, year);
-- Produces: per-dept-per-year rows, per-dept subtotals, grand total

-- CUBE — all possible subtotals (2^n grouping combinations)
SELECT department, location, COUNT(*) AS headcount
FROM   employees e JOIN departments d USING (department_id)
GROUP  BY CUBE (department, location);

-- GROUPING SETS — explicit list of groupings
SELECT department, location, COUNT(*)
FROM   employees e JOIN departments d USING (department_id)
GROUP  BY GROUPING SETS (
    (department, location),  -- both
    (department),            -- dept only
    (location),              -- location only
    ()                       -- grand total
);

-- GROUPING() function — detect which rows are subtotals
SELECT
    COALESCE(department, 'ALL') AS dept,
    COUNT(*) AS headcount,
    GROUPING(department) AS is_subtotal   -- 1 = this is a subtotal row
FROM employees
GROUP BY ROLLUP(department);
Commonly confused
COUNT(*) and COUNT(column) are different. COUNT(*) counts every row including those with NULLs. COUNT(column) counts only rows where that column is NOT NULL. If manager_id is NULL for the CEO, COUNT(*) includes them but COUNT(manager_id) does not.
Window functions do not collapse rows — GROUP BY does. A GROUP BY query with COUNT(*) returns one row per group. A window function COUNT(*) OVER (PARTITION BY department) returns the department count on every original row — the row count stays the same. You can use window functions in the same query as GROUP BY, but they run after grouping.
JOIN (without qualifier) is INNER JOIN. Many developers write JOIN expecting all rows and are surprised when rows disappear. INNER JOIN is the default — it only returns rows with matches in both tables. Use LEFT JOIN when you want all rows from the left table even without a match.
How this connects
Requires first

Join algorithms: hash join, merge join, nested loop

The SQL engine chooses a join algorithm based on the query planner's cost estimates. Nested loop join: for each row in the outer table, scan the inner table. O(n×m) — good for small tables or when the inner side has an index. Hash join: build a hash table from the smaller table, then probe it with each row from the larger table. O(n+m) — best for large unsorted tables. Merge join: sort both tables on the join key, then merge in linear time. O(n log n + m log m) — best when both sides are already sorted (e.g., by an index). Use EXPLAIN ANALYZE in PostgreSQL to see which algorithm was chosen and why.

The OVER clause and framing

Window functions use an OVER clause with three optional parts: PARTITION BY (divides rows into groups — like GROUP BY but doesn't collapse), ORDER BY (defines row order within the partition), and a frame specification (ROWS or RANGE between boundaries). The default frame for aggregate window functions is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. For ROWS BETWEEN 2 PRECEDING AND CURRENT ROW, each row sees a sliding window of itself and the two preceding rows. RANGE uses the logical range of values, not physical rows — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW includes all rows with a value less than or equal to the current row's ORDER BY value.

Sources

1
PostgreSQL 16 — Table Expressions and Joins. postgresql.org/docs/current/queries-table-expressions.html.
2
PostgreSQL 16 — Aggregate Functions. postgresql.org/docs/current/functions-aggregate.html.
3
PostgreSQL 16 — Window Functions. postgresql.org/docs/current/tutorial-window.html.
4
ISO/IEC 9075-2:2023 — SQL Foundation. iso.org/standard/76583.html.
Source confidence: High Last verified: Primary source: ISO/IEC 9075 · postgresql.org/docs/

Sources

1
PostgreSQL 16 — Table Expressions. postgresql.org/docs/current/queries-table-expressions.html.
2
PostgreSQL 16 — Aggregate Functions. postgresql.org/docs/current/functions-aggregate.html.
3
PostgreSQL 16 — Window Functions. postgresql.org/docs/current/tutorial-window.html.
4
ISO/IEC 9075-2:2023 — SQL Foundation. iso.org/standard/76583.html.