Databases — SQL & NoSQL

Interview Q&A

Relational and non-relational, side by side. Plain language, real depth. Print this page for a PDF.

Part 1 — Foundations

1. What a Database Really Is

In plain words

A database is a program whose whole job is to store data safely and find it again fast.

You could use files. But then you'd have to handle two people writing at once, crashes mid-write, finding one record among millions, and keeping related data in step. A database solves all of that for you.

A filing cabinet is storage. A librarian is a database — they know where everything is, stop two people taking the same book, and won't let you file a page in the wrong place.

What you're really paying a database to do

  • Durability — once it says "saved", it survives a power cut.
  • Concurrency — hundreds of people reading and writing at once without corrupting each other.
  • Fast lookup — find one row among 50 million without reading all 50 million.
  • Integrity — refuse data that breaks your rules (an order with no customer).
  • Querying — answer questions you didn't plan for, without rewriting your app.
Why not just use files? Two requests writing to the same file at the same time corrupt it. A crash halfway through leaves a broken file. Finding one record means reading the whole thing. Databases exist because every app eventually hits all three.

2. Relational vs Non-Relational

In plain words

Relational (SQL) stores data in tables — like spreadsheets with strict columns. Every row has the same shape, and tables link to each other.

Non-relational (NoSQL) is an umbrella term for everything else. The most common kind stores documents — each record is a flexible object that can hold nested data and doesn't have to match its neighbours.

Relational is a form everyone must fill in identically. Non-relational is a folder where each file can look however it needs to.

RELATIONAL — split across tables users id name email 1 Ada a@b.com addresses id user_id city 9 1 London 10 1 Pune 1 → many Data stored once. Joined when you need it. DOCUMENT — one nested object { _id: 1, name: "Ada", email: "a@b.com", addresses: [ { city: "London" }, { city: "Pune" } ] } Everything together. One read gets it all. The trade in one line Relational avoids duplicated data and enforces rules. Document is faster to read as one piece, and lets each record have its own shape.
Same information, two philosophies: split it up and link it, or keep it together in one object.

Side by side

Relational (SQL)Document (NoSQL)
StructureTables with fixed columnsFlexible documents
SchemaDefined up front, enforcedOptional, enforced in your app
RelationshipsJoins, enforced by the databaseEmbed, or link and join manually
LanguageSQL — standard across productsDifferent for every database
ScalingBigger machine first; sharding is workDesigned to spread across machines
Best whenData is structured and relatedData is varied or naturally nested
ExamplesPostgreSQL, MySQL, SQLiteMongoDB, DynamoDB, Firestore
"NoSQL" is a bad name. It doesn't mean "no SQL" — some NoSQL databases support SQL-like queries. It originally meant "not only SQL". The useful distinction is how data is structured, not which language you type.

3. How to Actually Choose

In plain words

There's no "better" database, only a better fit. The decision comes down to how connected your data is and whether every record has the same shape.

If you genuinely can't decide, pick relational. It's the easier one to change your mind about later.

Start relational unless you have a reason not to

This is the honest default, and a strong interview answer. Postgres handles JSON, scales further than most apps ever need, and gives you transactions and constraints for free. Most "we need NoSQL" decisions are made before the app has any users.

Questions that actually decide it

QuestionPoints to relationalPoints to document
Is the data highly connected?Yes — users, orders, products, paymentsNo — mostly standalone records
Do records all have the same fields?YesNo — every product has different attributes
Do you need multi-step operations to be safe?Yes — money, bookings, stockLess critical
Will you ask unplanned questions of it?Yes — reports, analyticsNo — you read by a known key
Is write volume enormous?ManageableHuge — logs, events, sensor data

Using both is normal

Postgres  → users, orders, payments      (needs correctness and joins)
MongoDB   → product catalogue             (every category has different fields)
Redis     → sessions, cache, rate limits  (speed, short-lived)
Elastic   → full-text search              (searching descriptions properly)

This is called polyglot persistence — using the right store for each job. The cost is more systems to run and back up, so don't do it until one database genuinely stops fitting.

The myth worth correcting: "SQL can't scale." Postgres and MySQL run some of the largest systems in the world. Scaling is harder past a certain point, but that point is far beyond where most applications ever get.
Part 2 — Relational Databases

4. Tables, Rows & Columns

In plain words

A table is one kind of thing — users, orders, products. A column is a property every one of them has. A row is one actual thing.

The rule that defines relational databases: every row in a table has exactly the same columns. No exceptions.

CREATE TABLE users (
  id         SERIAL PRIMARY KEY,       -- auto-numbering unique id
  name       VARCHAR(100) NOT NULL,    -- must have a value
  email      VARCHAR(255) UNIQUE NOT NULL,
  age        INTEGER CHECK (age >= 0),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Why the fixed structure helps

  • The database rejects bad data — you can't save a user with no email.
  • Queries are predictable — every row definitely has that column.
  • Storage is compact — the shape is stored once, not on every record.
  • The structure documents itself — reading the table tells you what a user is.

And where it hurts

Adding a column to a table with 100 million rows can lock it. Storing things with genuinely different shapes means either lots of empty columns or many small tables. This is exactly the pain document databases were built to remove.

5. Keys & Relationships

In plain words

A primary key is the unique id of a row — the thing that identifies it.

A foreign key is a column that points at another table's primary key. That's the entire mechanism behind "relational": rows referring to other rows.

A primary key is your passport number. A foreign key is a form that says "passenger: that passport number" — it points at you without copying your details.

CREATE TABLE orders (
  id      SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  total   NUMERIC(10,2) NOT NULL
);

REFERENCES users(id) tells the database: this must match a real user. Try to insert an order for user 999 who doesn't exist, and it refuses. That's referential integrity — the database protecting you from orphaned data.

ON DELETE options

OptionWhen the user is deleted
CASCADETheir orders are deleted too
SET NULLOrders stay, user_id becomes null
RESTRICTRefuse to delete the user while orders exist

The three relationship shapes

ONE-TO-ONE users profiles 1 — 1 user_id UNIQUE ONE-TO-MANY (the common one) users orders 1 — ∞ user_id MANY-TO-MANY (needs a third table) students enrolments courses student_id + course_id — one row per enrolment A student takes many courses; a course has many students. Neither table can hold that, so a join table sits in the middle.
One-to-many is by far the most common. Many-to-many always needs a third table — there's no other way to express it.

6. Normalization

In plain words

Normalization means storing each fact in exactly one place.

If a customer's address appears on 500 orders and they move house, you'd have 500 rows to update — and you'll miss some. Store it once, point at it from everywhere.

Saving a friend's number in your contacts once, instead of writing it on every note you send them.

-- ✗ Not normalized: the same facts repeated on every row
orders: id | customer_name | customer_email | customer_city | product | price
        1  | Ada           | a@b.com        | London        | Laptop  | 999
        2  | Ada           | a@b.com        | London        | Mouse   | 25

-- ✓ Normalized: each fact lives once
customers: id | name | email   | city
           1  | Ada  | a@b.com | London
orders:    id | customer_id | product | price
           1  | 1           | Laptop  | 999
           2  | 1           | Mouse   | 25

The forms, in plain terms

  • 1NF — no lists crammed into one cell. "red,blue,green" in a column should be three rows.
  • 2NF — every column depends on the whole key, not part of it.
  • 3NF — no column depends on another non-key column. Storing city and country where country is determined by city breaks this.

In practice: aim for 3NF and stop. The higher forms rarely change a real design.

When to deliberately break it

Denormalization means copying data on purpose, to avoid a join. It's a trade: faster reads, but now you must keep the copies in sync.

-- Store a running count instead of counting rows every time
posts: id | title | comment_count

-- Fast to read. But every new comment must update it,
-- and if that update fails the number is silently wrong.
Normalize first, denormalize when you measure a problem. Duplicated data that drifts out of sync is a much worse bug than a slow query — a slow query is visible, wrong data is not.

7. SQL Basics

In plain words

SQL is the language for talking to relational databases. You describe what you want, not how to fetch it — the database works out the fastest route.

Four commands do almost everything: SELECT (read), INSERT (create), UPDATE (change), DELETE (remove).

SELECT name, email FROM users WHERE age > 18 ORDER BY name LIMIT 10;

INSERT INTO users (name, email) VALUES ('Ada', 'a@b.com') RETURNING id;

UPDATE users SET name = 'Ada L' WHERE id = 1;

DELETE FROM users WHERE id = 1;
Always write the WHERE first. UPDATE users SET name = 'x' with no WHERE renames every user in the table. Run a SELECT with the same WHERE first to see exactly which rows you're about to change.

Filtering

WHERE age BETWEEN 18 AND 65
WHERE city IN ('London', 'Pune')
WHERE name LIKE 'A%'              -- starts with A
WHERE email IS NULL               -- NOT "= NULL" — that never matches
WHERE active = true AND (role = 'admin' OR verified = true)

NULL behaves strangely — and it's a classic question

NULL = NULL        → NULL (not true!)
NULL + 5           → NULL
WHERE x != 'a'     → skips rows where x is NULL

-- Use IS / IS NOT
WHERE x IS NULL
WHERE x IS DISTINCT FROM 'a'      -- treats NULL as a real value
COALESCE(x, 'default')            -- replace NULL with something

NULL doesn't mean zero or empty — it means unknown. Is an unknown value equal to another unknown value? The database refuses to guess, so the answer is also unknown.

Execution order — why an alias fails in WHERE

YOU WRITE SELECT FROM JOIN WHERE GROUP BY HAVING ORDER BY LIMIT IT RUNS 1 FROM 2 JOIN 3 WHERE 4 GROUP BY 5 HAVING 6 SELECT ← aliases born here 7 ORDER BY 8 LIMIT WHERE runs at step 3, SELECT at step 6 — so an alias doesn't exist yet in WHERE. ORDER BY is step 7, so it works there.
SQL is written in one order and executed in another. This single fact explains most "column does not exist" errors.
-- ✗ fails: total_price doesn't exist yet at WHERE
SELECT price * qty AS total_price FROM items WHERE total_price > 100;

-- ✓ repeat the expression, or use a subquery
SELECT price * qty AS total_price FROM items WHERE price * qty > 100;

-- ✓ ORDER BY runs after SELECT, so the alias is fine there
SELECT price * qty AS total_price FROM items ORDER BY total_price DESC;

8. Joins

In plain words

A join combines rows from two tables by matching a value — usually a foreign key pointing at a primary key.

The type of join decides what happens to rows that don't match. That's the only real difference between them.

INNER JOIN only the overlap — rows matching in BOTH LEFT JOIN ALL of the left, matches where they exist RIGHT JOIN ALL of the right, matches where they exist FULL JOIN everything from both sides The practical difference INNER: users WITH orders → a user with no orders vanishes LEFT: ALL users → no orders shows as NULL columns "Show every customer and their spend" needs LEFT — or you silently drop new customers.
Unmatched rows are the whole story. INNER drops them; LEFT keeps them with NULLs where the other table had nothing.
-- Only users who have placed orders
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON o.user_id = u.id;

-- Every user, including those with none (their total is NULL)
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;

-- Find users with NO orders — a very common pattern
SELECT u.name
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;

The mistake that silently changes your results

-- ✗ this turns a LEFT JOIN back into an INNER JOIN
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid';        -- NULL rows fail this test and disappear

-- ✓ put the condition in the JOIN instead
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';

Filtering the right-hand table in WHERE removes the NULL rows that LEFT JOIN just carefully kept. Conditions on the joined table belong in ON; conditions on the main table belong in WHERE.

Self join

-- Employees and their managers, from one table
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

9. Grouping & Aggregates

In plain words

Aggregate functions squash many rows into one number — count, sum, average.

GROUP BY means "do that separately for each ___". Group by city, and you get one number per city instead of one for everything.

SELECT COUNT(*), SUM(total), AVG(total), MIN(total), MAX(total) FROM orders;

-- One row per user
SELECT user_id, COUNT(*) AS order_count, SUM(total) AS spent
FROM orders
GROUP BY user_id;

WHERE vs HAVING

SELECT user_id, SUM(total) AS spent
FROM orders
WHERE created_at > '2024-01-01'    -- filters ROWS, before grouping
GROUP BY user_id
HAVING SUM(total) > 1000;           -- filters GROUPS, after grouping

Look back at the execution order diagram in section 7: WHERE runs at step 3 (before grouping), HAVING at step 5 (after). That's why you can't use an aggregate in WHERE — the groups don't exist yet.

The COUNT trap

COUNT(*)           -- counts rows
COUNT(column)      -- counts rows where that column is NOT NULL
COUNT(DISTINCT x)  -- counts unique values

In a LEFT JOIN, COUNT(*) gives 1 for a user with no orders (their NULL row still counts). COUNT(o.id) correctly gives 0.

Window functions — aggregates that keep the rows

-- Each row keeps its detail AND gets a running total
SELECT name, total,
       SUM(total) OVER (ORDER BY created_at) AS running_total,
       RANK()     OVER (ORDER BY total DESC) AS rank
FROM orders;

-- Top 3 per category, without a messy subquery
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn
  FROM products
) t WHERE rn <= 3;

GROUP BY collapses rows; a window function calculates across a set of rows but leaves every row intact. Worth knowing — it's a common mid-level interview topic.

10. Subqueries & CTEs

-- Subquery: a query inside a query
SELECT name FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 500);

-- CTE (WITH): the same thing, but readable
WITH big_spenders AS (
  SELECT user_id, SUM(total) AS spent
  FROM orders
  GROUP BY user_id
  HAVING SUM(total) > 1000
)
SELECT u.name, b.spent
FROM users u
JOIN big_spenders b ON b.user_id = u.id
ORDER BY b.spent DESC;
In plain words

A CTE ("common table expression", the WITH clause) is a named temporary result you can use in the rest of the query.

It does the same job as a subquery but reads top-to-bottom instead of inside-out. For anything beyond one level, it's far easier to follow — and to debug, because you can run each piece alone.

EXISTS is usually the better check

-- EXISTS stops at the first match — often faster than IN
SELECT name FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

-- NOT IN is dangerous with NULLs: if the subquery returns ANY null,
-- the whole thing returns no rows. NOT EXISTS is safe.
SELECT name FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

Recursive CTEs — for tree-shaped data

-- Every employee under a manager, however deep
WITH RECURSIVE chain AS (
  SELECT id, name, manager_id FROM employees WHERE id = 1
  UNION ALL
  SELECT e.id, e.name, e.manager_id
  FROM employees e JOIN chain c ON e.manager_id = c.id
)
SELECT * FROM chain;

This is one thing relational databases do genuinely well that document databases struggle with — walking a hierarchy of unknown depth in a single query.

11. Constraints & Data Types

In plain words

A constraint is a rule the database enforces itself. Break it and the write is rejected.

The point: your app has bugs, and it isn't the only thing touching the database. Rules enforced by the database can't be bypassed by a buggy deploy or a manual script.

ConstraintGuarantees
PRIMARY KEYUnique and never NULL — identifies the row
FOREIGN KEYMust point at a row that exists
UNIQUENo duplicates in this column
NOT NULLA value is required
CHECKAny condition you write
DEFAULTValue used when none is given
CREATE TABLE products (
  id       SERIAL PRIMARY KEY,
  sku      VARCHAR(50) UNIQUE NOT NULL,
  price    NUMERIC(10,2) NOT NULL CHECK (price >= 0),
  stock    INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
  status   VARCHAR(20) NOT NULL DEFAULT 'draft'
           CHECK (status IN ('draft','live','archived'))
);

A UNIQUE constraint is also how you avoid a race

-- ✗ two simultaneous signups can both pass this check
const exists = await db.query("SELECT 1 FROM users WHERE email = $1", [email]);
if (!exists) await db.query("INSERT INTO users …");

-- ✓ let the database decide, and handle the error
try { await db.query("INSERT INTO users …"); }
catch (e) { if (e.code === "23505") return res.status(409).json({ error: "Email taken" }); }

Check-then-insert always has a gap between the two steps. A UNIQUE constraint has no gap — the database guarantees it.

Types worth getting right

UseNotWhy
NUMERIC(10,2) for moneyFLOATFloats can't store 0.1 exactly — pennies go missing
TIMESTAMPTZTIMESTAMPStores the timezone; otherwise "3pm" is ambiguous
TEXT / VARCHARCHAR(n)CHAR pads with spaces
BOOLEANINTEGER 0/1Says what it means
UUID or BIGSERIALSERIALA 32-bit id runs out at ~2 billion
JSONBTEXT holding JSONJSONB can be indexed and queried
Postgres has JSONB — a real JSON column you can index and query into. So "I need flexible fields" is not by itself a reason to leave relational. You can keep the structured parts as columns and the variable parts as JSONB in the same table.
Part 3 — Non-Relational Databases

12. The Four Types

In plain words

"NoSQL" covers four quite different families. Lumping them together is the main reason the topic feels confusing — they solve different problems.

TypeStoresGood atExamples
DocumentJSON-like objectsFlexible records, nested dataMongoDB, Firestore, CouchDB
Key-valueA key → a valueSpeed. Caching, sessionsRedis, Memcached, DynamoDB
Wide-columnRows with dynamic columnsHuge write volume, time-seriesCassandra, HBase, ScyllaDB
GraphNodes and connectionsRelationships as the main thingNeo4j, Neptune

What they share

  • No fixed schema — records don't have to match each other.
  • Built to spread across machines from the start, rather than as an afterthought.
  • Fewer guarantees by default — often trading strict correctness for speed and availability.
  • No standard language — what you learn for MongoDB doesn't transfer to Cassandra.

13. Document Databases

In plain words

A document is one record stored as a JSON-like object. A collection is a group of them — the rough equivalent of a table.

The key freedom: two documents in the same collection can have completely different fields.

RelationalDocument
TableCollection
RowDocument
ColumnField
JoinEmbed, or $lookup
Primary key_id
{
  _id: ObjectId("..."),
  name: "Ada",
  email: "a@b.com",
  addresses: [                        // nested array, no second table
    { type: "home", city: "London" },
    { type: "work", city: "Pune" }
  ],
  preferences: { theme: "dark" }      // nested object
}

Where the flexibility genuinely helps

// A product catalogue where every category has different attributes
{ name: "T-shirt", size: "M", colour: "blue" }
{ name: "Laptop",  ram: "16GB", cpu: "M3", screen: 14 }
{ name: "Book",    author: "…", pages: 320, isbn: "…" }

In a table you'd need either dozens of mostly-empty columns, or a separate table per category, or a messy attribute-value table. Here it's natural.

Where it hurts

  • Nothing stops bad data. A typo in a field name creates a new field instead of an error. You need validation in your app — or MongoDB's schema validation, which most people don't enable.
  • Joins are awkward. $lookup exists but is slower and clumsier than a SQL join.
  • Duplicated data drifts. If you copy a product name into 10,000 orders, renaming the product means updating 10,000 documents.
  • Unplanned queries are harder — you design the shape around how you'll read it, so a new question may not fit.
The honest summary: document databases move work from the database into your application. The flexibility is real, and so is the responsibility — you now enforce the rules the database used to.

14. Embed vs Reference — the central decision

In plain words

This is the modelling question in a document database, the equivalent of designing tables in SQL.

Embed = put the related data inside the document. One read gets everything, but the data is duplicated.

Reference = store an id and look it up separately. No duplication, but you need a second query.

EMBED post = { title: "Hello", comments: [ { by: "Ada", text: "…" }, { by: "Bob", text: "…" } ] } REFERENCE post = { title: "Hello", authorId: 7 } …then a second query: users.findOne({ _id: 7 }) ✓ one read gets everything ✓ fast, no join ✗ duplicated if shared elsewhere ✗ document has a 16MB limit ✓ stored once — update once ✓ unlimited growth ✗ extra query per lookup ✗ no database-enforced integrity
Embed what is read together and owned by the parent. Reference what is shared, grows without limit, or changes often.

The decision rules

Embed when…Reference when…
You always read them togetherYou often need one without the other
The child belongs to one parentThe data is shared by many parents
The list is bounded (a few hundred)It grows without limit
It rarely changesIt changes often
// ✓ Embed — addresses belong to this user and are read with them
{ name: "Ada", addresses: [{ city: "London" }] }

// ✗ Don't embed — comments grow forever and hit the 16MB document limit
{ title: "Post", comments: [ …50,000 of them… ] }

// ✓ Reference instead
posts:    { _id: 1, title: "Post" }
comments: { postId: 1, text: "…" }

// ✓ The hybrid — embed a copy of what you display, reference for the rest
{ postId: 1, author: { id: 7, name: "Ada" }, text: "…" }
// Shows the name without a second query. If Ada renames herself,
// old comments keep the old name — which for a comment is often correct.
The rule of thumb: model around how you read the data, not how it's naturally shaped. In SQL you design for correctness and join on the way out. In document databases you design for the query — which means knowing your access patterns before you model.

15. MongoDB in Practice

In plain words

MongoDB queries are written as objects, not as a sentence like SQL. { age: { $gt: 30 } } means "age greater than 30".

The aggregation pipeline is its version of GROUP BY and joins: data flows through a list of stages, each one reshaping what came before.

db.users.insertOne({ name: "Ada", age: 36 });
db.users.find({ age: { $gt: 30 } });
db.users.updateOne({ _id: id }, { $set: { name: "Ada L" } });
db.users.deleteOne({ _id: id });

Query operators

{ age: { $gt: 30, $lte: 65 } }            // greater than, less than or equal
{ city: { $in: ["London", "Pune"] } }
{ name: { $regex: /^A/i } }                // starts with A, case-insensitive
{ $and: [{ active: true }, { age: { $gt: 18 } }] }
{ "address.city": "London" }               // reach into nested fields
{ tags: "urgent" }                         // matches if the ARRAY contains it

Update operators — never replace the whole document

{ $set:   { name: "Ada" } }        // change a field
{ $inc:   { views: 1 } }            // add to a number, atomically
{ $push:  { tags: "new" } }         // append to an array
{ $pull:  { tags: "old" } }         // remove from an array
{ $unset: { temp: "" } }            // delete a field
A classic mistake: updateOne({_id}, {name: "Ada"}) without $set replaces the entire document — every other field is deleted. Always use an operator.

The aggregation pipeline

MongoDB's answer to GROUP BY and joins. Data flows through stages, each transforming what the last produced.

db.orders.aggregate([
  { $match:  { status: "paid" } },                        // like WHERE — put it first
  { $group:  { _id: "$userId", total: { $sum: "$amount" } } },  // like GROUP BY
  { $sort:   { total: -1 } },                             // like ORDER BY
  { $limit:  10 },
  { $lookup: {                                            // like a LEFT JOIN
      from: "users", localField: "_id",
      foreignField: "_id", as: "user"
  }}
]);

Put $match as early as possible. Filtering 1,000 documents then grouping is far cheaper than grouping a million and filtering after — and it lets the stage use an index.

Transactions exist now

const session = client.startSession();
await session.withTransaction(async () => {
  await accounts.updateOne({ _id: a }, { $inc: { balance: -500 } }, { session });
  await accounts.updateOne({ _id: b }, { $inc: { balance:  500 } }, { session });
});

MongoDB has supported multi-document transactions since 4.0. The old "NoSQL has no transactions" line is out of date — but they're slower than in Postgres, and needing them often means the data should have been embedded together.

Schema validation — worth turning on

db.createCollection("users", {
  validator: { $jsonSchema: {
    required: ["email"],
    properties: { email: { bsonType: "string", pattern: "@" } }
  }}
});

Flexible doesn't have to mean unchecked. Most teams that regret MongoDB regret not enabling this.

16. Key-Value & Redis

In plain words

A key-value store is the simplest database: you give it a key, it gives you back a value. No queries, no joins, no searching by content.

Redis keeps everything in memory, which is why it answers in under a millisecond — and why it's used alongside a main database, not instead of one.

A cloakroom. You hand over a ticket, you get your coat. You can't ask "which coats are blue?" — that's not what it's for.

SET user:1 "{\"name\":\"Ada\"}"
GET user:1
SET session:abc "userId=7" EX 3600     # expires automatically after an hour
DEL user:1
INCR page:views                         # atomic — safe with many clients

What Redis is actually used for

UseWhy Redis
CachingAvoid repeating an expensive query
SessionsShared across all your server instances
Rate limitingINCR + expiry counts requests atomically
Job queuesLists make a simple, fast queue (BullMQ)
LeaderboardsSorted sets keep rankings in order automatically
LocksSET key NX — only succeeds if nobody else holds it
# Rate limiting in two commands
INCR    rate:user:7
EXPIRE  rate:user:7 60        # if the count exceeds your limit, reject

# A lock only one process can hold
SET lock:job "worker1" NX EX 30

The thing to remember

Memory is limited and not permanent. Redis can save to disk, but it's designed for data you can afford to lose — a cache you can rebuild, a session someone can re-create by logging in. Never make it the only copy of something important.

17. Wide-Column & Graph

Wide-column (Cassandra)

In plain words

Built for enormous write volume spread across many machines — sensor readings, logs, message history.

The catch: you must know your queries before you design the tables. There are no ad-hoc joins, and you organise data by how it will be read.

-- You design the table around ONE query pattern
CREATE TABLE messages_by_room (
  room_id   UUID,
  sent_at   TIMESTAMP,
  message   TEXT,
  PRIMARY KEY (room_id, sent_at)     -- partition key, then clustering key
) WITH CLUSTERING ORDER BY (sent_at DESC);

The partition key decides which machine the row lives on; the clustering key decides the order within it. Get the partition key wrong and one machine takes all the traffic — the "hot partition" problem.

It's normal here to store the same data several times, once per query pattern. That would be a design error in SQL; here it's the intended approach.

Graph (Neo4j)

In plain words

A graph database stores connections as first-class things, not as foreign keys you join on.

It's built for questions about relationships: "friends of friends", "shortest route", "who else bought this".

// Friends of friends, two hops out
MATCH (me:Person {name: "Ada"})-[:FRIEND]->()-[:FRIEND]->(fof)
WHERE NOT (me)-[:FRIEND]->(fof)
RETURN fof.name;

In SQL that's a self-join per hop, and it gets painful fast. Six degrees of separation would be six joins. A graph database walks the connections directly, so depth barely costs anything.

Be honest about when you need one. Graph databases are excellent for fraud detection, recommendations and network analysis. For an app with users and posts, a relational database with a foreign key is simpler and entirely sufficient.
Part 4 — Concepts That Apply to Both

18. Indexes

In plain words

An index is a sorted lookup structure kept beside your data. Without one, finding a row means checking every single row.

It's the single biggest performance lever in any database — relational or not.

The index at the back of a book. Without it you read every page to find "photosynthesis". With it you jump straight to page 231.

<50 | 50-99 | 100+ root 1-24 | 25-49 50-74 | 75-99 100-149 | 150+ branch 1 … 24 → rows 25 … 49 → rows 50 … 74 id 62 ✓ 75 … 99 → rows 100 … leaf leaves are linked in order — which is what makes ranges and ORDER BY fast WHERE id = 62 3 hops, not 10M rows Cost is O(log n): 10× the data adds only one more hop.
A B-tree stays balanced, so lookups cost roughly the same whether you have a thousand rows or ten million.
-- SQL
CREATE INDEX idx_users_email ON users(email);
CREATE UNIQUE INDEX idx_sku ON products(sku);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);  -- composite

-- MongoDB
db.users.createIndex({ email: 1 });
db.orders.createIndex({ userId: 1, createdAt: -1 });

What to index

  • Columns in WHERE, JOIN conditions and ORDER BY.
  • Every foreign key — most databases don't index these automatically, and it makes joins slow.
  • Anything you look up by: email, username, slug.

Why not index everything

Every index must be updated on every write, and takes disk and memory. Five indexes means an insert does six writes. It's a straight trade: faster reads, slower writes.

Composite indexes and the leftmost rule

CREATE INDEX idx ON orders(user_id, status, created_at);

WHERE user_id = 1                                  ✓ uses it
WHERE user_id = 1 AND status = 'paid'              ✓ uses it
WHERE user_id = 1 AND status = 'paid' AND created_at > …  ✓ fully
WHERE status = 'paid'                              ✗ can't — skipped the first column

Think of a phone book sorted by (surname, first name). You can find "Smith", and "Smith, John" — but not everyone called "John", because the sorting starts with surname.

Things that stop an index working

WHERE LOWER(email) = 'a@b.com'     ✗ function on the column
WHERE email = 'a@b.com'             ✓
-- or build the index to match: CREATE INDEX ON users(LOWER(email));

WHERE name LIKE '%ada%'             ✗ leading wildcard — can't use sort order
WHERE name LIKE 'ada%'              ✓

19. Transactions & ACID

In plain words

A transaction groups several operations so they all succeed or all fail. There's no halfway.

The classic case: moving money. Take £500 from one account, add it to another. If the second step fails, the first must be undone — otherwise the money vanishes.

BEGIN;
  UPDATE accounts SET balance = balance - 500 WHERE id = 1;
  UPDATE accounts SET balance = balance + 500 WHERE id = 2;
COMMIT;        -- or ROLLBACK, and neither change happened

ACID

LetterMeansIn practice
AtomicityAll or nothingA crash mid-transfer loses neither money nor creates it
ConsistencyRules always holdConstraints can't be broken, even temporarily
IsolationTransactions don't see each other's half-done workNobody reads a balance mid-transfer
DurabilityCommitted means savedSurvives an immediate power cut

How durability actually works

Before changing the data, the database writes what it's about to do to a write-ahead log. If the power fails mid-write, it reads the log on restart and finishes or undoes the work. That's also what makes replication possible — the log can be streamed to another server.

Rules for using them

  • Keep them short. A transaction holds locks; long ones block other users.
  • Never call an external API inside one. You'd hold a database lock while waiting on someone else's server.
  • Always release the connection in a finally block, whatever happens.
const client = await pool.connect();
try {
  await client.query("BEGIN");
  await client.query("UPDATE …");
  await client.query("UPDATE …");
  await client.query("COMMIT");
} catch (e) {
  await client.query("ROLLBACK");
  throw e;
} finally {
  client.release();          // ALWAYS
}

20. Isolation & Race Conditions

In plain words

A race condition is two operations happening at once and interfering. The result depends on timing — which is why these bugs are so hard to reproduce.

The classic: one item in stock, two people buy it simultaneously, both succeed.

Request A Request B stock = 1 read stock → 1 read stock → 1 "1 > 0, so I can sell it" "1 > 0, so I can sell it" write stock = 0 write stock = 0 One item. Sold twice. Stock still says 0. UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0
The gap between reading and writing is where the bug lives. The fix removes the gap by making the check part of the write.

Three ways to fix it

-- 1. Atomic update — the database does the check. Simplest, use when you can.
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock > 0;
-- if 0 rows were affected, it was out of stock

-- 2. Pessimistic lock — claim the row, make others wait
BEGIN;
  SELECT * FROM products WHERE id = 1 FOR UPDATE;   -- others block here
  UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

-- 3. Optimistic lock — detect the clash instead of preventing it
UPDATE products SET stock = 0, version = version + 1
WHERE id = 1 AND version = 5;
-- 0 rows affected means someone else got there first → retry or show a conflict

Pessimistic suits high-contention data (concert tickets). Optimistic suits low contention and user-facing edits, where blocking would feel worse than occasionally saying "someone else changed this".

Isolation levels

LevelDirty readNon-repeatable readPhantom read
Read UncommittedPossiblePossiblePossible
Read Committed (Postgres default)NoPossiblePossible
Repeatable Read (MySQL default)NoNoPossible
SerializableNoNoNo
  • Dirty read — seeing changes another transaction hasn't committed (and may undo).
  • Non-repeatable read — reading the same row twice and getting different values.
  • Phantom read — running the same search twice and finding new rows.

Higher isolation means more correctness and less concurrency. Serializable behaves as if transactions ran one at a time — safest, slowest.

Deadlocks

A holds row 1 and wants row 2; B holds row 2 and wants row 1. Neither can continue. The database detects this and kills one. Prevent it by always locking rows in a consistent order (e.g. ascending id) and keeping transactions short.

21. Query Performance

In plain words

Slow queries almost always come down to one of three things: no index, too many queries (N+1), or fetching too much data.

Before guessing, ask the database. EXPLAIN shows exactly what it intends to do — including whether it's about to read every single row.

EXPLAIN — ask the database what it plans to do

EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'a@b.com';
Seq Scan on users  (cost=0.00..2341.00 rows=1 width=123)   ← reading EVERY row
  Filter: (email = 'a@b.com')

-- after CREATE INDEX:
Index Scan using idx_users_email  (cost=0.29..8.31 rows=1)  ← jumping straight there

Seq Scan on a large table is the signal to add an index. ANALYZE actually runs the query and shows real timings, so you can see where the time went.

The N+1 problem — the most common real-world bug

// 1 query for posts, then 1 MORE per post = 101 queries
const posts = await Post.find();
for (const p of posts) p.author = await User.findById(p.userId);

// ✓ SQL — let the database join
SELECT p.*, u.name FROM posts p JOIN users u ON u.id = p.user_id;

// ✓ ORM — eager load
await prisma.post.findMany({ include: { author: true } });
await Post.find().populate("author");            // Mongoose

// ✓ Or batch it yourself — 2 queries, whatever the count
const users = await User.find({ _id: { $in: posts.map(p => p.userId) } });
const byId  = new Map(users.map(u => [String(u._id), u]));
posts.forEach(p => { p.author = byId.get(String(p.userId)); });

It works fine with 10 test rows and collapses with 10,000 real ones. Log your queries in development — one page firing 200 queries is easy to spot and easy to miss.

Other things that matter

  • Select only what you need. SELECT * on a table with a large text column moves far more data than you use.
  • Always paginate. Any list that grows needs LIMIT.
  • Keyset pagination beats OFFSET. OFFSET 100000 makes the database count through 100,000 rows to skip them.
-- ✗ gets slower the deeper you go
SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 100000;

-- ✓ same speed at any depth — jumps straight in via the index
SELECT * FROM posts WHERE id > 100000 ORDER BY id LIMIT 20;

22. Connections & Pooling

In plain words

Opening a database connection is slow — a network handshake, a login, memory allocated on the database server.

A pool opens a fixed set once and lends them out. A request borrows one, runs its query, and gives it straight back.

A library's copies of a book. Everyone borrows and returns; nobody buys a new copy for each reader.

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,                       // this process will open at most 20
  idleTimeoutMillis: 30000,
});

const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [id]);
The sizing trap: 10 server instances × a pool of 100 = 1,000 connections, and Postgres (default limit ~100) falls over. The total across all instances must stay under the database's limit. Ten to twenty per instance is a normal starting point — more connections than the database has CPU cores rarely helps anyway.

In serverless environments each function invocation can open its own connection, which exhausts the limit fast. That's what connection proxies (PgBouncer, Prisma Accelerate, RDS Proxy) exist to solve.

Part 5 — Scale & Practice

23. Replication

In plain words

Replication means keeping copies of your database on other machines, updated automatically.

Two reasons: safety (a machine dies, you have copies) and read capacity (spread reads across copies).

Application PRIMARY all writes go here Replica 1 reads Replica 2 reads Replica 3 reads · backups WRITE copies stream out READ Replication lag: a replica may be milliseconds behind — so a just-saved value can briefly be missing.
One machine takes all writes; copies serve reads. This works because most applications read far more than they write.

Replication lag — the bug it causes

// User saves their profile → goes to the PRIMARY
await db.write("UPDATE users SET name = 'Ada L' WHERE id = 1");

// Immediately redirected, and the read hits a REPLICA
const user = await db.read("SELECT * FROM users WHERE id = 1");
// → may still show the OLD name. The user thinks the save failed.

The fix: read your own writes from the primary for a short window after a write, or wait for the replica to catch up. This is a real production bug people hit and don't understand.

Failover

If the primary dies, a replica is promoted to take its place. Managed databases do this automatically in seconds. It's the main reason to run replicas even if you don't need the read capacity.

24. Sharding & Partitioning

In plain words

Replication copies the whole database to several machines. Sharding splits it — each machine holds a different slice of the data.

You shard when the data no longer fits on one machine, or the write volume exceeds what one machine can take. Replication doesn't help with writes; sharding does.

Shard 1: users with id 1 – 1,000,000
Shard 2: users with id 1,000,001 – 2,000,000
Shard 3: users with id 2,000,001 – 3,000,000

Choosing the shard key is the whole game

StrategyHowProblem
RangeSplit by id or date rangeNew data all lands on one shard — a hot spot
HashHash the key to pick a shardEven spread, but range queries hit every shard
DirectoryA lookup table decidesFlexible, but the lookup becomes a bottleneck

What you lose

  • Joins across shards become application work, or impossible.
  • Transactions across shards are slow and complicated.
  • Unique constraints across shards can't be enforced by the database.
  • Rebalancing when you add a shard means moving data around while live.
Avoid sharding for as long as you possibly can. A single Postgres instance on a decent machine handles tens of thousands of transactions per second and terabytes of data. Read replicas, caching, indexes and archiving old data will get you much further, with a fraction of the complexity.

Partitioning — sharding's simpler cousin

-- One database, but the table is stored in separate physical chunks
CREATE TABLE logs (…) PARTITION BY RANGE (created_at);
CREATE TABLE logs_2024_01 PARTITION OF logs
  FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

Queries that filter by date only touch the relevant partitions, and dropping last year's data becomes instant instead of a huge DELETE. All within one machine — much less painful than sharding.

25. CAP & Consistency

In plain words

When your database runs on several machines, the network between them will sometimes fail. When it does, you have to choose:

Stay consistent — refuse to answer rather than risk giving out stale data. Or stay available — keep answering, accepting some answers may be out of date.

You can't have both during a network failure. That's the CAP theorem.

The network splits in two CHOOSE CONSISTENCY (CP) "I can't confirm this is current, so I'll return an error." Banks · bookings · stock CHOOSE AVAILABILITY (AP) "Here's my copy. It might be a few seconds out of date." Feeds · likes · product views Partition tolerance isn't optional — networks fail. So the real choice is only ever C or A.
CAP is often misquoted as "pick two of three". You don't get to skip partition tolerance, so the actual decision is consistency or availability during a failure.

Eventual consistency

"Eventually consistent" means the copies will agree soon, just not instantly. For a like count or a view counter, that's completely fine. For an account balance, it isn't.

The useful question isn't "is this database ACID or eventually consistent" — it's "what happens if this particular piece of data is a few seconds stale?" Often the answer is "nothing", and then you can take the faster option.

A correction worth knowing: CAP only describes behaviour during a network failure. Normally, a well-run distributed database is both consistent and available. CAP is about the exceptional case, not everyday operation.

26. Caching

In plain words

A cache keeps a copy of an answer so you don't have to work it out again. Checking it first turns a 50ms query into a 1ms lookup.

The price is staleness: the moment the real data changes, your copy is wrong until you delete or refresh it.

// Cache-aside — the pattern you'll use most
async function getUser(id) {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);          // hit

  const user = await db.getUser(id);              // miss
  await redis.set(`user:${id}`, JSON.stringify(user), "EX", 300);
  return user;
}

The two hard parts

  • Invalidation. When the row changes, the cached copy is wrong. Either delete the key on write (DEL user:7) or accept staleness until the TTL expires. Deleting is more correct; TTL is simpler and usually enough.
  • Stampede. A popular key expires and 500 requests miss at once, all hitting the database together. Fix with a short lock so one request refills while the rest wait, or vary the expiry times so keys don't all die at once.

What's worth caching

Data read far more often than it changes, and expensive to produce. Caching a fast query that changes constantly adds complexity and a new class of bug for no gain.

Fix the database first. Reaching for a cache to hide a missing index means you now have two problems — a slow query and stale data. Add the index, then cache what's still slow.

27. Migrations & ORMs

In plain words

A migration is a versioned, repeatable change to your database structure, kept in your code repository.

Without them, the production database structure is whatever someone typed by hand months ago, and nobody can recreate it.

// migrations/001_add_users.sql
CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL);

// migrations/002_add_phone.sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

The tool records which have run, so each applies exactly once, in order, on every environment.

Safe migrations on a live database

  • Adding a nullable column is safe and fast.
  • Adding NOT NULL with a default can rewrite the whole table and lock it — add the column, backfill in batches, then add the constraint.
  • Dropping a column breaks any running old code. Deploy code that stops using it first, then drop it later.
  • Adding an index locks writes — use CREATE INDEX CONCURRENTLY in Postgres.
  • Always write the rollback before you run the migration.

ORMs

Gives youCosts you
Type safety and autocompleteGenerates SQL you didn't write
Migrations built inEasy to cause N+1 by accident
Less repetitive codeComplex queries get awkward
Swap databases more easilyAnother layer to learn and debug
Learn SQL first, then use an ORM. Otherwise you can't tell when the ORM is generating something terrible — and you won't recognise an N+1 when you see one. In interviews, "I use Prisma so I don't need SQL" is a weak answer.

28. Security & Backups

In plain words

Almost every database vulnerability is the same mistake: letting user input be treated as a command instead of as data.

And the backup rule is blunt — a backup you have never restored is a guess, not a backup.

SQL injection — and why parameters actually work

// ✗ the input becomes part of the query text
db.query(`SELECT * FROM users WHERE email = '${email}'`);
// input: '; DROP TABLE users; --

// ✓ the value travels separately
db.query("SELECT * FROM users WHERE email = $1", [email]);

It's not a magic escape. The database parses and plans the query first, with $1 as a placeholder, and only then binds the value. There is no point at which your data could be read as a command.

NoSQL injection — the one people miss

// Attacker sends: { "email": "a@b.com", "password": { "$gt": "" } }
await User.findOne({ email, password });        // $gt matches ANY password

// ✓ check the types first
if (typeof password !== "string") return res.status(400).json({ error: "Invalid" });

Document databases take objects as queries, so an object arriving where you expected a string becomes a query operator. Validate types before querying.

Other essentials

  • Least privilege — your app user should not be able to DROP TABLE.
  • Encrypt in transit (TLS) and at rest.
  • Never store passwords you can read back — hash with bcrypt or argon2.
  • Don't log query parameters that contain personal data.
  • Soft delete (deleted_at) for anything a user might want back.

Backups

pg_dump mydb > backup.sql          # logical — portable, slower to restore
# plus continuous archiving for point-in-time recovery
An untested backup is not a backup. Restore it somewhere, on a schedule, and time how long it takes. Plenty of teams discover their backups were empty only when they need them. Also check the backup isn't stored solely on the machine it's backing up.
Revision

Interview Q&A — quick answers

Choosing and comparing

  • SQL vs NoSQL? Relational stores fixed-shape rows in linked tables with enforced rules and joins; NoSQL covers flexible documents, key-value, wide-column and graph stores that trade some guarantees for flexibility and easier horizontal scaling.
  • When would you pick MongoDB over Postgres? Records with genuinely different shapes, naturally nested data read as one unit, or very high write volume with simple access patterns. Not "because it scales" — Postgres scales further than most apps need.
  • When would you pick Postgres? Nearly always by default: connected data, transactions that must be safe, unplanned queries and reporting. It also has JSONB if you need flexible fields.
  • What is polyglot persistence? Using different databases for different jobs in one system — Postgres for orders, Redis for sessions, Elasticsearch for search.

Relational

  • Primary vs foreign key? Primary uniquely identifies a row; foreign points at another table's primary key and is enforced by the database.
  • What is normalization? Storing each fact once to avoid duplication and update anomalies. Aim for 3NF, then denormalize deliberately if you measure a problem.
  • INNER vs LEFT JOIN? INNER keeps only matching rows; LEFT keeps all rows from the left table with NULLs where there's no match.
  • Why did my LEFT JOIN behave like an INNER JOIN? A WHERE condition on the right table filters out the NULL rows. Move it into the ON clause.
  • WHERE vs HAVING? WHERE filters rows before grouping; HAVING filters groups after. That's why aggregates can't appear in WHERE.
  • Why can't I use a SELECT alias in WHERE? WHERE executes before SELECT, so the alias doesn't exist yet. It works in ORDER BY, which runs after.
  • What's special about NULL? It means unknown, so NULL = NULL is not true. Use IS NULL, and beware NOT IN with nullable columns.
  • What is a CTE? A named temporary result (WITH) that makes complex queries readable and supports recursion for tree data.
  • DELETE vs TRUNCATE vs DROP? Delete rows (can be filtered, rolled back) / empty the table fast / remove the table entirely.

Non-relational

  • Embed or reference? Embed when read together, owned by one parent and bounded in size. Reference when shared, unbounded, or frequently changed.
  • MongoDB's document size limit? 16MB — which is why unbounded arrays like comments must be referenced, not embedded.
  • Does MongoDB have transactions? Yes, multi-document since 4.0. They work but are slower; needing them often signals the data should have been modelled together.
  • What is Redis used for? Caching, sessions, rate limiting, job queues, leaderboards and locks — anything where speed matters and losing the data is survivable.
  • Why is a graph database better for "friends of friends"? Each extra hop is another self-join in SQL; a graph database walks the connections directly, so depth costs little.

Core concepts

  • What is an index and what does it cost? A sorted structure making lookups O(log n) instead of scanning every row. It costs disk, memory, and slower writes since every index updates on every write.
  • Why isn't my index being used? A function wrapped around the column, a leading wildcard in LIKE, or a composite index whose first column you skipped.
  • Explain ACID. Atomicity (all or nothing), Consistency (rules hold), Isolation (no half-done work visible), Durability (committed survives a crash).
  • What is a race condition, and how do you fix it? Two operations interleaving on the same data. Fix with an atomic update, a pessimistic lock (FOR UPDATE), or optimistic locking with a version column.
  • Isolation levels? Read Uncommitted → Read Committed → Repeatable Read → Serializable, each preventing more anomalies at the cost of concurrency.
  • What is a deadlock? Two transactions each holding what the other wants. Prevent by locking in a consistent order and keeping transactions short.
  • What is the N+1 problem? One query for a list plus one per item. Fix with a join, eager loading, or one batched query.
  • Why a connection pool? Connections are slow to create and limited in number, so a fixed set is reused.

Scale

  • Replication vs sharding? Replication copies the whole database for redundancy and read capacity; sharding splits it so each machine holds different data, which is what helps with writes.
  • What is replication lag? Replicas trail the primary slightly, so a just-written value may not appear on a read. Read from the primary briefly after writing.
  • Explain CAP. During a network partition you must choose consistency or availability. Partition tolerance isn't optional, so it's really a two-way choice — and only applies during a failure.
  • What is eventual consistency? Copies converge shortly rather than instantly. Fine for like counts, not for balances.
  • How do you speed up a slow query? EXPLAIN ANALYZE first. Then: add the right index, fix N+1, select fewer columns, paginate by keyset, and only then consider caching.

Learning Path

  1. SQL basics — SELECT, WHERE, ORDER BY, LIMIT on a real dataset. Type the queries; don't just read them.
  2. Design a small schema — users, posts, comments. Get the keys and relationships right on paper first.
  3. Joins until they're automatic — especially LEFT JOIN and the "find rows with no match" pattern.
  4. Grouping and aggregates — GROUP BY, HAVING, and the difference from WHERE.
  5. Indexes — run EXPLAIN before and after adding one. Seeing Seq Scan become Index Scan makes it click.
  6. Transactions — write a money transfer, then deliberately break it halfway and watch the rollback.
  7. Connect from code — a pool, parameterised queries, and a migration tool. Build a small CRUD API.
  8. Race conditions — build a stock counter, hit it with concurrent requests, watch it break, then fix it three ways.
  9. MongoDB — the same app modelled as documents. Feel the embed-vs-reference decision for yourself.
  10. Redis — add caching and rate limiting to that API. Measure the difference.
  11. Performance — seed 100,000 rows, find the slow queries, fix the N+1s, add keyset pagination.
  12. Then — replication and read replicas, backup and restore drills, and window functions and CTEs for reporting.
The habit that matters most: for every query you write, ask "what does this do when the table has ten million rows?" Most database problems are invisible during development and obvious in production — and that single question surfaces them early.