15 — Databases, End to End
Weeks 9–11 for SQL (do this early — it's cheap points), then ongoing. Covers query writing, database internals, modeling, NoSQL, and the backend concerns that get asked in every full-stack and backend loop.
15.1 SQL — from zero
The mental model
SQL is declarative: you describe the result, the query planner decides how to get it. Understanding the logical execution order explains nearly every confusing behavior:
1. FROM pick the base tables
2. JOIN combine them
3. WHERE filter individual ROWS
4. GROUP BY collapse rows into groups
5. HAVING filter GROUPS
6. SELECT compute output columns (aliases are created HERE)
7. DISTINCT dedupe
8. ORDER BY sort
9. LIMIT truncateTwo consequences people get wrong constantly:
- A
SELECTalias can't be used inWHERE(WHERE runs first) but can be used inORDER BY(which runs later). - Aggregate conditions belong in
HAVING, notWHERE.WHERE COUNT(*) > 5is a syntax error because grouping hasn't happened yet.
The basics
SELECT name, salary
FROM employees
WHERE department = 'Engineering'
AND salary > 100000
AND hire_date >= '2024-01-01'
ORDER BY salary DESC
LIMIT 10;
-- NULL handling: NULL is not a value, it's "unknown"
WHERE email IS NULL -- correct
WHERE email = NULL -- always false, never matches. Classic bug.
COALESCE(nickname, name) -- first non-NULL
NULLIF(a, b) -- NULL if a = b, else a
-- Conditional logic
SELECT name,
CASE WHEN salary > 150000 THEN 'senior'
WHEN salary > 90000 THEN 'mid'
ELSE 'junior'
END AS level
FROM employees;
-- Set operations
SELECT id FROM a UNION SELECT id FROM b; -- dedupes (slower)
SELECT id FROM a UNION ALL SELECT id FROM b; -- keeps duplicates (faster)
SELECT id FROM a INTERSECT SELECT id FROM b;
SELECT id FROM a EXCEPT SELECT id FROM b;Joins — the whole picture
-- INNER: rows present in both
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- LEFT: all left rows, NULLs for missing right
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
-- RIGHT: mirror image of LEFT (rarely used — just flip the tables)
-- FULL OUTER: everything from both sides
-- CROSS: cartesian product (every combination) — usually a mistake
-- Anti-join: "find rows with NO match" — very common interview question
SELECT u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
-- Self join: compare rows within one table
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 "consecutive" problems
SELECT DISTINCT l1.num
FROM logs l1
JOIN logs l2 ON l1.id = l2.id - 1 AND l1.num = l2.num
JOIN logs l3 ON l1.id = l3.id - 2 AND l1.num = l3.num;A LEFT JOIN with a condition in WHERE silently becomes an INNER JOIN, because the WHERE filters out the NULL-padded rows. If the condition is about the right table, it belongs in the ON clause:
-- WRONG: acts as an inner join
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'complete'
-- RIGHT: keeps users with no completed orders
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'complete'That distinction is a favorite interview question and a common production bug.
Aggregation
SELECT department,
COUNT(*) AS total_rows,
COUNT(bonus) AS rows_with_bonus, -- COUNT(col) SKIPS NULLs
COUNT(DISTINCT title) AS distinct_titles,
SUM(salary), AVG(salary), MIN(salary), MAX(salary),
STRING_AGG(name, ', ') AS names -- GROUP_CONCAT in MySQL
FROM employees
GROUP BY department
HAVING COUNT(*) >= 3
ORDER BY AVG(salary) DESC;Every non-aggregated column in SELECT must appear in GROUP BY (Postgres enforces this; MySQL historically didn't, which produced silently wrong results).
Window functions — the intermediate/senior line
Compute across a set of rows without collapsing them.
SELECT name, department, salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS drnk,
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
SUM(salary) OVER (ORDER BY hire_date
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW) AS running_total,
LAG(salary, 1) OVER (ORDER BY hire_date) AS prev_salary,
LEAD(salary, 1) OVER (ORDER BY hire_date) AS next_salary,
NTILE(4) OVER (ORDER BY salary) AS quartile
FROM employees;| Function | Ties | Use for |
|---|---|---|
ROW_NUMBER() |
always distinct | "exactly one row per group" |
RANK() |
share, then skip (1,1,3) | competition ranking |
DENSE_RANK() |
share, no skip (1,1,2) | "top 3 distinct values" |
LAG / LEAD |
— | compare to previous/next row |
NTILE(n) |
— | percentile buckets |
FIRST_VALUE / LAST_VALUE |
— | boundary values in a window |
The two idioms that cover most interview questions:
-- Nth highest per group
SELECT * FROM (
SELECT *, DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) r
FROM employees
) t WHERE r <= 3;
-- Latest row per group ("most recent order per customer")
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) r
FROM orders
) t WHERE r = 1;Frame clauses (ROWS BETWEEN ...) control the window per row — used for running totals, moving averages, and cumulative counts.
CTEs and recursion
-- Common Table Expression: name a subquery, keep the query readable
WITH dept_avg AS (
SELECT department, AVG(salary) AS avg_sal
FROM employees GROUP BY department
)
SELECT e.name, e.salary, d.avg_sal
FROM employees e
JOIN dept_avg d ON e.department = d.department
WHERE e.salary > d.avg_sal;
-- Recursive CTE: hierarchies, org charts, graph traversal in SQL
WITH RECURSIVE org AS (
SELECT id, name, manager_id, 1 AS depth
FROM employees WHERE manager_id IS NULL -- anchor
UNION ALL
SELECT e.id, e.name, e.manager_id, o.depth + 1
FROM employees e JOIN org o ON e.manager_id = o.id -- recursive step
)
SELECT * FROM org ORDER BY depth;Recursive CTEs also generate date series (useful for filling gaps in time-series reports) and traverse graph edges.
The interview question patterns
| Pattern | Technique |
|---|---|
| Nth highest salary | DENSE_RANK() in a subquery, or LIMIT 1 OFFSET n-1 |
| Duplicate rows | GROUP BY col HAVING COUNT(*) > 1 |
| Delete duplicates, keep one | ROW_NUMBER() partitioned by the key, delete rn > 1 |
| Rows with no match | LEFT JOIN + IS NULL |
| Running total / moving average | window function with a frame clause |
| Month-over-month growth | LAG() over an ordered window |
| Consecutive occurrences | self-join on id = id ± 1, or ROW_NUMBER() gap-and-island |
| Top N per group | ROW_NUMBER() partitioned, filter rn <= N |
| Pivot rows into columns | SUM(CASE WHEN ... THEN 1 ELSE 0 END) per column |
| Median | PERCENTILE_CONT(0.5), or row numbers from both ends |
| Gaps in a sequence | self-join, or LAG/LEAD comparison |
| First/last event per user | ROW_NUMBER() ordered by timestamp |
Practice: LeetCode SQL 50 — all 50, roughly three weekends. That's the complete realistic interview surface. Then DataLemur for harder analytics questions if you're targeting data-adjacent roles.
15.2 Database internals — how it actually works
This is what separates "can write SQL" from "understands databases," and it's asked in every backend loop.
Storage and B+ trees
Tables live in fixed-size pages (typically 8 KB in Postgres, 16 KB in InnoDB) on disk. The database reads and writes whole pages, which is why disk seeks matter more than bytes.
An index is a B+ tree: a balanced tree with high fan-out (hundreds of keys per node), so depth stays around 3–4 even for billions of rows. Only leaf nodes hold data pointers, and leaves are linked, which makes range scans fast.
Lookup cost = tree depth = O(log_fanout n) ≈ 3–4 page reads
Table scan = O(n) pagesThat's the entire value of an index: 4 page reads instead of a million.
Why B+ trees and not binary trees: binary tree depth for a billion rows is ~30, and each level is a separate disk read. High fan-out collapses 30 reads into 4. The structure exists because of disk physics.
Clustered vs secondary index:
- Clustered — the table rows are stored in index order. One per table (MySQL InnoDB: the primary key). Range queries on it are extremely fast.
- Secondary — stores the indexed column plus a pointer to the row. A lookup may need a second read to fetch the full row ("bookmark lookup"). A covering index includes all columns the query needs, avoiding that second read entirely.
Other index types: hash (O(1) equality, no ranges), GIN/GiST (Postgres, for full-text and JSON), bitmap (low-cardinality columns, analytics).
Index rules that get asked
CREATE INDEX idx_user_created ON orders(user_id, created_at);- Leftmost prefix rule. A composite index on
(a, b, c)serves queries filtering ona,(a,b), or(a,b,c)— notbalone or(b,c). - Functions on a column defeat the index.
WHERE UPPER(email) = 'X'cannot use an index onemail. Fix: an expression indexCREATE INDEX ON users(UPPER(email)), or store a normalized column. Same forWHERE amount + 1 > 100andWHERE DATE(created_at) = '2026-01-01'— rewrite as a range:created_at >= '2026-01-01' AND created_at < '2026-01-02'. - Leading wildcards defeat the index.
LIKE 'abc%'uses it;LIKE '%abc'cannot. - Low selectivity is not worth indexing. An index on a boolean column that's 50/50 is usually ignored by the planner — a scan is cheaper than random page reads.
- Indexes cost writes. Every INSERT/UPDATE/DELETE maintains every index on the table. Over-indexing is a real production problem.
- Order the composite index by how you query, with equality columns before range columns: for
WHERE user_id = ? AND created_at > ?, index(user_id, created_at)— not the reverse.
Reading a query plan
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;What to look for:
- Seq Scan on a large table with a selective filter → a missing index
- Index Scan / Index Only Scan → good; "index only" means the index covered the query
- Nested Loop with a big outer row count → possibly a missing index on the join key
- Hash Join → fine for large joins
- Rows estimated vs actual wildly different → stale statistics; run
ANALYZE - Sort with a high cost → an index on the ORDER BY column could remove it
Being able to say "I'd run EXPLAIN ANALYZE and look for a sequential scan on the filtered column" is a concrete, credible answer to "how would you debug a slow query."
Transactions and ACID
Atomicity — all or nothing. Implemented with an undo log. Consistency — constraints and invariants hold before and after. Isolation — concurrent transactions don't corrupt each other. Durability — once committed, it survives a crash. Implemented with a write-ahead log (WAL): the intent is written to an append-only log and fsynced before the data pages change. On crash recovery, the log is replayed.
Isolation levels and the anomalies
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| Read Uncommitted | possible | possible | possible |
| Read Committed | prevented | possible | possible |
| Repeatable Read | prevented | prevented | possible* |
| Serializable | prevented | prevented | prevented |
*Postgres's Repeatable Read (snapshot isolation) also prevents phantoms in practice.
- Dirty read — you see another transaction's uncommitted change.
- Non-repeatable read — you read a row twice in one transaction and get different values.
- Phantom read — you run the same range query twice and get different rows (someone inserted).
- Lost update — two transactions read-modify-write the same row; one overwrite is silently lost. The fix is
SELECT ... FOR UPDATE(pessimistic) or a version column with a compare-and-set (optimistic).
Defaults: Postgres and Oracle use Read Committed; MySQL InnoDB uses Repeatable Read. Knowing your database's default is a good sign.
MVCC (Multi-Version Concurrency Control) — how Postgres avoids read locks: each write creates a new row version, and readers see a consistent snapshot from their transaction's start. Readers never block writers and writers never block readers. The cost is dead-row accumulation, which is why VACUUM exists.
Deadlock — transaction A holds lock 1 and wants lock 2; B holds 2 and wants 1. Databases detect this and kill one transaction. Prevention: always acquire locks in a consistent order, keep transactions short, and use lower isolation where safe.
Optimistic vs pessimistic locking:
-- Pessimistic: lock the row now, others wait
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- Optimistic: check a version at write time, retry on conflict
UPDATE accounts SET balance = 90, version = version + 1
WHERE id = 1 AND version = 7; -- 0 rows affected → someone else won, retryOptimistic wins when conflicts are rare (most web workloads); pessimistic wins under genuine contention (seat booking, inventory).
15.3 Data modeling
Normalization
- 1NF — atomic values, no repeating groups (no comma-separated lists in a column)
- 2NF — 1NF + every non-key column depends on the whole primary key
- 3NF — 2NF + no transitive dependencies (non-key columns don't depend on other non-key columns)
In practice: normalize until it hurts, then denormalize until it works. Normalization removes update anomalies and duplication; denormalization removes joins from hot read paths. Both are correct in their place, and saying that is the mature answer.
Keys and constraints
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','paid','shipped','cancelled')),
total_cents BIGINT NOT NULL CHECK (total_cents >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, idempotency_key)
);
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);Points worth making: store money as integer cents, never floats (floating point can't represent 0.1 exactly — a genuine class of production bug). Use TIMESTAMPTZ and store UTC. Prefer surrogate keys (an auto ID) over natural keys, because natural keys change. Put constraints in the database, not only in application code — the database is the last line of defense and multiple services may write to it.
Relationships
-- One-to-many: the foreign key lives on the MANY side
orders.user_id → users.id
-- Many-to-many: a junction table
CREATE TABLE student_courses (
student_id BIGINT REFERENCES students(id),
course_id BIGINT REFERENCES courses(id),
enrolled_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (student_id, course_id)
);
-- One-to-one: FK with a UNIQUE constraint, or share the PKSchema migrations
Migrations must be backward compatible during a rolling deploy, because old and new code run simultaneously:
- Add the new nullable column (old code ignores it)
- Deploy code that writes both old and new
- Backfill existing rows in batches
- Deploy code that reads the new column
- Drop the old column in a later release
Never do a blocking ALTER TABLE on a large hot table — adding a NOT NULL column with a default rewrites the whole table on older engines. Use CREATE INDEX CONCURRENTLY in Postgres to avoid locking writes.
15.4 NoSQL
When to actually use it
Use SQL by default. Choose NoSQL when you have a specific reason:
- write volume beyond what a single primary can absorb (Cassandra)
- a genuinely schema-less or rapidly changing shape (documents)
- O(1) key access at massive scale with no complex queries (DynamoDB, Redis)
- relationship traversal is the core query (graph)
- full-text search (Elasticsearch, as a secondary index — never as the source of truth)
"NoSQL scales better" alone is not an answer. Modern Postgres handles very large workloads, and you lose joins, transactions, and ad-hoc querying.
Redis — the one you'll use most
r.set("session:abc", data, ex=3600) # string + TTL
r.incr("rate:user:42") # atomic counter
r.lpush("queue:jobs", job); r.brpop("queue:jobs") # list as a queue
r.sadd("online_users", user_id) # set
r.zadd("leaderboard", {"alice": 42}) # sorted set → rankings, top-N
r.hset("user:1", mapping={"name": "A"}) # hash → object fields
r.setnx("lock:resource", token) # distributed lock (+ TTL always)Redis is single-threaded for commands, which is why individual operations are atomic and why one slow command (KEYS * on a large DB) blocks everything. Use SCAN, not KEYS.
Persistence: RDB (periodic snapshots — fast restart, can lose recent writes) vs AOF (append-only log — more durable, larger). Know both exist, and that Redis is not a database of record unless you configure it to be.
Distributed locks with Redis: SET key token NX PX 30000. Always set a TTL (otherwise a crashed holder deadlocks the resource forever) and always check the token before releasing (otherwise you release someone else's lock). Mention that this is not perfectly safe under partitions — Redlock's safety is genuinely debated. Knowing there's a debate is the signal.
Document stores
MongoDB and similar: JSON documents, flexible schema, embed related data to avoid joins. Choose embedding when data is always read together and bounded in size; choose referencing when it's large, unbounded, or shared. The classic modeling failure is unbounded array growth inside a document (a post with a million embedded comments).
Wide-column
Cassandra/HBase: the query drives the schema, not the other way around. You design the partition key so each query hits exactly one partition. Massive write throughput, tunable consistency via quorum (W + R > N). Bad at ad-hoc queries — if you need a new access pattern, you build a new table.
15.5 Backend database concerns
The N+1 query problem
The most common real-world performance bug, and a frequent interview question.
# N+1: one query for users, then one per user for their orders
users = session.query(User).all() # 1 query
for user in users:
print(user.orders) # N more queries
# Fixed: eager load in a single join
users = session.query(User).options(joinedload(User.orders)).all() # 1 querySymptom: a page that's fast with 10 rows and unusable with 1,000. Diagnosis: log the SQL and count the queries.
Connection pooling
Opening a database connection costs a TCP handshake plus authentication — 10–100 ms. Pools keep connections open and hand them out.
Postgres uses one process per connection, so it can't handle thousands. Pool sizing rule of thumb: (2 × CPU cores) + effective spindle count, usually 10–30 per app instance, not hundreds. At high instance counts, put PgBouncer in front. "Too many connections" outages are extremely common in production.
Pagination
-- Offset pagination: simple, but degrades badly
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
-- The database must scan and discard 100,000 rows. Also: rows shift between
-- pages when new data arrives, so users see duplicates or miss items.
-- Cursor (keyset) pagination: O(log n), stable under inserts
SELECT * FROM posts
WHERE (created_at, id) < ('2026-08-01 10:00:00', 4242)
ORDER BY created_at DESC, id DESC
LIMIT 20;Cursor pagination is the correct answer for any infinite-scroll or large dataset. The tuple comparison (created_at, id) breaks ties deterministically.
Bulk operations
# Slow: N round trips
for row in rows:
session.add(Model(**row))
session.commit()
# Fast: one round trip, one transaction
session.bulk_insert_mappings(Model, rows)
session.commit()
# Postgres: COPY is far faster than INSERT for large loadsRound trips dominate. Batching is usually a 10–100× improvement, and knowing that is a concrete performance answer.
Soft deletes, auditing, and time
deleted_at TIMESTAMPTZ NULL -- soft delete: filter WHERE deleted_at IS NULL
created_at, updated_at TIMESTAMPTZ -- always have bothSoft deletes preserve history and make "undo" possible, but every query must remember the filter, and unique constraints get complicated (use a partial index: UNIQUE (email) WHERE deleted_at IS NULL).
Always store UTC. Convert at the presentation layer. Timezone bugs are a large fraction of real production incidents.
Security
- SQL injection — always use parameterized queries. Never build SQL with string formatting. This is asked and it's non-negotiable.
cur.execute("SELECT * FROM users WHERE email = %s", (email,)) # safe cur.execute(f"SELECT * FROM users WHERE email = '{email}'") # exploitable - Least privilege — the app's database user should not be a superuser and should not have DDL rights in production.
- Encryption — TLS in transit; at rest via disk or column-level encryption. Hash passwords with bcrypt/argon2, never a plain hash, and never store them recoverably.
- PII — know what you store, and be able to delete it (GDPR/CCPA right to erasure). Soft deletes complicate this.
15.6 Study plan and checks
| Week | Work |
|---|---|
| 9 | SQL basics + joins. LeetCode SQL 50, problems 1–20. |
| 10 | Aggregation + window functions. SQL 50, problems 21–40. |
| 11 | CTEs + the query patterns table. SQL 50, problems 41–50. |
| 12 | Internals: B+ trees, indexes, EXPLAIN ANALYZE on a real table in one of your projects |
| 13 | Transactions, isolation levels, MVCC, deadlocks |
| Ongoing | Modeling, NoSQL, backend concerns — apply them in job-sentinel and atlas-ra |
Section check:
- Write "3rd highest salary per department" from memory.
- Explain why
WHERE UPPER(email) = 'X'can't use an index onemail. - Explain the leftmost-prefix rule.
- Explain the difference between a non-repeatable read and a phantom read.
- Explain MVCC in one sentence.
- Explain the N+1 problem and how you'd detect it.
- Explain why cursor pagination beats offset pagination.
- Write a parameterized query and explain why string formatting is unsafe.
→ Next: 16 — CS Fundamentals