Skip to content

SQL Cheatsheet

Standard language for managing and querying relational databases.

01

SELECT & Query Basics

SELECT, WHERE & ORDER BY

SELECT retrieves rows from one or more tables. Always specify columns explicitly instead of * for performance and clarity (schema changes won't break your app). WHERE filters rows before grouping. ORDER BY sorts results (ASC default, DESC descending). LIMIT/OFFSET implement pagination — for large datasets, prefer keyset pagination (WHERE id > last_id).

sql
-- basic query: select specific columns
SELECT id, name, email
FROM users
WHERE age >= 18 AND status = 'active'
ORDER BY name ASC, created_at DESC
LIMIT 10 OFFSET 0;

-- select all columns (avoid in production)
SELECT * FROM products;

-- column aliases with AS
SELECT name AS product_name, price * 1.1 AS price_with_tax
FROM products;

DISTINCT & Aliases

DISTINCT removes duplicate rows from the result set. It operates on the entire row, not individual columns — SELECT DISTINCT city, country returns unique city+country pairs. Table aliases (u, o) shorten queries and are required when joining a table to itself. Column aliases rename output columns for readability.

sql
-- unique values only
SELECT DISTINCT country FROM users;
SELECT DISTINCT city, country FROM users;  -- unique combos

-- table aliases (essential for joins)
SELECT u.name, o.total
FROM users AS u
JOIN orders AS o ON u.id = o.user_id;

-- column alias (AS is optional)
SELECT name product_name, COUNT(*) count
FROM products
GROUP BY name;

Filtering: BETWEEN, IN, IS NULL

BETWEEN is inclusive on both ends. IN matches any value in a list or subquery. NULL requires IS NULL / IS NOT NULL (cannot use = NULL). Be careful with NOT IN and subqueries — if the subquery returns any NULL, NOT IN returns no rows at all. Use NOT EXISTS instead, which handles NULLs correctly and is often faster.

sql
SELECT * FROM products
WHERE price BETWEEN 10 AND 100        -- inclusive range
  AND category IN ('tech', 'home')    -- match any value
  AND stock IS NOT NULL               -- exclude NULLs
  AND discount IS NULL;               -- only NULLs

-- NOT IN with NULL caveat: returns nothing if subquery has NULL!
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);  -- risky if NULLs

-- safer: NOT EXISTS
SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

LIKE & Pattern Matching

LIKE uses % (zero or more chars) and _ (exactly one char) as wildcards. LIKE is case-sensitive in most databases except MySQL (case-insensitive by default). Use ILIKE in PostgreSQL for case-insensitive matching. For complex patterns, use regex (~ in PostgreSQL, REGEXP in MySQL). LIKE with a leading % cannot use indexes — consider full-text search for performance.

sql
-- LIKE: basic pattern matching
SELECT * FROM users WHERE name LIKE 'A%';     -- starts with A
SELECT * FROM users WHERE name LIKE '%son';    -- ends with son
SELECT * FROM users WHERE name LIKE '%a%';     -- contains a
SELECT * FROM users WHERE name LIKE '_a%';     -- second char is a

-- ILIKE (PostgreSQL): case-insensitive
SELECT * FROM users WHERE name ILIKE 'a%';

-- SIMILAR TO (PostgreSQL): regex-like
SELECT * FROM users WHERE name SIMILAR TO '[AB]%';

-- full regex (PostgreSQL)
SELECT * FROM users WHERE name ~ '^A[a-z]+$';

CASE Expressions

CASE is SQL's if-then-else, evaluated per row. It can appear in SELECT, WHERE, ORDER BY, and HAVING. The 'pivot' pattern (SUM of CASE) transforms rows into columns — useful for reporting. CASE returns NULL if no WHEN matches and there's no ELSE. Always include ELSE for predictable results.

sql
-- conditional logic in queries
SELECT
  name,
  price,
  CASE
    WHEN price < 10 THEN 'cheap'
    WHEN price < 50 THEN 'moderate'
    WHEN price < 100 THEN 'expensive'
    ELSE 'luxury'
  END AS price_category
FROM products;

-- CASE in aggregation (pivot table)
SELECT
  category,
  COUNT(*) AS total,
  SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_count,
  SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END) AS inactive_count
FROM products
GROUP BY category;
02

JOINs

INNER JOIN

INNER JOIN returns only rows that have matches in both tables. JOIN is shorthand for INNER JOIN. For multi-table queries, join tables step by step. ON specifies the join condition; USING(column) is shorthand when both tables have the same column name. Inner joins exclude non-matching rows from both sides.

sql
-- only matching rows from both tables
SELECT u.name, o.total, o.created_at
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.total > 100
ORDER BY o.total DESC;

-- multiple joins
SELECT u.name, o.total, p.product_name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id;

-- USING (when column names match)
SELECT * FROM users
JOIN profiles ON users.id = profiles.user_id;

LEFT JOIN (LEFT OUTER JOIN)

LEFT JOIN returns ALL rows from the left table, with NULLs for non-matching right rows. This is essential for 'include everything' queries. The anti-join pattern (WHERE right.id IS NULL) finds rows in the left table with no match in the right — useful for 'users who haven't ordered'. COUNT(right.id) counts non-NULL values, so it returns 0 for users without orders.

sql
-- all users, with their orders (NULL if no orders)
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.name;

-- find users with NO orders (anti-join pattern)
SELECT u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

-- count orders per user (including zero)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

RIGHT & FULL OUTER JOIN

RIGHT JOIN returns all right-table rows; it's equivalent to swapping tables and using LEFT JOIN (which is more readable). FULL OUTER JOIN returns all rows from both tables, with NULLs where there's no match — useful for data reconciliation. MySQL doesn't support FULL OUTER JOIN directly; emulate it with LEFT JOIN UNION RIGHT JOIN.

sql
-- RIGHT JOIN: all rows from right table
SELECT u.name, o.total
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
-- returns all orders, even orphaned ones (user_id = NULL)

-- FULL OUTER JOIN: all rows from both tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- returns all users AND all orders, matching where possible

-- Note: RIGHT JOIN is rarely used (just swap tables and use LEFT)
-- FULL OUTER JOIN is useful for finding mismatches between tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id
WHERE u.id IS NULL OR o.user_id IS NULL;

CROSS JOIN & Self Join

CROSS JOIN produces a Cartesian product — every row of A paired with every row of B. Use it for generating combinations (sizes × colors). Self joins (joining a table to itself) are common for hierarchical data (employee-manager), finding duplicates, or comparing rows within the same table. Always use table aliases in self joins to distinguish the two 'copies'.

sql
-- CROSS JOIN: Cartesian product (every combination)
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;
-- produces all size+color combinations

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

-- self join for hierarchical data
SELECT c.name AS child, p.name AS parent
FROM categories c
JOIN categories p ON c.parent_id = p.id;

-- self join to find duplicates
SELECT a.id, a.email, b.id AS dup_id
FROM users a
JOIN users b ON a.email = b.email AND a.id < b.id;

NATURAL JOIN & JOIN Types Summary

NATURAL JOIN automatically joins on columns with the same name — convenient but dangerous because schema changes can silently change join behavior. Avoid it in production. LATERAL joins allow a subquery to reference columns from the outer query — powerful for 'top N per group' queries. The comma syntax (FROM a, b) is equivalent to CROSS JOIN.

sql
-- NATURAL JOIN: joins on all matching column names
-- (rarely recommended — implicit, fragile)
SELECT * FROM users NATURAL JOIN profiles;
-- joins on any column that exists in BOTH tables

-- Summary of join types:
-- INNER JOIN : matching rows only
-- LEFT JOIN  : all left + matching right
-- RIGHT JOIN : all right + matching left
-- FULL JOIN  : all from both sides
-- CROSS JOIN : Cartesian product
-- SELF JOIN  : table joined to itself

-- LATERAL JOIN (PostgreSQL): subquery can reference outer query
SELECT u.name, recent.*
FROM users u,
LATERAL (
  SELECT * FROM orders o
  WHERE o.user_id = u.id
  ORDER BY o.created_at DESC
  LIMIT 3
) recent;
03

GROUP BY & Aggregation

GROUP BY & HAVING

GROUP BY collapses rows into groups, one row per group. Aggregate functions (COUNT, SUM, AVG, MIN, MAX) operate on each group. WHERE filters individual rows BEFORE grouping; HAVING filters groups AFTER aggregation. Non-aggregated columns in SELECT must appear in GROUP BY (standard SQL). MySQL is lenient but unpredictable — always include all non-aggregated columns in GROUP BY.

sql
-- aggregate per group
SELECT category, COUNT(*) AS cnt, AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING COUNT(*) > 5
ORDER BY cnt DESC;

-- HAVING filters groups (after aggregation)
-- WHERE filters rows (before aggregation)
SELECT dept, AVG(salary) AS avg_sal
FROM employees
WHERE status = 'active'       -- filter rows first
GROUP BY dept
HAVING AVG(salary) > 50000;   -- then filter groups

Aggregate Functions

COUNT(*) counts all rows including NULLs; COUNT(column) counts non-NULL values only. COUNT(DISTINCT col) counts unique values. SUM/AVG ignore NULLs. AVG = SUM/COUNT(non-NULL), so NULLs affect the average. STRING_AGG (PostgreSQL) / GROUP_CONCAT (MySQL) concatenate strings per group. BOOL_OR/BOOL_AND return true if any/all values are true.

sql
SELECT
  COUNT(*) AS total_rows,           -- counts all rows
  COUNT(email) AS emails_filled,    -- counts non-NULL emails
  COUNT(DISTINCT country) AS countries,
  SUM(amount) AS total_revenue,
  AVG(amount) AS avg_order,
  MIN(amount) AS smallest_order,
  MAX(amount) AS largest_order,
  -- string aggregation (PostgreSQL)
  STRING_AGG(name, ', ') AS all_names,
  -- boolean aggregation
  BOOL_OR(is_active) AS any_active,
  BOOL_AND(is_active) AS all_active
FROM orders;

GROUP BY Multiple Columns

Grouping by multiple columns creates a hierarchy of groups. WITH ROLLUP adds subtotal and grand total rows (NULL in the grouped column). GROUPING SETS let you specify exactly which grouping combinations you want — more flexible than ROLLUP. CUBE generates all possible grouping combinations. These are essential for reporting and OLAP queries.

sql
-- multi-level grouping
SELECT
  EXTRACT(YEAR FROM created_at) AS yr,
  EXTRACT(MONTH FROM created_at) AS mon,
  category,
  COUNT(*) AS cnt,
  SUM(total) AS revenue
FROM orders
GROUP BY yr, mon, category
ORDER BY yr DESC, mon DESC, cnt DESC;

-- GROUP BY with ROLLUP (subtotals + grand total)
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category WITH ROLLUP;
-- last row has NULL category = grand total

-- GROUPING SETS (PostgreSQL): specify multiple groupings
SELECT category, status, COUNT(*)
FROM products
GROUP BY GROUPING SETS ((category, status), (category), ());

HAVING vs WHERE

The key distinction: WHERE filters individual rows before aggregation (cannot use SUM, COUNT, etc.), while HAVING filters groups after aggregation (can use aggregate functions). Use WHERE to reduce the data early (better performance), then HAVING to filter the aggregated results. Both can appear in the same query — WHERE first, then GROUP BY, then HAVING.

sql
-- WHERE: filters rows BEFORE grouping
-- Cannot use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10          -- OK: filter on raw column
GROUP BY category;

-- HAVING: filters groups AFTER grouping
-- Can use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category
HAVING COUNT(*) > 5       -- OK: filter on aggregate
   AND AVG(price) > 20;   -- OK: multiple aggregate filters

-- combining both
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10          -- filter rows
GROUP BY category         -- group
HAVING COUNT(*) > 5;      -- filter groups

Date/Time Aggregation

Date truncation is essential for time-series reporting. DATE(col) extracts just the date; EXTRACT/TIME_PART gets specific components (year, month, hour). TO_CHAR formats dates for grouping and display. For time-series analysis, consider DATE_TRUNC('month', col) which keeps the timestamp type. Index date columns for performance on large tables.

sql
-- group by date parts
SELECT
  DATE(created_at) AS order_date,
  COUNT(*) AS orders,
  SUM(total) AS revenue
FROM orders
GROUP BY DATE(created_at)
ORDER BY order_date DESC;

-- group by hour
SELECT
  EXTRACT(HOUR FROM created_at) AS hr,
  COUNT(*) AS cnt
FROM orders
WHERE created_at >= CURRENT_DATE
GROUP BY hr
ORDER BY hr;

-- monthly revenue trend
SELECT
  TO_CHAR(created_at, 'YYYY-MM') AS month,
  SUM(total) AS revenue,
  COUNT(*) AS orders,
  AVG(total) AS avg_order
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY month
ORDER BY month;
04

Subqueries & CTEs

Scalar & Column Subqueries

Scalar subqueries return a single value and can be used anywhere a value is expected. Column subqueries return one column and are used with IN, ANY, ALL. Subqueries in SELECT (correlated) execute once per outer row — can be slow on large datasets. Consider rewriting as a JOIN with GROUP BY for better performance.

sql
-- scalar subquery (returns single value)
SELECT name, age
FROM users
WHERE age > (SELECT AVG(age) FROM users);

-- column subquery (returns one column, multiple rows)
SELECT name
FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 100);

-- subquery in SELECT
SELECT
  u.name,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;

Correlated Subqueries & EXISTS

Correlated subqueries reference the outer query and execute once per outer row — potentially slow. EXISTS/NOT EXISTS are efficient because they short-circuit (stop at first match). NOT EXISTS is the preferred way to find 'rows without matching rows' — it handles NULLs correctly and is often faster than NOT IN. The database may optimize correlated subqueries into joins.

sql
-- correlated: subquery references outer query
SELECT u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id
    AND o.total > 1000
);

-- NOT EXISTS: users without any orders
SELECT u.name
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- correlated subquery in SELECT (runs per row)
SELECT
  u.name,
  (SELECT MAX(o.total)
   FROM orders o
   WHERE o.user_id = u.id) AS max_order
FROM users u;

Common Table Expressions (CTE)

CTEs (WITH clause) create named temporary result sets that make complex queries readable. Unlike subqueries, CTEs can be referenced multiple times and read top-to-bottom. In most databases, CTEs are inlined (optimization happens at the query level). PostgreSQL 12+ supports MATERIALIZED/NOT MATERIALIZED hints. CTEs are also required for recursive queries.

sql
-- CTE: named temporary result set
WITH active_users AS (
  SELECT id, name FROM users WHERE status = 'active'
),
user_orders AS (
  SELECT user_id, COUNT(*) AS cnt, SUM(total) AS revenue
  FROM orders
  GROUP BY user_id
)
SELECT au.name, uo.cnt, uo.revenue
FROM active_users au
LEFT JOIN user_orders uo ON au.id = uo.user_id
ORDER BY uo.revenue DESC NULLS LAST;

-- CTEs improve readability for complex queries
-- They are NOT materialized (just syntactic sugar) in most DBs
-- (PostgreSQL 12+ can materialize with MATERIALIZED keyword)

Recursive CTE

Recursive CTEs reference themselves, enabling tree/graph traversal and sequence generation. Structure: base case UNION ALL recursive case. The recursive case references the CTE and must terminate (add a WHERE to prevent infinite loops). Common uses: org charts, category trees, dependency graphs, date sequences. Each database has slightly different syntax — check your DBMS docs.

sql
-- hierarchical data: org chart
WITH RECURSIVE org_tree AS (
  -- base case: top-level managers
  SELECT id, name, manager_id, 1 AS level
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- recursive case: direct reports
  SELECT e.id, e.name, e.manager_id, ot.level + 1
  FROM employees e
  JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT level, name FROM org_tree ORDER BY level, name;

-- generate a series of dates
WITH RECURSIVE dates AS (
  SELECT DATE '2024-01-01' AS d
  UNION ALL
  SELECT d + 1 FROM dates WHERE d < '2024-01-31'
)
SELECT d FROM dates;

-- factorial
WITH RECURSIVE fact(n, result) AS (
  SELECT 1, 1
  UNION ALL
  SELECT n + 1, result * (n + 1) FROM fact WHERE n < 10
)
SELECT * FROM fact;

Subquery Operators: ANY, ALL

ANY and ALL compare a value against a subquery result set. > ANY means 'greater than at least one'. > ALL means 'greater than every one'. = ANY is equivalent to IN. <> ALL is equivalent to NOT IN but handles NULLs more safely. These operators are less commonly used than IN/EXISTS but can express certain queries more naturally.

sql
-- ANY: greater than ANY of the values (= at least one)
SELECT * FROM products
WHERE price > ANY (
  SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds at least one tech product's price

-- ALL: greater than ALL values (= every one)
SELECT * FROM products
WHERE price > ALL (
  SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds every tech product's price

-- = ANY is equivalent to IN
SELECT * FROM users
WHERE id = ANY (SELECT user_id FROM orders);

-- <> ALL is equivalent to NOT IN (but NULL-safe)
SELECT * FROM users
WHERE id <> ALL (SELECT user_id FROM orders WHERE total < 0);
05

Window Functions

ROW_NUMBER, RANK, DENSE_RANK

ROW_NUMBER assigns unique sequential numbers (1, 2, 3...). RANK gives the same rank to ties but skips subsequent numbers (1, 1, 3). DENSE_RANK gives the same rank to ties without skipping (1, 1, 2). PARTITION BY divides rows into groups; the function resets per partition. The 'top N per group' pattern (ROW_NUMBER + WHERE rn <= N) is extremely common in analytics.

sql
SELECT
  name,
  salary,
  dept,
  ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
  RANK() OVER (ORDER BY salary DESC) AS rank,
  DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees;

-- top 3 earners per department
SELECT * FROM (
  SELECT
    name,
    dept,
    salary,
    ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
  FROM employees
) ranked
WHERE rn <= 3;

LAG & LEAD

LAG accesses a previous row's value; LEAD accesses a future row's value. Both accept an optional offset (default 1) and default value (default NULL). Essential for time-series analysis: day-over-day changes, moving comparisons, gap detection. NULLIF prevents division by zero in percentage calculations. Always specify ORDER BY in the OVER clause for deterministic results.

sql
-- compare each row to previous/next
SELECT
  date,
  revenue,
  LAG(revenue) OVER (ORDER BY date) AS prev_day,
  LEAD(revenue) OVER (ORDER BY date) AS next_day,
  revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
  ROUND(
    (revenue - LAG(revenue) OVER (ORDER BY date)) * 100.0
    / NULLIF(LAG(revenue) OVER (ORDER BY date), 0),
    2
  ) AS pct_change
FROM daily_sales
ORDER BY date;

-- LAG with offset and default
SELECT
  date,
  revenue,
  LAG(revenue, 7) OVER (ORDER BY date) AS revenue_7_days_ago
FROM daily_sales;

Running Totals & Moving Averages

Window frames define which rows the function operates on. ROWS BETWEEN uses physical row offsets; RANGE uses logical value ranges (better for date gaps). UNBOUNDED PRECEDING means 'from the start'. Running totals (cumulative SUM) and moving averages are the most common analytical patterns. Without a frame, aggregate window functions use the default: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.

sql
-- cumulative sum (running total)
SELECT
  date,
  revenue,
  SUM(revenue) OVER (ORDER BY date) AS running_total,
  SUM(revenue) OVER (ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS last_7_days_sum,
  AVG(revenue) OVER (ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM daily_sales
ORDER BY date;

-- window frame options:
-- ROWS BETWEEN n PRECEDING AND n FOLLOWING  -- physical rows
-- RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW -- logical range
-- ROWS UNBOUNDED PRECEDING = from start to current
-- ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING = all rows

NTILE & PERCENT_RANK

NTILE(n) divides ordered rows into n roughly equal groups (quartiles, deciles, percentiles). PERCENT_RANK gives the relative rank (0 to 1). CUME_DIST gives the cumulative distribution. FIRST_VALUE/LAST_VALUE return values from the first/last row in the frame — note LAST_VALUE needs an explicit frame (UNBOUNDED FOLLOWING) because the default frame ends at the current row.

sql
-- divide into quartiles
SELECT
  name,
  salary,
  NTILE(4) OVER (ORDER BY salary DESC) AS quartile,
  PERCENT_RANK() OVER (ORDER BY salary) AS pct_rank,
  CUME_DIST() OVER (ORDER BY salary) AS cumulative_dist
FROM employees;

-- first/last value in a partition
SELECT
  dept,
  name,
  salary,
  FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC) AS top_earner,
  LAST_VALUE(name) OVER (
    PARTITION BY dept ORDER BY salary DESC
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS lowest_earner
FROM employees;

Window Aggregate Functions

Window aggregates (SUM, AVG, COUNT, etc. with OVER) compute aggregate values WITHOUT collapsing rows — each row gets the aggregate attached. This is the key difference from GROUP BY: you keep all detail rows while also seeing the summary. Perfect for comparing individual values to group averages, calculating percentages, and adding context columns to detail reports.

sql
-- aggregates over windows (no row collapse!)
SELECT
  name,
  dept,
  salary,
  -- compare to department average
  AVG(salary) OVER (PARTITION BY dept) AS dept_avg,
  salary - AVG(salary) OVER (PARTITION BY dept) AS diff_from_avg,
  -- percentage of department total
  salary * 100.0 / SUM(salary) OVER (PARTITION BY dept) AS pct_of_dept,
  -- count per department
  COUNT(*) OVER (PARTITION BY dept) AS dept_size
FROM employees
ORDER BY dept, salary DESC;

-- key advantage: aggregates without GROUP BY
-- every row is preserved, with the aggregate value attached
06

DDL: Tables & Schema

CREATE TABLE & Data Types

CREATE TABLE defines schema. SERIAL (PostgreSQL) / AUTO_INCREMENT (MySQL) auto-generates IDs. VARCHAR(n) has a limit; TEXT is unlimited. DECIMAL(p,s) is exact (use for money!), FLOAT is approximate. CHECK constraints enforce business rules. DEFAULT provides values when not specified. JSONB (PostgreSQL) enables indexed JSON queries. Always use TIMESTAMP WITH TIME ZONE for timestamps that span timezones.

sql
CREATE TABLE users (
  id          SERIAL PRIMARY KEY,          -- auto-increment (PostgreSQL)
  -- MySQL: id INT AUTO_INCREMENT PRIMARY KEY
  username    VARCHAR(50) UNIQUE NOT NULL,
  email       VARCHAR(255) UNIQUE NOT NULL,
  password    VARCHAR(255) NOT NULL,
  age         INT CHECK (age >= 0 AND age <= 150),
  salary      DECIMAL(10, 2) DEFAULT 0.00, -- precision, scale
  bio         TEXT,                        -- unlimited length
  avatar      BYTEA,                       -- binary (PostgreSQL)
  metadata    JSONB,                       -- JSON (PostgreSQL)
  status      VARCHAR(20) DEFAULT 'active',
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- common types:
-- INT, BIGINT, SMALLINT, DECIMAL(p,s), NUMERIC
-- VARCHAR(n), CHAR(n), TEXT
-- DATE, TIME, TIMESTAMP, INTERVAL
-- BOOLEAN, UUID, JSON/JSONB, BYTEA/BLOB

Constraints: PRIMARY, FOREIGN, UNIQUE, CHECK

Constraints enforce data integrity at the database level. PRIMARY KEY uniquely identifies rows (implies NOT NULL + UNIQUE). FOREIGN KEY maintains referential integrity — ON DELETE CASCADE removes children when parent is deleted. UNIQUE prevents duplicates. CHECK enforces custom rules. Defining constraints in the database (not just app code) ensures integrity regardless of how data is accessed.

sql
CREATE TABLE orders (
  id          SERIAL PRIMARY KEY,
  user_id     INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  total       DECIMAL(10,2) NOT NULL CHECK (total >= 0),
  status      VARCHAR(20) NOT NULL DEFAULT 'pending',
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  -- table-level constraints
  CONSTRAINT valid_status CHECK (status IN ('pending','paid','shipped','cancelled')),
  CONSTRAINT unique_user_order UNIQUE (user_id, created_at)
);

-- foreign key actions:
-- ON DELETE CASCADE  : delete child rows when parent deleted
-- ON DELETE SET NULL : set FK to NULL (column must be nullable)
-- ON DELETE RESTRICT : prevent parent deletion (default)
-- ON UPDATE CASCADE  : update FK when parent PK changes

-- add constraint to existing table
ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);

ALTER TABLE

ALTER TABLE modifies existing table structure. Adding columns with defaults is usually fast (PostgreSQL 11+ doesn't rewrite the table). Dropping columns may lock the table. Changing column types can require a full table rewrite and may fail if data doesn't convert. Always test schema migrations on a copy first. Use migration tools (Flyway, Alembic, Rails migrations) for version-controlled schema changes.

sql
-- add column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN verified BOOLEAN DEFAULT false;

-- drop column
ALTER TABLE users DROP COLUMN avatar;

-- rename column/table
ALTER TABLE users RENAME COLUMN username TO login;
ALTER TABLE users RENAME TO accounts;

-- change column type
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;
-- MySQL: ALTER TABLE users MODIFY COLUMN age BIGINT;

-- add/drop constraints
ALTER TABLE users ADD CONSTRAINT email_unique UNIQUE (email);
ALTER TABLE users DROP CONSTRAINT email_unique;

-- set default
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active';

DROP, TRUNCATE & Indexes

DROP TABLE removes the table entirely; TRUNCATE empties it but keeps the structure (much faster than DELETE, resets identity). Indexes speed up queries but slow down writes — index strategically. Composite indexes work left-to-right: idx(a,b,c) helps WHERE a=?, WHERE a=? AND b=?, but NOT WHERE b=?. GIN indexes enable full-text search. Partial indexes save space by indexing only matching rows.

sql
-- DROP: permanently remove table (structure + data)
DROP TABLE IF EXISTS old_logs CASCADE;
-- CASCADE drops dependent objects (views, FKs)

-- TRUNCATE: remove all data, keep structure (faster than DELETE)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY;  -- reset SERIAL counter
TRUNCATE TABLE orders, order_items CASCADE;  -- multiple tables

-- CREATE INDEX for query performance
CREATE INDEX idx_users_email ON users(email);
CREATE UNIQUE INDEX idx_users_username ON users(username);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
CREATE INDEX idx_products_name ON products USING gin(to_tsvector('english', name));

-- partial index (PostgreSQL)
CREATE INDEX idx_active_users ON users(last_login)
WHERE status = 'active';

-- DROP INDEX
DROP INDEX IF EXISTS idx_users_email;

Views & Materialized Views

Views are saved queries that act as virtual tables — they run the underlying query each time. Use views to simplify complex queries, enforce security (column-level access), and provide stable APIs. Materialized views store the actual results — faster to query but must be refreshed. Use materialized views for expensive aggregations that don't need real-time data. CONCURRENTLY refreshes without locking (PostgreSQL).

sql
-- VIEW: stored query (virtual table, runs on access)
CREATE VIEW active_users AS
SELECT id, name, email FROM users WHERE status = 'active';

SELECT * FROM active_users WHERE name LIKE 'A%';

-- updatable view (simple views can be INSERTed/UPDATEd)
CREATE VIEW user_summary AS
SELECT id, name, email, age FROM users;

-- MATERIALIZED VIEW: stored result (must refresh)
CREATE MATERIALIZED VIEW monthly_stats AS
SELECT
  DATE_TRUNC('month', created_at) AS month,
  COUNT(*) AS orders,
  SUM(total) AS revenue
FROM orders
GROUP BY month;

REFRESH MATERIALIZED VIEW monthly_stats;
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_stats; -- no lock

-- drop
DROP VIEW IF EXISTS active_users;
DROP MATERIALIZED VIEW IF EXISTS monthly_stats;
07

DML: Insert, Update, Delete

INSERT

INSERT adds rows. Multiple VALUES in one statement is more efficient than separate inserts. INSERT...SELECT copies data between tables. RETURNING (PostgreSQL/Oracle) retrieves auto-generated values (like SERIAL ids) in one round trip — essential for application code. Use DEFAULT VALUES to insert a row with all defaults. Always specify column names to make your code resilient to schema changes.

sql
-- single row
INSERT INTO users (name, email, age)
VALUES ('Alice', '[email protected]', 30);

-- multiple rows
INSERT INTO users (name, email) VALUES
  ('Bob', '[email protected]'),
  ('Carol', '[email protected]'),
  ('Dave', '[email protected]');

-- INSERT ... SELECT (copy data between tables)
INSERT INTO archive_users (name, email, deleted_at)
SELECT name, email, NOW()
FROM users
WHERE status = 'deleted';

-- INSERT with RETURNING (PostgreSQL)
INSERT INTO users (name, email)
VALUES ('Eve', '[email protected]')
RETURNING id, created_at;  -- returns the generated id

-- DEFAULT values
INSERT INTO users DEFAULT VALUES;

UPDATE

UPDATE modifies existing rows. ALWAYS include a WHERE clause unless you intend to update every row. The FROM clause (PostgreSQL) allows joins in updates. RETURNING shows which rows were modified. Use transactions for multi-step updates so you can ROLLBACK if something goes wrong. A common mistake is forgetting WHERE — consider running a SELECT with the same WHERE first to verify the affected rows.

sql
-- basic update
UPDATE users
SET age = 31, status = 'verified', updated_at = NOW()
WHERE id = 1;

-- update based on another table
UPDATE products p
SET price = p.price * 1.1
FROM categories c
WHERE p.category_id = c.id AND c.name = 'electronics';

-- update with subquery
UPDATE users
SET status = 'premium'
WHERE id IN (
  SELECT user_id FROM orders
  GROUP BY user_id HAVING SUM(total) > 1000
);

-- UPDATE with RETURNING (PostgreSQL)
UPDATE users SET status = 'inactive'
WHERE last_login < '2023-01-01'
RETURNING id, name;

-- WARNING: UPDATE without WHERE affects ALL rows!

DELETE & TRUNCATE

DELETE removes rows one at a time (logged, can be rolled back, slower). TRUNCATE removes all rows at once (minimal logging, much faster, resets auto-increment, cannot be rolled back in some DBs). For audit trails, use soft deletes (a deleted_at timestamp) instead of hard deletes. Always use WHERE with DELETE. Consider foreign key constraints — ON DELETE CASCADE handles child rows automatically.

sql
-- delete specific rows
DELETE FROM users WHERE status = 'inactive';

-- delete with subquery
DELETE FROM orders
WHERE user_id IN (
  SELECT id FROM users WHERE status = 'deleted'
);

-- delete with RETURNING (PostgreSQL)
DELETE FROM users
WHERE last_login < '2020-01-01'
RETURNING id, name;

-- delete all rows (slow, logged, can be rolled back)
DELETE FROM logs;

-- TRUNCATE (fast, minimal logging, resets identity)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY CASCADE;

-- soft delete pattern (preferred for audit)
UPDATE users SET deleted_at = NOW() WHERE id = 1;
SELECT * FROM users WHERE deleted_at IS NULL; -- active users

UPSERT (INSERT ... ON CONFLICT)

UPSERT (update or insert) handles duplicate-key conflicts atomically. PostgreSQL uses ON CONFLICT (column) DO UPDATE/DO NOTHING. MySQL uses ON DUPLICATE KEY UPDATE. EXCLUDED (PostgreSQL) / VALUES() (MySQL) refers to the proposed insert values. This is essential for idempotent operations and avoiding race conditions. Without upsert, you'd need SELECT-then-INSERT/UPDATE which is prone to race conditions.

sql
-- PostgreSQL: ON CONFLICT (upsert)
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id)
DO UPDATE SET email = EXCLUDED.email, updated_at = NOW()
RETURNING *;

-- DO NOTHING on conflict
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id) DO NOTHING;

-- MySQL: ON DUPLICATE KEY UPDATE
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON DUPLICATE KEY UPDATE email = VALUES(email);

-- SQLite: ON CONFLICT
INSERT INTO users (id, name)
VALUES (1, 'Alice')
ON CONFLICT(id) DO UPDATE SET name = excluded.name;

MERGE Statement

MERGE (aka UPSERT on steroids) combines INSERT, UPDATE, and DELETE in a single atomic statement based on whether rows match. It's the most efficient way to sync data between sources. WHEN MATCHED triggers UPDATE/DELETE for existing rows; WHEN NOT MATCHED triggers INSERT for new rows. Available in SQL Server, Oracle, PostgreSQL 15+, and DB2. MySQL doesn't support MERGE — use INSERT...ON DUPLICATE KEY.

sql
-- MERGE: conditional insert/update/delete in one statement
-- (SQL Server, Oracle, PostgreSQL 15+)
MERGE INTO products AS target
USING (VALUES
  (1, 'Widget', 9.99),
  (2, 'Gadget', 19.99),
  (3, 'Gizmo', 29.99)
) AS source (id, name, price)
ON target.id = source.id
WHEN MATCHED THEN
  UPDATE SET name = source.name, price = source.price
WHEN NOT MATCHED THEN
  INSERT (id, name, price) VALUES (source.id, source.name, source.price)
WHEN MATCHED AND source.price < 0 THEN
  DELETE;

-- useful for:
-- - syncing data from external sources
-- - bulk upsert with conditional logic
-- - ETL operations
08

Transactions & ACID

BEGIN, COMMIT, ROLLBACK

Transactions group operations into an atomic unit — all succeed (COMMIT) or all fail (ROLLBACK). This is the 'A' in ACID. BEGIN/START TRANSACTION starts a transaction. SAVEPOINT creates a named rollback point within a transaction — you can roll back to it without aborting the whole transaction. Always commit or rollback — leaving a transaction open holds locks and can cause deadlocks.

sql
-- basic transaction
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- both updates succeed or both fail (atomicity)

-- rollback on error
BEGIN;
  INSERT INTO orders (user_id, total) VALUES (1, 50.00);
  -- oops, something went wrong
  ROLLBACK;
-- the insert is undone

-- transaction with savepoints
BEGIN;
  INSERT INTO logs (msg) VALUES ('step 1');
  SAVEPOINT my_savepoint;
  INSERT INTO logs (msg) VALUES ('step 2');
  ROLLBACK TO my_savepoint;  -- undo step 2, keep step 1
  INSERT INTO logs (msg) VALUES ('step 3');
COMMIT;  -- commits step 1 and step 3

Isolation Levels

Isolation levels balance consistency vs concurrency. READ COMMITTED (default in PostgreSQL/Oracle) prevents dirty reads but allows non-repeatable reads. REPEATABLE READ prevents non-repeatable reads but allows phantom reads. SERIALIZABLE prevents all anomalies but reduces concurrency. Higher isolation = more locks = less concurrency. Choose the lowest level that meets your correctness requirements.

sql
-- set isolation level for transaction
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
  -- can see committed data from other transactions
  SELECT balance FROM accounts WHERE id = 1;
COMMIT;

-- isolation levels (from weakest to strongest):
-- READ UNCOMMITTED: can read uncommitted (dirty) data
-- READ COMMITTED: only committed data (PostgreSQL default)
-- REPEATABLE READ: same query returns same results within txn
-- SERIALIZABLE: transactions appear to run sequentially

-- PostgreSQL: set per transaction
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- MySQL: set per session
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- check current level
SHOW TRANSACTION ISOLATION LEVEL;

Locking & SELECT FOR UPDATE

SELECT FOR UPDATE locks rows so other transactions can't modify them until you commit. This implements pessimistic concurrency control. SKIP LOCKED is essential for job queues — multiple workers can grab jobs without blocking each other. NOWAIT fails fast instead of waiting. Use locking sparingly — it reduces concurrency and can cause deadlocks. Prefer optimistic concurrency (version columns) for most use cases.

sql
-- pessimistic locking: lock rows for update
BEGIN;
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
  -- row is locked; other transactions must wait
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;  -- lock released

-- NOWAIT: don't wait if locked, error immediately
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;

-- SKIP LOCKED: skip locked rows (useful for job queues)
SELECT * FROM jobs WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 10;

-- SHARE LOCK: allow reads but prevent updates
SELECT * FROM products WHERE id = 1 FOR SHARE;

Deadlocks & Error Handling

Deadlocks occur when two transactions hold locks that each other needs. The database detects deadlocks and aborts one transaction (the victim). Prevent deadlocks by acquiring locks in a consistent order across all transactions. Always be prepared to retry transactions that fail due to deadlocks or serialization failures. Keep transactions short to reduce lock contention. Application code should catch SQLSTATE 40P01 (deadlock) and retry.

sql
-- deadlock example:
-- Transaction A:
BEGIN;
  UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- locks row 1
  UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- waits for row 2

-- Transaction B (concurrent):
BEGIN;
  UPDATE accounts SET balance = balance - 30 WHERE id = 2; -- locks row 2
  UPDATE accounts SET balance = balance + 30 WHERE id = 1; -- waits for row 1
-- DEADLOCK! Database detects and kills one transaction

-- prevention: always lock in consistent order
-- Transaction A and B both lock id=1 first, then id=2

-- PostgreSQL: error codes for handling
-- 40P01: deadlock_detected
-- 40001: serialization_failure
-- 40P02: transaction_integrity_constraint_violation

-- retry pattern (pseudocode):
-- for attempt in range(3):
--     try:
--         BEGIN; ... COMMIT; break
--     except deadlock:
--         ROLLBACK; continue

ACID Properties

ACID is the foundation of reliable database transactions. Atomicity: all operations in a transaction succeed or fail together. Consistency: transactions move the database from one valid state to another (constraints are enforced). Isolation: concurrent transactions don't interfere (controlled by isolation level). Durability: once committed, data survives crashes (achieved via write-ahead logging). NoSQL databases often sacrifice some ACID properties for scalability.

sql
-- ACID guarantees for transactions:

-- A: Atomicity (all or nothing)
BEGIN;
  INSERT INTO orders (id, total) VALUES (1, 100);
  INSERT INTO order_items (order_id, product_id) VALUES (1, 5);
  -- if either fails, both are rolled back
COMMIT;

-- C: Consistency (valid state to valid state)
-- constraints are checked at commit
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative
  CHECK (balance >= 0);
-- a transaction that would make balance negative fails

-- I: Isolation (concurrent transactions don't interfere)
-- controlled by isolation level (see previous section)

-- D: Durability (committed data survives crashes)
-- achieved via WAL (Write-Ahead Logging) + fsync
-- synchronous_commit = on (default) ensures durability
09

Indexes, Views & Stored Procedures

Index Types & Strategies

B-tree indexes (default) handle equality (=) and range (<, >, BETWEEN) queries. Composite indexes follow the leftmost prefix rule — a (a,b,c) index helps WHERE a=?, WHERE a=? AND b=?, but not WHERE b=?. Partial indexes save space by indexing only a subset. Expression indexes enable indexed queries on functions (LOWER, computed columns). Use EXPLAIN ANALYZE to verify indexes are used — an unused index wastes space and slows writes.

sql
-- B-tree index (default): equality and range queries
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_date ON orders(created_at);

-- composite index (order matters!)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- helps: WHERE user_id = 1
-- helps: WHERE user_id = 1 AND status = 'paid'
-- does NOT help: WHERE status = 'paid' (leftmost prefix rule)

-- partial index: smaller, faster for common filters
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';

-- expression index
CREATE INDEX idx_lower_email ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = '[email protected]';

-- unique index
CREATE UNIQUE INDEX idx_unique_email ON users(email);

-- EXPLAIN: see if index is used
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';

Stored Procedures & Functions

Functions return values and can be used in SELECT; procedures perform actions and are called with CALL. Stored procedures encapsulate business logic in the database — reducing network round trips and centralizing logic. However, they can make scaling harder (logic split between app and DB) and are database-specific. Use them for data-intensive operations that benefit from proximity to the data. PostgreSQL uses PL/pgSQL; MySQL uses its own procedural SQL.

sql
-- PostgreSQL function
CREATE OR REPLACE FUNCTION get_user_orders(p_user_id INT)
RETURNS TABLE(order_id INT, total DECIMAL) AS $$
BEGIN
  RETURN QUERY
  SELECT id, total FROM orders WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql;

-- call function
SELECT * FROM get_user_orders(1);

-- PostgreSQL procedure (can manage transactions, PostgreSQL 11+)
CREATE PROCEDURE transfer_money(
  from_id INT, to_id INT, amount DECIMAL
) LANGUAGE plpgsql AS $$
BEGIN
  UPDATE accounts SET balance = balance - amount WHERE id = from_id;
  UPDATE accounts SET balance = balance + amount WHERE id = to_id;
  COMMIT;
END;
$$;

CALL transfer_money(1, 2, 100.00);

-- MySQL stored procedure
DELIMITER //
CREATE PROCEDURE GetActiveUsers()
BEGIN
  SELECT * FROM users WHERE status = 'active';
END //
DELIMITER ;
CALL GetActiveUsers();

Triggers

Triggers execute automatically on data changes. BEFORE triggers can modify the incoming data (e.g., set timestamps, validate). AFTER triggers perform side effects (e.g., audit logging, denormalization). Use triggers sparingly — they're hidden from application code, making debugging harder. Common use cases: audit trails, computed columns, enforcing complex constraints, and synchronizing denormalized data. Always document triggers clearly.

sql
-- PostgreSQL trigger: audit log on update
CREATE OR REPLACE FUNCTION audit_user_change()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO user_audit (user_id, old_name, new_name, changed_at)
  VALUES (OLD.id, OLD.name, NEW.name, NOW());
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_user_audit
AFTER UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION audit_user_change();

-- trigger timing: BEFORE / AFTER / INSTEAD OF
-- trigger events: INSERT / UPDATE / DELETE / TRUNCATE
-- granularity: FOR EACH ROW / FOR EACH STATEMENT

-- MySQL trigger
CREATE TRIGGER before_user_insert
BEFORE INSERT ON users
FOR EACH ROW
SET NEW.created_at = NOW();

-- drop trigger
DROP TRIGGER IF EXISTS trg_user_audit ON users;

JSON Operations (PostgreSQL)

JSONB (PostgreSQL) stores JSON in a binary format, enabling indexing and efficient queries. -> returns JSON, ->> returns text. @> checks containment (does the JSON contain this?). GIN indexes make JSON queries fast. Use JSON columns for flexible/semi-structured data (event logs, API responses, configuration) while keeping relational data in normal columns. JSONB is preferable to JSON (faster, indexable, no duplicate keys).

sql
-- JSONB columns (PostgreSQL)
CREATE TABLE events (
  id SERIAL PRIMARY KEY,
  data JSONB NOT NULL
);

INSERT INTO events (data) VALUES
  ('{"type": "click", "user": {"id": 1, "name": "Alice"}, "tags": ["web", "mobile"]}');

-- extract fields (-> for JSON, ->> for text)
SELECT data->'type' AS type,           -- "click" (JSON)
       data->'user'->>'name' AS name,  -- Alice (text)
       data->'tags'->0 AS first_tag    -- "web"
FROM events;

-- filter by JSON field
SELECT * FROM events WHERE data->>'type' = 'click';
SELECT * FROM events WHERE data @> '{"type": "click"}';  -- containment

-- GIN index for JSON queries
CREATE INDEX idx_events_data ON events USING gin(data);
SELECT * FROM events WHERE data @> '{"user": {"id": 1}}';

-- modify JSON
UPDATE events SET data = jsonb_set(data, '{user,name}', '"Bob"');

Full-Text Search

Full-text search enables natural language queries (stemming, ranking, stopwords). to_tsvector converts text to searchable tokens; to_tsquery creates a search query; @@ matches. ts_rank scores results; ts_headline highlights matches. GIN indexes make this fast. For large-scale search, consider dedicated engines (Elasticsearch, Solr), but PostgreSQL FTS is excellent for moderate datasets and avoids infrastructure complexity.

sql
-- PostgreSQL full-text search
CREATE TABLE articles (
  id SERIAL PRIMARY KEY,
  title VARCHAR(200),
  body TEXT
);

-- create a full-text search index
CREATE INDEX idx_articles_search ON articles
USING gin(to_tsvector('english', title || ' ' || body));

-- search with ranking
SELECT
  title,
  ts_rank(to_tsvector('english', body), query) AS rank,
  ts_headline('english', body, query) AS snippet
FROM articles, to_tsquery('english', 'database & performance') query
WHERE to_tsvector('english', title || ' ' || body) @@ query
ORDER BY rank DESC
LIMIT 10;

-- simplified with generated column
ALTER TABLE articles ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_search_vec ON articles USING gin(search_vector);

SELECT * FROM articles WHERE search_vector @@ to_tsquery('database');
10

Performance & Query Optimization

EXPLAIN & Query Plans

EXPLAIN shows the query plan — how the database will execute your query. EXPLAIN ANALYZE actually runs it and shows real timings. Look for Sequential Scans on large tables (add indexes), expensive Sorts (add indexes), and row estimate mismatches (run ANALYZE to update statistics). The cost numbers are relative, not absolute. Understanding query plans is the #1 skill for SQL performance tuning.

sql
-- EXPLAIN: show query plan without running
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';

-- EXPLAIN ANALYZE: run the query and show actual timing
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';

-- EXPLAIN ANALYZE with buffers (I/O stats)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users
JOIN orders ON users.id = orders.user_id;

-- key things to look for in the plan:
-- Seq Scan: full table scan (bad for large tables — add index)
-- Index Scan: using an index (good)
-- Bitmap Index Scan: index + heap lookup (good for many rows)
-- Hash Join: builds hash table (good for large joins)
-- Nested Loop: good for small result sets
-- Sort: explicit sort (consider index to avoid)
-- cost: estimated cost (first row, all rows)
-- rows: estimated vs actual rows (big mismatch = stale stats)

Common Performance Pitfalls

Sargability (Search Argument Able) means the database can use indexes. Functions on columns (DATE(col), UPPER(col)) prevent index usage — rewrite as range queries or use expression indexes. SELECT * wastes I/O and prevents covering indexes. OFFSET pagination is O(n) — use keyset pagination (WHERE id > last_id) for O(1). Large batch operations should be chunked to avoid long locks and replication lag.

sql
-- 1. Sargability: avoid functions on indexed columns
-- BAD: function prevents index usage
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01';
-- GOOD: range query uses index
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';

-- 2. Avoid SELECT * (more I/O, prevents covering indexes)
-- BAD
SELECT * FROM users WHERE status = 'active';
-- GOOD
SELECT id, name, email FROM users WHERE status = 'active';

-- 3. Use LIMIT with ORDER BY for pagination
-- BAD: loads all rows
SELECT * FROM products ORDER BY id;
-- GOOD: keyset pagination
SELECT * FROM products WHERE id > 1000 ORDER BY id LIMIT 20;

-- 4. Batch large updates
-- BAD: one giant transaction
DELETE FROM logs WHERE date < '2023-01-01';
-- GOOD: batch in chunks
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 10000;
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 20000;

UNION, INTERSECT & EXCEPT

Set operations combine result sets. UNION removes duplicates (expensive sort); UNION ALL keeps them (faster — prefer when you know there are no duplicates or want them). INTERSECT returns rows in both. EXCEPT returns rows in the first but not the second. All require compatible column types. UNION ALL can replace complex OR conditions and often performs better because it can use different indexes for each branch.

sql
-- UNION: combine results, remove duplicates
SELECT name FROM customers
UNION
SELECT name FROM suppliers;
-- UNION ALL: faster, keeps duplicates
SELECT name FROM customers
UNION ALL
SELECT name FROM suppliers;

-- INTERSECT: rows in BOTH results
SELECT product_id FROM sales_2023
INTERSECT
SELECT product_id FROM sales_2024;

-- EXCEPT (MINUS in Oracle): rows in first but not second
SELECT product_id FROM all_products
EXCEPT
SELECT product_id FROM discontinued_products;

-- rules:
-- - same number of columns
-- - compatible types
-- - column names come from first query
-- - UNION is often faster than OR conditions

Database-Specific Tips

VACUUM (PostgreSQL) reclaims space from deleted rows (MVCC leaves 'dead tuples'). ANALYZE updates table statistics for the query planner — run after bulk loads. OPTIMIZE TABLE (MySQL) defragments tables. Index foreign keys explicitly (PostgreSQL doesn't auto-index them). Monitor index usage with pg_stat_user_indexes and drop unused ones. Connection pooling (PgBouncer, ProxySQL) is essential for high-traffic apps — opening connections is expensive.

sql
-- PostgreSQL: VACUUM to reclaim space
VACUUM ANALYZE users;  -- update stats, reclaim dead rows
VACUUM FULL users;     -- rewrites table (locks, but reclaims all space)

-- PostgreSQL: ANALYZE to update statistics
ANALYZE users;  -- helps query planner make better decisions

-- MySQL: OPTIMIZE TABLE
OPTIMIZE TABLE users;

-- Common indexing rules across databases:
-- 1. Index foreign keys (not automatic in all DBs)
-- 2. Index columns used in WHERE, JOIN, ORDER BY, GROUP BY
-- 3. Composite indexes: high selectivity column first
-- 4. Don't over-index (slows writes, uses disk)
-- 5. Drop unused indexes (check with pg_stat_user_indexes)

-- Connection pooling (application level):
-- - Use PgBouncer (PostgreSQL) or ProxySQL (MySQL)
-- - Reuse connections instead of reconnecting per request
-- - Set appropriate pool size (not too high!)

Data Types & NULL Handling

NULL represents unknown/missing data, not zero or empty. NULL comparisons always yield NULL (unknown), which is falsy in WHERE. Use IS NULL / IS NOT NULL to test. COALESCE provides fallbacks. NULLIF converts specific values to NULL (useful for division by zero). Aggregates skip NULLs — COUNT(col) counts non-NULLs, COUNT(*) counts all rows. In LEFT JOINs, use COUNT(right_table.col) to get 0 for non-matching rows.

sql
-- NULL is not zero or empty string — it's "unknown"
SELECT NULL = NULL;   -- NULL (not true!)
SELECT NULL IS NULL;  -- true
SELECT NULL <> 1;     -- NULL (unknown)

-- COALESCE: first non-NULL value
SELECT COALESCE(nickname, first_name, 'Anonymous') FROM users;

-- NULLIF: return NULL if two values are equal
SELECT NULLIF(score, 0) FROM tests;  -- NULL instead of 0
-- useful for avoiding division by zero:
SELECT total / NULLIF(count, 0) FROM stats;

-- aggregate functions ignore NULL
SELECT AVG(score) FROM tests;  -- avg of non-NULL scores only
SELECT COUNT(score) FROM tests;  -- count of non-NULL
SELECT COUNT(*) FROM tests;  -- count of all rows

-- use LEFT JOIN + COUNT carefully
SELECT u.name, COUNT(o.id) AS orders  -- COUNT(o.id) = 0 for no orders
FROM users u LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
11

Recursive CTEs

Basic Recursive CTE Structure

A recursive CTE references itself to generate hierarchical or sequential data. It has two parts joined by UNION ALL: an anchor query (the base case/starting point) and a recursive query (which references the CTE and adds to the result). The recursion continues until the recursive query returns no rows. Use recursive CTEs for tree traversal (org charts, file systems), sequence generation, and graph pathfinding. Always include a termination condition in the WHERE clause to prevent infinite loops.

sql
-- Recursive CTE: a CTE that references itself
-- Three parts: anchor, UNION ALL, recursive member
WITH RECURSIVE countdown(n) AS (
    -- Anchor: starting point
    SELECT 1 AS n
    UNION ALL
    -- Recursive member: references the CTE itself
    SELECT n + 1 FROM countdown WHERE n < 10
)
SELECT n FROM countdown;
-- Result: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

-- PostgreSQL uses WITH RECURSIVE
-- SQL Server/MySQL 8+: WITH (RECURSIVE optional in MySQL)
-- SQLite: WITH RECURSIVE

Hierarchical Data (Org Chart)

Recursive CTEs excel at traversing hierarchical data like org charts, category trees, or file systems. The anchor selects the root node; the recursive member joins the table to the CTE on the parent-child relationship (manager_id = id). Adding a depth column tracks how many levels deep each row is, and a path column (string concatenation) shows the full ancestry chain. This replaces the need for multiple self-joins or application-side recursion. The CAST on path prevents type errors during recursion.

sql
-- Employee hierarchy with manager relationships
CREATE TABLE employees (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    manager_id INT REFERENCES employees(id)
);

-- Find all direct and indirect reports of employee 1 (CEO)
WITH RECURSIVE org_chain AS (
    -- Anchor: the starting employee
    SELECT id, name, manager_id, 0 AS depth, CAST(name AS VARCHAR(500)) AS path
    FROM employees WHERE id = 1
    UNION ALL
    -- Recursive: find employees whose manager is in the chain
    SELECT e.id, e.name, e.manager_id, oc.depth + 1,
           CAST(oc.path || ' > ' || e.name AS VARCHAR(500))
    FROM employees e
    JOIN org_chain oc ON e.manager_id = oc.id
)
SELECT id, name, depth, path FROM org_chain
ORDER BY depth, name;

-- depth shows hierarchy level, path shows the management chain

Generating Sequences & Dates

Recursive CTEs can generate sequences and date ranges — useful for filling gaps in time-series reports. By generating all dates in a range and LEFT JOINing to your data, you ensure every date appears in the output even when there are no records. This is a common pattern for dashboards and charts. PostgreSQL also has generate_series() as a simpler alternative. Always set a termination condition (WHERE n < 100) to prevent infinite recursion. Some databases limit recursion depth (e.g., 100 by default in MySQL via cte_max_recursion_depth).

sql
-- Generate a series of numbers
WITH RECURSIVE numbers(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM numbers WHERE n < 100
)
SELECT n FROM numbers;

-- Generate a date range (all days in January 2024)
WITH RECURSIVE dates(d) AS (
    SELECT DATE '2024-01-01'
    UNION ALL
    SELECT d + INTERVAL '1 day' FROM dates
    WHERE d < DATE '2024-01-31'
)
SELECT d FROM dates;

-- Generate a time series for gap-filling in reports
-- (ensures every date appears even if no data exists)
SELECT d.date, COALESCE(SUM(o.amount), 0) AS daily_total
FROM dates d
LEFT JOIN orders o ON o.order_date = d.date
GROUP BY d.date ORDER BY d.date;

Graph Pathfinding (BFS)

Recursive CTEs can perform breadth-first search (BFS) on graph structures. The anchor finds edges from the start node; the recursive member extends paths by joining edges to the current path's endpoint. Cycle prevention is critical in cyclic graphs — check that the destination node isn't already in the path (using LIKE or a string search). The hops limit is a safety net against infinite recursion. This approach works for route finding, dependency resolution, and network analysis. For weighted shortest paths, consider Dijkstra's algorithm in application code.

sql
-- Find all paths in a directed graph
CREATE TABLE edges (src VARCHAR(10), dst VARCHAR(10));

-- Find all reachable nodes from 'A' with the path taken
WITH RECURSIVE paths AS (
    -- Anchor: start from node A
    SELECT src, dst, CAST(src || '->' || dst AS VARCHAR(1000)) AS path,
           1 AS hops
    FROM edges WHERE src = 'A'
    UNION ALL
    -- Recursive: extend the path
    SELECT p.src, e.dst,
           CAST(p.path || '->' || e.dst AS VARCHAR(1000)),
           p.hops + 1
    FROM paths p
    JOIN edges e ON p.dst = e.src
    WHERE p.hops < 10  -- prevent infinite loops in cyclic graphs
      AND p.path NOT LIKE '%' || e.dst || '%'
)
SELECT DISTINCT path, hops FROM paths ORDER BY hops;

-- Cycle prevention: check the path doesn't already contain the node

Factorial & Aggregation with Recursion

Recursive CTEs can perform mathematical computations like factorials by carrying state (n, fact) through each iteration. The anchor sets the base case (0! = 1), and the recursive member computes the next value from the previous. Running totals can also be computed this way, though window functions (SUM(amount) OVER (ORDER BY id)) are more efficient and idiomatic for cumulative aggregates. Recursive CTEs for computation are mainly educational — use them when window functions or procedural code can't express the logic. Each recursion level adds a row, so the result set grows with depth.

sql
-- Compute factorial using recursive CTE
WITH RECURSIVE factorial(n, fact) AS (
    -- Anchor: 0! = 1
    SELECT 0, 1
    UNION ALL
    -- Recursive: n! = n * (n-1)!
    SELECT n + 1, fact * (n + 1) FROM factorial WHERE n < 10
)
SELECT n, fact FROM factorial;

-- Running accumulation: cumulative sum
WITH RECURSIVE running_total AS (
    SELECT id, amount, amount AS cumulative
    FROM transactions WHERE id = 1
    UNION ALL
    SELECT t.id, t.amount, rt.cumulative + t.amount
    FROM transactions t
    JOIN running_total rt ON t.id = rt.id + 1
)
SELECT * FROM running_total ORDER BY id;

-- Note: window functions (SUM OVER) are usually better for this
12

PIVOT & UNPIVOT

PIVOT (Rows to Columns)

PIVOT transforms rows into columns — perfect for cross-tab reports where you want categories as column headers. The IN list specifies which values become columns. SQL Server and Oracle have native PIVOT syntax. The inner query provides the source data, and PIVOT applies an aggregate (SUM, AVG, COUNT) for each column group. This is equivalent to conditional aggregation but more readable for wide pivots. Use PIVOT when you have a fixed, known set of values to pivot on.

sql
-- Convert rows to columns (cross-tabulation)
-- Source: sales data with rows per quarter
CREATE TABLE sales (quarter VARCHAR(10), region VARCHAR(50), amount DECIMAL(10,2));

-- SQL Server PIVOT syntax:
SELECT region, [Q1], [Q2], [Q3], [Q4]
FROM (
    SELECT quarter, region, amount FROM sales
) AS src
PIVOT (
    SUM(amount) FOR quarter IN ([Q1], [Q2], [Q3], [Q4])
) AS pvt;

-- Result:
-- region  | Q1    | Q2    | Q3    | Q4
-- North   | 1000  | 1500  | 1200  | 1800
-- South   | 800   | 1100  | 900   | 1300

Conditional Aggregation (Universal PIVOT)

Conditional aggregation (SUM + CASE) is the universal pivot technique that works in every SQL database. Each CASE expression filters for one category, and SUM aggregates the matching values. This is often faster than PIVOT and more flexible. The ELSE 0 ensures non-matching rows contribute zero. PostgreSQL's crosstab() function (from the tablefunc extension) is more concise but requires fixed output columns. Use conditional aggregation when you need cross-database compatibility or when PIVOT syntax isn't available.

sql
-- Works in ALL databases (MySQL, PostgreSQL, SQLite, etc.)
SELECT
    region,
    SUM(CASE WHEN quarter = 'Q1' THEN amount ELSE 0 END) AS Q1,
    SUM(CASE WHEN quarter = 'Q2' THEN amount ELSE 0 END) AS Q2,
    SUM(CASE WHEN quarter = 'Q3' THEN amount ELSE 0 END) AS Q3,
    SUM(CASE WHEN quarter = 'Q4' THEN amount ELSE 0 END) AS Q4,
    SUM(amount) AS total
FROM sales
GROUP BY region
ORDER BY region;

-- PostgreSQL-specific: crosstab() from tablefunc extension
-- SELECT * FROM crosstab('SELECT region, quarter, amount FROM sales ORDER BY 1,2')
-- AS ct(region VARCHAR, Q1 DECIMAL, Q2 DECIMAL, Q3 DECIMAL, Q4 DECIMAL);

Dynamic PIVOT (Dynamic SQL)

Dynamic SQL builds a query string at runtime when pivot columns aren't known in advance (e.g., pivoting by month when months vary). The process: query distinct values, build a column list, construct the PIVOT statement, and execute with sp_executesql (SQL Server) or PREPARE/EXECUTE (MySQL). Always sanitize with QUOTENAME() or quote_ident() to prevent SQL injection. Dynamic SQL is powerful but adds complexity and security risks — use sparingly and prefer fixed pivots when possible. Application-side pivoting is often a safer alternative.

sql
-- When pivot columns are unknown at write time, use dynamic SQL
-- SQL Server example:
DECLARE @cols AS NVARCHAR(MAX),
        @query AS NVARCHAR(MAX);

-- Build the column list dynamically
SELECT @cols = STUFF((
    SELECT DISTINCT ',' + QUOTENAME(quarter)
    FROM sales FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 1, '');

-- Build and execute the pivot query
SET @query = 'SELECT region, ' + @cols + '
FROM (SELECT quarter, region, amount FROM sales) x
PIVOT (SUM(amount) FOR quarter IN (' + @cols + ')) p';

EXEC sp_executesql @query;

-- WARNING: dynamic SQL is vulnerable to SQL injection
-- — always use QUOTENAME() to sanitize column names

UNPIVOT (Columns to Rows)

UNPIVOT reverses PIVOT — it transforms columns into rows. This is useful for normalizing denormalized data, converting wide import files to long format, or preparing data for charting. SQL Server has native UNPIVOT syntax. The UNION ALL approach works everywhere: each SELECT extracts one column and labels it with a fixed value. UNION ALL (not UNION) preserves duplicates and is faster. UNPIVOT is common in ETL pipelines when source data arrives in spreadsheet format (wide) but needs to be stored normalized (long).

sql
-- Convert columns back to rows
-- Source: wide table with Q1-Q4 columns
-- Target: narrow table with quarter/amount rows

-- SQL Server UNPIVOT:
SELECT region, quarter, amount
FROM quarterly_sales
UNPIVOT (
    amount FOR quarter IN (Q1, Q2, Q3, Q4)
) AS unpvt;

-- Universal UNION ALL approach (all databases):
SELECT region, 'Q1' AS quarter, Q1 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q2' AS quarter, Q2 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q3' AS quarter, Q3 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q4' AS quarter, Q4 AS amount FROM quarterly_sales;

-- Result: one row per region/quarter combination

Practical Pivot Report Example

This real-world pivot report combines monthly breakdowns with year-over-year comparison in a single query. Conditional aggregation (SUM + CASE) creates both monthly columns and yearly totals. The yoy_change column computes the difference inline. HAVING filters out products with no sales. This pattern is common in BI dashboards and financial reports. The EXTRACT function works in most databases (use DATEPART in SQL Server, strftime in SQLite). For truly dynamic columns, combine with dynamic SQL or handle pivoting in the application layer.

sql
-- Monthly sales pivot with year-over-year comparison
SELECT
    product_name,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 1  THEN amount ELSE 0 END) AS jan,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 2  THEN amount ELSE 0 END) AS feb,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 3  THEN amount ELSE 0 END) AS mar,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 4  THEN amount ELSE 0 END) AS apr,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 5  THEN amount ELSE 0 END) AS may,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 6  THEN amount ELSE 0 END) AS jun,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2024 THEN amount ELSE 0 END) AS total_2024,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2023 THEN amount ELSE 0 END) AS total_2023,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2024 THEN amount ELSE 0 END) -
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2023 THEN amount ELSE 0 END) AS yoy_change
FROM orders o
JOIN products p ON o.product_id = p.id
WHERE order_date BETWEEN '2023-01-01' AND '2024-12-31'
GROUP BY product_name
HAVING SUM(amount) > 0
ORDER BY total_2024 DESC;
14

Triggers

Trigger Basics (AFTER/BEFORE)

Triggers are database-level code that runs automatically when data changes. AFTER triggers log or propagate changes (can't modify NEW). BEFORE triggers validate or transform data before it's written (can modify NEW). FOR EACH ROW fires once per affected row; FOR EACH STATEMENT fires once per statement. Use triggers for audit logging, enforcing complex constraints, and auto-updating derived columns. Avoid triggers for business logic — they're hidden, hard to debug, and can cause cascading effects. Each database has different trigger syntax; PostgreSQL uses functions as trigger bodies.

sql
-- A trigger fires automatically on INSERT/UPDATE/DELETE
-- MySQL syntax:
DELIMITER //
CREATE TRIGGER audit_log
AFTER INSERT ON employees
FOR EACH ROW
BEGIN
    INSERT INTO audit_table (table_name, action, row_id, changed_at)
    VALUES ('employees', 'INSERT', NEW.id, NOW());
END //
DELIMITER ;

-- BEFORE triggers can modify the NEW values:
CREATE TRIGGER validate_email
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
    IF NEW.email NOT LIKE '%@%.%' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid email';
    END IF;
END //

-- PostgreSQL uses CREATE FUNCTION + CREATE TRIGGER:
-- CREATE TRIGGER audit AFTER INSERT ON employees
-- FOR EACH ROW EXECUTE FUNCTION audit_func();

Audit Logging Trigger

Audit triggers capture every data change for compliance and debugging. The audit table stores the action type, old and new values, who made the change (CURRENT_USER), and when (CURRENT_TIMESTAMP). You need separate triggers for INSERT, UPDATE, and DELETE. OLD references pre-change values (available in UPDATE/DELETE), NEW references post-change values (available in INSERT/UPDATE). Audit tables grow indefinitely — partition by date or archive old data. This pattern satisfies SOX, HIPAA, and GDPR requirements for data change tracking.

sql
-- Track all changes to a critical table
CREATE TABLE employee_audit (
    audit_id INT AUTO_INCREMENT PRIMARY KEY,
    action VARCHAR(10),       -- INSERT, UPDATE, DELETE
    employee_id INT,
    old_name VARCHAR(100),
    new_name VARCHAR(100),
    old_salary DECIMAL(10,2),
    new_salary DECIMAL(10,2),
    changed_by VARCHAR(50) DEFAULT CURRENT_USER,
    changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TRIGGER trg_audit_insert
AFTER INSERT ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, new_name, new_salary)
VALUES ('INSERT', NEW.id, NEW.name, NEW.salary);

CREATE TRIGGER trg_audit_update
AFTER UPDATE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, new_name, old_salary, new_salary)
VALUES ('UPDATE', NEW.id, OLD.name, NEW.name, OLD.salary, NEW.salary);

CREATE TRIGGER trg_audit_delete
AFTER DELETE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, old_salary)
VALUES ('DELETE', OLD.id, OLD.name, OLD.salary);

Computed/Derived Column Trigger

Triggers can auto-compute derived columns, ensuring consistency without application code. BEFORE INSERT/UPDATE triggers set NEW.final_price based on other columns. However, modern databases support GENERATED (computed) columns natively — these are always correct, can't be manually overridden, and may be indexed. Prefer GENERATED columns over triggers for computed values. Use triggers only when the computation involves external data, conditional logic, or cross-table dependencies that GENERATED columns can't handle. Remember that triggers add overhead to every write operation.

sql
-- Auto-update a derived column when source data changes
CREATE TABLE products (
    id INT PRIMARY KEY,
    price DECIMAL(10,2),
    discount_percent DECIMAL(5,2),
    final_price DECIMAL(10,2)  -- computed: price * (1 - discount/100)
);

-- BEFORE INSERT: compute final_price
CREATE TRIGGER calc_final_price_insert
BEFORE INSERT ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);

-- BEFORE UPDATE: recompute if price or discount changes
CREATE TRIGGER calc_final_price_update
BEFORE UPDATE ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);

-- Alternative: use GENERATED columns (MySQL 5.7+, PostgreSQL 12+)
-- final_price DECIMAL(10,2) GENERATED ALWAYS AS
--     (price * (1 - discount_percent / 100)) STORED

Preventing Deletes with Triggers

Triggers can enforce data protection rules that CHECK constraints can't express. BEFORE DELETE triggers can block deletions entirely (using SIGNAL/RAISE) or implement soft deletes (marking records as deleted instead of removing them). SIGNAL SQLSTATE '45000' is the MySQL way to raise a user-defined error. PostgreSQL uses RAISE EXCEPTION. This is useful for protecting reference data, preventing deletion of parent records with children, or implementing immutable audit trails. Be cautious: triggers that prevent operations can surprise developers — document them clearly and consider application-level checks instead.

sql
-- Prevent deletion of critical records
CREATE TRIGGER prevent_delete_admin
BEFORE DELETE ON users
FOR EACH ROW
BEGIN
    IF OLD.role = 'admin' THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = 'Cannot delete admin users';
    END IF;
END //

-- Soft delete instead of hard delete
CREATE TRIGGER soft_delete
BEFORE DELETE ON articles
FOR EACH ROW
BEGIN
    -- Prevent actual deletion, mark as deleted instead
    INSERT INTO articles (id, title, body, deleted_at)
    VALUES (OLD.id, OLD.title, OLD.body, NOW())
    ON DUPLICATE KEY UPDATE deleted_at = NOW();
    -- Still need to prevent the DELETE — use SIGNAL or a flag
END //

-- PostgreSQL: raise exception in trigger function
-- IF OLD.role = 'admin' THEN
--     RAISE EXCEPTION 'Cannot delete admin users';
-- END IF;

Trigger Management & Debugging

Managing triggers is essential for maintenance. SHOW TRIGGERS (MySQL) and information_schema views list all triggers. Drop triggers with DROP TRIGGER IF EXISTS. Disabling triggers temporarily is useful for bulk data loads (which would trigger expensive audit/logging for every row). PostgreSQL uses ALTER TABLE ... DISABLE/ENABLE TRIGGER; SQL Server uses DISABLE/ENABLE TRIGGER. Always re-enable triggers after maintenance. Debugging triggers is hard — they run silently. Add logging to a debug table, or test trigger logic in isolation first. Excessive triggers create hidden complexity and performance issues.

sql
-- View existing triggers
-- MySQL:
SHOW TRIGGERS;
SELECT * FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = 'mydb';

-- PostgreSQL:
SELECT tgname, tgrelid::regclass, tgtype FROM pg_trigger;

-- SQL Server:
SELECT name, type_desc FROM sys.triggers WHERE parent_id = OBJECT_ID('employees');

-- Drop a trigger
DROP TRIGGER IF EXISTS audit_log;  -- MySQL
DROP TRIGGER IF EXISTS audit_log ON employees;  -- PostgreSQL

-- Disable/enable triggers (PostgreSQL)
ALTER TABLE employees DISABLE TRIGGER ALL;
ALTER TABLE employees ENABLE TRIGGER ALL;

-- Disable for bulk operations (SQL Server)
DISABLE TRIGGER trg_audit ON employees;
-- ... bulk operations ...
ENABLE TRIGGER trg_audit ON employees;
15

User-Defined Functions (UDFs)

Scalar Functions (Return Single Value)

Scalar UDFs return a single value and can be used in SELECT, WHERE, and computed columns. DETERMINIC means the output depends only on inputs (enables caching). READS SQL DATA declares the function reads from tables. UDFs encapsulate reusable logic (discounts, formatting, calculations) so it's consistent across queries. However, scalar UDFs in SQL Server can cause performance issues (row-by-row execution) — use inline table-valued functions or computed columns instead when possible. MySQL 8.0+ optimizes deterministic functions better. Always document the function's purpose and parameters.

sql
-- MySQL: a function that returns one value
CREATE FUNCTION calculate_discount(
    price DECIMAL(10,2),
    customer_tier VARCHAR(20)
) RETURNS DECIMAL(10,2)
DETERMINISTIC
READS SQL DATA
BEGIN
    DECLARE discount_rate DECIMAL(5,2);
    SET discount_rate = CASE customer_tier
        WHEN 'gold'   THEN 0.20
        WHEN 'silver' THEN 0.10
        WHEN 'bronze' THEN 0.05
        ELSE 0.00
    END;
    RETURN price * (1 - discount_rate);
END //

-- Usage in queries:
SELECT name, price, calculate_discount(price, tier) AS final_price
FROM orders;

-- DETERMINISTIC: same inputs always give same output (cacheable)
-- READS SQL DATA: function reads but doesn't modify tables

Table-Valued Functions (Return Rows)

Table-valued functions (TVFs) return a result set (rows) that you can query like a table. Inline TVFs (SQL Server) are as fast as views — the query optimizer inlines them. Multi-statement TVFs materialize results into a temp table first, which can be slower. PostgreSQL functions returning TABLE or SETOF are equivalent. TVFs are parameterized views — use them when you need a view with parameters. They're great for encapsulating complex JOINs and filters. Prefer inline TVFs over multi-statement TVFs for performance. In PostgreSQL, also consider using parameterized views with WHERE clauses.

sql
-- SQL Server: Inline table-valued function (fast, like a view)
CREATE FUNCTION fn_OrdersByCustomer(@cust_id INT)
RETURNS TABLE
AS
RETURN (
    SELECT o.id, o.order_date, o.total
    FROM orders o
    WHERE o.customer_id = @cust_id
);
-- Usage: SELECT * FROM fn_OrdersByCustomer(42);

-- PostgreSQL: function returning a table
CREATE OR REPLACE FUNCTION get_orders_by_customer(cust_id INT)
RETURNS TABLE(order_id INT, order_date DATE, total DECIMAL) AS $$
    SELECT id, order_date, total
    FROM orders
    WHERE customer_id = cust_id;
$$ LANGUAGE SQL;

-- Usage:
SELECT * FROM get_orders_by_customer(42);

-- Multi-statement TVF (SQL Server) — slower, materializes result
-- CREATE FUNCTION fn_ComplexReport(@date DATE)
-- RETURNS @result TABLE (...)
-- AS BEGIN ... INSERT INTO @result ... RETURN END

String Manipulation Functions

Custom string functions encapsulate text processing logic that built-in functions don't cover. The get_first_name function uses LOCATE and SUBSTRING to extract the first word. The make_slug function chains LOWER, REPLACE, and REGEXP_REPLACE to create URL-friendly slugs. Mark these DETERMINISTIC since the same input always produces the same output. String functions in SQL are database-specific — PostgreSQL has split_part(), MySQL has SUBSTRING_INDEX(). Creating UDFs standardizes behavior across your application. Be aware that complex string manipulation in SQL is often cleaner in application code.

sql
-- Create a function to split full name into parts
CREATE FUNCTION get_first_name(full_name VARCHAR(200))
RETURNS VARCHAR(100)
DETERMINISTIC
BEGIN
    DECLARE space_pos INT;
    SET space_pos = LOCATE(' ', full_name);
    IF space_pos > 0 THEN
        RETURN SUBSTRING(full_name, 1, space_pos - 1);
    ELSE
        RETURN full_name;
    END IF;
END //

-- Function to generate slug from a title
CREATE FUNCTION make_slug(title VARCHAR(500))
RETURNS VARCHAR(500)
DETERMINISTIC
BEGIN
    DECLARE slug VARCHAR(500);
    SET slug = LOWER(title);
    SET slug = REPLACE(slug, ' ', '-');
    SET slug = REGEXP_REPLACE(slug, '[^a-z0-9-]', '');
    RETURN slug;
END //

-- Usage:
SELECT get_first_name('John Doe Smith') AS first_name;  -- John
SELECT make_slug('Hello World! 2024') AS slug;           -- hello-world-2024

Aggregate Functions (Custom)

Custom aggregate functions let you define new aggregation logic beyond SUM, AVG, COUNT. PostgreSQL's CREATE AGGREGATE requires a state transition function (SFUNC, called per row) and a final function (FINALFUNC, called once at the end). This example computes geometric mean (the nth root of the product). Custom aggregates are powerful for statistical, financial, or domain-specific calculations. The state accumulates across rows; the final function computes the result. MySQL and SQL Server don't support custom aggregates directly — use stored procedures or application-side computation instead.

sql
-- PostgreSQL: custom aggregate function
-- Step 1: Define a state transition function
CREATE OR REPLACE FUNCTION geom_mean_state(state numeric[], val numeric)
RETURNS numeric[] AS $$
BEGIN
    IF val IS NULL THEN RETURN state; END IF;
    IF state IS NULL THEN
        RETURN ARRAY[val];
    ELSE
        RETURN state || val;
    END IF;
END;
$$ LANGUAGE plpgsql;

-- Step 2: Define the final function
CREATE OR REPLACE FUNCTION geom_mean_final(vals numeric[])
RETURNS numeric AS $$
BEGIN
    IF vals IS NULL OR array_length(vals, 1) IS NULL THEN
        RETURN NULL;
    END IF;
    RETURN exp(avg(ln(v)) FROM unnest(vals) AS v);
END;
$$ LANGUAGE plpgsql;

-- Step 3: Create the aggregate
CREATE AGGREGATE geometric_mean(numeric) (
    SFUNC = geom_mean_state,
    STYPE = numeric[],
    FINALFUNC = geom_mean_final,
    INITCOND = '{}'
);

-- Usage:
SELECT category, geometric_mean(price) FROM products GROUP BY category;

Function vs Stored Procedure

Functions and stored procedures serve different purposes. Functions return a value and can be embedded in SELECT/WHERE — they must be deterministic-ish (no side effects in most databases). Stored procedures can modify data, manage transactions, and return multiple result sets — but can't be used inside queries (call with CALL/EXEC). Use functions for computations and data retrieval; use procedures for multi-step operations (transfers, batch processing, ETL). Functions are composable; procedures are imperative. In PostgreSQL, functions can do almost everything procedures can (including data modification), blurring the distinction.

sql
-- FUNCTIONS: return values, usable in queries, no side effects
CREATE FUNCTION get_full_name(fname VARCHAR, lname VARCHAR)
RETURNS VARCHAR(200)
DETERMINISTIC
RETURN CONCAT(fname, ' ', lname);

-- Can be used in SELECT:
SELECT get_full_name(first, last) FROM users;  -- OK
SELECT * FROM users WHERE get_full_name(first, last) LIKE 'J%';  -- OK

-- STORED PROCEDURES: can modify data, use transactions, return result sets
CREATE PROCEDURE transfer_funds(
    IN from_acct INT, IN to_acct INT, IN amount DECIMAL(10,2)
)
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;
    START TRANSACTION;
    UPDATE accounts SET balance = balance - amount WHERE id = from_acct;
    UPDATE accounts SET balance = balance + amount WHERE id = to_acct;
    INSERT INTO transfers (from_id, to_id, amount) VALUES (from_acct, to_acct, amount);
    COMMIT;
END //

-- Call procedure (can't use in SELECT):
CALL transfer_funds(1, 2, 100.00);
16

Database Design & Normalization

First Normal Form (1NF)

First Normal Form requires atomic values — each cell holds one piece of data, not lists or arrays. Comma-separated values in a column violate 1NF because you can't query, index, or update individual items. The fix: create one row per item (with a composite primary key) or split into a separate detail table. 1NF also requires a primary key to uniquely identify each row. Violating 1NF makes queries like 'find all orders containing a mouse' require string parsing — slow and error-prone. Always start with 1NF compliance.

sql
-- 1NF: each column contains atomic (indivisible) values
--       no repeating groups, each row is unique

-- VIOLATION: comma-separated values in one column
CREATE TABLE bad_orders (
    id INT,
    customer_name VARCHAR(100),
    products VARCHAR(500)  -- "laptop, mouse, keyboard" — BAD!
);

-- 1NF COMPLIANT: separate row per product
CREATE TABLE orders (
    order_id INT,
    customer_name VARCHAR(100),
    product_name VARCHAR(100),  -- one product per row
    PRIMARY KEY (order_id, product_name)  -- composite key for uniqueness
);

-- Better: normalize further with separate tables
CREATE TABLE orders (order_id INT PRIMARY KEY, customer_name VARCHAR(100));
CREATE TABLE order_items (
    order_id INT REFERENCES orders(order_id),
    product_name VARCHAR(100),
    quantity INT,
    PRIMARY KEY (order_id, product_name)
);

Second & Third Normal Form (2NF, 3NF)

2NF eliminates partial dependencies — every non-key column must depend on the ENTIRE primary key, not just part of it. This matters only with composite keys. 3NF eliminates transitive dependencies — non-key columns must depend only on the primary key, not on other non-key columns. For example, customer_name depends on customer_id, which depends on order_id (transitive). Normalization reduces data redundancy (store each fact once) and anomalies (update customer name in one place, not in every order). Most practical databases aim for 3NF or BCNF.

sql
-- 2NF: 1NF + no partial dependencies (non-key attrs depend on FULL key)
-- Problem: order_items has composite key (order_id, product_id)
-- but product_name depends only on product_id (partial dependency)

-- VIOLATION of 2NF:
CREATE TABLE bad_order_items (
    order_id INT,
    product_id INT,
    product_name VARCHAR(100),  -- depends on product_id only!
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- 2NF COMPLIANT: move product_name to products table
CREATE TABLE products (
    product_id INT PRIMARY KEY,
    product_name VARCHAR(100)
);
CREATE TABLE order_items (
    order_id INT,
    product_id INT REFERENCES products(product_id),
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- 3NF: 2NF + no transitive dependencies
-- (non-key attrs don't depend on other non-key attrs)
-- Problem: orders table has customer_name that depends on customer_id
-- Solution: separate customers table (see above)

Denormalization (When to Break Rules)

Denormalization intentionally violates normal forms to improve read performance at the cost of write complexity and storage. In normalized databases, retrieving a complete order requires 4 JOINs — expensive for high-traffic dashboards. Denormalized tables pre-join and pre-compute data for fast reads. The trade-off: writes must update multiple places (risk of inconsistency), and storage increases. Use denormalization for read-heavy systems (analytics, reporting, data warehouses). Materialized views provide managed denormalization — the database handles the refresh. OLTP systems should stay normalized; OLAP systems are typically denormalized (star/snowflake schemas).

sql
-- Denormalization: intentionally adding redundancy for performance
-- Normalized (3NF): requires JOINs to get full order info
SELECT o.order_id, c.name, p.product_name, oi.quantity, p.price
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.id;

-- Denormalized: store redundant data for read speed
CREATE TABLE order_summary (
    order_id INT PRIMARY KEY,
    customer_id INT,
    customer_name VARCHAR(100),    -- redundant (also in customers)
    customer_email VARCHAR(200),   -- redundant
    total_amount DECIMAL(10,2),    -- pre-calculated
    item_count INT,                -- pre-calculated
    order_date TIMESTAMP
);

-- Trade-off: faster reads, slower writes, risk of inconsistency
-- Use for: reporting tables, read-heavy dashboards, data warehouses

-- Materialized views are a managed form of denormalization:
CREATE MATERIALIZED VIEW sales_summary AS
SELECT date, region, SUM(amount) AS total FROM sales GROUP BY date, region;
REFRESH MATERIALIZED VIEW sales_summary;

Primary Keys, Foreign Keys & Constraints

Constraints enforce data integrity at the database level. PRIMARY KEY uniquely identifies rows and creates a clustered index. UNIQUE prevents duplicates (allows multiple NULLs in most databases). CHECK enforces custom rules (salary > 0). FOREIGN KEY maintains referential integrity — ON DELETE SET NULL/CASCADE/RESTRICT controls what happens when a parent row is deleted. ON UPDATE CASCADE propagates PK changes to FKs. Constraints are the last line of defense against bad data — even if application code has bugs, the database rejects invalid data. Always define constraints; they're documentation and enforcement combined.

sql
CREATE TABLE departments (
    dept_id INT PRIMARY KEY AUTO_INCREMENT,
    dept_name VARCHAR(100) NOT NULL UNIQUE,
    budget DECIMAL(12,2) CHECK (budget >= 0),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE employees (
    emp_id INT PRIMARY KEY AUTO_INCREMENT,
    emp_name VARCHAR(100) NOT NULL,
    email VARCHAR(200) UNIQUE,
    dept_id INT,
    salary DECIMAL(10,2) CHECK (salary > 0 AND salary < 1000000),
    hire_date DATE NOT NULL,
    manager_id INT REFERENCES employees(emp_id),  -- self-reference
    FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
        ON DELETE SET NULL      -- don't delete dept if employees exist
        ON UPDATE CASCADE,      -- update FK if dept_id changes
    INDEX idx_dept (dept_id),
    INDEX idx_email (email)
);

-- Constraint types:
-- PRIMARY KEY: unique + not null (clustered index)
-- UNIQUE: no duplicates (allows NULLs)
-- NOT NULL: required field
-- CHECK: custom condition
-- FOREIGN KEY: referential integrity
-- DEFAULT: value when not specified

Indexing Strategy

Indexes dramatically speed up reads but slow down writes (each index must be updated on INSERT/UPDATE/DELETE). B-tree indexes support equality, range, and sorting. Composite indexes follow the leftmost prefix rule — you can use (a, b) for queries on a or a+b, but not b alone. Covering indexes (INCLUDE clause) store extra columns so the query never touches the table — extremely fast. Partial indexes index only a subset of rows, saving space. Monitor index usage (pg_stat_user_indexes in PostgreSQL) and drop unused ones. A good rule: index foreign keys and columns in WHERE/JOIN clauses. Over-indexing hurts write performance and wastes storage.

sql
-- B-tree index (default): good for =, <, >, BETWEEN, ORDER BY
CREATE INDEX idx_last_name ON employees(last_name);

-- Composite index: order matters! (leftmost prefix rule)
CREATE INDEX idx_dept_salary ON employees(dept_id, salary);
-- Usable for: WHERE dept_id = 5
-- Usable for: WHERE dept_id = 5 AND salary > 50000
-- NOT usable for: WHERE salary > 50000 (skips dept_id)

-- Covering index: includes all columns a query needs
CREATE INDEX idx_covering ON orders(customer_id, order_date)
    INCLUDE (total_amount, status);
-- Query can be satisfied from index alone (no table lookup)

-- Partial/partial index: index only matching rows
CREATE INDEX idx_active_users ON users(last_login)
    WHERE active = true;  -- PostgreSQL

-- Don't over-index: every index slows writes
-- Index columns used in: WHERE, JOIN, ORDER BY, GROUP BY
-- Drop unused indexes: SELECT * FROM pg_stat_user_indexes;
17

EXPLAIN & Query Optimization

Reading EXPLAIN Output

EXPLAIN reveals how the database executes a query — which indexes are used, how tables are joined, and how many rows are examined. EXPLAIN ANALYZE (PostgreSQL) or EXPLAIN with execution (MySQL 8.0+) actually runs the query and shows real timing. Look for: Seq Scan / ALL (full table scan — bad for large tables), Index Scan (good), rows estimate (high = expensive). 'Using filesort' or 'Using temporary' in MySQL indicates extra work. If EXPLAIN shows a full table scan on a large table, you need an index. Always EXPLAIN before optimizing — don't guess.

sql
-- EXPLAIN shows the query execution plan
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;

-- EXPLAIN ANALYZE actually runs the query (PostgreSQL)
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

-- PostgreSQL output:
-- Index Scan using idx_customer on orders  (cost=0.29..8.31 rows=1 width=74)
--   Index Cond: (customer_id = 42)
--   Execution Time: 0.042 ms

-- Key columns to check:
-- - type: scan method (const > eq_ref > ref > range > index > ALL)
-- - rows: estimated rows examined (lower is better)
-- - key: which index is used (NULL = no index, bad!)
-- - Extra: "Using filesort" or "Using temporary" = warning signs

-- MySQL: EXPLAIN FORMAT=JSON for detailed output
-- SQL Server: SET SHOWPLAN_TEXT ON; or Actual Execution Plan in SSMS

Common Performance Issues

Several common patterns prevent index usage and cause full table scans. Functions on indexed columns (YEAR(date), UPPER(name)) prevent index usage — rewrite as range conditions. Leading wildcards in LIKE ('%pattern') can't use B-tree indexes — use full-text search instead. SELECT * wastes bandwidth and prevents covering index optimization. Implicit type conversions (comparing string column to integer) can disable indexes. OR conditions are sometimes less efficient than IN. Always verify with EXPLAIN that your indexes are actually being used — an unused index is wasted storage and write overhead.

sql
-- 1. Missing index → full table scan
-- Bad: SELECT * FROM orders WHERE customer_id = 42; (no index)
-- Fix: CREATE INDEX idx_customer ON orders(customer_id);

-- 2. Index not used due to function on column
-- Bad: WHERE YEAR(order_date) = 2024  (function prevents index use)
-- Good: WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'

-- 3. SELECT * instead of specific columns
-- Bad: SELECT * FROM large_table WHERE id = 1;
-- Good: SELECT id, name, email FROM large_table WHERE id = 1;

-- 4. OR conditions preventing index use
-- Bad: WHERE dept = 'A' OR dept = 'B' OR dept = 'C'
-- Good: WHERE dept IN ('A', 'B', 'C')

-- 5. LIKE with leading wildcard
-- Bad: WHERE name LIKE '%son'  (can't use index)
-- OK:  WHERE name LIKE 'John%'  (can use index)

-- 6. Implicit type conversion
-- Bad: WHERE string_column = 123  (converts to string, skips index)
-- Good: WHERE string_column = '123'

JOIN Optimization

JOIN optimization is critical for multi-table queries. Ensure join columns (usually foreign keys) are indexed — unindexed joins cause nested loop scans (O(n*m)). The query optimizer usually picks the best join order, but you can help by filtering early (WHERE before JOIN conceptually). INNER JOIN is faster than OUTER JOIN when you don't need unmatched rows. EXISTS is often more efficient than IN for correlated subqueries because it short-circuits on the first match. Avoid joining tables you don't need — each join multiplies the work. For complex reports, consider materialized views or pre-aggregated summary tables.

sql
-- Join order matters: smallest table first (optimizer usually handles this)
-- Ensure join columns are indexed (usually foreign keys)
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);

-- Use INNER JOIN when you don't need unmatched rows (faster than OUTER)
SELECT c.name, COUNT(o.order_id) AS order_count
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
GROUP BY c.name;

-- Avoid joining unnecessary tables — fetch details lazily if needed
-- Bad: join 5 tables when you only need 2 columns
-- Good: split into simpler queries or use a covering index

-- EXISTS vs IN for subqueries
-- EXISTS is often faster for large subquery results:
SELECT name FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- IN is better for small lists:
SELECT * FROM orders WHERE customer_id IN (1, 2, 3);

Pagination Optimization

OFFSET-based pagination (LIMIT 10 OFFSET 10000) is O(n) — the database must scan and discard all skipped rows, making deep pages extremely slow. Keyset (cursor) pagination uses WHERE last_value < cursor to seek directly — O(1) regardless of page depth. This requires an index on the sort column. For ties (same timestamp), use a composite cursor (created_at, id). Avoid COUNT(*) for total counts on large tables — it scans the entire table. Use approximate counts (pg_class.reltuples in PostgreSQL) or don't show total counts at all (infinite scroll). Keyset pagination is the standard for high-performance APIs.

sql
-- Bad: OFFSET pagination (slow for large offsets)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 10000;
-- Must scan and discard 10000 rows — gets slower as you page deeper

-- Good: Keyset (cursor) pagination using WHERE
SELECT * FROM orders
WHERE created_at < '2024-01-15 10:30:00'  -- last seen value
ORDER BY created_at DESC
LIMIT 10;
-- Uses index efficiently — constant time regardless of page depth

-- For composite keyset pagination (handles ties):
SELECT * FROM orders
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 10;

-- Count total (expensive on large tables — avoid if possible)
-- Instead of COUNT(*), use an approximate count:
SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';

Query Rewriting & Optimization Checklist

Query optimization is an iterative process: EXPLAIN, identify bottlenecks, rewrite, repeat. Key techniques: replace IN subqueries with JOINs (often faster), use UNION ALL instead of UNION (skips deduplication sort), batch INSERTs (1 query vs 1000), and use prepared statements (caches the query plan). CTEs improve readability but in older PostgreSQL versions they're materialized (can't be optimized) — PostgreSQL 12+ inlines them. Keep table statistics updated (ANALYZE) so the planner makes good decisions. The golden rule: measure with EXPLAIN ANALYZE, don't guess. What's fast on one database/version may be slow on another.

sql
-- 1. Replace subqueries with JOINs when possible
-- Slow: SELECT * FROM orders WHERE customer_id IN
--       (SELECT id FROM customers WHERE active = true);
-- Fast: SELECT o.* FROM orders o
--       JOIN customers c ON o.customer_id = c.id WHERE c.active = true;

-- 2. Use UNION ALL instead of UNION (avoids dedup sort)
SELECT 'A' UNION ALL SELECT 'B';  -- fast
SELECT 'A' UNION SELECT 'B';       -- sorts to remove duplicates

-- 3. Batch operations instead of row-by-row
-- Slow: 1000 individual INSERTs
-- Fast: INSERT INTO t VALUES (1,'a'), (2,'b'), (3,'c'), ...;

-- 4. Use CTEs for readability, but know they may not optimize well
-- (PostgreSQL 12+ inlines CTEs; older versions materialize them)

-- 5. Avoid SELECT DISTINCT when you can use GROUP BY or EXISTS
-- 6. Use prepared statements for repeated queries (plan caching)
PREPARE get_user AS SELECT * FROM users WHERE id = $1;
EXECUTE get_user(42);

-- 7. Analyze tables for up-to-date statistics
ANALYZE orders;  -- PostgreSQL: updates planner statistics
18

NoSQL vs SQL Comparison

SQL vs NoSQL: When to Use What

The SQL vs NoSQL choice depends on your data model, consistency requirements, and scale. SQL databases enforce schema, support ACID transactions, and excel at complex queries with JOINs — ideal for financial systems and any app where data integrity is paramount. NoSQL databases trade consistency for scalability and flexibility: document stores (MongoDB) for evolving schemas, key-value stores (Redis) for caching, column-family (Cassandra) for massive write throughput, and graph databases (Neo4j) for relationship-heavy data. Modern SQL databases now support JSON, full-text search, and scaling, reducing the need for NoSQL in many cases.

sql
-- SQL (Relational): structured, consistent, queryable
-- Best for: financial systems, e-commerce, CRM, any ACID-requiring app
-- Examples: PostgreSQL, MySQL, SQL Server, Oracle

-- NoSQL: flexible schema, horizontal scaling, specific data models
-- Document: MongoDB, CouchDB — JSON-like documents, flexible schema
-- Key-Value: Redis, DynamoDB — fast lookups, caching, sessions
-- Column-Family: Cassandra, HBase — wide-column, time-series, write-heavy
-- Graph: Neo4j, ArangoDB — relationships, social networks, recommendations

-- Decision factors:
-- 1. Data structure: fixed → SQL, evolving/varied → NoSQL
-- 2. Consistency: strict ACID → SQL, eventual consistency OK → NoSQL
-- 3. Scale: vertical (bigger server) → SQL, horizontal (more servers) → NoSQL
-- 4. Queries: complex JOINs → SQL, simple lookups → NoSQL
-- 5. Team expertise: SQL is universal, NoSQL varies

-- Many modern databases blur the line:
-- PostgreSQL: JSON columns, full-text search, pub/sub
-- MongoDB: transactions (multi-document ACID since 4.0)

Document Store Patterns (MongoDB-style)

Document stores embed related data in a single document rather than normalizing across tables. This eliminates JOINs for read-heavy access patterns but duplicates data (customer info in every order). Embedding works when data is accessed together and has a bounded size. For unbounded relationships (a customer with thousands of orders), use referencing (store customer_id, fetch separately). PostgreSQL's JSONB columns give you document-store flexibility within a relational database — you get ACID transactions, indexing (GIN), and SQL queries on JSON. This hybrid approach is increasingly popular, reducing the need for a separate NoSQL database.

sql
-- In SQL, you'd normalize this into 3 tables:
-- customers, orders, order_items

-- In a document store (MongoDB), you might embed everything:
-- (pseudo-code, not SQL)
// db.orders.insertOne({
//   _id: 1,
//   customer: { name: "Alice", email: "[email protected]" },
//   items: [
//     { product: "Laptop", price: 999, qty: 1 },
//     { product: "Mouse", price: 25, qty: 2 }
//   ],
//   total: 1049,
//   status: "shipped",
//   created_at: ISODate("2024-01-15")
// })

-- SQL equivalent with JSON column (PostgreSQL):
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    data JSONB NOT NULL  -- stores the entire document
);
INSERT INTO orders (data) VALUES ('{
    "customer": {"name": "Alice", "email": "[email protected]"},
    "items": [{"product": "Laptop", "price": 999, "qty": 1}],
    "total": 999
}');

-- Query JSON data:
SELECT data->'customer'->>'name' AS name FROM orders
WHERE data->'total' > 500;

Key-Value Store Patterns (Redis-style)

Key-value stores like Redis excel at ultra-fast lookups (sub-millisecond) because data lives in memory. Common use cases: caching expensive query results, session storage (with TTL expiration), real-time counters (atomic INCR), and leaderboards (sorted sets). Redis data structures (lists, sets, sorted sets, hashes) go beyond simple key-value. The trade-off: data is in-memory (limited by RAM) and persistence is optional. Use Redis as a cache layer in front of SQL — write-through or cache-aside patterns. For session data, Redis's automatic expiration (TTL) is ideal. SQL databases can emulate caching with a cache table, but can't match Redis's speed for hot data.

sql
-- Redis is an in-memory key-value store — not SQL
-- Common patterns (Redis commands, not SQL):

-- Caching: store expensive query results
// SET user:42:profile '{"name":"Alice","age":30}' EX 3600
// GET user:42:profile  -- returns cached data, expires in 1 hour

-- Session storage: fast, ephemeral
// SET session:abc123 '{"user_id":42}' EX 1800  -- 30 min TTL

-- Counters & leaderboards:
// INCR page:home:views           -- atomic counter
// ZADD leaderboard 1500 "alice"  -- sorted set for rankings
// ZREVRANGE leaderboard 0 9      -- top 10 players

-- SQL equivalent for caching (materialized/precomputed):
CREATE TABLE cache (
    cache_key VARCHAR(200) PRIMARY KEY,
    cache_value TEXT,
    expires_at TIMESTAMP,
    INDEX idx_expires (expires_at)
);
-- Periodically: DELETE FROM cache WHERE expires_at < NOW();

-- When to use Redis vs SQL cache:
-- Redis: sub-millisecond reads, data structures (sets, sorted sets)
-- SQL: when you need ACID or already have a database connection

Polyglot Persistence (Mixing Databases)

Polyglot persistence uses different databases for different data needs within one application. PostgreSQL handles transactions, Redis handles caching, Elasticsearch handles search, S3 handles files. The challenge is keeping data consistent across stores — the solution is event-driven architecture: write to the primary database (source of truth), then asynchronously propagate changes to other stores via Change Data Capture (CDC) or message queues (Kafka, RabbitMQ). This gives eventual consistency — reads from secondary stores may lag slightly. The benefit: each store is optimized for its workload. The cost: operational complexity. Start with a single SQL database; add specialized stores only when you hit clear performance limits.

sql
-- Modern applications often use MULTIPLE database types:
-- Each data store handles what it does best

-- Typical architecture:
-- 1. PostgreSQL: core transactional data (users, orders, payments)
--    ACID guarantees, complex queries, foreign keys
CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR UNIQUE);
CREATE TABLE orders (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id));

-- 2. Redis: session storage, caching, rate limiting
--    Fast in-memory access, auto-expiring keys

-- 3. Elasticsearch: full-text search, log analytics
--    Inverted index, faceted search, aggregations

-- 4. S3/Object storage: files, images, backups
--    Cheap, unlimited, HTTP-accessible

-- 5. TimescaleDB/InfluxDB: time-series metrics
--    Optimized for timestamped data, downsampling

-- Challenge: data consistency across stores
-- Solution: event-driven architecture (CDC, message queues)
--   1. Write to PostgreSQL (source of truth)
--   2. Publish event to Kafka
--   3. Consumers update Redis cache, Elasticsearch index, etc.
--   4. eventual consistency — reads may be slightly stale

ACID vs BASE Consistency Models

ACID (Atomicity, Consistency, Isolation, Durability) guarantees strict consistency — transactions are all-or-nothing, and data always satisfies constraints. This is essential for financial systems where partial updates would cause errors. BASE (Basically Available, Soft state, Eventually consistent) trades immediate consistency for availability and partition tolerance — data may be temporarily inconsistent but converges over time. The CAP theorem states you can't have all three (Consistency, Availability, Partition tolerance) simultaneously during network partitions. SQL databases prioritize C+A (single-node) or C+P (distributed). Many NoSQL databases prioritize A+P (Cassandra, DynamoDB). Choose ACID when correctness is critical; BASE when availability and scale matter more.

sql
-- ACID (SQL databases):
-- Atomicity: all operations in a transaction succeed or fail together
-- Consistency: data always satisfies constraints (FK, CHECK, etc.)
-- Isolation: concurrent transactions don't interfere
-- Durability: committed data survives crashes

-- SQL transaction example:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- If either fails, ROLLBACK undoes both
COMMIT;  -- both updates are permanent

-- BASE (NoSQL databases):
-- Basically Available: system responds (may be stale)
-- Soft state: state changes without input (eventual consistency)
-- Eventually consistent: data converges over time

-- NoSQL trade-off: higher availability & partition tolerance
-- at the cost of immediate consistency

-- CAP Theorem: in a network partition, choose:
-- - CP (consistency): refuse writes (some NoSQL: HBase, MongoDB)
-- - AP (availability): accept writes, reconcile later (Cassandra, DynamoDB)

-- SQL databases are typically CA (consistent + available, no partition tolerance
-- in single-node setups; distributed SQL like CockroachDB adds P)
19

CTE & Recursive CTE

Basic CTE

CTE (Common Table Expression) is a temporary named result set. Improves readability by breaking complex queries. Multiple CTEs can be chained with commas. CTEs are only valid for the single statement.

sql
WITH high_earners AS (
  SELECT * FROM employees WHERE salary > 80000
), by_dept AS (
  SELECT department, COUNT(*) AS cnt FROM high_earners GROUP BY department
)
SELECT * FROM by_dept ORDER BY cnt DESC;

Recursive CTE

Recursive CTEs reference themselves. The anchor is the base case. UNION ALL connects to the recursive part. Used for hierarchical data: org charts, file systems, graph traversal. Must have a termination condition.

sql
WITH RECURSIVE org_chart AS (
  -- Anchor: top-level managers
  SELECT id, name, manager_id, 1 AS level
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  -- Recursive: subordinates
  SELECT e.id, e.name, e.manager_id, oc.level + 1
  FROM employees e
  JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart;

Fibonacci with CTE

Recursive CTEs can generate sequences. The anchor provides the first value. Each iteration computes the next. The WHERE clause prevents infinite recursion. Useful for mathematical sequences.

sql
WITH RECURSIVE fib(n, a, b) AS (
  SELECT 1, 0, 1
  UNION ALL
  SELECT n + 1, b, a + b FROM fib WHERE n < 10
)
SELECT n, a FROM fib;
-- Result: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

Tree Traversal

Build paths by concatenating names in each recursion. The CAST ensures the path column is wide enough. Useful for breadcrumbs, file paths, and category hierarchies. ORDER BY path sorts hierarchically.

sql
WITH RECURSIVE category_tree AS (
  SELECT id, name, parent_id, CAST(name AS VARCHAR(1000)) AS path
  FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, c.parent_id, ct.path || ' > ' || c.name
  FROM categories c JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, path FROM category_tree ORDER BY path;

CTE vs Subquery

CTEs improve readability and can be referenced multiple times. Subqueries are inline and cannot be reused. CTEs are not always materialized; the optimizer may inline them. Use CTEs for clarity.

sql
-- CTE: more readable, can be referenced multiple times
WITH active_users AS (SELECT * FROM users WHERE active = 1)
SELECT * FROM active_users WHERE age > 18
UNION ALL
SELECT * FROM active_users WHERE age <= 18;
-- Subquery: inline, cannot be reused
SELECT * FROM (SELECT * FROM users WHERE active = 1) au WHERE au.age > 18;
20

Indexes Deep Dive

B-Tree Index

B-Tree is the default index type. Composite indexes follow the leftmost prefix rule: a query can use the index if it filters on leading columns. Order columns by selectivity and query patterns.

sql
CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_name_age ON users(last_name, first_name, age);
-- Composite index: useful for
-- WHERE last_name = 'Smith' AND first_name = 'John'
-- WHERE last_name = 'Smith' (leftmost prefix)

Partial Index

Partial indexes only include rows matching the WHERE clause. Smaller and faster than full indexes. Ideal for queries that always filter on a condition. Reduces write overhead.

sql
CREATE INDEX idx_active_users ON users(last_login) WHERE active = 1;
-- Only indexes active users, saving space
-- Useful when queries always filter on active = 1

Covering Index

A covering index includes all columns needed by a query, enabling index-only scans. PostgreSQL uses INCLUDE for non-key columns. Dramatically speeds up SELECT queries by avoiding table lookups.

sql
-- PostgreSQL: INCLUDE clause
CREATE INDEX idx_users_covering ON users(last_name) INCLUDE (first_name, email);
-- The query is "covered" if all columns are in the index:
SELECT first_name, email FROM users WHERE last_name = 'Smith';
-- No table lookup needed (index-only scan)

Index Types

Different index types serve different needs. B-Tree for general use. Hash for equality only. GIN for full-text and JSON. GiST for geometric data. Choose based on query patterns.

sql
-- B-Tree: default, good for equality and range
CREATE INDEX idx_btree ON users(email);
-- Hash: equality only (PostgreSQL)
CREATE INDEX idx_hash ON users(email) USING HASH;
-- GIN: full-text search, arrays, JSON
CREATE INDEX idx_gin ON docs USING GIN (tsv);
-- GiST: geometric, range types
CREATE INDEX idx_gist ON places USING GIST (location);

Index Maintenance

Monitor index usage to remove unused indexes that slow writes. REINDEX rebuilds fragmented indexes. ANALYZE updates statistics for the query planner. Regular maintenance keeps performance optimal.

sql
-- Check index usage (PostgreSQL)
SELECT * FROM pg_stat_user_indexes;
-- Find unused indexes
SELECT relname, indexrelname FROM pg_stat_user_indexes WHERE idx_scan = 0;
-- Rebuild fragmented index
REINDEX INDEX idx_users_email;
-- Analyze for query planner
ANALYZE users;
21

Transactions

ACID Properties

ACID: Atomicity (all or nothing), Consistency (valid state), Isolation (concurrent transactions dont interfere), Durability (committed data persists). BEGIN starts, COMMIT saves, ROLLBACK undoes.

sql
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Or: ROLLBACK to undo

Savepoints

Savepoints create partial rollback points within a transaction. ROLLBACK TO undoes to the savepoint without ending the transaction. Useful for handling errors in multi-step operations without restarting.

sql
BEGIN;
INSERT INTO orders VALUES (1);
SAVEPOINT sp1;
INSERT INTO orders VALUES (2);
-- Oops, rollback to savepoint
ROLLBACK TO sp1;
-- Only order 1 is inserted
INSERT INTO orders VALUES (3);
COMMIT;

Isolation Levels

Isolation levels balance consistency vs performance. READ COMMITTED (default) prevents dirty reads. REPEATABLE READ prevents non-repeatable reads. SERIALIZABLE prevents phantom reads but is slowest.

sql
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Levels (increasing isolation):
-- READ UNCOMMITTED: dirty reads
-- READ COMMITTED: no dirty reads (default)
-- REPEATABLE READ: no non-repeatable reads
-- SERIALIZABLE: full isolation

Deadlocks

Deadlocks occur when transactions hold locks each other needs. Databases detect deadlocks and abort one transaction. Prevent by accessing tables in a consistent order. Keep transactions short.

sql
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
UPDATE accounts SET balance = 0 WHERE id = 2;  -- Waits
-- Transaction 2
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 2;
UPDATE accounts SET balance = 0 WHERE id = 1;  -- Waits
-- Deadlock! Database aborts one transaction

Optimistic Locking

Optimistic locking assumes conflicts are rare. The version column tracks changes. If the UPDATE affects 0 rows, the data was modified by another transaction. Retry or notify the user. Avoids long lock holds.

sql
-- Add version column
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- Update with version check
UPDATE products SET price = 100, version = version + 1
WHERE id = 1 AND version = 5;
-- If 0 rows affected, someone else updated first
22

JSON in SQL

PostgreSQL JSONB

JSONB stores JSON in a binary format, enabling indexing and fast queries. ->> extracts as text, -> extracts as JSON. JSONB is preferable to JSON for querying. Use GIN indexes for JSONB columns.

sql
CREATE TABLE events (id SERIAL, data JSONB);
INSERT INTO events (data) VALUES ('{"user": "alice", "action": "login"}');
SELECT data->>'user' AS user_name FROM events;
SELECT * FROM events WHERE data->>'action' = 'login';

JSON Queries

-> navigates JSON, ->> returns text. @> checks containment. jsonb_set updates nested values. jsonb_object_keys returns top-level keys. These operators enable powerful JSON querying.

sql
SELECT data->'address'->'city' AS city FROM users;
SELECT * FROM users WHERE data @> '{"role": "admin"}';
SELECT jsonb_object_keys(data) FROM users;
-- Update JSON
UPDATE users SET data = jsonb_set(data, '{last_login}', '"2024-01-01"');

JSON Aggregation

json_agg aggregates rows into a JSON array. json_build_object constructs JSON objects from columns. Useful for generating API responses directly from SQL. Combines relational and document data.

sql
SELECT department,
  json_agg(json_build_object('name', name, 'salary', salary)) AS employees
FROM employees
GROUP BY department;
-- Result: {"department": "Eng", "employees": [{"name": "Alice", "salary": 90000}, ...]}

MySQL JSON

MySQL uses $.path syntax for JSON. JSON_EXTRACT gets values, JSON_SET updates. ->> is shorthand for JSON_EXTRACT with text result. MySQL JSON is validated on insert.

sql
CREATE TABLE config (id INT, settings JSON);
INSERT INTO config VALUES (1, '{"theme": "dark", "lang": "en"}');
SELECT settings->>'$.theme' FROM config;
SELECT * FROM config WHERE JSON_EXTRACT(settings, '$.lang') = 'en';
-- Update
UPDATE config SET settings = JSON_SET(settings, '$.theme', 'light');

JSON Indexes

GIN indexes on JSONB enable fast querying of any key. Expression indexes on specific paths are smaller and faster for targeted queries. Index frequently-queried JSON paths for performance.

sql
-- PostgreSQL GIN index on JSONB
CREATE INDEX idx_events_data ON events USING GIN (data);
-- Index specific path
CREATE INDEX idx_events_user ON events ((data->>'user'));
-- MySQL functional index
CREATE INDEX idx_theme ON config ((CAST(settings->>'$.theme' AS CHAR(50))));
23

Performance Tuning

EXPLAIN ANALYZE

EXPLAIN shows the query plan; ANALYZE executes it with timing. Seq Scan indicates missing index. Index Scan is ideal. Look for high cost numbers and slow operations. Always EXPLAIN before optimizing.

sql
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = '[email protected]';
-- Shows: scan type, cost, rows, actual time
-- Seq Scan: full table scan (slow)
-- Index Scan: uses index (fast)
-- Bitmap Heap Scan: index + table lookup

Query Optimization

Select only needed columns to reduce I/O. Avoid functions on indexed columns (non-sargable). Sargable (Search Argument Able) queries can use indexes. Use range conditions instead of functions.

sql
-- BAD: SELECT * fetches all columns
SELECT * FROM users;
-- GOOD: select only needed columns
SELECT id, name FROM users;
-- BAD: function on indexed column
WHERE YEAR(created_at) = 2024
-- GOOD: sargable
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'

JOIN Optimization

Index all join columns. The optimizer chooses join order based on stats. INNER JOIN is usually fastest. Avoid joining on expressions. For large datasets, consider denormalization or materialized views.

sql
-- Use INNER JOIN for required relationships
SELECT u.name, o.total FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- Index join columns
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- For large joins, ensure both columns are indexed

Pagination

OFFSET pagination is O(n) - it scans all skipped rows. Keyset (cursor) pagination is O(1) - it uses an index. Use a tuple comparison for stable sorting. Much faster for deep pagination.

sql
-- BAD: OFFSET scans all skipped rows
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10000;
-- GOOD: keyset pagination
SELECT * FROM users WHERE id > 10000 ORDER BY id LIMIT 10;
-- Stable pagination with cursor
SELECT * FROM users WHERE (created_at, id) > ('2024-01-01', 100) ORDER BY created_at, id LIMIT 10;

Materialized Views

Materialized views store query results physically. Faster than views for expensive aggregations. REFRESH updates the data (concurrently with CONCURRENTLY option). Index them for fast queries.

sql
CREATE MATERIALIZED VIEW sales_summary AS
SELECT product_id, SUM(quantity) AS total, AVG(price) AS avg_price
FROM sales GROUP BY product_id;
-- Refresh periodically
REFRESH MATERIALIZED VIEW sales_summary;
-- Create index on materialized view
CREATE INDEX ON sales_summary (total);
24

Advanced JOINs

Self Join

A self join queries a table against itself. Use aliases to distinguish. Common for hierarchical data (employee-manager) and finding pairs. The a.id < b.id trick avoids duplicate pairs.

sql
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- Find pairs in same department
SELECT a.name, b.name FROM employees a, employees b
WHERE a.department = b.department AND a.id < b.id;

Cross Join

CROSS JOIN produces a Cartesian product: every row in A combined with every row in B. Useful for generating combinations. Be careful: it can produce huge result sets. Often used implicitly with comma syntax.

sql
-- Cartesian product: every combination
SELECT s.size, c.color FROM sizes s CROSS JOIN colors c;
-- Generate all size/color combinations
-- Useful for generating test data or matrices

FULL OUTER JOIN

FULL OUTER JOIN returns all rows from both tables. NULLs fill non-matching sides. Useful for finding unmatched records in both directions. Not supported in MySQL (emulate with UNION of LEFT and RIGHT joins).

sql
SELECT u.name, o.order_id
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- Returns all users and all orders
-- NULLs where there is no match

Anti-Join

Anti-join finds rows in A that do not match B. NOT EXISTS is usually the clearest and often fastest. LEFT JOIN with IS NULL is an alternative. Use for finding missing relationships.

sql
-- Users who have never ordered
SELECT u.* FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: LEFT JOIN ... WHERE IS NULL
SELECT u.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

Semi-Join

Semi-join returns rows from A that match at least one row in B. EXISTS is efficient because it stops at the first match. IN is equivalent but may perform differently. Use EXISTS for correlated subqueries.

sql
-- Users who have at least one order
SELECT u.* FROM users u
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: IN
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders);
25

Common Pitfalls

NULL Comparisons

NULL is unknown, not a value. = NULL always returns NULL (treated as false). Use IS NULL and IS NOT NULL. NULL propagates through arithmetic. Use COALESCE to provide defaults.

sql
-- NULL = NULL is NULL (not true!)
SELECT * FROM users WHERE phone = NULL;  -- Returns nothing
SELECT * FROM users WHERE phone IS NULL;  -- Correct
SELECT * FROM users WHERE phone IS NOT NULL;
-- NULL in arithmetic yields NULL
SELECT 5 + NULL;  -- NULL

SQL Injection

SQL injection allows attackers to execute arbitrary SQL. Never concatenate user input into queries. Always use parameterized queries/prepared statements. Validate and sanitize all input. Use ORM parameter binding.

sql
-- BAD: string concatenation
query = "SELECT * FROM users WHERE name = '" + input + "'"
-- GOOD: parameterized queries
SELECT * FROM users WHERE name = ?;
-- PostgreSQL: $1
-- MySQL: ?
-- Always use parameters, never concatenate

GROUP BY Pitfalls

When using GROUP BY, all non-aggregated columns in SELECT must be in GROUP BY. Otherwise, the result is ambiguous. MySQL allows this (returns arbitrary value) but it is incorrect. Always follow the standard.

sql
-- BAD: non-aggregated column not in GROUP BY
SELECT department, name, COUNT(*) FROM employees GROUP BY department;
-- Error: which name to show?
-- GOOD: aggregate or include in GROUP BY
SELECT department, COUNT(*) FROM employees GROUP BY department;
SELECT department, MAX(name) FROM employees GROUP BY department;

Floating Point

FLOAT and DOUBLE are approximate types. Use DECIMAL/NUMERIC for exact precision (money, measurements). DECIMAL(10,2) allows 10 digits with 2 after the decimal. Never use FLOAT for financial data.

sql
-- Floating point precision issues
SELECT 0.1 + 0.2;  -- 0.30000000000000004
-- Use DECIMAL for money
CREATE TABLE accounts (balance DECIMAL(10, 2));
SELECT 0.10 + 0.20;  -- 0.30 (exact)

Implicit Type Conversion

Implicit type conversion can disable indexes and cause full table scans. Always compare matching types. If necessary, cast explicitly. Check column types and ensure query parameters match.

sql
-- BAD: comparing string to number
SELECT * FROM users WHERE phone = 1234567890;
-- May cause full table scan due to type conversion
-- GOOD: compare same types
SELECT * FROM users WHERE phone = '1234567890';
-- Or cast explicitly
SELECT * FROM users WHERE CAST(phone AS BIGINT) = 1234567890;

Was this helpful?