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.
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
-- 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
-- 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
-- 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
-- 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
-- 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);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.