Databases
Master SQL: SELECT, JOINs, subqueries, GROUP BY, aggregations, and indexes.
Basic SELECT Syntax
SELECT column1, column2 FROM table_name WHERE condition ORDER BY column ASC|DESC LIMIT n;
Selecting Columns
-- All columns (avoid in production – over-fetching)
-- Specific columns SELECT id, first_name, last_name, email FROM customers;
-- Column alias SELECT first_name AS name, email AS contact FROM customers;
-- Computed column SELECT product_name, price, price * 0.9 AS discounted_price FROM products; ```
WHERE Clause Operators
| Operator | Example |
|---|---|
| `=` | `WHERE status = 'active'` |
| `<>` or `!=` | `WHERE status <> 'deleted'` |
| `>`, `<`, `>=`, `<=` | `WHERE price > 100` |
| `BETWEEN` | `WHERE price BETWEEN 10 AND 100` |
| `IN` | `WHERE country IN ('DE', 'FR', 'PL')` |
| `NOT IN` | `WHERE category NOT IN ('archive')` |
| `LIKE` | `WHERE name LIKE 'A%'` (starts with A) |
| `IS NULL` | `WHERE deleted_at IS NULL` |
| `IS NOT NULL` | `WHERE verified_at IS NOT NULL` |
| `AND`, `OR`, `NOT` | Combine conditions |
DISTINCT
-- Remove duplicate rows SELECT DISTINCT country FROM customers;
NULL Handling
NULL is not a value — it means absence of value. Use `IS NULL` and `IS NOT NULL`, never `= NULL`.
Schema
CREATE TABLE customers ( id SERIAL PRIMARY KEY, name VARCHAR(100), email VARCHAR(200) UNIQUE, country VARCHAR(2), plan VARCHAR(20), -- 'free', 'pro', 'enterprise' mrr NUMERIC(10,2), -- monthly recurring revenue created_at DATE );
Business Queries
All active enterprise customers in Germany: ```sql SELECT id, name, email, mrr FROM customers WHERE country = 'DE' AND plan = 'enterprise' AND mrr > 0 ORDER BY mrr DESC; ```
Customers registered this month: ```sql SELECT name, email, created_at FROM customers WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE) AND created_at < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month' ORDER BY created_at; ```
Find customers with missing emails (data quality check): ```sql SELECT id, name FROM customers WHERE email IS NULL OR email = ''; ```
Search by partial name (case-insensitive): ```sql SELECT id, name, email FROM customers WHERE LOWER(name) LIKE LOWER('%acme%'); ```
Top 10 highest-paying customers: ```sql SELECT name, plan, mrr FROM customers WHERE mrr IS NOT NULL ORDER BY mrr DESC LIMIT 10; ```
JOIN Fundamentals
A JOIN combines rows from two or more tables based on a related column.
INNER JOIN
Returns only rows where the condition is true in both tables.
SELECT o.id, c.name, o.total FROM orders o INNER JOIN customers c ON o.customer_id = c.id;
LEFT JOIN (LEFT OUTER JOIN)
Returns all rows from the left table plus matched rows from the right. Unmatched right rows become NULL.
-- All customers, even those with no orders SELECT c.name, o.id AS order_id FROM customers c LEFT JOIN orders o ON o.customer_id = c.id;
RIGHT JOIN
Returns all rows from the right table. Rarely used — you can always rewrite as LEFT JOIN by swapping tables.
FULL OUTER JOIN
Returns all rows from both tables. Unmatched sides are NULL.
SELECT c.name, o.id FROM customers c FULL OUTER JOIN orders o ON o.customer_id = c.id;
CROSS JOIN
Cartesian product — every row from left combined with every row from right.
-- All combinations of sizes and colors SELECT s.size, c.color FROM sizes s CROSS JOIN colors c;
Self JOIN
Join a table to itself — useful for hierarchical data.
-- Employee and their manager SELECT e.name AS employee, m.name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.id;
JOIN on Multiple Conditions
SELECT * FROM products p JOIN price_history ph ON ph.product_id = p.id AND ph.valid_from <= CURRENT_DATE AND ph.valid_to > CURRENT_DATE;
Schema
customers(id, name, country) orders(id, customer_id, created_at, status) order_items(id, order_id, product_id, qty, unit_price) products(id, name, category)
All Completed Orders with Customer Names
SELECT o.id AS order_id, c.name AS customer, o.created_at, SUM(oi.qty * oi.unit_price) AS total FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items oi ON oi.order_id = o.id WHERE o.status = 'completed' GROUP BY o.id, c.name, o.created_at ORDER BY o.created_at DESC;
Customers Who Have Never Ordered
SELECT c.id, c.name, c.country FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.id IS NULL;
Products in Orders (with Category)
SELECT p.category, p.name AS product, SUM(oi.qty) AS units_sold FROM order_items oi JOIN products p ON p.id = oi.product_id JOIN orders o ON o.id = oi.order_id WHERE o.status = 'completed' GROUP BY p.category, p.name ORDER BY units_sold DESC LIMIT 20;
Three-level Join: Order → Customer → Country Totals
SELECT c.country, COUNT(DISTINCT o.id) AS order_count, SUM(oi.qty * oi.unit_price) AS revenue FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items oi ON oi.order_id = o.id WHERE o.status = 'completed' GROUP BY c.country ORDER BY revenue DESC;
What is a Subquery?
A subquery (inner query) is a SELECT statement nested inside another SQL statement. It runs first and its result is used by the outer query.
Subquery in WHERE
-- Customers who spent more than average SELECT name, email FROM customers WHERE id IN ( SELECT customer_id FROM orders WHERE total > (SELECT AVG(total) FROM orders) );
Correlated Subquery
A correlated subquery references a column from the outer query. It runs once per row of the outer query — can be slow on large datasets.
-- Customers whose last order was more than 90 days ago SELECT c.name, c.email FROM customers c WHERE ( SELECT MAX(created_at) FROM orders WHERE customer_id = c.id ) < NOW() - INTERVAL '90 days';
EXISTS and NOT EXISTS
More efficient than IN for large datasets — stops scanning as soon as one match is found.
-- Customers who have placed at least one order SELECT c.name FROM customers c WHERE EXISTS ( SELECT 1 FROM orders WHERE customer_id = c.id
-- Customers with no orders SELECT c.name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders WHERE customer_id = c.id ); ```
CTEs (Common Table Expressions)
CTEs make complex queries readable by naming intermediate results.
WITH
monthly_revenue AS (
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY 1
),
prev_month AS (
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue
FROM monthly_revenue
)
SELECT
month,
revenue,
ROUND((revenue - prev_revenue) / prev_revenue * 100, 1) AS growth_pct
FROM prev_month
ORDER BY month;Aggregate Functions
| Function | Description |
|---|---|
| `COUNT(*)` | Number of rows |
| `COUNT(col)` | Non-NULL values in column |
| `COUNT(DISTINCT col)` | Unique non-NULL values |
| `SUM(col)` | Sum of values |
| `AVG(col)` | Average of values |
| `MIN(col)` | Minimum value |
| `MAX(col)` | Maximum value |
GROUP BY
Groups rows with the same values in specified columns into summary rows.
SELECT country, COUNT(*) AS customer_count, SUM(mrr) AS total_mrr FROM customers GROUP BY country ORDER BY total_mrr DESC;
Rule: every column in SELECT must either be in GROUP BY or wrapped in an aggregate function.
HAVING
Filters groups after aggregation (WHERE filters rows before aggregation).
-- Countries with more than 100 customers SELECT country, COUNT(*) AS cnt FROM customers GROUP BY country HAVING COUNT(*) > 100 ORDER BY cnt DESC;
Combining WHERE and HAVING
-- Countries with more than 10 enterprise customers SELECT country, COUNT(*) AS enterprise_count FROM customers WHERE plan = 'enterprise' -- filter rows first GROUP BY country HAVING COUNT(*) > 10 -- then filter groups ORDER BY enterprise_count DESC;
ROLLUP and CUBE (Subtotals)
-- Subtotals per country and plan SELECT country, plan, SUM(mrr) AS mrr FROM customers GROUP BY ROLLUP(country, plan); -- Produces: (country, plan), (country, NULL), (NULL, NULL) rows
Schema
orders(id, customer_id, created_at, status, total) order_items(id, order_id, product_id, qty, unit_price) products(id, name, category)
Monthly Revenue Report
SELECT
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
COUNT(*) AS order_count,
ROUND(SUM(total), 2) AS revenue,
ROUND(AVG(total), 2) AS avg_order_value
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month;Top 5 Product Categories by Revenue
SELECT p.category, SUM(oi.qty * oi.unit_price) AS category_revenue, COUNT(DISTINCT oi.order_id) AS orders_count FROM order_items oi JOIN orders o ON o.id = oi.order_id JOIN products p ON p.id = oi.product_id WHERE o.status = 'completed' GROUP BY p.category ORDER BY category_revenue DESC LIMIT 5;
Customer Cohort: First Month Purchase Rate
WITH first_orders AS (
SELECT customer_id, MIN(DATE_TRUNC('month', created_at)) AS cohort_month
FROM orders
GROUP BY customer_id
)
SELECT
TO_CHAR(cohort_month, 'YYYY-MM') AS cohort,
COUNT(*) AS customers,
SUM(CASE WHEN o2.customer_id IS NOT NULL THEN 1 ELSE 0 END) AS repurchased
FROM first_orders fo
LEFT JOIN orders o2
ON o2.customer_id = fo.customer_id
AND DATE_TRUNC('month', o2.created_at) = fo.cohort_month + INTERVAL '1 month'
GROUP BY cohort_month
ORDER BY cohort_month;What is an Index?
An index is a data structure that speeds up data retrieval at the cost of extra storage and slower writes. Think of it as a book's index — instead of scanning every page, you jump directly to the entry.
B-Tree Index (Default)
B-Tree is the default index type in PostgreSQL. Efficient for:
CREATE INDEX idx_customers_email ON customers (email); CREATE INDEX idx_orders_created ON orders (created_at);
Composite (Multi-column) Index
Index on multiple columns — most effective when queries filter on all indexed columns.
CREATE INDEX idx_orders_status_date ON orders (status, created_at); -- Efficiently serves: WHERE status = 'completed' AND created_at > '2024-01-01' -- Also serves: WHERE status = 'completed' (leftmost prefix rule) -- Does NOT efficiently serve: WHERE created_at > '2024-01-01' (only right column)
Partial Index
Index only a subset of rows — smaller, faster.
-- Index only active users (saves space if most users are inactive) CREATE INDEX idx_users_active ON users (email) WHERE is_active = true;
Covering Index (Index-Only Scan)
Include additional columns so the query can be answered from the index alone.
CREATE INDEX idx_orders_covering ON orders (customer_id) INCLUDE (total, status); -- Query: SELECT total, status FROM orders WHERE customer_id = 42 -- Satisfied entirely from index — no table heap access needed
When NOT to Use Indexes
- Small tables (< 1000 rows): sequential scan is faster.
- Columns with very low cardinality (e.g., boolean `is_active` with 95% true).
- Columns that are rarely used in WHERE, JOIN, or ORDER BY.
- Write-heavy tables where insert/update cost outweighs read benefit.
EXPLAIN ANALYZE
Always verify with execution plans:
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'completed' AND created_at > '2024-01-01'; -- Look for: Seq Scan (bad for large tables) vs Index Scan (good) -- Check actual rows, actual time, and loops
Problem
The support team reports that the "Daily Orders" page takes 45 seconds to load. The query:
SELECT o.id, c.name, o.total, o.status, o.created_at
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= CURRENT_DATE - INTERVAL '30 days'
AND o.status IN ('completed', 'refunded')
ORDER BY o.created_at DESC
LIMIT 100;Table sizes: orders = 8M rows, customers = 500K rows.
Step 1: Run EXPLAIN ANALYZE
Seq Scan on orders (cost=0.00..234000.00 rows=8000000 ...) Filter: (created_at >= ... AND status IN (...)) Rows Removed by Filter: 7,960,000
The database scans all 8M rows and discards 99.5%. Classic missing index symptom.
Step 2: Create Composite Index
CREATE INDEX idx_orders_status_date ON orders (status, created_at DESC);
Step 3: Verify
Index Scan Backward using idx_orders_status_date on orders
(cost=0.56..1240.00 rows=40000 ...)
Index Cond: (status IN ('completed','refunded')
AND created_at >= ...)Query time drops from 45 seconds to 80 milliseconds.
Step 4: Covering Index for customer JOIN
If `customers` lookups are also slow, add covering index:
CREATE INDEX idx_customers_id_name ON customers (id) INCLUDE (name);
This allows the planner to do an index-only scan on customers instead of heap fetches.
Lesson Learned
Always check `EXPLAIN ANALYZE` before adding indexes. Understand which column goes first in a composite index — the high-selectivity filter column (status) goes first so the index can narrow down rows quickly.