SQL is the language of relational databases, and despite the rise of ORMs, every web developer benefits from understanding it directly. ORMs help you write queries faster; SQL helps you understand what queries are being run and how to make them fast.
The Core Queries
SELECT
-- All columns
SELECT * FROM users;
-- Specific columns
SELECT id, name, email FROM users;
-- With a condition
SELECT id, name FROM users WHERE active = true;
-- Multiple conditions
SELECT id, name FROM users
WHERE active = true
AND created_at > '2024-01-01';
-- Sorted
SELECT id, name FROM users ORDER BY created_at DESC;
-- Limited
SELECT id, name FROM users ORDER BY created_at DESC LIMIT 20;
-- Offset (page 3 with 20 per page)
SELECT id, name FROM users ORDER BY created_at DESC LIMIT 20 OFFSET 40;
INSERT
-- Single row
INSERT INTO users (name, email, created_at)
VALUES ('Alice', 'alice@example.com', NOW());
-- Multiple rows
INSERT INTO users (name, email) VALUES
('Alice', 'alice@example.com'),
('Bob', 'bob@example.com'),
('Carol', 'carol@example.com');
-- Returning the created row (PostgreSQL)
INSERT INTO users (name, email)
VALUES ('Alice', 'alice@example.com')
RETURNING id, created_at;
UPDATE
-- Update specific rows
UPDATE users
SET active = false, updated_at = NOW()
WHERE last_login < NOW() - INTERVAL '1 year';
-- Always include WHERE (without it, updates every row)
UPDATE users SET active = true; -- affects all users!
DELETE
DELETE FROM users WHERE id = 123;
-- Soft delete pattern (keep the row, mark it deleted)
UPDATE users SET deleted_at = NOW() WHERE id = 123;
Joins
Joins combine rows from multiple tables based on a relationship.
-- Tables:
-- users: id, name
-- posts: id, user_id, title, published_at
-- INNER JOIN: only rows with a match in both tables
SELECT users.name, posts.title
FROM posts
INNER JOIN users ON posts.user_id = users.id;
-- LEFT JOIN: all posts, with user info if available (user_id might be null)
SELECT posts.title, users.name
FROM posts
LEFT JOIN users ON posts.user_id = users.id;
-- Multiple joins
SELECT
posts.title,
users.name AS author,
COUNT(comments.id) AS comment_count
FROM posts
INNER JOIN users ON posts.user_id = users.id
LEFT JOIN comments ON comments.post_id = posts.id
GROUP BY posts.id, posts.title, users.name;
Visual guide to JOINs:
-
INNER JOIN: rows where both sides match -
LEFT JOIN: all rows from left, match from right (or NULL) -
RIGHT JOIN: all rows from right, match from left (or NULL) -
FULL OUTER JOIN: all rows from both sides
Aggregations
-- Count
SELECT COUNT(*) FROM users WHERE active = true;
SELECT COUNT(DISTINCT country) FROM users;
-- Sum, avg, min, max
SELECT
SUM(amount) AS total,
AVG(amount) AS average,
MIN(amount) AS minimum,
MAX(amount) AS maximum
FROM orders
WHERE status = 'completed';
-- GROUP BY
SELECT
country,
COUNT(*) AS user_count,
AVG(age) AS avg_age
FROM users
GROUP BY country
ORDER BY user_count DESC;
-- HAVING (filter on aggregated results)
SELECT
user_id,
COUNT(*) AS post_count
FROM posts
GROUP BY user_id
HAVING COUNT(*) > 10 -- only users with more than 10 posts
ORDER BY post_count DESC;
Subqueries and CTEs
-- Subquery in WHERE
SELECT name FROM users
WHERE id IN (
SELECT DISTINCT user_id FROM posts WHERE published_at > NOW() - INTERVAL '30 days'
);
-- CTE (Common Table Expression) — more readable
WITH active_users AS (
SELECT id, name FROM users WHERE active = true
),
recent_posters AS (
SELECT DISTINCT user_id FROM posts
WHERE published_at > NOW() - INTERVAL '30 days'
)
SELECT active_users.name
FROM active_users
INNER JOIN recent_posters ON active_users.id = recent_posters.user_id;
CTEs make complex queries readable by breaking them into named steps.
Indexes
Indexes dramatically speed up lookups at the cost of slower writes and storage.
-- Create an index on a frequently queried column
CREATE INDEX idx_users_email ON users(email);
-- Composite index (for queries that filter on both columns)
CREATE INDEX idx_posts_user_published ON posts(user_id, published_at);
-- Unique index (also enforces uniqueness)
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
When to index:
- Columns in
WHEREclauses - Foreign key columns (joins use them)
- Columns in
ORDER BYwhen queries also filter
When NOT to index everything:
- Small tables (full scans are fast)
- Columns that are written frequently
- Columns with very low cardinality (e.g., boolean columns — few distinct values mean the index isn't selective)
Check if your index is used:
EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com';
-- Look for "Index Scan" vs "Seq Scan"
Common Patterns
Upsert (insert or update)
-- PostgreSQL
INSERT INTO user_settings (user_id, theme, font_size)
VALUES (123, 'dark', 16)
ON CONFLICT (user_id)
DO UPDATE SET theme = EXCLUDED.theme, font_size = EXCLUDED.font_size;
-- MySQL
INSERT INTO user_settings (user_id, theme, font_size)
VALUES (123, 'dark', 16)
ON DUPLICATE KEY UPDATE theme = VALUES(theme), font_size = VALUES(font_size);
Pagination with cursor
-- Offset pagination (breaks when rows are added/deleted between requests)
SELECT * FROM posts ORDER BY id DESC LIMIT 20 OFFSET 40;
-- Cursor pagination (stable)
SELECT * FROM posts
WHERE id < 12345 -- last_seen_id from previous page
ORDER BY id DESC
LIMIT 20;
Window Functions
-- Rank users by post count within each country
SELECT
name,
country,
post_count,
RANK() OVER (PARTITION BY country ORDER BY post_count DESC) AS rank_in_country
FROM (
SELECT u.name, u.country, COUNT(p.id) AS post_count
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
GROUP BY u.id, u.name, u.country
) user_posts;
-- Running total
SELECT
date,
revenue,
SUM(revenue) OVER (ORDER BY date) AS cumulative_revenue
FROM daily_revenue;
Transactions
Group operations so they either all succeed or all fail:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- If anything fails, rollback everything
COMMIT; -- or ROLLBACK to undo
In application code:
// Node.js with pg
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [100, fromId]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [100, toId]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
N+1 Queries
The most common ORM performance problem: loading a list then querying each item separately.
// N+1 problem: 1 query for users + N queries for their posts
const users = await User.findAll();
for (const user of users) {
user.posts = await Post.findAll({ where: { userId: user.id } });
}
// 101 queries for 100 users
// Fix: one query with JOIN
const users = await User.findAll({ include: Post });
// 1 query: SELECT users.*, posts.* FROM users LEFT JOIN posts...
In raw SQL, joins are almost always more efficient than separate queries in a loop.
SQL rewards understanding over abstraction. When an ORM query is slow, EXPLAIN ANALYZE shows you exactly what's happening and why.
Top comments (0)