SQL & Relational Databases¶
Comprehensive guide to SQL: DDL, DML, query patterns, joins, aggregations, transactions, and optimization.
SQL Basics¶
SQL Statement Types: - DDL (Data Definition): CREATE, ALTER, DROP, TRUNCATE - DML (Data Manipulation): SELECT, INSERT, UPDATE, DELETE - DCL (Data Control): GRANT, REVOKE - TCL (Transaction Control): COMMIT, ROLLBACK, SAVEPOINT
Query Clause Order:
CREATE TABLE (DDL)¶
Define table structure.
-- Basic table
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE,
age INT CHECK (age >= 0),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Table with foreign key
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
amount DECIMAL(10, 2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Table with composite primary key
CREATE TABLE order_items (
order_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL,
price DECIMAL(10, 2),
PRIMARY KEY (order_id, product_id),
FOREIGN KEY (order_id) REFERENCES orders(id)
);
-- Create index for faster queries
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id ON orders(user_id);
INSERT (DML)¶
Add rows to table.
-- Single row
INSERT INTO users (name, email, age)
VALUES ('Alice', 'alice@example.com', 30);
-- Multiple rows
INSERT INTO users (name, email, age)
VALUES
('Bob', 'bob@example.com', 25),
('Charlie', 'charlie@example.com', 35),
('Diana', 'diana@example.com', 28);
-- Insert from another table
INSERT INTO users_backup (name, email, age)
SELECT name, email, age FROM users WHERE age > 30;
-- Insert with auto-generated ID (can retrieve with LAST_INSERT_ID())
INSERT INTO orders (user_id, amount) VALUES (1, 99.99);
SELECT LAST_INSERT_ID(); -- Returns: 1
UPSERT (INSERT or UPDATE)¶
UPSERT — Insert if not exists, update if already exists. Use case: sync data from external sources.
MySQL: INSERT ... ON DUPLICATE KEY UPDATE¶
-- If unique key/primary key exists, update; otherwise insert
INSERT INTO users (id, name, email, age)
VALUES (1, 'John', 'john@example.com', 30)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
email = VALUES(email),
age = VALUES(age);
-- Update only specific columns
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON DUPLICATE KEY UPDATE
name = 'Alice',
updated_at = NOW();
-- MySQL 8.0.20+: Use NEW keyword
INSERT INTO users (id, name, email)
VALUES (1, 'John', 'john@example.com')
ON DUPLICATE KEY UPDATE
name = NEW.name,
email = NEW.email;
-- Bulk upsert multiple rows
INSERT INTO users (id, name, email, age)
VALUES
(1, 'John', 'john@example.com', 30),
(2, 'Jane', 'jane@example.com', 25),
(3, 'Bob', 'bob@example.com', 35)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
email = VALUES(email),
age = VALUES(age);
PostgreSQL: INSERT ... ON CONFLICT DO UPDATE¶
-- Insert, or update on conflict with unique/primary key
INSERT INTO users (id, name, email, age)
VALUES (1, 'John', 'john@example.com', 30)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
email = EXCLUDED.email,
age = EXCLUDED.age;
-- Conflict on composite unique key
INSERT INTO user_settings (user_id, setting_key, setting_value)
VALUES (1, 'theme', 'dark')
ON CONFLICT (user_id, setting_key) DO UPDATE SET
setting_value = EXCLUDED.setting_value,
updated_at = NOW();
-- Don't update, just ignore conflict
INSERT INTO users (id, name, email)
VALUES (1, 'John', 'john@example.com')
ON CONFLICT (id) DO NOTHING;
SQL Server: MERGE Statement¶
-- Most flexible: can INSERT, UPDATE, DELETE in one statement
MERGE INTO users AS target
USING (SELECT 1 AS id, 'John' AS name, 'john@example.com' AS email, 30 AS age) AS source
ON target.id = source.id
WHEN MATCHED THEN
UPDATE SET
target.name = source.name,
target.email = source.email,
target.age = source.age
WHEN NOT MATCHED THEN
INSERT (id, name, email, age)
VALUES (source.id, source.name, source.email, source.age);
-- Bulk merge
MERGE INTO users AS target
USING (VALUES
(1, 'John', 'john@example.com', 30),
(2, 'Jane', 'jane@example.com', 25)
) AS source(id, name, email, age)
ON target.id = source.id
WHEN MATCHED THEN
UPDATE SET name = source.name, email = source.email, age = source.age
WHEN NOT MATCHED THEN
INSERT (id, name, email, age) VALUES (source.id, source.name, source.email, source.age);
-- Also allow DELETE
MERGE INTO users AS target
USING source_data AS source
ON target.id = source.id
WHEN MATCHED THEN UPDATE SET ...
WHEN NOT MATCHED THEN INSERT ...
WHEN NOT MATCHED BY SOURCE AND created_before = '2020-01-01' THEN DELETE;
Alternative: Check & Do-If Pattern¶
For databases without native UPSERT:
-- Check if exists, then decide
SET @id = 1;
SET @name = 'John';
SET @email = 'john@example.com';
IF EXISTS(SELECT 1 FROM users WHERE id = @id)
UPDATE users SET name = @name, email = @email WHERE id = @id;
ELSE
INSERT INTO users (id, name, email) VALUES (@id, @name, @email);
Performance Considerations¶
- Single UPSERT — Atomic, no race conditions
- Bulk UPSERT — Much faster than row-by-row inserts/updates
- Indexes — Ensure unique key is indexed for UPSERT performance
-- Create unique index for UPSERT to work efficiently
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- Now upsert by email
INSERT INTO users (email, name)
VALUES ('john@example.com', 'John')
ON DUPLICATE KEY UPDATE name = VALUES(name);
SELECT (DML)¶
Query and retrieve data.
-- Basic SELECT
SELECT id, name, email FROM users;
-- SELECT all columns
SELECT * FROM users;
-- WITH aliases
SELECT
id AS user_id,
name AS full_name,
email AS user_email
FROM users;
-- DISTINCT values (remove duplicates)
SELECT DISTINCT city FROM users;
-- LIMIT and OFFSET (pagination)
SELECT * FROM users LIMIT 10 OFFSET 20; -- Skip 20, take 10
SELECT * FROM users LIMIT 20, 10; -- MySQL shorthand (offset, limit)
WHERE (Filtering)¶
Filter rows based on conditions.
-- Equality
SELECT * FROM users WHERE age = 30;
-- Comparison operators
SELECT * FROM users WHERE age > 25;
SELECT * FROM users WHERE age >= 25;
SELECT * FROM users WHERE age < 30;
SELECT * FROM users WHERE age <= 30;
SELECT * FROM users WHERE age <> 30; -- NOT equal
-- AND / OR
SELECT * FROM users WHERE age > 25 AND city = 'New York';
SELECT * FROM users WHERE age > 25 OR city = 'New York';
SELECT * FROM users WHERE (age > 25 OR age < 20) AND city = 'New York';
-- IN (multiple values)
SELECT * FROM users WHERE id IN (1, 3, 5);
SELECT * FROM orders WHERE status IN ('PENDING', 'PROCESSING', 'SHIPPED');
-- BETWEEN (inclusive)
SELECT * FROM users WHERE age BETWEEN 25 AND 35;
-- LIKE (pattern matching)
SELECT * FROM users WHERE name LIKE 'A%'; -- Starts with A
SELECT * FROM users WHERE name LIKE '%a'; -- Ends with a
SELECT * FROM users WHERE name LIKE '%li%'; -- Contains li
SELECT * FROM users WHERE email LIKE '%.com'; -- Email ends with .com
-- IS NULL / IS NOT NULL
SELECT * FROM users WHERE email IS NULL;
SELECT * FROM users WHERE email IS NOT NULL;
JOIN (Combining Tables)¶
Combine rows from multiple tables.
-- INNER JOIN: Only matching rows
SELECT
users.id,
users.name,
orders.id AS order_id,
orders.amount
FROM users
INNER JOIN orders ON users.id = orders.user_id
WHERE orders.amount > 50;
-- LEFT JOIN: All left rows + matched right (NULL for non-matching)
SELECT
users.name,
orders.amount,
orders.created_at
FROM users
LEFT JOIN orders ON users.id = orders.user_id;
-- Result: All users, even if they have no orders (NULL in order columns)
-- RIGHT JOIN: All right rows + matched left
SELECT
users.name,
orders.amount
FROM users
RIGHT JOIN orders ON users.id = orders.user_id;
-- Result: All orders, with NULL if user deleted
-- FULL OUTER JOIN: All rows from both tables
SELECT
users.name,
orders.amount
FROM users
FULL OUTER JOIN orders ON users.id = orders.user_id;
-- Multiple JOINs
SELECT
u.name,
o.amount,
oi.quantity,
p.name AS product_name
FROM users u
INNER JOIN orders o ON u.id = o.user_id
INNER JOIN order_items oi ON o.id = oi.order_id
INNER JOIN products p ON oi.product_id = p.id
WHERE o.created_at >= '2024-01-01';
-- SELF JOIN (table joined to itself)
SELECT
e1.name AS employee,
e2.name AS manager
FROM employees e1
LEFT JOIN employees e2 ON e1.manager_id = e2.id;
GROUP BY & Aggregations¶
Group rows and calculate aggregate values.
-- COUNT: number of rows
SELECT COUNT(*) AS total_users FROM users;
SELECT COUNT(email) FROM users; -- NULL emails excluded
-- SUM: total
SELECT SUM(amount) AS total_revenue FROM orders;
-- AVG: average
SELECT AVG(amount) AS avg_order_value FROM orders;
-- MIN / MAX
SELECT MIN(age) AS youngest, MAX(age) AS oldest FROM users;
-- GROUP BY: group rows by column(s)
SELECT
city,
COUNT(*) AS user_count,
AVG(age) AS avg_age
FROM users
GROUP BY city;
-- GROUP BY multiple columns
SELECT
DATE_TRUNC('month', created_at) AS month,
status,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
GROUP BY DATE_TRUNC('month', created_at), status;
-- HAVING: filter AFTER grouping (use WHERE before grouping, HAVING after)
SELECT
city,
COUNT(*) AS user_count
FROM users
WHERE age > 25 -- Filter before grouping
GROUP BY city
HAVING COUNT(*) > 5; -- Filter after grouping (cities with >5 users)
-- Common aggregates with grouping
SELECT
user_id,
COUNT(*) AS num_orders,
SUM(amount) AS total_spent,
AVG(amount) AS avg_order_value,
MIN(amount) AS min_order,
MAX(amount) AS max_order
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 1000; -- Only users who spent > 1000
ORDER BY (Sorting)¶
Sort results.
-- Ascending (default)
SELECT * FROM users ORDER BY age;
SELECT * FROM users ORDER BY age ASC;
-- Descending
SELECT * FROM users ORDER BY age DESC;
-- Multiple columns
SELECT * FROM users ORDER BY age DESC, name ASC;
-- Sorts by age (descending), then by name (ascending)
-- Order by expression
SELECT
name,
age,
(2024 - birth_year) AS calculated_age
FROM users
ORDER BY (2024 - birth_year) DESC;
-- LIMIT with ORDER BY (top N)
SELECT * FROM orders ORDER BY amount DESC LIMIT 5; -- Top 5 orders
UPDATE (DML)¶
Modify existing rows.
-- Update single row
UPDATE users
SET age = 31, email = 'newemail@example.com'
WHERE id = 1;
-- Update multiple rows
UPDATE users
SET active = true
WHERE created_at >= '2024-01-01';
-- Update with calculation
UPDATE orders
SET amount = amount * 1.1 -- 10% increase
WHERE created_at < '2024-01-01';
-- UPDATE with JOIN (update based on related table)
UPDATE users u
SET subscription_level = 'premium'
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id
AND o.amount > 1000
);
-- Be careful! Missing WHERE updates all rows
UPDATE users SET age = 25; -- ❌ DANGEROUS: updates ALL users!
DELETE (DML)¶
Remove rows.
-- Delete single row
DELETE FROM users WHERE id = 5;
-- Delete multiple rows
DELETE FROM orders
WHERE created_at < '2020-01-01';
-- Delete with JOIN
DELETE FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM users u WHERE u.id = o.user_id
); -- Delete orphaned orders
-- Be careful! Missing WHERE deletes all rows
DELETE FROM users; -- ❌ DANGEROUS: deletes ALL users!
-- Truncate: delete all rows (faster than DELETE, can't rollback)
TRUNCATE TABLE users; -- ⚠️ Use with caution
Advanced Patterns¶
Subqueries¶
-- Subquery in WHERE
SELECT * FROM users
WHERE id IN (
SELECT user_id FROM orders WHERE amount > 100
);
-- Subquery in FROM
SELECT user_summary.user_id, user_summary.total_orders
FROM (
SELECT user_id, COUNT(*) AS total_orders
FROM orders
GROUP BY user_id
) AS user_summary
WHERE user_summary.total_orders > 5;
-- Correlated subquery (references outer query)
SELECT u.name, u.email
FROM users u
WHERE (
SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id
) > 3; -- Users with >3 orders
CASE (Conditional Logic)¶
-- Simple CASE
SELECT
name,
age,
CASE age
WHEN 18 THEN 'Young Adult'
WHEN 25 THEN 'Adult'
ELSE 'Senior'
END AS age_group
FROM users;
-- Searched CASE (more flexible)
SELECT
name,
age,
CASE
WHEN age < 18 THEN 'Minor'
WHEN age BETWEEN 18 AND 25 THEN 'Young Adult'
WHEN age BETWEEN 26 AND 65 THEN 'Adult'
ELSE 'Senior'
END AS age_category
FROM users;
-- CASE with aggregation
SELECT
CASE status
WHEN 'SHIPPED' THEN 'Completed'
WHEN 'PENDING' THEN 'Incomplete'
ELSE 'Other'
END AS order_status,
COUNT(*) AS count
FROM orders
GROUP BY CASE status
WHEN 'SHIPPED' THEN 'Completed'
WHEN 'PENDING' THEN 'Incomplete'
ELSE 'Other'
END;
UNION (Combine Results)¶
-- UNION: combine with DISTINCT
SELECT name, email, 'user' AS source FROM users
UNION
SELECT company_name, contact_email, 'company' FROM companies;
-- UNION ALL: keep duplicates
SELECT name FROM employees
UNION ALL
SELECT name FROM contractors;
Normalization¶
Reduce redundancy and anomalies.
- 1NF: Atomic values, no repeating groups
- 2NF: 1NF + no partial dependency on composite key
- 3NF: 2NF + no transitive dependency (non-key doesn't depend on non-key)
- BCNF: Stricter 3NF
Example: Normalize to 3NF
-- ❌ Non-normalized (redundant data)
CREATE TABLE orders_bad (
order_id INT,
user_id INT,
user_name VARCHAR(100), -- Redundant (repeats for each order)
user_email VARCHAR(100), -- Redundant
product_name VARCHAR(100), -- Redundant
amount DECIMAL(10, 2)
);
-- ✅ Normalized (3NF)
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(100)
);
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
amount DECIMAL(10, 2),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE order_items (
order_id INT,
product_id INT,
quantity INT,
PRIMARY KEY (order_id, product_id),
FOREIGN KEY (order_id) REFERENCES orders(id)
);
Indexing Strategy¶
Speed up queries at cost of slower writes.
-- Create index
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id_created ON orders(user_id, created_at);
-- Query uses index if condition matches
SELECT * FROM users WHERE email = 'alice@example.com'; -- ✅ Uses idx_users_email
-- Composite index (leftmost-prefix rule)
SELECT * FROM orders
WHERE user_id = 1 AND created_at > '2024-01-01'; -- ✅ Uses composite index
SELECT * FROM orders
WHERE created_at > '2024-01-01'; -- ❌ Doesn't use composite index (created_at not first)
-- Check query plan (MySQL)
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
-- Look for: Using index, Index range, no Full table scan
-- Drop index if not needed
DROP INDEX idx_users_email ON users;
WHERE vs HAVING¶
| Clause | When | Filters |
|---|---|---|
| WHERE | Before grouping | Individual rows |
| HAVING | After grouping | Grouped rows / aggregates |
SELECT
city,
COUNT(*) AS user_count
FROM users
WHERE age > 25 -- ← Filters rows BEFORE grouping
GROUP BY city
HAVING COUNT(*) > 5; -- ← Filters groups AFTER aggregation
Transactions & Isolation¶
-- Transaction: all-or-nothing
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- Both succeed together
-- Or: ROLLBACK; (undo all changes)
-- Isolation levels (trade consistency vs performance)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- READ UNCOMMITTED: dirty reads (see uncommitted data)
-- READ COMMITTED: no dirty reads (see committed data only)
-- REPEATABLE READ: no non-repeatable reads (consistent within transaction)
-- SERIALIZABLE: full isolation (safest, slowest)
Interview Talking Points¶
- INNER vs LEFT JOIN? INNER: only matching rows; LEFT: all left rows + matched right (NULLs otherwise)
- WHERE vs HAVING? WHERE filters before grouping; HAVING filters after aggregation
- N+1 query problem? Running 1 query + N more for each result (slow); use JOIN instead
- Normalization trade-off? Reduces redundancy but requires more JOINs; denormalize for read performance
- Index pros/cons? Pros: faster queries; Cons: slower writes, extra storage
- Composite index leftmost-prefix? Only first column used if WHERE doesn't match first column order
- Transaction isolation? Higher isolation = safer (no dirty reads) but slower; lower isolation = faster but riskier
- UNION vs UNION ALL? UNION removes duplicates; UNION ALL keeps them (UNION ALL faster)
See also: Databases Overview, Scaling & NoSQL