SQL (Structured Query Language) is the universal language for interacting with relational databases. This module builds your foundation from the ground up, ensuring you can write any query with confidence.
Estimated Time: 8-10 hours Hands-On: 30+ practice exercises Project: Build queries for an e-commerce analytics dashboard
A relational database organizes data into tables (relations) with rows (records) and columns (attributes). Tables can be linked through relationships using keys.
A column (or combination of columns) that uniquely identifies each row in a table.
Copy
CREATE TABLE users ( id SERIAL PRIMARY KEY, -- Auto-incrementing unique identifier email VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(100) NOT NULL);
Rules:
Must be unique for every row
Cannot be NULL
Should rarely (ideally never) change
Often an auto-incrementing integer or UUID
Foreign Key
A column that references the primary key of another table, creating a relationship.
Copy
CREATE TABLE orders ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), total DECIMAL(10, 2) NOT NULL, created_at TIMESTAMP DEFAULT NOW());
-- Select all columns from users tableSELECT * FROM users;-- Select specific columnsSELECT name, email FROM users;-- Select with column aliasSELECT name AS customer_name, email AS contact_emailFROM users;
Avoid SELECT * in production code! It fetches unnecessary data, breaks when schema changes, and prevents index-only scans. Always specify the columns you need.
The WHERE clause filters rows based on conditions.
Copy
-- Basic equalitySELECT * FROM users WHERE id = 1;-- String matchingSELECT * FROM users WHERE email = '[email protected]';-- Comparison operatorsSELECT * FROM products WHERE price > 100;SELECT * FROM products WHERE stock <= 10;-- Not equalSELECT * FROM users WHERE status != 'deleted';SELECT * FROM users WHERE status <> 'deleted'; -- Same as !=
-- AND: Both conditions must be trueSELECT * FROM products WHERE category = 'electronics' AND price < 500;-- OR: At least one condition must be trueSELECT * FROM products WHERE category = 'electronics' OR category = 'computers';-- NOT: Negates a conditionSELECT * FROM users WHERE NOT status = 'banned';-- Complex combinations (use parentheses!)SELECT * FROM products WHERE (category = 'electronics' OR category = 'computers') AND price BETWEEN 100 AND 1000 AND stock > 0;
-- IN: Match any value in a listSELECT * FROM orders WHERE status IN ('pending', 'processing', 'shipped');-- BETWEEN: Range inclusiveSELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31';-- LIKE: Pattern matchingSELECT * FROM users WHERE email LIKE '%@gmail.com'; -- Ends withSELECT * FROM users WHERE name LIKE 'John%'; -- Starts withSELECT * FROM users WHERE name LIKE '%son%'; -- Contains-- ILIKE: Case-insensitive LIKE (PostgreSQL)SELECT * FROM users WHERE name ILIKE '%john%';-- IS NULL / IS NOT NULLSELECT * FROM orders WHERE shipped_at IS NULL; -- Not yet shippedSELECT * FROM users WHERE phone IS NOT NULL; -- Has phone number
-- Sort ascending (default)SELECT * FROM products ORDER BY price;SELECT * FROM products ORDER BY price ASC;-- Sort descendingSELECT * FROM products ORDER BY price DESC;-- Multiple sort columnsSELECT * FROM products ORDER BY category ASC, price DESC;-- Sort with NULL handlingSELECT * FROM products ORDER BY discount NULLS LAST; -- NULLs at the end
-- Get first N rowsSELECT * FROM products ORDER BY created_at DESC LIMIT 10;-- Pagination with OFFSETSELECT * FROM products ORDER BY id LIMIT 20 OFFSET 40; -- Page 3 (rows 41-60)-- Better pagination with keyset (cursor-based)SELECT * FROM products WHERE id > 1000 -- Last seen IDORDER BY id LIMIT 20;
Performance Tip: OFFSET becomes slow for large values because the database must scan and discard all skipped rows. Use keyset pagination for better performance on large datasets.
-- COUNT: Number of rowsSELECT COUNT(*) FROM orders; -- All rowsSELECT COUNT(shipped_at) FROM orders; -- Non-NULL values onlySELECT COUNT(DISTINCT user_id) FROM orders; -- Unique users-- SUM: Total of numeric valuesSELECT SUM(total) FROM orders WHERE status = 'completed';-- AVG: AverageSELECT AVG(price) FROM products WHERE category = 'electronics';-- MIN / MAXSELECT MIN(price), MAX(price) FROM products;-- CombinedSELECT COUNT(*) AS total_orders, SUM(total) AS revenue, AVG(total) AS avg_order_value, MIN(total) AS smallest_order, MAX(total) AS largest_orderFROM ordersWHERE status = 'completed';
Group rows by column values and apply aggregates to each group.
Copy
-- Orders per userSELECT user_id, COUNT(*) AS order_count, SUM(total) AS total_spentFROM ordersGROUP BY user_id;-- Sales by categorySELECT category, COUNT(*) AS product_count, AVG(price) AS avg_price, SUM(stock) AS total_inventoryFROM productsGROUP BY category;-- Multiple grouping columnsSELECT EXTRACT(YEAR FROM created_at) AS year, EXTRACT(MONTH FROM created_at) AS month, COUNT(*) AS order_count, SUM(total) AS revenueFROM ordersGROUP BY EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at)ORDER BY year, month;
WHERE filters rows before grouping. HAVING filters groups after aggregation.
Copy
-- Find users with more than 5 ordersSELECT user_id, COUNT(*) AS order_countFROM ordersGROUP BY user_idHAVING COUNT(*) > 5;-- Categories with average price over $100SELECT category, AVG(price) AS avg_priceFROM productsGROUP BY categoryHAVING AVG(price) > 100ORDER BY avg_price DESC;-- Combined WHERE and HAVINGSELECT user_id, SUM(total) AS total_spentFROM ordersWHERE status = 'completed' -- Filter rows firstGROUP BY user_idHAVING SUM(total) > 1000 -- Then filter groupsORDER BY total_spent DESC;
Returns only rows that have matches in both tables.
Copy
-- Get order details with user namesSELECT o.id AS order_id, o.total, o.created_at, u.name AS customer_name, u.emailFROM orders oINNER JOIN users u ON o.user_id = u.id;
Copy
┌─────────────────────────────────────────────────────────────────────┐│ INNER JOIN │├─────────────────────────────────────────────────────────────────────┤│ ││ USERS ORDERS RESULT ││ ┌───┬───────┐ ┌───┬─────────┐ ┌───┬───────┬────┐ ││ │ 1 │ Alice │ ─────────▶│ 1 │ user: 1 │──────▶│ 1 │ Alice │ O1 │ ││ │ 2 │ Bob │ ─────────▶│ 2 │ user: 1 │──────▶│ 2 │ Alice │ O2 │ ││ │ 3 │ Carol │ ✗ │ 3 │ user: 2 │──────▶│ 3 │ Bob │ O3 │ ││ └───┴───────┘ └───┴─────────┘ └───┴───────┴────┘ ││ ││ Carol has no orders ──▶ Not in result ││ │└─────────────────────────────────────────────────────────────────────┘
Returns all rows from the left table, with matching rows from the right (or NULL if no match).
Copy
-- All users, with their orders (if any)SELECT u.id, u.name, COUNT(o.id) AS order_count, COALESCE(SUM(o.total), 0) AS total_spentFROM users uLEFT JOIN orders o ON u.id = o.user_idGROUP BY u.id, u.name;
Copy
┌─────────────────────────────────────────────────────────────────────┐│ LEFT JOIN │├─────────────────────────────────────────────────────────────────────┤│ ││ USERS ORDERS RESULT ││ ┌───┬───────┐ ┌───┬─────────┐ ┌───┬───────┬────┐ ││ │ 1 │ Alice │ ─────────▶│ 1 │ user: 1 │──────▶│ 1 │ Alice │ O1 │ ││ │ 2 │ Bob │ ─────────▶│ 2 │ user: 1 │──────▶│ 2 │ Alice │ O2 │ ││ │ 3 │ Carol │ ─────────▶│ 3 │ user: 2 │──────▶│ 3 │ Bob │ O3 │ ││ └───┴───────┘ └───┴─────────┘ │ 4 │ Carol │NULL│ ││ └───┴───────┴────┘ ││ ││ Carol included with NULL for order data ││ │└─────────────────────────────────────────────────────────────────────┘
-- RIGHT JOIN: All rows from right tableSELECT u.name, o.id AS order_idFROM users uRIGHT JOIN orders o ON u.id = o.user_id;-- FULL OUTER JOIN: All rows from both tablesSELECT u.name, o.id AS order_idFROM users uFULL OUTER JOIN orders o ON u.id = o.user_id;
-- Orders with user info and product detailsSELECT o.id AS order_id, u.name AS customer, p.name AS product, oi.quantity, oi.unit_price, oi.quantity * oi.unit_price AS line_totalFROM orders oINNER JOIN users u ON o.user_id = u.idINNER JOIN order_items oi ON o.id = oi.order_idINNER JOIN products p ON oi.product_id = p.idWHERE o.created_at > '2024-01-01'ORDER BY o.id, p.name;
Join a table to itself, useful for hierarchical or comparative data.
Copy
-- Employees with their managersSELECT e.name AS employee, m.name AS managerFROM employees eLEFT JOIN employees m ON e.manager_id = m.id;-- Find products with the same category and similar pricesSELECT p1.name AS product1, p2.name AS product2, p1.category, p1.price AS price1, p2.price AS price2FROM products p1INNER JOIN products p2 ON p1.category = p2.category AND p1.id < p2.id -- Avoid duplicates AND ABS(p1.price - p2.price) < 50;
Cartesian product: every row from first table paired with every row from second.
Copy
-- All possible size/color combinationsSELECT s.name AS size, c.name AS colorFROM sizes sCROSS JOIN colors c;-- Generate date series with categoriesSELECT d.date, c.name AS categoryFROM generate_series('2024-01-01', '2024-12-31', '1 day') AS d(date)CROSS JOIN categories c;
-- Users who have placed ordersSELECT * FROM usersWHERE id IN (SELECT DISTINCT user_id FROM orders);-- Products priced above averageSELECT * FROM productsWHERE price > (SELECT AVG(price) FROM products);-- Users with more than 5 ordersSELECT * FROM usersWHERE id IN ( SELECT user_id FROM orders GROUP BY user_id HAVING COUNT(*) > 5);
References the outer query — executes once per outer row.
Copy
-- Products with price above their category averageSELECT p1.name, p1.price, p1.categoryFROM products p1WHERE p1.price > ( SELECT AVG(p2.price) FROM products p2 WHERE p2.category = p1.category -- References outer query);-- Users with their latest orderSELECT u.name, (SELECT MAX(created_at) FROM orders o WHERE o.user_id = u.id) AS last_orderFROM users u;
-- Users who have placed at least one orderSELECT u.name, u.emailFROM users uWHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id);-- Products that have never been orderedSELECT p.name, p.priceFROM products pWHERE NOT EXISTS ( SELECT 1 FROM order_items oi WHERE oi.product_id = p.id);
Performance Tip: EXISTS often performs better than IN for large subqueries because it can stop as soon as it finds one match.
-- Monthly revenue with growth calculationSELECT current_month.month, current_month.revenue, prev_month.revenue AS prev_revenue, current_month.revenue - COALESCE(prev_month.revenue, 0) AS growthFROM ( SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY DATE_TRUNC('month', created_at)) current_monthLEFT JOIN ( SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY DATE_TRUNC('month', created_at)) prev_month ON current_month.month = prev_month.month + INTERVAL '1 month'ORDER BY current_month.month;
WITH monthly_revenue AS ( SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders WHERE status = 'completed' GROUP BY DATE_TRUNC('month', created_at))SELECT month, revenue, AVG(revenue) OVER () AS avg_monthly_revenue, revenue - AVG(revenue) OVER () AS diff_from_avgFROM monthly_revenueORDER BY month;
WITH-- Step 1: Calculate user order statsuser_stats AS ( SELECT user_id, COUNT(*) AS order_count, SUM(total) AS total_spent, AVG(total) AS avg_order FROM orders WHERE status = 'completed' GROUP BY user_id),-- Step 2: Classify users into tiersuser_tiers AS ( SELECT user_id, order_count, total_spent, CASE WHEN total_spent >= 10000 THEN 'platinum' WHEN total_spent >= 5000 THEN 'gold' WHEN total_spent >= 1000 THEN 'silver' ELSE 'bronze' END AS tier FROM user_stats),-- Step 3: Count users per tiertier_summary AS ( SELECT tier, COUNT(*) AS user_count, SUM(total_spent) AS tier_revenue FROM user_tiers GROUP BY tier)-- Final resultSELECT tier, user_count, tier_revenue, ROUND(100.0 * tier_revenue / SUM(tier_revenue) OVER (), 2) AS revenue_pctFROM tier_summaryORDER BY tier_revenue DESC;
For hierarchical data like org charts, categories, or graphs.
Copy
-- Employee hierarchy (org chart)WITH RECURSIVE org_chart AS ( -- Base case: top-level employees (no manager) SELECT id, name, manager_id, 1 AS level, name::TEXT AS path FROM employees WHERE manager_id IS NULL UNION ALL -- Recursive case: employees with managers SELECT e.id, e.name, e.manager_id, oc.level + 1, oc.path || ' > ' || e.name FROM employees e INNER JOIN org_chart oc ON e.manager_id = oc.id)SELECT * FROM org_chart ORDER BY path;-- Category tree with all descendantsWITH RECURSIVE category_tree AS ( SELECT id, name, parent_id, 0 AS depth, ARRAY[id] AS path FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.name, c.parent_id, ct.depth + 1, ct.path || c.id FROM categories c INNER JOIN category_tree ct ON c.parent_id = ct.id)SELECT REPEAT(' ', depth) || name AS category_tree, depth, pathFROM category_treeORDER BY path;
Window functions perform calculations across a set of rows related to the current row, without collapsing them into a single output row like aggregates.
-- Rank products by price within each categorySELECT name, category, price, ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS row_num, RANK() OVER (PARTITION BY category ORDER BY price DESC) AS rank, DENSE_RANK() OVER (PARTITION BY category ORDER BY price DESC) AS dense_rankFROM products;
-- Compare each order to the previous order for the same userSELECT user_id, id AS order_id, total, created_at, LAG(total) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_order_total, total - LAG(total) OVER (PARTITION BY user_id ORDER BY created_at) AS change, LEAD(total) OVER (PARTITION BY user_id ORDER BY created_at) AS next_order_totalFROM orders;-- Calculate month-over-month revenue growthWITH monthly_revenue AS ( SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY DATE_TRUNC('month', created_at))SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS prev_month, ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month)) / LAG(revenue) OVER (ORDER BY month), 2) AS growth_pctFROM monthly_revenue;
-- Running total of salesSELECT created_at::date AS date, total, SUM(total) OVER (ORDER BY created_at) AS running_totalFROM orders;-- 7-day moving averageSELECT date, daily_revenue, AVG(daily_revenue) OVER ( ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ) AS moving_avg_7dFROM daily_sales;-- Running total within each monthSELECT created_at::date AS date, total, SUM(total) OVER ( PARTITION BY DATE_TRUNC('month', created_at) ORDER BY created_at ) AS monthly_running_totalFROM orders;
-- Compare each product to the most expensive in its categorySELECT name, category, price, FIRST_VALUE(name) OVER ( PARTITION BY category ORDER BY price DESC ) AS top_product, FIRST_VALUE(price) OVER ( PARTITION BY category ORDER BY price DESC ) AS top_price, ROUND(100.0 * price / FIRST_VALUE(price) OVER ( PARTITION BY category ORDER BY price DESC ), 2) AS pct_of_topFROM products;
-- Percentile ranksSELECT name, salary, PERCENT_RANK() OVER (ORDER BY salary) AS percentile, NTILE(4) OVER (ORDER BY salary) AS quartileFROM employees;-- Median salary per departmentSELECT DISTINCT department, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) OVER (PARTITION BY department) AS median_salaryFROM employees;
Using a products table with columns: id, name, category, price, stock, created_at:
Copy
-- 1. Find all products in the 'Electronics' category priced under $500-- 2. Find products with low stock (less than 10) that need reordering-- 3. List the 5 most recently added products-- 4. Find products with names containing 'Pro' or 'Ultra'
Solutions
Copy
-- 1SELECT * FROM products WHERE category = 'Electronics' AND price < 500;-- 2SELECT name, stock, category FROM products WHERE stock < 10ORDER BY stock ASC;-- 3SELECT * FROM products ORDER BY created_at DESCLIMIT 5;-- 4SELECT * FROM products WHERE name ILIKE '%pro%' OR name ILIKE '%ultra%';
-- 1. Count products per category-- 2. Find the average price per category, only for categories with avg > $200-- 3. Find the total inventory value (price * stock) per category-- 4. Find categories with more than 50 products
Solutions
Copy
-- 1SELECT category, COUNT(*) as product_count FROM products GROUP BY category ORDER BY product_count DESC;-- 2SELECT category, AVG(price) as avg_price FROM products GROUP BY category HAVING AVG(price) > 200;-- 3SELECT category, SUM(price * stock) as inventory_value FROM products GROUP BY category ORDER BY inventory_value DESC;-- 4SELECT category, COUNT(*) as count FROM products GROUP BY category HAVING COUNT(*) > 50;
-- 1. List all orders with customer name and email-- 2. Find customers who have never placed an order-- 3. Get the top 10 customers by total spending-- 4. List products that have been ordered more than 100 times
Solutions
Copy
-- 1SELECT o.id, o.total, o.created_at, u.name, u.emailFROM orders oINNER JOIN users u ON o.user_id = u.idORDER BY o.created_at DESC;-- 2SELECT u.name, u.emailFROM users uLEFT JOIN orders o ON u.id = o.user_idWHERE o.id IS NULL;-- 3SELECT u.name, u.email, SUM(o.total) as total_spentFROM users uINNER JOIN orders o ON u.id = o.user_idGROUP BY u.id, u.name, u.emailORDER BY total_spent DESCLIMIT 10;-- 4SELECT p.name, COUNT(oi.id) as times_orderedFROM products pINNER JOIN order_items oi ON p.id = oi.product_idGROUP BY p.id, p.nameHAVING COUNT(oi.id) > 100ORDER BY times_ordered DESC;
-- 1. Rank products by price within each category-- 2. For each order, show the running total for that user-- 3. Calculate the percentage each product contributes to its category's total stock-- 4. Find the second-highest priced product in each category
Solutions
Copy
-- 1SELECT name, category, price, RANK() OVER (PARTITION BY category ORDER BY price DESC) as price_rankFROM products;-- 2SELECT user_id, id as order_id, total, created_at, SUM(total) OVER (PARTITION BY user_id ORDER BY created_at) as running_totalFROM orders;-- 3SELECT name, category, stock, ROUND(100.0 * stock / SUM(stock) OVER (PARTITION BY category), 2) as stock_pctFROM products;-- 4WITH ranked AS ( SELECT name, category, price, ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) as rn FROM products)SELECT name, category, priceFROM rankedWHERE rn = 2;