Joins & Set Ops — Cheat Sheet
MySQL · 5 topics. Download the PDF or the Instagram carousel and share it.
JOINs — INNER, LEFT, RIGHT, CROSS
INNER JOIN returns matching rows; LEFT/RIGHT JOIN includes unmatched rows from one side; CROSS JOIN produces a Cartesian product; choose the right join to avoid silent data exclusion.
- ✓INNER JOIN returns only matched rows from both sides — it silently excludes rows with no match, which can lead to incorrect reports.
- ✓LEFT JOIN returns all rows from the left table; use it when the right side is optional (e.g., customers with or without orders).
- ✓MySQL does not support FULL OUTER JOIN natively; emulate it with LEFT JOIN UNION RIGHT JOIN.
- ✓Always index the columns used in ON clauses — especially FK columns on the child table — to avoid full table scans.
- ✓A missing ON condition in a JOIN produces a CROSS JOIN (Cartesian product) — every row times every row.
- ✓Use EXPLAIN to verify that joins use indexes (type = ref/eq_ref) rather than full table scans (type = ALL).
-- Schema
-- customers: id, name, city
-- orders: id, customer_id, total, created_at
-- INNER JOIN: only customers who have at least one order
SELECT c.name, o.id AS order_id, o.total
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id;
-- Customers with no orders are excluded
-- LEFT JOIN: all customers, NULL for orders if none exist
SELECT c.name,
COUNT(o.id) AS order_count,
SUM(o.total) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name;
-- Returns every customer, 0 count and NULL sum for those with no ordersSubqueries
Scalar, row, and table subqueries appear in SELECT, FROM, and WHERE; correlated subqueries reference the outer query and execute per row — often replaceable with JOIN for better performance.
- ✓Correlated subqueries execute once per outer row — avoid in WHERE/SELECT on large tables; rewrite as JOIN.
- ✓EXISTS stops at the first matching row; IN fetches all matching values — EXISTS is usually faster for large subsets.
- ✓Derived tables in FROM must have an alias; MySQL may materialise them into a temp table.
- ✓MySQL 8.0 CTEs (WITH clause) are syntactically cleaner than derived tables with the same execution plan.
- ✓LATERAL joins (MySQL 8.0.14+) allow derived tables to reference outer query columns — useful for "latest N per group".
- ✓Always check EXPLAIN for correlated subqueries — look for "dependent subquery" in Extra which confirms O(n²) execution.
-- Scalar subquery in SELECT (executes once per output row)
SELECT
o.id,
o.total,
(SELECT AVG(total) FROM orders) AS avg_order_total,
o.total - (SELECT AVG(total) FROM orders) AS diff_from_avg
FROM orders o;
-- IN subquery: customers who placed an order this month
SELECT id, email
FROM customers
WHERE id IN (
SELECT DISTINCT customer_id
FROM orders
WHERE created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
);
-- EXISTS (often faster than IN for large sets — stops at first match)
SELECT id, email
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.id -- correlated reference
AND o.created_at >= '2024-01-01'
);
-- Note: EXISTS uses index on orders.customer_id — check EXPLAIN
-- NOT EXISTS — customers with NO orders (better than LEFT JOIN IS NULL for large tables)
SELECT id, email
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);GROUP BY & Aggregation
GROUP BY collapses rows by distinct values; aggregate functions (COUNT, SUM, AVG, MIN, MAX) operate per group; ROLLUP and CUBE extend GROUP BY for hierarchical totals.
- ✓ONLY_FULL_GROUP_BY (default MySQL 5.7+): every SELECT column must be in GROUP BY or wrapped in an aggregate.
- ✓HAVING filters groups after aggregation; WHERE filters rows before — they are not interchangeable.
- ✓COUNT(col) counts non-NULL values; COUNT(*) counts all rows — they differ when the column has NULLs.
- ✓WITH ROLLUP adds subtotal and grand total rows; use GROUPING() to distinguish real NULLs from rollup markers.
- ✓GROUP_CONCAT aggregates values into a comma-separated string per group; max length controlled by group_concat_max_len.
- ✓Aggregation and window functions can be combined: aggregate with GROUP BY, then rank groups with window functions.
-- Revenue and order count by status
SELECT
status,
COUNT(*) AS order_count,
SUM(total) AS revenue,
AVG(total) AS avg_order_value,
MIN(total) AS min_order,
MAX(total) AS max_order,
GROUP_CONCAT(id ORDER BY id LIMIT 5) AS sample_ids
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY status
HAVING COUNT(*) >= 10 -- filter groups with at least 10 orders
ORDER BY revenue DESC;
-- Multi-column grouping: revenue by month and category
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month,
category,
COUNT(*) AS orders,
SUM(total) AS revenue
FROM orders o
JOIN products p ON o.product_id = p.id
GROUP BY month, category
ORDER BY month, revenue DESC;
-- COUNT(DISTINCT): unique customer count per day
SELECT
DATE(created_at) AS day,
COUNT(DISTINCT customer_id) AS unique_customers,
COUNT(*) AS total_orders
FROM orders
GROUP BY day
ORDER BY day;HAVING Clause
HAVING filters groups after aggregation (unlike WHERE which filters rows before); it can reference aggregate expressions that are not valid in WHERE.
- ✓WHERE filters rows before GROUP BY; HAVING filters groups after aggregation.
- ✓Always push non-aggregate conditions into WHERE for better performance.
- ✓HAVING can reference aggregate expressions (COUNT(*), SUM()) that are invalid in WHERE.
- ✓COUNT(*) includes NULL rows; COUNT(column) excludes NULLs.
- ✓GROUP_CONCAT concatenates values within a group — useful for producing comma-separated lists.
- ✓WITH ROLLUP adds subtotals and grand total rows to GROUP BY results.
-- Find customers with more than 5 orders and total spend > $1000
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(total) AS total_spent,
AVG(total) AS avg_order_value
FROM orders
WHERE status <> 'CANCELLED' -- ✓ filter rows BEFORE grouping (WHERE)
GROUP BY customer_id
HAVING order_count > 5 -- ✓ filter groups AFTER aggregating (HAVING)
AND total_spent > 1000
ORDER BY total_spent DESC;
-- Products with average rating below 3 and at least 10 reviews
SELECT
product_id,
AVG(rating) AS avg_rating,
COUNT(*) AS review_count
FROM reviews
GROUP BY product_id
HAVING avg_rating < 3.0 AND review_count >= 10;
-- HAVING without GROUP BY — rare but valid (entire table is one group)
SELECT COUNT(*) AS total FROM orders HAVING total > 1000;UNION & UNION ALL
UNION deduplicates results from two SELECT statements; UNION ALL is faster (no dedup); both require matching column count and compatible types.
- ✓UNION deduplicates combined rows (expensive); UNION ALL appends without dedup (fast).
- ✓Always use UNION ALL unless you explicitly need duplicate removal.
- ✓All SELECT statements must have the same number of columns with compatible types.
- ✓Column names in the result come from the first SELECT statement.
- ✓ORDER BY and LIMIT apply to the full combined result — place them at the end.
- ✓UNION is useful for querying sharded tables, time-partitioned archives, or multi-status reports.
-- UNION — deduplicates (sorts combined result) SELECT id, name, 'current' AS source FROM products UNION SELECT id, name, 'archive' AS source FROM products_archive; -- Removes exact duplicate rows across both queries -- UNION ALL — no deduplication (faster) SELECT id, name, 'UK' AS region FROM products_uk UNION ALL SELECT id, name, 'EU' AS region FROM products_eu UNION ALL SELECT id, name, 'US' AS region FROM products_us; -- All rows returned including any duplicates -- Performance tip: -- UNION: requires sort/hash to detect duplicates → O(N log N) -- UNION ALL: simple append → O(N) -- Always prefer UNION ALL unless duplicate removal is required -- Column aliases — only first SELECT's aliases appear in result SELECT id AS product_id, name AS product_name FROM products UNION ALL SELECT id, name FROM archived_products; -- aliases from first SELECT are used