Database NotesRohit’s interview study guide
Chapter 27

Review Questions

Test yourself across the whole site — SQL, design and indexes, transactions and scaling, MongoDB and Redis — with every predicted result checked against a real server.

5 topics

#SQL and querying

Added

These questions check the parts of SQL that people get wrong under pressure: the order a query is logically evaluated in, what NULL does to counts, sorts and comparisons, how many rows a join or a union returns, and the MySQL-specific quirks of arithmetic and string comparison. Answer each one out loud before opening it. The first paragraph of each answer is what you'd say in the interview; the walkthrough is the reasoning behind it. Every query was run on MySQL 9.7 against the site's sample schema.

Why does WHERE annual > 1500000 fail when annual is defined in the SELECT?
SQL
SELECT name, salary * 12 AS annual
FROM employees
WHERE annual > 1500000;
Show answer

Because WHERE is evaluated before SELECT, so the alias doesn't exist yet. MySQL says so:

Result
ERROR 1054 (42S22): Unknown column 'annual' in 'where clause'

Repeat the expression (WHERE salary * 12 > 1500000), or filter in an outer query. MySQL also lets you use the alias in HAVING, which runs after SELECT:

SQL
SELECT name, salary * 12 AS annual
FROM employees
HAVING annual > 1500000
ORDER BY annual DESC;
Result
+-------+------------+
| name  | annual     |
+-------+------------+
| Asha  | 2220000.00 |
| Ben   | 1704000.00 |
| Chloe | 1704000.00 |
| Elena | 1560000.00 |
+-------+------------+
What’s happening
  1. The logical order is FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT. The database can execute it differently, but the meaning follows this order.
  2. When WHERE runs, each row only has its table columns (name, salary, …). annual is created later, in SELECT, hence error 1054.
  3. ORDER BY runs after SELECT, so ORDER BY annual is fine in every database.
  4. HAVING without GROUP BY that refers to a SELECT alias is a MySQL extension: it treats the whole result as one group and allows the alias. PostgreSQL 18 rejects the same shape (tried on a small table e) with column "e.name" must appear in the GROUP BY clause or be used in an aggregate function. In portable SQL, wrap the query: SELECT * FROM (…) AS t WHERE annual > 1500000.
  5. The same rule explains why window functions can't appear in WHERE: they're computed in the SELECT step too.

Learn more: Logical query processing order.

WHERE vs HAVING: which departments have at least three employees?

Write the query, and say why WHERE COUNT(*) >= 3 can't work.

Show answer

WHERE filters rows before grouping; HAVING filters groups after aggregation. An aggregate doesn't exist at WHERE time, so the condition belongs in HAVING:

SQL
SELECT department_id, COUNT(*) AS n
FROM employees
GROUP BY department_id
HAVING n >= 3
ORDER BY n DESC;
Result
+---------------+---+
| department_id | n |
+---------------+---+
|             1 | 4 |
|             2 | 3 |
+---------------+---+
What’s happening
  1. GROUP BY department_id forms groups: 1 (4 people), 2 (3), 3 (2) and NULL (Jonas).
  2. COUNT(*) is computed per group; HAVING n >= 3 then keeps Engineering and Sales.
  3. WHERE COUNT(*) >= 3 fails with ERROR 1111 (HY000): Invalid use of group function, because WHERE judges one row at a time, before any group exists.
  4. Put row-level conditions in WHERE whenever you can (WHERE hired_on >= '2020-01-01'): it removes rows before the grouping work, which is cheaper, and it can use indexes.
  5. HAVING n uses the alias — allowed in MySQL. The portable form is HAVING COUNT(*) >= 3.

Learn more: HAVING vs WHERE.

Predict: COUNT(*), COUNT(city) and COUNT(DISTINCT city)

Customers' cities are Toronto, Mumbai, London, NULL, Toronto, Dubai.

SQL
SELECT COUNT(*), COUNT(city), COUNT(DISTINCT city) FROM customers;
Show answer

6, 5, 4.

Result
+----------+-------------+----------------------+
| COUNT(*) | COUNT(city) | COUNT(DISTINCT city) |
+----------+-------------+----------------------+
|        6 |           5 |                    4 |
+----------+-------------+----------------------+
What’s happening
  1. COUNT(*) counts rows, whatever they contain: 6.
  2. COUNT(city) counts non-NULL values. Sofia's city is NULL, so 5.
  3. COUNT(DISTINCT city) counts different non-NULL values: Toronto, Mumbai, London, Dubai — 4. Toronto appears twice but counts once, and NULL isn't a value to count.
  4. COUNT(1) is identical to COUNT(*) in MySQL; there's no performance difference, despite the folklore.
  5. Every other aggregate (SUM, AVG, MIN, MAX) ignores NULLs the same way — which is why AVG can silently change its denominator.

Learn more: COUNT(*) vs COUNT(column) vs COUNT(DISTINCT).

Where does a NULL city sort, and how do you move it to the end?
SQL
SELECT name, city FROM customers ORDER BY city;
Show answer

First. MySQL treats NULL as smaller than any value, so it comes first in ascending order and last in descending order:

Result
+-------+---------+
| name  | city    |
+-------+---------+
| Sofia | NULL    |
| Zara  | Dubai   |
| Liam  | London  |
| Maya  | Mumbai  |
| Rohit | Toronto |
| Noah  | Toronto |
+-------+---------+

To put NULLs last while keeping the ascending order, sort by a "is it null?" flag first:

SQL
SELECT name, city FROM customers ORDER BY city IS NULL, city;
Result
+-------+---------+
| name  | city    |
+-------+---------+
| Zara  | Dubai   |
| Liam  | London  |
| Maya  | Mumbai  |
| Rohit | Toronto |
| Noah  | Toronto |
| Sofia | NULL    |
+-------+---------+
What’s happening
  1. city IS NULL is 0 for real cities and 1 for Sofia, so sorting by it first puts all real cities before her.
  2. Within the 0 group, the second key city sorts alphabetically.
  3. MySQL has no NULLS FIRST / NULLS LAST syntax; PostgreSQL does, and its default is the opposite of MySQL's: on PostgreSQL 18, ORDER BY city put the NULL row last. Worth knowing when a query moves between them.
  4. Rohit and Noah tie on Toronto, and their relative order isn't guaranteed. Add a tiebreaker (, id) whenever the order matters, for example for pagination.

Learn more: Sorting with ORDER BY and MySQL vs PostgreSQL: SQL differences you'll hit first.

Predict six MySQL expressions
SQL
SELECT 7 / 2 AS slash, 7 DIV 2 AS div_, 7 % 2 AS modulo,
       '10' < '9' AS str_cmp, 10 < 9 AS num_cmp, 1 / 0 AS div_zero;
Show answer

3.5000, 3, 1, 1, 0 and NULL (with a warning).

Result
+--------+------+--------+---------+---------+----------+
| slash  | div_ | modulo | str_cmp | num_cmp | div_zero |
+--------+------+--------+---------+---------+----------+
| 3.5000 |    3 |      1 |       1 |       0 |     NULL |
+--------+------+--------+---------+---------+----------+
What’s happening
  1. / is always decimal division in MySQL, even between integers: 3.5000 (four decimal places by default, from div_precision_increment). PostgreSQL 18 returns 3 for SELECT 7/2, because integer divided by integer stays an integer there.
  2. DIV is integer division (truncating): 3. % (or MOD) is the remainder: 1.
  3. '10' < '9' compares strings, character by character: '1' sorts before '9', so it's true (1). Numbers compare numerically: 10 < 9 is 0. This is why storing numbers in VARCHAR columns breaks sorting.
  4. 1 / 0 returns NULL in a SELECT, with Warning 1365 Division by 0. In an INSERT or UPDATE under the default strict mode (which includes ERROR_FOR_DIVISION_BY_ZERO), it's an error instead.
  5. Booleans print as 1 and 0 because MySQL's BOOLEAN is just TINYINT(1).

Learn more: String, number and date functions.

Predict: does 'abc' equal 'abc ' (with trailing spaces)?
SQL
SELECT 'abc' = 'abc   ' AS no_pad,
       'abc' = 'abc   ' COLLATE utf8mb4_general_ci AS pad_space;
Show answer

It depends on the collation: 0 with MySQL 8.0+'s default utf8mb4_0900_ai_ci, but 1 with utf8mb4_general_ci, the utf8mb4 default on MySQL 5.7.

Result
+--------+-----------+
| no_pad | pad_space |
+--------+-----------+
|      0 |         1 |
+--------+-----------+
What’s happening
  1. Every collation has a pad attribute. information_schema.COLLATIONS shows utf8mb4_0900_ai_ci as NO PAD and utf8mb4_general_ci as PAD SPACE.
  2. PAD SPACE collations compare as if the shorter string were padded with spaces, so trailing spaces are ignored and the strings are equal.
  3. NO PAD collations compare the actual characters, so 'abc ' is longer and different.
  4. Upgrading from 5.7 to 8.0 and switching to the new default collation can therefore change query results and even make a unique index accept 'abc' and 'abc ' as two values. It's an "old and new" trap worth naming.
  5. Both are case-insensitive (_ci), so 'abc' = 'ABC' is 1 either way. Use a _bin or _cs collation for case-sensitive comparison.

Learn more: Strings: CHAR, VARCHAR, TEXT and character sets.

Spot the bug: BETWEEN on a DATETIME column misses an order

Orders 1 and 3 were placed on 1 February and 10 February 2025. Why does this return only order 1?

SQL
SELECT id, ordered_at FROM orders
WHERE ordered_at BETWEEN '2025-02-01' AND '2025-02-10'
ORDER BY ordered_at;
Show answer

'2025-02-10' compared with a DATETIME means midnight at the start of 10 February. Order 3 was placed at 09:20 that day, after the upper bound:

Result
+----+---------------------+
| id | ordered_at          |
+----+---------------------+
|  1 | 2025-02-01 10:00:00 |
+----+---------------------+

Use a half-open range instead:

SQL
SELECT id, ordered_at FROM orders
WHERE ordered_at >= '2025-02-01' AND ordered_at < '2025-02-11'
ORDER BY ordered_at;
Result
+----+---------------------+
| id | ordered_at          |
+----+---------------------+
|  1 | 2025-02-01 10:00:00 |
|  3 | 2025-02-10 09:20:00 |
+----+---------------------+
What’s happening
  1. BETWEEN a AND b is inclusive at both ends: >= a AND <= b. That's fine for integers and DATE columns.
  2. For DATETIME, the string '2025-02-10' is converted to 2025-02-10 00:00:00. Anything later that day is > b.
  3. >= start AND < next day includes the whole last day at any precision, including fractional seconds, and it can still use an index on ordered_at.
  4. Writing DATE(ordered_at) BETWEEN … also gives the right rows, but wrapping the column in a function prevents a normal index from being used.

Learn more: Dates and times: DATETIME vs TIMESTAMP and Filtering with WHERE.

Predict the row counts: customers JOIN orders vs LEFT JOIN, and UNION vs UNION ALL

There are 6 customers and 8 orders; Zara has no orders. Predict all four numbers.

SQL
SELECT
  (SELECT COUNT(*) FROM customers c LEFT JOIN orders o ON o.customer_id = c.id) AS left_rows,
  (SELECT COUNT(*) FROM customers c JOIN orders o ON o.customer_id = c.id)      AS inner_rows;

SELECT
  (SELECT COUNT(*) FROM (SELECT city FROM customers UNION     SELECT city FROM customers) u) AS union_rows,
  (SELECT COUNT(*) FROM (SELECT city FROM customers UNION ALL SELECT city FROM customers) u) AS union_all_rows;
Show answer

9 and 8; 5 and 12.

Result
+-----------+------------+
| left_rows | inner_rows |
+-----------+------------+
|         9 |          8 |
+-----------+------------+

+------------+----------------+
| union_rows | union_all_rows |
+------------+----------------+
|          5 |             12 |
+------------+----------------+
What’s happening
  1. The inner join returns one row per matching pair. Every order has exactly one customer, so there are 8 pairs.
  2. The LEFT JOIN returns those 8 plus one NULL-padded row for each customer with no match — Zara — so 9. A join's row count is driven by matches, not by the size of the left table.
  3. UNION ALL simply appends: 6 + 6 = 12 rows.
  4. UNION removes duplicate rows from the combined result: the distinct cities are Toronto, Mumbai, London, Dubai and NULL — 5. Unlike =, UNION treats two NULLs as duplicates.
  5. UNION costs a de-duplication step (a temporary table or sort). Use UNION ALL unless you need duplicates removed.

Learn more: LEFT and RIGHT OUTER JOIN and Set operations: UNION, INTERSECT and EXCEPT.

Predict: a CASE with no ELSE
SQL
SELECT name,
       CASE WHEN stock = 0  THEN 'sold out'
            WHEN stock < 10 THEN 'low' END AS stock_flag
FROM products ORDER BY id;
Show answer

Products that match neither branch get NULL — a CASE without ELSE defaults to ELSE NULL.

Result
+---------------------------------------+------------+
| name                                  | stock_flag |
+---------------------------------------+------------+
| ThinkBook 14                          | NULL       |
| MacBook Air                           | low        |
| Pixel 10                              | NULL       |
| iPhone 17                             | sold out   |
| SQL Performance Explained             | NULL       |
| Designing Data-Intensive Applications | NULL       |
| USB-C Charger                         | NULL       |
| Noise-cancelling Headphones           | low        |
+---------------------------------------+------------+
What’s happening
  1. CASE tests the WHENs in order and stops at the first true one. The iPhone (stock 0) matches the first branch, so it's "sold out", not "low", even though 0 < 10 is also true.
  2. The MacBook (5) and headphones (8) match the second branch.
  3. Everything else matches nothing and falls to the implicit ELSE NULL.
  4. This matters inside aggregates: SUM(CASE WHEN … THEN 1 END) returns NULL for a group where nothing matched, while SUM(CASE WHEN … THEN 1 ELSE 0 END) returns 0.

Learn more: CASE expressions.

How many rows does COUNT() OVER () return, compared with COUNT()?

Sales has three employees.

SQL
SELECT name, COUNT(*) OVER () AS total, salary
FROM employees
WHERE department_id = 2;
Show answer

Three rows, each with total = 3. A window function adds a value to every row; an aggregate without OVER collapses the rows into one.

Result
+-------+-------+-----------+
| name  | total | salary    |
+-------+-------+-----------+
| Elena |     3 | 130000.00 |
| Farid |     3 |  95000.00 |
| Grace |     3 |  99000.00 |
+-------+-------+-----------+
What’s happening
  1. WHERE department_id = 2 runs first and leaves three rows.
  2. COUNT(*) OVER () uses an empty window — all rows of the result — so each row gets the count of the whole result: 3.
  3. SELECT name, COUNT(*) FROM … WHERE department_id = 2 without OVER would collapse to one row, and under ONLY_FULL_GROUP_BY it's rejected because name isn't grouped.
  4. That's the interview line: "GROUP BY reduces rows; window functions keep every row and add a computed column." It's why window functions are the tool for running totals, ranks and percent-of-total.

Learn more: Window functions: OVER and PARTITION BY.

DELETE vs TRUNCATE inside a transaction — predict the counts and the next id

The table t_del has three rows with ids 1–3.

SQL
START TRANSACTION; DELETE FROM t_del;   ROLLBACK;
SELECT COUNT(*) AS after_delete_rollback FROM t_del;

START TRANSACTION; TRUNCATE TABLE t_del; ROLLBACK;
SELECT COUNT(*) AS after_truncate_rollback FROM t_del;

INSERT INTO t_del (v) VALUES (9);
SELECT * FROM t_del;
Show answer

3, then 0, then the new row gets id 1. DELETE is a normal, transactional row operation; TRUNCATE is DDL that commits implicitly, can't be rolled back and resets AUTO_INCREMENT.

Result
+-----------------------+
| after_delete_rollback |
+-----------------------+
|                     3 |
+-----------------------+

+-------------------------+
| after_truncate_rollback |
+-------------------------+
|                       0 |
+-------------------------+

+----+------+
| id | v    |
+----+------+
|  1 |    9 |
+----+------+
What’s happening
  1. DELETE FROM t_del removes rows one by one inside the transaction, writing undo records. ROLLBACK uses them to restore all three rows.
  2. TRUNCATE TABLE drops and recreates the table internally. It causes an implicit commit (of the transaction it's in and of itself), so the ROLLBACK afterwards has nothing to undo: the table stays empty.
  3. Because the table was recreated, the AUTO_INCREMENT counter starts again, so the next insert gets id 1. After a DELETE, it would have been 4.
  4. Other differences: TRUNCATE doesn't fire DELETE triggers and fails if another table's foreign key references this one. DROP TABLE removes the table itself, structure and all.
  5. Use DELETE when you need a WHERE, triggers or the ability to roll back; TRUNCATE to empty a large table fast.

Learn more: DELETE vs TRUNCATE vs DROP and Autocommit and implicit commits.

#Design, indexes and performance

Added

These questions cover schema design (normal forms, keys, when to break the rules) and the index questions that come up in every backend interview: the leftmost-prefix rule, covering indexes, and the handful of reasons MySQL ignores an index you thought it would use. The index answers were checked with EXPLAIN on a purpose-built big_orders table of 50,000 rows with three secondary indexes. On this MySQL 9.7 server, @@explain_format is TREE, so a plain EXPLAIN prints the plan as an indented tree of -> lines; EXPLAIN FORMAT=TRADITIONAL prints the classic table with type, key and key_len. Both formats appear below, whichever makes the point more clearly. The table:

SQL
CREATE TABLE big_orders (
  id          INT AUTO_INCREMENT PRIMARY KEY,
  customer_id INT NOT NULL,
  status      VARCHAR(10) NOT NULL,
  ordered_at  DATETIME NOT NULL,
  ref         VARCHAR(20) NOT NULL,      -- 'R0000001' … 'R0050000'
  total       DECIMAL(10,2) NOT NULL,
  KEY idx_cust_status_date (customer_id, status, ordered_at),
  KEY idx_ref  (ref),
  KEY idx_date (ordered_at)
);
What’s happening
  1. 50,000 rows were generated with a recursive CTE (after raising cte_max_recursion_depth), then ANALYZE TABLE big_orders refreshed the index statistics the optimizer uses.
  2. customer_id runs from 1 to 2,000, 25 orders each. Because of the arithmetic used to generate the data, each customer's 25 orders all share one status (customer 42's are cancelled, customer 43's shipped).
  3. ordered_at is spread over 2023–2025, and ref is a unique string from R0000001 to R0050000.
  4. The three secondary indexes exist to show the leftmost-prefix rule (idx_cust_status_date), type conversion and LIKE (idx_ref) and functions on columns (idx_date).
Which normal form does this table break, and why is unit_price allowed to stay?
order_items (order_id, product_id) is the primary key
order_id | product_id | quantity | unit_price | product_name
Show answer

product_name breaks second normal form (2NF): it depends on only part of the composite key, product_id, not on the whole (order_id, product_id). Move it to products. unit_price stays, because it isn't the product's current price — it's the price on that order, a fact about the order line.

What’s happening
  1. 1NF: every column holds a single value, and rows are identified by a key. This table passes.
  2. 2NF: every non-key column must depend on the whole key. quantity depends on both (how many of this product on this order). product_name depends only on product_id, so it's a partial dependency.
  3. The cost of the violation is update anomalies: rename a product and you must update every order line that mentions it, or the names disagree.
  4. 3NF additionally forbids non-key columns depending on other non-key columns (a transitive dependency), like storing customer_city on orders next to customer_id.
  5. unit_price looks like a copy of products.price, but it records history: when the price changes, past orders must keep the price the customer paid. Saying "this is deliberate denormalisation of a point-in-time value" is the senior answer.

Learn more: First, second and third normal form and Why normalise: update, insert and delete anomalies.

Which of these WHERE clauses can use the index (customer_id, status, ordered_at), and how much of it?
  1. WHERE customer_id = 42 AND ordered_at >= '2025-01-01'
  2. WHERE customer_id = 42 AND status = 'shipped' AND ordered_at >= '2025-01-01'
  3. WHERE status = 'shipped'
Show answer

(1) uses only the first column, (2) uses all three, (3) can't use it at all. It's the leftmost-prefix rule: a B+tree on (a, b, c) is sorted by a, then b within a, then c within b, so it can only be searched from the left.

SQL
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders
WHERE customer_id = 42 AND ordered_at >= '2025-01-01';

EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders
WHERE customer_id = 42 AND status = 'shipped' AND ordered_at >= '2025-01-01';

EXPLAIN FORMAT=TRADITIONAL SELECT * FROM big_orders
WHERE status = 'shipped';
Result
+------+-------------------------------+----------------------+---------+-------+------+----------+-----------------------+
| type | possible_keys                 | key                  | key_len | ref   | rows | filtered | Extra                 |
+------+-------------------------------+----------------------+---------+-------+------+----------+-----------------------+
| ref  | idx_cust_status_date,idx_date | idx_cust_status_date | 4       | const |   25 |    50.00 | Using index condition |
+------+-------------------------------+----------------------+---------+-------+------+----------+-----------------------+

+-------+-------------------------------+----------------------+---------+------+------+----------+-----------------------+
| type  | possible_keys                 | key                  | key_len | ref  | rows | filtered | Extra                 |
+-------+-------------------------------+----------------------+---------+------+------+----------+-----------------------+
| range | idx_cust_status_date,idx_date | idx_cust_status_date | 51      | NULL |    1 |   100.00 | Using index condition |
+-------+-------------------------------+----------------------+---------+------+------+----------+-----------------------+

+------+---------------+------+---------+------+-------+----------+-------------+
| type | possible_keys | key  | key_len | ref  | rows  | filtered | Extra       |
+------+---------------+------+---------+------+-------+----------+-------------+
| ALL  | NULL          | NULL | NULL    | NULL | 50072 |    10.00 | Using where |
+------+---------------+------+---------+------+-------+----------+-------------+

(The id, select_type, table and partitions columns are trimmed to fit.)

What’s happening
  1. key_len is the number of index bytes used for searching. customer_id INT is 4 bytes; status VARCHAR(10) in utf8mb4 is 10 × 4 + 2 = 42; DATETIME is 5. So 4 means "customer_id only" and 51 means all three columns.
  2. Query 1 skips status, so the index can only seek to customer_id = 42 (25 rows). ordered_at sits behind status in the sort order, so it can't narrow the search; MySQL checks it on each index entry instead — that's what "Using index condition" (index condition pushdown) means.
  3. Query 2 has equality on the first two columns and a range on the third, so the whole index narrows the search to an exact slice.
  4. Query 3 has no condition on customer_id, so the index is useless for searching: possible_keys is NULL and the plan is a full table scan (ALL) over about 50,000 rows.
  5. The design rule that follows: put equality columns first and the range column last, and order the columns to match your most important queries. (MySQL 8.0.13+ can sometimes "skip scan" a missing leading column with few distinct values, but don't design around it.)

Learn more: Composite indexes and the leftmost-prefix rule and Reading EXPLAIN.

What does "covering index" mean, and which of these two queries is covered?
SQL
EXPLAIN SELECT customer_id, status, ordered_at FROM big_orders
WHERE customer_id = 42 AND status = 'paid';

EXPLAIN SELECT customer_id, status, ordered_at, total FROM big_orders
WHERE customer_id = 42 AND status = 'paid';
Show answer

An index covers a query when every column the query needs is in the index, so MySQL never has to read the table row. The first query is covered; adding total breaks it.

Result
-> Covering index lookup on big_orders using idx_cust_status_date (customer_id = 42, status = 'paid')  (cost=0.35 rows=1)

-> Index lookup on big_orders using idx_cust_status_date (customer_id = 42, status = 'paid')  (cost=0.35 rows=1)
What’s happening
  1. The first query selects only customer_id, status and ordered_at — all three are in idx_cust_status_date. The tree plan says Covering index lookup; in the traditional format the same query shows Extra: Using index.
  2. total isn't in the index. For the second query MySQL finds matching entries in the index, then must fetch each row from the clustered index to read total: a plain "Index lookup".
  3. In InnoDB, every secondary index entry also stores the row's primary key, because that's how it finds the row. So SELECT id, customer_id FROM … WHERE customer_id = 42 is covered too, even though id isn't listed in the index definition.
  4. The extra lookup is cheap for one row but adds a random read per row; for a query that touches thousands of rows, covering can be the difference between milliseconds and seconds.
  5. The trade-off: wider indexes cost disk, memory and slower writes. Add columns to an index for your hottest queries, not for every query.

Learn more: Covering indexes and Clustered and secondary indexes in InnoDB.

Why does WHERE YEAR(ordered_at) = 2024 AND MONTH(ordered_at) = 3 ignore idx_date?
SQL
EXPLAIN SELECT * FROM big_orders
WHERE YEAR(ordered_at) = 2024 AND MONTH(ordered_at) = 3;
Show answer

Because the indexed column is wrapped in a function. The index is sorted by ordered_at, not by YEAR(ordered_at), so MySQL can't search it and scans the whole table:

Result
-> Filter: ((year(big_orders.ordered_at) = 2024) and (month(big_orders.ordered_at) = 3))  (cost=5059 rows=50028)
    -> Table scan on big_orders  (cost=5059 rows=50028)

Rewrite the condition as a range on the bare column:

SQL
EXPLAIN SELECT * FROM big_orders
WHERE ordered_at >= '2024-03-01' AND ordered_at < '2024-04-01';
Result
-> Index range scan on big_orders using idx_date over ('2024-03-01 00:00:00' <= ordered_at < '2024-04-01 00:00:00'), with index condition: ((big_orders.ordered_at >= TIMESTAMP'2024-03-01 00:00:00') and (big_orders.ordered_at < TIMESTAMP'2024-04-01 00:00:00'))  (cost=543 rows=1207)
What’s happening
  1. The tree is read from the innermost line out: a table scan of about 50,000 rows feeds a filter that evaluates YEAR() and MONTH() on every row.
  2. The rewritten query is the same set of rows, expressed so the optimizer can seek: an index range scan estimated at 1,207 rows, with a cost about a tenth of the scan's (543 vs 5,059).
  3. "Sargable" is the jargon: a predicate is sargable when the column stands alone on one side of the comparison. col + 1 = 5, LOWER(col) = 'x' and DATE(col) = '…' are not.
  4. If you really need to filter by an expression, MySQL 8.0.13+ supports functional indexes, e.g. CREATE INDEX idx_year ON big_orders ((YEAR(ordered_at))), and the optimizer uses it when the query uses the identical expression.

Learn more: Why an index isn't used and Functional, prefix and invisible indexes.

Spot the bug: WHERE ref = 1234 on a VARCHAR column

ref is an indexed VARCHAR. Why is this a full table scan, and what do the warnings say?

SQL
EXPLAIN SELECT * FROM big_orders WHERE ref = 1234;
SHOW WARNINGS;
Show answer

Comparing a string column with a number makes MySQL convert every ref value to a number before comparing, which is the same as wrapping the column in a function. The index can't be used:

Result
-> Filter: (big_orders.ref = 1234)  (cost=5059 rows=5003)
    -> Table scan on big_orders  (cost=5059 rows=50028)

+---------+------+-----------------------------------------------------------------------------------------------+
| Level   | Code | Message                                                                                       |
+---------+------+-----------------------------------------------------------------------------------------------+
| Warning | 1739 | Cannot use ref access on index 'idx_ref' due to type or collation conversion on field 'ref'   |
| Warning | 1739 | Cannot use range access on index 'idx_ref' due to type or collation conversion on field 'ref' |
+---------+------+-----------------------------------------------------------------------------------------------+

With a string literal of the right type, the same index gives a one-row lookup:

SQL
EXPLAIN SELECT * FROM big_orders WHERE ref = 'R0001234';
Result
-> Index lookup on big_orders using idx_ref (ref = 'R0001234')  (cost=0.35 rows=1)
What’s happening
  1. MySQL's rule for comparing a string with a number is to compare them as numbers. Many strings convert to the same number ('1234', ' 1234', '1234abc' all become 1234), so the sorted string index can't tell MySQL where to look.
  2. Warning 1739 states it plainly: no ref or range access on idx_ref because of the type conversion.
  3. The reverse case is safe: an INT column compared with a string literal (WHERE id = '42') converts the literal once, and the index is used.
  4. This bug usually arrives through application code: a driver binds a JavaScript number to a VARCHAR column, or a join compares an INT column with a VARCHAR one. Keep join columns the same type and collation — a collation mismatch triggers the same warning.

Learn more: Why an index isn't used.

LIKE '%1234' vs LIKE 'R00012%' — which can use the index?
Show answer

Only the prefix pattern. A leading wildcard means the value could start with anything, so a sorted index is no help:

SQL
EXPLAIN SELECT * FROM big_orders WHERE ref LIKE '%1234';
EXPLAIN SELECT * FROM big_orders WHERE ref LIKE 'R00012%';
Result
-> Filter: (big_orders.ref like '%1234')  (cost=5059 rows=5558)
    -> Table scan on big_orders  (cost=5059 rows=50028)

-> Index range scan on big_orders using idx_ref over ('R00012' <= ref <= 'R00012????????????????????????????????????????????????????????'), with index condition: (big_orders.ref like 'R00012%')  (cost=45.3 rows=100)
What’s happening
  1. LIKE 'R00012%' is a range: every value from 'R00012' up to 'R00012' followed by the highest possible characters. The plan shows exactly that range (the ? characters are how EXPLAIN prints "maximum character"), and estimates 100 rows: R0001200 to R0001299.
  2. LIKE '%1234' can match anywhere in the sorted order, so MySQL reads every row and tests the pattern: a table scan.
  3. For "contains" searches on text, use a FULLTEXT index (MATCH … AGAINST) or an external search engine. For "ends with", store a reversed copy of the column in a generated column and index that.
  4. A prefix LIKE is case-insensitive under the default _ci collation and still uses the index, because the index is built with the same collation.

Learn more: Full-text search and Generated columns.

Why is page 2,001 slow with LIMIT 20 OFFSET 40000, and what's the fix?
SQL
EXPLAIN ANALYZE SELECT id, ref, total FROM big_orders
ORDER BY id LIMIT 20 OFFSET 40000;

EXPLAIN ANALYZE SELECT id, ref, total FROM big_orders
WHERE id > 40000 ORDER BY id LIMIT 20;
Show answer

OFFSET still reads every skipped row: to return rows 40,001–40,020, MySQL walks through the first 40,000 and throws them away. Keyset (seek) pagination remembers the last id seen and starts from there, so every page costs the same:

Result
-> Limit/Offset: 20/40000 row(s)  (cost=3244 rows=20) (actual time=4.88..4.88 rows=20 loops=1)
    -> Index scan on big_orders using PRIMARY  (cost=3244 rows=40020) (actual time=0.0801..3.92 rows=40020 loops=1)

-> Limit: 20 row(s)  (cost=3905 rows=20) (actual time=0.075..0.0777 rows=20 loops=1)
    -> Filter: (big_orders.id > 40000)  (cost=3905 rows=19488) (actual time=0.0624..0.0643 rows=20 loops=1)
        -> Index range scan on big_orders using PRIMARY over (40000 < id)  (cost=3905 rows=19488) (actual time=0.0611..0.0621 rows=20 loops=1)
What’s happening
  1. EXPLAIN ANALYZE actually runs the query and adds actual time (milliseconds, first row..last row) and actual rows to each step.
  2. The OFFSET plan's index scan produced 40,020 rows to return 20, taking about 4.9 ms on this small, cached table. The cost grows linearly with the page number, and on a real table with wide rows and a cold cache it becomes seconds.
  3. The keyset plan seeks straight to id > 40000 and stops after 20 rows: 0.08 ms. (Its estimated cost is higher because the optimizer doesn't model the early stop well — the actual figures are what count.)
  4. The trade-offs: keyset pagination needs a unique, indexed sort key (use (created_at, id) with a row comparison if you sort by a non-unique column), and it supports "next/previous page" rather than jumping to page 2,001. For infinite scroll and APIs that's exactly right.
  5. Rows inserted or deleted between requests don't cause skipped or repeated items with keyset, which is another reason APIs prefer cursors.

Learn more: Pagination: OFFSET vs keyset and EXPLAIN ANALYZE.

AUTO_INCREMENT or UUID for the primary key?
Show answer

Default to AUTO_INCREMENT (BIGINT) in InnoDB, and use UUIDs when ids must be generated outside the database or must not be guessable — preferably a time-ordered UUID stored in BINARY(16).

What’s happening
  1. InnoDB stores the table in primary-key order (the clustered index). Increasing keys append to the end of the B+tree; random UUIDv4 keys insert all over it, causing page splits, fragmentation and a much larger working set in the buffer pool.
  2. Every secondary index stores the primary key too, so a 36-character CHAR(36) UUID makes every index bigger. BINARY(16) stores the same value in 16 bytes, versus 8 for a BIGINT.
  3. MySQL's UUID_TO_BIN(uuid, 1) swaps the time fields of a version-1 UUID() so that values sort by time, which fixes most of the insert-order problem; UUIDv7 (time-ordered by design) gives the same benefit when generated by the application.
  4. UUIDs win when clients or several services create ids before inserting (offline apps, merging shards, idempotent APIs) and when you don't want sequential ids exposed in URLs.
  5. A common compromise: an AUTO_INCREMENT primary key for the engine, plus a unique public UUID column for the outside world.

Learn more: Primary keys: AUTO_INCREMENT vs UUID.

When is denormalising worth it?
Show answer

When a read is frequent, expensive to compute and tolerant of a little staleness — and you have a plan to keep the copy correct. Normalise by default; denormalise deliberately, for a measured reason.

What’s happening
  1. Normalisation stores each fact once, so writes are simple and anomalies impossible. The cost is joins and aggregation on every read.
  2. Good reasons to copy data: a counter shown on every page (posts.comment_count instead of COUNT(*) each time), a summary table for dashboards, a point-in-time value that must not change (order_items.unit_price), or a read model for search.
  3. Every copy needs an owner: update it in the same transaction, with a trigger, or asynchronously from an event or the binlog — and decide how stale it may be.
  4. Try cheaper fixes first: the right index, a covering index, caching in Redis, or a read replica. Denormalising is a schema change that every future writer has to know about.
  5. In MongoDB the trade-off is reversed: embedding (denormalising) is the default, and you normalise when data grows without bound or is shared and updated often.

Learn more: Denormalisation and when it's worth it and Embedding vs referencing.

EXPLAIN vs EXPLAIN ANALYZE: what's the difference, and what does rows=1 … rows=0 mean here?
SQL
EXPLAIN ANALYZE SELECT * FROM big_orders
WHERE customer_id = 43 AND status = 'pending' AND ordered_at >= '2025-01-01';
Show answer

EXPLAIN shows the plan and the optimizer's estimates without running the query; EXPLAIN ANALYZE runs it and adds what actually happened. Here the optimizer estimated 1 row and actually found 0:

Result
-> Index range scan on big_orders using idx_cust_status_date over (customer_id = 43 AND status = 'pending' AND '2025-01-01 00:00:00' <= ordered_at), with index condition: ((big_orders.`status` = 'pending') and (big_orders.customer_id = 43) and (big_orders.ordered_at >= TIMESTAMP'2025-01-01 00:00:00'))  (cost=0.71 rows=1) (actual time=0.00517..0.00517 rows=0 loops=1)
What’s happening
  1. (cost=0.71 rows=1) is the estimate: the optimizer thinks about one row matches. It never estimates 0, so 1 is its "very few" answer.
  2. (actual time=0.00517..0.00517 rows=0 loops=1) is the measurement: time to the first row and to the last row in milliseconds, rows produced, and how many times this step ran. Customer 43's 25 orders in this data are all shipped, so none are pending.
  3. Big gaps between estimated and actual rows point to stale statistics (ANALYZE TABLE), skewed data (consider a histogram) or a misleading predicate — and they're the usual reason for a bad plan.
  4. Because EXPLAIN ANALYZE executes the statement, running it on a slow query takes as long as the query. It's meant for SELECTs: tried on a single-table UPDATE here, it printed -> <not executable by iterator executor> and changed nothing. A plain EXPLAIN never runs anything.
  5. loops matters in joins: an inner step with loops=1000 ran once per outer row, so multiply its time by the loops to see its real cost.

Learn more: EXPLAIN ANALYZE and Selectivity and cardinality.

#Transactions and scaling

Added

These questions are about what happens when more than one thing touches the data at once — isolation levels, lost updates, deadlocks, job queues — and what changes when one server isn't enough: replicas, partitioning, sharding, pooling and backups. The concurrency answers were produced by running two real sessions at the same time against MySQL 9.7 (session B started a second after session A, while A was paused in DO SLEEP(2)), on a two-row stock table reset before each experiment:

skuqty
iphone0
pixel20

Replication, sharding and failover need several machines, which the sandbox doesn't have, so those answers are explained rather than demonstrated. The server's defaults quoted below were read from it: max_connections 151, innodb_lock_wait_timeout 50, innodb_flush_log_at_trx_commit 1 and innodb_deadlock_detect on.

What does ACID stand for, in one sentence each?
Show answer

Atomicity: all of a transaction's changes happen or none do. Consistency: a transaction moves the database from one valid state to another, respecting every constraint. Isolation: concurrent transactions don't see each other's half-finished work (to the degree the isolation level promises). Durability: once COMMIT returns, the change survives a crash.

What’s happening
  1. Atomicity is implemented in InnoDB with the undo log: every change records how to reverse it, and ROLLBACK (or crash recovery) replays those records backwards.
  2. Consistency is partly the database's job (primary keys, foreign keys, CHECK, NOT NULL) and partly the application's (business rules like "balance never negative" unless you encode them as constraints).
  3. Isolation is MVCC plus locks. It's the one property with a dial — READ UNCOMMITTED to SERIALIZABLE — because full isolation costs concurrency.
  4. Durability is the redo log, flushed to disk at commit (innodb_flush_log_at_trx_commit = 1, the default), plus the doublewrite buffer for torn pages. Relaxing that setting to 2 or 0 trades up to about a second of commits for speed.
  5. The crisp interview version: "A and D are about crashes, I is about concurrency, C is about rules."

Learn more: Transactions and ACID.

Predict the second read under REPEATABLE READ and under READ COMMITTED

Session A reads the Pixel's stock twice inside one transaction. Between the reads, session B (autocommit) runs UPDATE stock SET qty = qty - 5 WHERE sku = 'pixel'.

SQL
-- session A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- then: READ COMMITTED
START TRANSACTION;
SELECT qty AS first_read  FROM stock WHERE sku = 'pixel';
DO SLEEP(2);                                   -- B's UPDATE commits here
SELECT qty AS second_read FROM stock WHERE sku = 'pixel';
COMMIT;
Show answer

Under REPEATABLE READ (InnoDB's default) the second read is still 20. Under READ COMMITTED it's 15 — a non-repeatable read.

Result
-- REPEATABLE READ run
+-------------+
| second_read |
+-------------+
|          20 |
+-------------+

-- READ COMMITTED run
+-------------+
| second_read |
+-------------+
|          15 |
+-------------+

In both runs first_read was 20, and a SELECT after A's COMMIT returned 15.

What’s happening
  1. SELECT @@transaction_isolation on this server returns REPEATABLE-READ: MySQL's default, unlike PostgreSQL's and SQL Server's READ COMMITTED.
  2. Under REPEATABLE READ, A's first plain SELECT creates a read view (a snapshot), and every later plain SELECT in the transaction reads from it. B's committed update is newer than the snapshot, so InnoDB reconstructs the old version from the undo log: 20.
  3. Under READ COMMITTED, each statement gets a fresh read view, so the second SELECT sees B's committed 15.
  4. After A commits, a new statement outside the transaction sees 15 in both cases. Nothing was blocked: B's UPDATE didn't wait for A's reads, because plain reads take no locks in InnoDB (that's MVCC).
  5. Neither level shows a dirty read (uncommitted data); only READ UNCOMMITTED does.

Learn more: Isolation levels and MVCC: how InnoDB gives each transaction a snapshot.

Under REPEATABLE READ, A read 20, B subtracted 5, then A runs qty = qty - 1. What does A read next?
SQL
-- session A (REPEATABLE READ)
START TRANSACTION;
SELECT qty AS read_1 FROM stock WHERE sku = 'pixel';      -- 20
DO SLEEP(2);                                              -- B: qty = qty - 5, committed
UPDATE stock SET qty = qty - 1 WHERE sku = 'pixel';
SELECT qty AS read_2 FROM stock WHERE sku = 'pixel';
COMMIT;
Show answer

14 — not 19. Writes in InnoDB always work on the latest committed row, not on the transaction's snapshot, and after its own write A sees its own change.

Result
+--------+
| read_1 |
+--------+
|     20 |
+--------+

Query OK, 1 row affected
Rows matched: 1  Changed: 1  Warnings: 0

+--------+
| read_2 |
+--------+
|     14 |
+--------+
What’s happening
  1. read_1 comes from A's snapshot: 20.
  2. B commits 20 − 5 = 15 while A sleeps.
  3. A's UPDATE is a current read: it locks the row and reads its newest committed version, 15, and writes 15 − 1 = 14. If it used the snapshot it would write 19 and silently erase B's change.
  4. read_2 is a plain read again, but a transaction always sees its own changes, so it shows 14. A has now seen 20 and then 14 for the same row — the snapshot isn't "frozen" for rows you've modified.
  5. The same "current read" rule applies to SELECT … FOR UPDATE and FOR SHARE. That's how InnoDB's REPEATABLE READ avoids lost updates for qty = qty - 1 — and why the next question still loses one.

Learn more: Locking reads: FOR UPDATE and FOR SHARE.

Spot the bug: the stock that should be 14 ends up 19

The application reads the stock, subtracts one in code, and writes the result back. B's qty = qty - 5 commits in between. Why is the final stock 19?

SQL
-- session A
START TRANSACTION;
SELECT qty AS read_1 FROM stock WHERE sku = 'pixel';      -- app sees 20
DO SLEEP(2);                                              -- B: qty = qty - 5, committed
UPDATE stock SET qty = 19 WHERE sku = 'pixel';            -- app computed 20 - 1
COMMIT;
SELECT qty AS final FROM stock WHERE sku = 'pixel';
Show answer

It's a lost update: A wrote a value it computed from a stale read, overwriting B's committed change. Both transactions succeeded, and five units of stock vanished from the books.

Result
+-------+
| final |
+-------+
|    19 |
+-------+
What’s happening
  1. A's SELECT read 20 from its snapshot. The application computed 19 in memory.
  2. B committed 15. A's UPDATE … SET qty = 19 doesn't read the column at all; it just overwrites the row with 19. No error, no warning.
  3. Fix 1, the simplest: let the database do the arithmetic, SET qty = qty - 1 (previous question) — atomic and correct.
  4. Fix 2, pessimistic locking: read with SELECT qty … FOR UPDATE. B's update then waits until A commits, and A's read sees the real current value.
  5. Fix 3, optimistic locking: add a version column and write … WHERE sku = 'pixel' AND version = <version I read>; if another writer got there first, 0 rows are affected and the application retries (next question).

Learn more: Optimistic vs pessimistic locking.

Optimistic locking: predict the second update

Two editors both loaded document 1 at version = 3. Editor A saves first, then editor B.

SQL
UPDATE docs SET body = 'edit by A', version = version + 1 WHERE id = 1 AND version = 3;
UPDATE docs SET body = 'edit by B', version = version + 1 WHERE id = 1 AND version = 3;
SELECT * FROM docs;
Show answer

A's update succeeds and bumps the version to 4; B's matches 0 rows, so it changes nothing and the application knows there was a conflict.

Result
Query OK, 1 row affected
Rows matched: 1  Changed: 1  Warnings: 0

Query OK, 0 rows affected
Rows matched: 0  Changed: 0  Warnings: 0

+----+-----------+---------+
| id | body      | version |
+----+-----------+---------+
|  1 | edit by A |       4 |
+----+-----------+---------+
What’s happening
  1. Both editors read version 3 without taking any lock — that's the "optimistic" part: assume conflicts are rare.
  2. A's WHERE id = 1 AND version = 3 matches, and the row becomes version 4.
  3. B's WHERE … version = 3 no longer matches anything. The affected-row count (0, which drivers return as affectedRows) is the conflict signal.
  4. The application then reloads, merges or asks the user — or, for automatic jobs, retries the read-modify-write.
  5. Choose optimistic locking when conflicts are rare and transactions span user think-time (forms, APIs). Choose SELECT … FOR UPDATE when conflicts are frequent and the whole read-modify-write happens inside one short transaction. ORMs like Sequelize and Prisma-based patterns implement the version check for you.

Learn more: Optimistic vs pessimistic locking.

Two transactions update pixel and iphone in opposite orders. What happens?
SQL
-- session A                                   -- session B (starts 0.5 s later)
START TRANSACTION;                             START TRANSACTION;
UPDATE stock SET qty = qty - 1                 UPDATE stock SET qty = qty + 1
  WHERE sku = 'pixel';                           WHERE sku = 'iphone';
DO SLEEP(1);                                   DO SLEEP(1);
UPDATE stock SET qty = qty + 1                 UPDATE stock SET qty = qty - 1
  WHERE sku = 'iphone';                          WHERE sku = 'pixel';
COMMIT;                                        COMMIT;
Show answer

A deadlock: each holds the row the other needs. InnoDB detects the cycle immediately and rolls one transaction back — here B — so the other can finish:

Result
-- session B
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

-- afterwards
+--------+------+
| sku    | qty  |
+--------+------+
| iphone |    1 |
| pixel  |   19 |
+--------+------+
What’s happening
  1. A locks the pixel row; half a second later B locks the iphone row.
  2. A's second update wants iphone and waits for B. B's second update wants pixel and would wait for A — a cycle, which no amount of waiting can resolve.
  3. InnoDB's deadlock detector (on by default, innodb_deadlock_detect) picks a victim — normally the transaction that's cheapest to undo — and rolls all of it back. B's earlier iphone + 1 is undone too.
  4. A then gets the lock, commits, and the final state is A's work only: pixel 19, iphone 1. B's application must retry the whole transaction, which is what the message says.
  5. Prevent most deadlocks by touching rows in a consistent order (for example, sorted by primary key), keeping transactions short, and indexing the columns in your WHERE so fewer rows are locked. SHOW ENGINE INNODB STATUS shows the latest deadlock in detail.

Learn more: Deadlocks and Row, gap and next-key locks.

Two workers take the next job from a queue table. What does SKIP LOCKED give each, and what does NOWAIT do?
SQL
-- worker A (keeps its transaction open for 2 s)
START TRANSACTION;
SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;

-- worker B, 1 s later
START TRANSACTION;
SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
COMMIT;
START TRANSACTION;
SELECT id FROM jobs WHERE id = 1 FOR UPDATE NOWAIT;
Show answer

A gets job 1, B gets job 2 without waiting. And B's NOWAIT attempt on job 1 fails immediately instead of blocking:

Result
-- worker A
+----+
| id |
+----+
|  1 |
+----+

-- worker B
+----+
| id |
+----+
|  2 |
+----+

ERROR 3572 (HY000): Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.
What’s happening
  1. A's FOR UPDATE locks job 1 until A commits.
  2. B's query would normally wait on job 1. SKIP LOCKED tells InnoDB to pretend locked rows aren't there, so B's LIMIT 1 lands on job 2.
  3. NOWAIT doesn't skip; it fails at once with error 3572 if a row is locked, so the application can do something else instead of hanging until innodb_lock_wait_timeout (50 s by default).
  4. This is how you build a reliable job queue in MySQL 8.0+: workers claim different rows with no coordination, then mark them running and commit.
  5. Both options are MySQL 8.0.1+. Before that, people used GET_LOCK(), or an UPDATE … SET owner = ? WHERE owner IS NULL LIMIT 1 claim followed by a SELECT.

Learn more: NOWAIT and SKIP LOCKED.

Predict: what survives this ROLLBACK?

autocommit is on (the default).

SQL
START TRANSACTION;
INSERT INTO audit (note) VALUES ('first');
CREATE TABLE scratch_tmp (x INT);
INSERT INTO audit (note) VALUES ('second');
ROLLBACK;
SELECT * FROM audit;
Show answer

Both rows survive. CREATE TABLE causes an implicit commit, which commits 'first' and ends the transaction; 'second' then runs in autocommit mode and commits on its own. The ROLLBACK has nothing left to undo.

Result
+----+--------+
| id | note   |
+----+--------+
|  1 | first  |
|  2 | second |
+----+--------+
What’s happening
  1. START TRANSACTION begins an explicit transaction; 'first' is inserted but not committed.
  2. DDL statements (CREATE, ALTER, DROP, TRUNCATE, RENAME) and some others (LOCK TABLES, START TRANSACTION itself) commit the current transaction first, then run. MySQL DDL isn't transactional in the PostgreSQL sense.
  3. After the implicit commit there's no open transaction, and with autocommit = 1 every statement is its own transaction: 'second' commits immediately.
  4. ROLLBACK with no open transaction is a no-op and isn't an error — which is why this bug is silent.
  5. Keep schema changes out of data transactions. PostgreSQL can roll back CREATE TABLE inside a transaction; MySQL can't.

Learn more: Autocommit and implicit commits.

A user updates their profile, then reloads and sees the old data. The app reads from replicas. Why, and what do you do?
Show answer

Replication lag. MySQL replication is asynchronous by default: the write commits on the source and reaches the replica a little later, so a read routed to a replica straight after the write can see the old row. The fix is read-your-writes: send that user's reads to the source for a short time after they write, or wait until the replica has applied their transaction.

What’s happening
  1. On commit, the source writes the change to its binary log. A replica's I/O thread copies it into its relay log and its SQL (applier) threads replay it. Lag is the time between those steps — usually milliseconds, but seconds or more under heavy writes or long transactions.
  2. Common fixes: route reads to the source for N seconds after a write (or for the rest of the request); pin a session to the source after a write; or use GTIDs — record the transaction's GTID on write and have the replica WAIT_FOR_EXECUTED_GTID_SET() before reading.
  3. Semi-synchronous replication makes the source wait until at least one replica has received the transaction, which protects against data loss on failover but does not by itself guarantee the replica has applied it, so it doesn't fix this read.
  4. Not every read needs it: product listings can tolerate a second of staleness; "my profile", "my order" and balances usually can't.
  5. This can't be demonstrated in the single-server sandbox; it needs a source and at least one replica.

Learn more: Read replicas and replication lag and Asynchronous, semi-synchronous and group replication.

Partitioning vs sharding — what's the difference, and when do you need each?
Show answer

Partitioning splits one table into pieces inside one server; the database routes queries to the right pieces. Sharding splits the data across several servers; something above the database (the application, a proxy like Vitess or ProxySQL) has to route each query.

What’s happening
  1. MySQL partitioning (PARTITION BY RANGE (YEAR(ordered_at)), LIST, HASH, KEY) is transparent to queries. Its main wins are partition pruning (a query for 2025 only reads the 2025 partition) and cheap data retention (ALTER TABLE … DROP PARTITION instead of a huge DELETE).
  2. It doesn't add capacity: one server's CPU, memory and disk still serve everything. And every unique key must include the partitioning column, which is a real design constraint.
  3. Sharding adds write capacity and storage by giving each server a subset of rows, chosen by a shard key (often tenant or user id). The cost: cross-shard joins and transactions are hard, rebalancing is work, and every query should include the shard key.
  4. Order of escalation: indexes and query fixes → bigger server (scale up) → read replicas and caching → partitioning for very large tables → sharding when writes or data size exceed one server.
  5. MongoDB has sharding built in (mongos routers, config servers, chunks), which is one reason it's chosen for very large write-heavy workloads; MySQL needs external tooling.

Learn more: Partitioning, Sharding and Scaling up vs scaling out.

Why use a connection pool, and how big should it be?
Show answer

Opening a MySQL connection is expensive (TCP, TLS, authentication, per-connection server memory) and the server has a hard max_connections limit, so applications keep a pool of open connections and lend them out per query or transaction. Size it small — roughly the number of queries the database can usefully run in parallel — not the number of users.

What’s happening
  1. Without a pool, every request pays the connection setup and the server spends memory on a thread per connection. Under load the server refuses new connections with error 1040, Too many connections.
  2. With a pool (mysql2's createPool, Knex, Prisma, Sequelize all have one), requests borrow a connection, run, and return it. Extra requests queue in the application instead of overloading the database.
  3. A database can only do so much at once: a pool of 10–20 per application instance is typical. Ten instances × 100 connections each is how you exhaust max_connections (151 by default) during a deploy.
  4. Always return connections — release in a finally — or the pool drains and requests hang. Transactions must use one connection from BEGIN to COMMIT.
  5. Serverless functions can't keep a long-lived pool, so they need an external pooler or proxy (RDS Proxy, ProxySQL, PlanetScale-style HTTP drivers) to stop each invocation opening its own connection.

Learn more: Connection pooling and Connection limits in serverless.

#MongoDB

Added

These questions follow Rohit's MongoDB interview doc: projections, querying arrays and embedded documents, update operators (positional $, $[], arrayFilters, upserts), the aggregation pipeline, indexes and explain(), and transactions. Every prediction was run in mongosh against MongoDB 9.0 (a single-member replica set, so transactions work) in a database called ch15. Most questions use this small users collection — note that Dev has no age field at all:

_idnameagetagsscores
1Asha41admin, devexam 72, quiz 95
2Ben35devexam 88, quiz 60
3Chloe29(empty array)quiz 81
4Dev(missing)dev, ops(empty array)

Each scores entry is an embedded document like { type: "exam", score: 72 }.

Predict the projection output, and what happens if you mix 1 and 0?
mongosh
db.users.find({ age: { $gt: 30 } }, { name: 1 })
db.users.find({ age: { $gt: 30 } }, { name: 1, _id: 0 })
db.users.find({}, { name: 1, age: 0 })
Show answer

_id is always returned unless you exclude it, so the first query returns _id and name. You can't mix inclusion and exclusion in one projection — the only exception is _id: 0.

Result
ch15> db.users.find({ age: { $gt: 30 } }, { name: 1 })
[ { _id: 1, name: 'Asha' }, { _id: 2, name: 'Ben' } ]
ch15> db.users.find({ age: { $gt: 30 } }, { name: 1, _id: 0 })
[ { name: 'Asha' }, { name: 'Ben' } ]
ch15> db.users.find({}, { name: 1, age: 0 })
MongoServerError: Cannot do exclusion on field age in inclusion projection
What’s happening
  1. { age: { $gt: 30 } } matches Asha (41) and Ben (35). Dev has no age, and a missing field never satisfies $gt.
  2. { name: 1 } is an inclusion projection: return only the listed fields — plus _id, which is included by default.
  3. _id: 0 is the one exclusion allowed inside an inclusion projection.
  4. { name: 1, age: 0 } asks for "only name" and "everything except age" at once, which is contradictory, so the server rejects it.
  5. Projections matter for performance too: returning less data means less network traffic, and the right projection can make a query covered (see the explain() question below).

Learn more: Projections.

Predict four counts on the tags array
mongosh
db.users.countDocuments({ tags: "dev" })
db.users.countDocuments({ tags: ["dev"] })
db.users.countDocuments({ tags: { $all: ["dev", "ops"] } })
db.users.countDocuments({ tags: { $size: 0 } })
Show answer

3, 1, 1, 1.

Result
ch15> db.users.countDocuments({ tags: "dev" })
3
ch15> db.users.countDocuments({ tags: ["dev"] })
1
ch15> db.users.countDocuments({ tags: { $all: ["dev", "ops"] } })
1
ch15> db.users.countDocuments({ tags: { $size: 0 } })
1
What’s happening
  1. { tags: "dev" } on an array field means "the array contains "dev"": Asha, Ben and Dev.
  2. { tags: ["dev"] } means "the array equals exactly ["dev"]" — same elements, same order. Only Ben.
  3. $all means "contains every listed value, in any order, possibly with others". Only Dev has both dev and ops.
  4. $size: 0 matches arrays with exactly zero elements: Chloe. $size only takes an exact number; for "at least N" use { "tags.N-1": { $exists: true } } or $expr with $size.
  5. An index on tags is a multikey index (one entry per element), which is what makes the "contains" query fast.

Learn more: Querying arrays: $size, $all and $elemMatch.

Who has an exam score above 90? Dotted conditions vs $elemMatch
mongosh
db.users.find({ "scores.type": "exam", "scores.score": { $gt: 90 } }, { name: 1, _id: 0 })
db.users.find({ scores: { $elemMatch: { type: "exam", score: { $gt: 90 } } } }, { name: 1, _id: 0 })
Show answer

The first query returns Asha — wrongly: her exam score is 72. The second returns nobody, which is correct.

Result
ch15> db.users.find({ "scores.type": "exam", "scores.score": { $gt: 90 } }, { name: 1, _id: 0 })
[ { name: 'Asha' } ]
ch15> db.users.find({ scores: { $elemMatch: { type: "exam", score: { $gt: 90 } } } }, { name: 1, _id: 0 }).toArray()
[]
What’s happening
  1. Each dotted condition on an array is checked independently: "some element has type: "exam"" and "some element has score > 90".
  2. Asha has an exam element (score 72) and a different element, her quiz, with 95. Both conditions are true, so she matches.
  3. $elemMatch requires one element to satisfy all the conditions together. None of the exam elements has a score above 90, so nothing matches.
  4. Rule: when several conditions must apply to the same embedded document in an array, use $elemMatch. For a single condition, the dotted form is equivalent and shorter.
  5. (.toArray() was added to the second query only so mongosh prints []; an empty cursor prints nothing.)

Learn more: Querying arrays: $size, $all and $elemMatch and Querying embedded documents.

Predict: missing fields with $lt, $ne and null

Dev has no age field.

mongosh
db.users.countDocuments({ age: { $lt: 40 } })
db.users.countDocuments({ age: { $ne: 35 } })
db.users.countDocuments({ age: null })
Show answer

2, 3, 1. A missing field fails comparisons like $lt, but matches $ne and matches null.

Result
ch15> db.users.countDocuments({ age: { $lt: 40 } })
2
ch15> db.users.countDocuments({ age: { $ne: 35 } })
3
ch15> db.users.countDocuments({ age: null })
1
What’s happening
  1. $lt: 40 matches Ben (35) and Chloe (29). Comparison operators only match values of a comparable type, and a missing field has no value, so Dev is out.
  2. $ne: 35 means "not equal to 35", and a missing field is not equal to 35, so Asha, Chloe and Dev match. That's different from SQL, where age <> 35 would drop a NULL age.
  3. { age: null } matches documents where age is null or missing — Dev here.
  4. To find only documents that lack the field, use { age: { $exists: false } } (also 1 here). To find only an explicit null, use { age: { $type: "null" } }.
  5. Schemaless means the "no value" cases are your job: decide whether documents may omit fields, and enforce it with schema validation if not.

Learn more: Comparison operators and $exists and $type.

Positional $, $[] and arrayFilters — predict the cart after each update

The cart starts as items: [ { sku: "A", qty: 1 }, { sku: "B", qty: 2 }, { sku: "A", qty: 5 } ].

mongosh
db.carts.updateOne({ _id: 1, "items.sku": "A" }, { $inc: { "items.$.qty": 10 } })
db.carts.updateOne({ _id: 1 }, { $inc: { "items.$[].qty": 100 } })
db.carts.updateOne({ _id: 1 }, { $set: { "items.$[it].qty": 0 } }, { arrayFilters: [ { "it.sku": "A" } ] })
Show answer

$ updates only the first matching element, $[] updates every element, and $[it] with arrayFilters updates every element that matches the filter:

Result
ch15> db.carts.findOne({ _id: 1 }).items          // after $
[ { sku: 'A', qty: 11 }, { sku: 'B', qty: 2 }, { sku: 'A', qty: 5 } ]
ch15> db.carts.findOne({ _id: 1 }).items          // after $[]
[
  { sku: 'A', qty: 111 },
  { sku: 'B', qty: 102 },
  { sku: 'A', qty: 105 }
]
ch15> db.carts.findOne({ _id: 1 }).items          // after $[it]
[ { sku: 'A', qty: 0 }, { sku: 'B', qty: 102 }, { sku: 'A', qty: 0 } ]
What’s happening
  1. The positional $ refers to the first array element that matched the query's array condition ("items.sku": "A"). The second A (qty 5) is untouched — the classic surprise. The query must include a condition on the array for $ to work.
  2. $[] (all positional) needs no array condition and applies to every element: each qty goes up by 100.
  3. $[it] is a placeholder named in arrayFilters: { "it.sku": "A" } selects elements with sku A, so both A lines become 0 and B keeps 102.
  4. Each updateOne reported matchedCount: 1, modifiedCount: 1: the counts are about documents, not array elements.
  5. Use $ for "the one element I found", arrayFilters for "these elements", $[] for "all of them". arrayFilters and $[] are MongoDB 3.6+.

Learn more: The positional $ operator and [$[] and arrayFilters](#and-arrayfilters).

Upsert twice with $setOnInsert — what's createdBy? And what does updateOne({ age: 5 }) do?
mongosh
db.people.updateOne({ name: "ankit" }, { $set: { age: 90 }, $setOnInsert: { createdBy: "upsert" } }, { upsert: true })
db.people.updateOne({ name: "ankit" }, { $set: { age: 91 }, $setOnInsert: { createdBy: "upsert-again" } }, { upsert: true })
db.people.find({}, { _id: 0 })
db.people.updateOne({ name: "ankit" }, { age: 5 })
Show answer

The first call inserts (upsertedCount: 1), the second updates. createdBy stays "upsert", because $setOnInsert only applies when the upsert inserts. The last call is rejected: an update document must use operators.

Result
ch15> db.people.updateOne({ name: "ankit" }, { $set: { age: 90 }, $setOnInsert: { createdBy: "upsert" } }, { upsert: true })
{
  acknowledged: true,
  insertedId: ObjectId('6ad24a0c302be24bfe35e2bd'),
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1
}
ch15> db.people.updateOne({ name: "ankit" }, { $set: { age: 91 }, $setOnInsert: { createdBy: "upsert-again" } }, { upsert: true })
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
ch15> db.people.find({}, { _id: 0 })
[ { name: 'ankit', age: 91, createdBy: 'upsert' } ]
ch15> db.people.updateOne({ name: "ankit" }, { age: 5 })
MongoInvalidArgumentError: Update document requires atomic operators
What’s happening
  1. Nothing matches { name: "ankit" } at first, so the upsert builds a new document from the query's equality fields (name) plus the $set and $setOnInsert fields, and generates an _id.
  2. The second call matches that document, so it's an ordinary update: $set changes age to 91 and $setOnInsert is ignored.
  3. That makes $setOnInsert the right home for creation-only fields like createdAt and createdBy.
  4. updateOne(filter, { age: 5 }) has no $ operator. The shell (and the Node driver) refuses it with MongoInvalidArgumentError before it reaches the server. To replace a whole document, call replaceOne({ name: "ankit" }, { name: "ankit", age: 5 }) — which here left { name: 'ankit', age: 5 }, createdBy gone.
  5. Upserts race: two concurrent upserts with the same filter can both insert unless a unique index on the filter field makes the second one fail (and retry as an update).

Learn more: Upserts and replaceOne and findOneAndUpdate.

$lookup and $unwind: predict the document counts

Customers: Rohit (orders 101, 102), Maya (order 103, whose items array is empty) and Zara (no orders).

mongosh
db.customers.aggregate([ { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } }, { $unwind: "$orders" }, { $count: "n" } ])
db.customers.aggregate([ { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } }, { $unwind: { path: "$orders", preserveNullAndEmptyArrays: true } }, { $count: "n" } ])
db.orders.aggregate([ { $unwind: "$items" }, { $count: "n" } ])
Show answer

3, 4 and 4. $lookup always produces an array; $unwind makes one document per element and drops documents whose array is empty unless you ask it not to.

Result
ch15> db.customers.aggregate([ { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } }, { $project: { _id: 0, name: 1, orderIds: "$orders._id" } } ])
[
  { name: 'Rohit', orderIds: [ 101, 102 ] },
  { name: 'Maya', orderIds: [ 103 ] },
  { name: 'Zara', orderIds: [] }
]
ch15> …{ $unwind: "$orders" }, { $count: "n" } ])
[ { n: 3 } ]
ch15> …{ $unwind: { path: "$orders", preserveNullAndEmptyArrays: true } }, { $count: "n" } ])
[ { n: 4 } ]
ch15> db.orders.aggregate([ { $unwind: "$items" }, { $count: "n" } ])
[ { n: 4 } ]
What’s happening
  1. $lookup is a left outer join: every customer stays, with an orders array holding the matches — two for Rohit, one for Maya, an empty array for Zara (the first command shows it).
  2. Plain $unwind turns Rohit into 2 documents and Maya into 1, and removes Zara: 3. That's the inner-join behaviour people don't expect.
  3. preserveNullAndEmptyArrays: true keeps Zara as one document without an orders field: 4 — the true left join.
  4. On orders, $unwind: "$items" gives 2 + 2 + 0 = 4: order 103's empty items array silently drops it. Any count after an $unwind can quietly lose documents.
  5. $group needs an _id: { $group: { _id: null, revenue: { $sum: "$total" } } } totals everything (revenue: 2093, orders: 3 here), while leaving _id out fails with MongoServerError: a group specification must include an _id.

Learn more: $lookup: joining collections, $unwind and $group and accumulators.

Read the explain(): COLLSCAN vs IXSCAN vs a covered query

events has 5,000 documents, 20 of them with userId: 42. Predict the three numbers before and after creating { userId: 1, ts: 1 }, and for the projection { _id: 0, userId: 1, ts: 1 }.

mongosh
const s = db.events.find({ userId: 42 }).explain("executionStats").executionStats;
({ nReturned: s.nReturned, totalKeysExamined: s.totalKeysExamined, totalDocsExamined: s.totalDocsExamined })
Show answer

Without the index, MongoDB reads all 5,000 documents to return 20. With it, it reads 20 index keys and 20 documents. With a projection the index can satisfy, it reads 0 documents — a covered query.

Result
// no index (COLLSCAN)
{ nReturned: 20, totalKeysExamined: 0, totalDocsExamined: 5000 }
// after db.events.createIndex({ userId: 1, ts: 1 })  (IXSCAN + FETCH)
{ nReturned: 20, totalKeysExamined: 20, totalDocsExamined: 20 }
// projection { _id: 0, userId: 1, ts: 1 }  (PROJECTION_COVERED)
{ nReturned: 20, totalKeysExamined: 20, totalDocsExamined: 0 }
// projection { userId: 1, ts: 1 }  — _id not excluded
{ nReturned: 20, totalKeysExamined: 20, totalDocsExamined: 20 }
What’s happening
  1. The healthy ratio is nReturned ≈ totalKeysExamined ≈ totalDocsExamined. 20 returned from 5,000 examined is the signature of a missing index: the winning plan's stage was COLLSCAN.
  2. After createIndex, the plan is an IXSCAN on userId_1_ts_1 followed by FETCH to load each document: 20 keys, 20 documents.
  3. When the query and projection use only indexed fields, MongoDB answers from the index alone: totalDocsExamined: 0, and the winning plan's stage is PROJECTION_COVERED.
  4. Forgetting _id: 0 breaks coverage, because _id isn't in this index — 20 documents examined again. It's the most common reason a "covered" query isn't.
  5. explain() without arguments shows only the chosen plan (queryPlanner); "executionStats" runs the query and adds the counts; "allPlansExecution" also shows the plans that lost.

Learn more: explain() and executionStats and Covered queries.

Compound index order: which queries and sorts can use it?

With indexes { userId: 1, ts: 1 } and { type: 1, amount: 1 }, which of these need an in-memory SORT or a COLLSCAN?

mongosh
db.events.find({ ts: { $gte: new Date("2025-01-03") } })
db.events.find({ userId: 42 }).sort({ ts: -1 })
db.events.find({ userId: 42 }).sort({ amount: 1 })
db.events.find().sort({ type: 1, amount: -1 }).limit(5)
db.events.find().sort({ type: -1, amount: -1 }).limit(5)
Show answer

Querying ts alone is a COLLSCAN; sorting by ts after userId uses the index in either direction; sorting by amount needs a SORT; and { type: 1, amount: -1 } can't use { type: 1, amount: 1 }, but its exact reverse { type: -1, amount: -1 } can.

Result
// helper: walk the winning plan and print its stages, outermost first
ts only                      COLLSCAN
userId, sort ts -1           FETCH <- IXSCAN
userId, sort amount          SORT <- FETCH <- IXSCAN
sort type 1, amount -1       SORT <- COLLSCAN
sort type -1, amount -1      LIMIT <- FETCH <- IXSCAN
What’s happening
  1. Like MySQL's leftmost-prefix rule, a compound index can serve queries on a prefix of its fields. ts isn't a prefix of { userId, ts }, so the query scans the collection.
  2. With userId fixed by equality, the index entries for user 42 are already sorted by ts, and an index can be walked backwards, so sort({ ts: -1 }) needs no SORT stage.
  3. amount isn't in that index, so the 20 matching documents are fetched and sorted in memory (SORT). Fine for 20, a problem for 20,000: a blocking sort holds every document before returning the first, and past 100 MB it has to spill to temporary files on disk (allowed by default since MongoDB 6.0; an error before that).
  4. A compound index supports a sort on its fields in the same directions or exactly reversed. { type: 1, amount: -1 } is neither, so it fell back to SORT <- COLLSCAN; { type: -1, amount: -1 } is the full reverse and used the index with LIMIT stopping after 5.
  5. This is the ESR rule for designing compound indexes: Equality fields first, then Sort fields, then Range fields.

The helper used for the stage chains:

mongosh
function planStages(cursor) {
  let p = cursor.explain().queryPlanner.winningPlan;
  p = p.queryPlan || p;
  const out = [];
  while (p) { out.push(p.stage); p = p.inputStage; }
  return out.join(" <- ");
}

Learn more: Single-field and compound indexes and The ESR rule.

A unique index on email, and two users without an email. What happens?
mongosh
db.members.createIndex({ email: 1 }, { unique: true })
db.members.insertOne({ email: "a@x.com" })
db.members.insertOne({ email: "a@x.com" })
db.members.insertOne({ name: "no email 1" })
db.members.insertOne({ name: "no email 2" })
Show answer

The duplicate email fails, as expected — and so does the second document without an email, because a unique index treats a missing field as null, and two nulls are duplicates:

Result
E11000 duplicate key error collection: ch15.members index: email_1 dup key: { email: "a@x.com" }
E11000 duplicate key error collection: ch15.members index: email_1 dup key: { email: null }

Make it a partial unique index that only covers documents that have the field:

mongosh
db.members.createIndex(
  { email: 1 },
  { unique: true, partialFilterExpression: { email: { $exists: true } } }
)

With that index, both email-less documents were inserted, and the duplicate a@x.com still failed with the same E11000 error: 3 documents in total.

What’s happening
  1. E11000 is MongoDB's duplicate-key error code; the message names the collection, the index and the key.
  2. Documents that lack an indexed field are indexed with the value null. Under a unique index, only one such document can exist.
  3. partialFilterExpression indexes only documents matching the filter, so email-less documents aren't in the index at all and can't collide.
  4. The catch: a partial index is only used for queries whose filter implies the partial filter. find({ email: null }) can't use it — its plan was a COLLSCAN.
  5. Older code used sparse: true for the same purpose; partial indexes (3.2+) are the more general, recommended form.

Learn more: Unique and partial indexes.

Inside a transaction, what does a reader outside it see — and after abortTransaction?
mongosh
const session = db.getMongo().startSession();
const accts = session.getDatabase("ch15").accounts;   // ava: 100, raj: 50
session.startTransaction({ writeConcern: { w: "majority" } });
accts.updateOne({ _id: "ava" }, { $inc: { balance: -30 } });
accts.updateOne({ _id: "raj" }, { $inc: { balance: 30 } });
print("inside txn:", JSON.stringify(accts.find().toArray()));
print("outside txn:", JSON.stringify(db.accounts.find().toArray()));
session.abortTransaction();
print("after abort:", JSON.stringify(db.accounts.find().toArray()));
Show answer

Inside, the transfer is visible; outside, nobody sees it until commit; after abortTransaction() both writes are gone.

Result
inside txn: [{"_id":"ava","balance":70},{"_id":"raj","balance":80}]
outside txn: [{"_id":"ava","balance":100},{"_id":"raj","balance":50}]
after abort: [{"_id":"ava","balance":100},{"_id":"raj","balance":50}]

Running the same two updates again and calling session.commitTransaction() left ava: 70, raj: 80 for every reader.

What’s happening
  1. Operations go through the session's database handle (accts); that's what attaches them to the transaction. db.accounts is outside it.
  2. The transaction reads its own writes (70 and 80). Other readers see the last committed state (100 and 50) — snapshot isolation, with no locks taken on the readers' side.
  3. abortTransaction() discards both changes atomically. A crash before commit would do the same.
  4. Multi-document transactions need a replica set (MongoDB 4.0+) or sharded cluster (4.2+); this sandbox runs a one-member replica set for that reason. Single-document updates are atomic without a transaction.
  5. Prefer designs that keep related data in one document so you don't need transactions; when you do use them, keep them short (the default limit is 60 seconds) and retry on TransientTransactionError — the Node driver's session.withTransaction() does that for you.

Write concern is the other half: with { w: 0 } an insert returned acknowledged: false (the client didn't wait for the server at all), while { w: "majority", j: true } waited until a majority had written it to the journal and returned acknowledged: true.

Learn more: Multi-document transactions, When not to use transactions and Write concern.

#Redis and choosing a database

Added

The last set covers Redis — expiry, atomic counters, locks, sorted sets, rate limiting, persistence and messaging — and the "which database would you choose?" questions that close many system-design interviews. The Redis answers were run with redis-cli against Redis 8.10 (keys prefixed ch15:), and the output is shown the way the interactive client prints it. The choosing questions have no single right answer; what interviewers listen for is the trade-off you name and the question you ask back.

Predict the four TTLs
redis-cli
127.0.0.1:6379> SET ch15:session abc EX 60
127.0.0.1:6379> TTL ch15:session
127.0.0.1:6379> SET ch15:session xyz
127.0.0.1:6379> TTL ch15:session
127.0.0.1:6379> SET ch15:session abc EX 60
127.0.0.1:6379> SET ch15:session def KEEPTTL
127.0.0.1:6379> TTL ch15:session
127.0.0.1:6379> TTL ch15:nope
Show answer

60, −1, 60, −2. A plain SET on an existing key removes its expiry; KEEPTTL keeps it; -2 means the key doesn't exist.

Result
127.0.0.1:6379> SET ch15:session abc EX 60
OK
127.0.0.1:6379> TTL ch15:session
(integer) 60
127.0.0.1:6379> SET ch15:session xyz
OK
127.0.0.1:6379> TTL ch15:session
(integer) -1
127.0.0.1:6379> SET ch15:session abc EX 60
OK
127.0.0.1:6379> SET ch15:session def KEEPTTL
OK
127.0.0.1:6379> TTL ch15:session
(integer) 60
127.0.0.1:6379> TTL ch15:nope
(integer) -2
What’s happening
  1. SET … EX 60 stores the value with a 60-second expiry; TTL returns the seconds left.
  2. SET without an expiry option replaces the key entirely, value and TTL, so the key now lives forever: TTL returns -1 ("exists, no expiry"). This is a classic cache bug — an update path that forgets EX makes the entry permanent.
  3. KEEPTTL (Redis 6.0+) changes the value but keeps the existing expiry.
  4. -2 means "no such key" — either never set or already expired. Commands that modify a value in place (INCR, HSET, LPUSH) don't touch the TTL; commands that replace the whole value (SET, GETSET) clear it.
  5. Expired keys are removed lazily (when accessed) and by a background sampler, so memory is reclaimed gradually, not at the exact second.

Learn more: Expiry and eviction.

Predict INCR on a number and on a name
redis-cli
127.0.0.1:6379> SET ch15:views 10
127.0.0.1:6379> INCR ch15:views
127.0.0.1:6379> INCRBY ch15:views 5
127.0.0.1:6379> SET ch15:name rohit
127.0.0.1:6379> INCR ch15:name
Show answer

11, 16, then an error: Redis stores strings, and INCR only works when the string is an integer.

Result
127.0.0.1:6379> INCR ch15:views
(integer) 11
127.0.0.1:6379> INCRBY ch15:views 5
(integer) 16
127.0.0.1:6379> INCR ch15:name
(error) ERR value is not an integer or out of range
What’s happening
  1. SET ch15:views 10 stores the string "10". INCR parses it as a 64-bit integer, adds one, stores "11" and returns the new value.
  2. The read-modify-write happens inside the server, in one step. Redis executes commands one at a time on its main thread, so a thousand clients calling INCR at once never lose an increment — unlike GET, add in the app, SET.
  3. "rohit" isn't an integer, so INCR fails and the value is unchanged.
  4. INCR on a missing key treats it as 0, so counters need no initialisation. INCRBYFLOAT handles decimals.
  5. This atomicity is why Redis is the usual home for page-view counters, rate limits and id generators.

Learn more: Data structures.

Two workers try to take the same lock. What does each get, and how do you release it safely?
redis-cli
127.0.0.1:6379> SET ch15:lock worker-1 NX PX 30000
127.0.0.1:6379> SET ch15:lock worker-2 NX PX 30000
127.0.0.1:6379> GET ch15:lock
Show answer

Worker 1 gets OK, worker 2 gets (nil) — the key already exists — and the lock holds worker 1's token for 30 seconds.

Result
127.0.0.1:6379> SET ch15:lock worker-1 NX PX 30000
OK
127.0.0.1:6379> SET ch15:lock worker-2 NX PX 30000
(nil)
127.0.0.1:6379> GET ch15:lock
"worker-1"
What’s happening
  1. NX means "only set if it does not exist", so exactly one SET wins. PX 30000 attaches a 30-second expiry in the same atomic command, so a crashed holder can't keep the lock forever.
  2. The value is a unique token per holder (a random string in real code, not just a worker name). That's what makes release safe.
  3. Release must check the token and delete in one atomic step, or you can delete someone else's lock after yours expired. The standard way is a tiny Lua script: if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end, run with EVAL.
  4. The expiry is also the weakness: if the holder pauses longer than 30 s (a GC pause, a slow call), the lock expires and a second worker gets in. For correctness-critical work, pass a fencing token (an increasing number) to the resource so it rejects stale holders.
  5. With a single Redis node, a failover can lose a lock that wasn't replicated yet. Redlock (several independent nodes) addresses that, but it's debated; for strict guarantees many teams use the database (SELECT … FOR UPDATE) or a consensus system like etcd or ZooKeeper.

Learn more: Distributed locks.

Predict a leaderboard with a tie
redis-cli
127.0.0.1:6379> ZADD ch15:board 120 asha 95 ben 120 chloe 80 dev
127.0.0.1:6379> ZREVRANGE ch15:board 0 2 WITHSCORES
127.0.0.1:6379> ZREVRANK ch15:board ben
127.0.0.1:6379> ZINCRBY ch15:board 30 dev
127.0.0.1:6379> LPUSH ch15:board x
Show answer

The top three are chloe 120, asha 120, ben 95 — ties are ordered by member name, and in reverse order that puts chloe first. Ben's rank from the top is 2 (zero-based). Dev rises to 110. LPUSH fails: the key holds a sorted set.

Result
127.0.0.1:6379> ZADD ch15:board 120 asha 95 ben 120 chloe 80 dev
(integer) 4
127.0.0.1:6379> ZREVRANGE ch15:board 0 2 WITHSCORES
1) "chloe"
2) "120"
3) "asha"
4) "120"
5) "ben"
6) "95"
127.0.0.1:6379> ZREVRANK ch15:board ben
(integer) 2
127.0.0.1:6379> ZINCRBY ch15:board 30 dev
"110"
127.0.0.1:6379> LPUSH ch15:board x
(error) WRONGTYPE Operation against a key holding the wrong kind of value
What’s happening
  1. A sorted set keeps members ordered by score, and members with equal scores in lexicographic order of the member string: asha < chloe. ZREVRANGE reverses both, so chloe comes before asha.
  2. Ranks are zero-based: ZREVRANK 2 means third place. ZRANK (lowest first) gave 1 for Ben.
  3. ZINCRBY is atomic like INCR: adding points never loses a concurrent update. Updates and rank lookups are O(log N), which is why sorted sets are the standard leaderboard.
  4. Each key has one type (TYPE ch15:board returns zset); using a command for another type returns WRONGTYPE. Key naming conventions (ch15:board, user:42:cart) are how teams keep track.
  5. To break ties by time instead of name, encode it in the score (for example score * 1e10 + (MAX_TS - ts)), since the score is the only sort key.

Learn more: Data structures.

Build a limit of N requests per minute per user — predict the counter and the TTL
redis-cli
127.0.0.1:6379> INCR ch15:rl:user42:2025-10-16T10:15
127.0.0.1:6379> EXPIRE ch15:rl:user42:2025-10-16T10:15 60 NX
127.0.0.1:6379> INCR ch15:rl:user42:2025-10-16T10:15
127.0.0.1:6379> INCR ch15:rl:user42:2025-10-16T10:15
127.0.0.1:6379> EXPIRE ch15:rl:user42:2025-10-16T10:15 60 NX
127.0.0.1:6379> TTL ch15:rl:user42:2025-10-16T10:15
Show answer

The counter goes 1, 2, 3; the first EXPIRE … NX returns 1 (set) and the second 0 (an expiry already exists), so the window isn't extended by later requests.

Result
127.0.0.1:6379> INCR ch15:rl:user42:2025-10-16T10:15
(integer) 1
127.0.0.1:6379> EXPIRE ch15:rl:user42:2025-10-16T10:15 60 NX
(integer) 1
127.0.0.1:6379> INCR ch15:rl:user42:2025-10-16T10:15
(integer) 2
127.0.0.1:6379> INCR ch15:rl:user42:2025-10-16T10:15
(integer) 3
127.0.0.1:6379> EXPIRE ch15:rl:user42:2025-10-16T10:15 60 NX
(integer) 0
127.0.0.1:6379> TTL ch15:rl:user42:2025-10-16T10:15
(integer) 60
What’s happening
  1. This is a fixed-window limiter: one key per user per minute (the minute is in the key name). Each request runs INCR and is rejected if the returned count exceeds N.
  2. The key needs an expiry so old windows disappear. EXPIRE … NX (Redis 7.0+) only sets it if none exists, so it's safe to send on every request; on older versions, set it only when INCR returned 1.
  3. Run the two commands in one round trip (MULTI/EXEC or a pipeline), or in a Lua script, so a crash between them can't leave a counter with no expiry.
  4. The weakness of fixed windows is the boundary: a user can send N requests at 10:15:59 and N more at 10:16:00. A sliding window (a sorted set of request timestamps, trimmed with ZREMRANGEBYSCORE) or a token bucket smooths that out at a higher cost.
  5. Because every app server talks to the same Redis, the limit holds across a whole fleet — the reason to use Redis rather than an in-memory counter per process.

Learn more: Rate limiting with Redis.

Cache-aside: walk through a read and a write, and say what goes wrong under load
Show answer

Read: check Redis; on a miss, read the database, store the result with a TTL, return it. Write: update the database, then delete the cache key (don't update it). Under load, the classic failure is a stampede: a hot key expires and hundreds of requests miss at once and all hit the database.

Result
127.0.0.1:6379> SET ch15:product:7 '{"name":"USB-C Charger","price":29}' PX 200
OK
127.0.0.1:6379> GET ch15:product:7
"{\"name\":\"USB-C Charger\",\"price\":29}"
-- 300 ms later
127.0.0.1:6379> GET ch15:product:7
(nil)
127.0.0.1:6379> EXISTS ch15:product:7
(integer) 0
What’s happening
  1. The entry above was cached with a 200 ms TTL to make expiry visible: read straight away it's there; 300 ms later GET returns (nil) and EXISTS returns 0. In the application, (nil) is the cache miss that triggers a database read and a fresh SET … EX.
  2. Deleting on write (instead of writing the new value into the cache) avoids a race where two concurrent writers leave the older value in Redis. The next read repopulates it from the database.
  3. There's still a small window where a reader caches a value read just before the write committed. TTLs bound how long any such staleness can last — always set one.
  4. Stampede defences: a short lock (SET key:lock NX PX) so only one request rebuilds while others wait or serve stale data; random jitter on TTLs so keys don't expire together; or refreshing hot keys early, before they expire.
  5. Write-through (update the cache synchronously with the database) and write-behind (write to the cache, flush to the database later) are the alternatives; write-behind is fast but can lose writes if Redis fails first.

Learn more: Caching patterns: cache-aside, write-through and write-behind and Cache invalidation and stampedes.

What happens on this server when Redis runs out of memory, and what survives a restart?
redis-cli
127.0.0.1:6379> CONFIG GET maxmemory
127.0.0.1:6379> CONFIG GET maxmemory-policy
127.0.0.1:6379> CONFIG GET save
127.0.0.1:6379> CONFIG GET appendonly
Show answer

With these defaults Redis has no memory limit of its own (maxmemory 0) and the policy is noeviction: if a limit is set and reached, writes fail with an out-of-memory error rather than dropping data. For persistence it takes RDB snapshots on a schedule and the AOF is off, so a crash can lose the writes since the last snapshot.

Result
127.0.0.1:6379> CONFIG GET maxmemory
1) "maxmemory"
2) "0"
127.0.0.1:6379> CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"
127.0.0.1:6379> CONFIG GET save
1) "save"
2) "3600 1 300 100 60 10000"
127.0.0.1:6379> CONFIG GET appendonly
1) "appendonly"
2) "no"
What’s happening
  1. maxmemory 0 means "no limit" on 64-bit systems: Redis grows until the OS or the container stops it. Production caches should set maxmemory explicitly.
  2. For a cache, set a policy such as allkeys-lru or allkeys-lfu (evict the least recently or least frequently used keys) or volatile-ttl (only keys with an expiry). noeviction is right when Redis holds data you can't lose, like queues or sessions.
  3. save 3600 1 300 100 60 10000 means: snapshot after 3,600 s if at least 1 key changed, after 300 s if 100 changed, after 60 s if 10,000 changed. Snapshots are compact and fast to load, but everything since the last one is lost in a crash.
  4. The AOF (appendonly yes) logs every write; with appendfsync everysec (the usual setting) you lose at most about a second. Many deployments use both: AOF for durability, RDB for backups and fast restarts.
  5. These values were read from the sandbox's default configuration; this server wasn't pushed to its memory limit, because it's shared with other work.

Learn more: Expiry and eviction and Persistence: RDB and AOF.

Pub/Sub or Streams for "send a welcome email when a user signs up"?
redis-cli
127.0.0.1:6379> PUBLISH ch15:news "hello"
127.0.0.1:6379> XADD ch15:stream '*' event signup user 42
127.0.0.1:6379> XADD ch15:stream '*' event order user 42
127.0.0.1:6379> XGROUP CREATE ch15:stream mailers 0
127.0.0.1:6379> XREADGROUP GROUP mailers worker-1 COUNT 1 STREAMS ch15:stream '>'
127.0.0.1:6379> XREADGROUP GROUP mailers worker-2 COUNT 1 STREAMS ch15:stream '>'
127.0.0.1:6379> XPENDING ch15:stream mailers
Show answer

Streams. PUBLISH returned 0: nobody was subscribed, so the message is simply gone. A stream stores entries, and a consumer group hands each entry to one worker and tracks it as pending until it's acknowledged.

Result
127.0.0.1:6379> PUBLISH ch15:news "hello"
(integer) 0
127.0.0.1:6379> XADD ch15:stream '*' event signup user 42
"1792165895341-0"
127.0.0.1:6379> XADD ch15:stream '*' event order user 42
"1792165895410-0"
127.0.0.1:6379> XGROUP CREATE ch15:stream mailers 0
OK
127.0.0.1:6379> XREADGROUP GROUP mailers worker-1 COUNT 1 STREAMS ch15:stream '>'
1) 1) "ch15:stream"
   2) 1) 1) "1792165895341-0"
         2) 1) "event"
            2) "signup"
            3) "user"
            4) "42"
127.0.0.1:6379> XREADGROUP GROUP mailers worker-2 COUNT 1 STREAMS ch15:stream '>'
1) 1) "ch15:stream"
   2) 1) 1) "1792165895410-0"
         2) 1) "event"
            2) "order"
            3) "user"
            4) "42"
127.0.0.1:6379> XPENDING ch15:stream mailers
1) (integer) 2
2) "1792165895341-0"
3) "1792165895410-0"
4) 1) 1) "worker-1"
      2) "1"
   2) 1) "worker-2"
      2) "1"
What’s happening
  1. Pub/Sub is fire-and-forget: PUBLISH returns the number of subscribers that received the message. If a subscriber is down or not yet connected, it never sees it. Good for live notifications, bad for jobs.
  2. XADD … * appends an entry with an auto-generated id (<milliseconds>-<sequence>) and keeps it in the stream.
  3. XGROUP CREATE … 0 creates a consumer group that starts from the beginning. XREADGROUP … '>' means "give me entries never delivered to this group", so worker 1 got the signup and worker 2 the order: the work is shared, not duplicated.
  4. XPENDING shows both entries as delivered but unacknowledged, one per worker. A worker calls XACK after sending the email; if it crashes first, another worker can take over the entry with XCLAIM or XAUTOCLAIM.
  5. Streams give you at-least-once delivery, so the email job must be idempotent. For very high volume, long retention or replay across many teams, Kafka is the usual step up.

Learn more: Pub/Sub and Streams.

Redis, Valkey and the licence question — what changed, and does it matter for your app?
Show answer

Redis moved from BSD to source-available licences (RSALv2/SSPLv1) in 2024; the Linux Foundation forked the last BSD version as Valkey; and Redis 8 (2025) added AGPLv3 as a third option. For an application that just uses Redis as a server, none of the licences restrict you; they matter mainly to companies offering Redis as a hosted service.

What’s happening
  1. Redis 7.4 (2024) switched from the 3-clause BSD licence to a dual RSALv2 / SSPLv1 licence, which forbids offering Redis itself as a managed service without an agreement.
  2. In response, Valkey forked Redis 7.2.4 under BSD, backed by AWS, Google and others; several clouds' managed "Redis" services moved to it.
  3. Redis 8 added AGPLv3 — an OSI-approved open-source licence with a network clause — alongside the other two, and folded features that used to be in Redis Stack (JSON, the query engine, probabilistic types, time series) into the core.
  4. For you: client libraries and the protocol are the same, so application code works against Redis or Valkey. The decisions are about features (Redis 8's built-in modules) and which managed service you use.
  5. This sandbox runs Redis 8.10 (redis_version:8.10.2 in INFO server). Licence details change; check redis.io and valkey.io before quoting them in a legal context.

Learn more: Redis, Valkey and licensing.

Explain CAP — and why PACELC is the better question
Show answer

CAP: when a network partition happens, a distributed database must choose between consistency (every read sees the latest write, or errors) and availability (every request gets an answer, possibly stale). You can't opt out of partitions, so "CA" isn't a real choice for a distributed system. PACELC adds: Else, when there's no partition, you still trade latency against consistency — which is the trade-off you live with every day.

What’s happening
  1. A partition is when nodes can't reach each other. A CP system (MongoDB with majority writes, etcd, ZooKeeper) refuses writes on the minority side to stay consistent. An AP system (Cassandra, DynamoDB with eventual reads) keeps answering on both sides and reconciles later.
  2. "C" in CAP means linearisability — not the "C" in ACID, which is about constraints. Mixing them up is a common interview slip.
  3. PACELC: if Partition, choose A or C; Else choose Latency or Consistency. Synchronous replication to a majority (consistent) costs a round trip on every write; asynchronous replication (lower latency) allows stale replica reads.
  4. Most systems are tunable per operation: MongoDB's write concern and read concern, Cassandra's consistency levels, MySQL's semi-sync replication and whether you read from replicas.
  5. A good answer ties it to the product: "For balances I want CP behaviour and accept errors during a partition; for a product catalogue, AP and slightly stale reads are fine."

Learn more: CAP and PACELC and Consistency models.

You're designing an online store. Where do orders, the product catalogue, sessions and sales analytics live?
Show answer

A reasonable answer: orders and payments in a relational database (MySQL or PostgreSQL) for transactions and constraints; the catalogue in the same database, or in MongoDB if products have very different attributes per category; sessions, carts and caches in Redis; and analytics in a column store or warehouse fed from the operational database — not by running big reports on the OLTP database.

What’s happening
  1. Orders need multi-row transactions (order, items, stock, payment), foreign keys and reliable reporting — the relational model's strengths. "Money goes in SQL" is a sound default.
  2. A catalogue with flexible, nested attributes (laptops have RAM and CPU, books have ISBN and author) fits documents well; a JSON column in MySQL or JSONB in PostgreSQL is often enough before adding a second database.
  3. Sessions and carts are small, key-based, short-lived and read constantly: Redis with TTLs. Losing a cart is annoying; losing an order is not acceptable, so the choice of store follows the cost of losing data.
  4. Analytics is OLAP: scans and aggregates over millions of rows, few writers. Column stores (ClickHouse, BigQuery, Snowflake, Redshift) read only the columns a query needs and compress well; feeding them by change-data-capture from the binlog keeps the OLTP database fast.
  5. Every extra database adds operations, consistency work (keeping copies in sync) and on-call burden. Start with one relational database plus Redis, and add others when a measured need appears — that's polyglot persistence done deliberately.

Learn more: A decision guide, OLTP vs OLAP and Polyglot persistence.

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.