SQL & MySQL Interview Questions & Answers
SQL and MySQL interview questions covering joins, indexing, transactions, and query optimization -- the database fundamentals almost every technical interview touches.
17 Questions
~26 min read
Beginner: 2
Intermediate: 8
Advanced: 7
Coding 3
Use a subquery with LIMIT/OFFSET, or a window function like DENSE_RANK(), to skip the top value and return the next one.
Detailed Answer
A simple portable approach: `SELECT MAX(salary) FROM employees WHERE salary < (SELECT MAX(salary) FROM employees)`. A more flexible version for the Nth-highest value uses a window function: `SELECT salary FROM (SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk FROM employees) t WHERE rnk = 2`. DENSE_RANK (rather than RANK or ROW_NUMBER) correctly handles duplicate salary values without skipping ranks.
Common Mistakes
Using LIMIT 1 OFFSET 1 without an ORDER BY, which returns an arbitrary row rather than the actual second-highest value.
UNION combines result sets and removes duplicate rows; UNION ALL combines them without deduplication, which is faster since it skips the dedup step.
Detailed Answer
If you know the two result sets can't contain duplicates, or duplicates are acceptable/expected, UNION ALL avoids the (potentially expensive) sort/hash step UNION needs to identify and remove duplicate rows, making it meaningfully faster on large result sets.
Best Practices
Default to UNION ALL unless you specifically need deduplication -- using UNION out of habit adds unnecessary overhead.
Group by the columns that define a duplicate and filter with HAVING COUNT(*) > 1.
Detailed Answer
`SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1` returns every email value that appears more than once. To get the actual duplicate rows (not just the count), join this result back against the original table, or use a window function like `COUNT(*) OVER (PARTITION BY email)` in a subquery and filter where that count is greater than 1.
Conceptual 8
INNER JOIN returns only matching rows from both tables; LEFT JOIN returns all rows from the left table plus matches from the right (NULLs where there's no match); RIGHT JOIN is the mirror of LEFT JOIN.
Detailed Answer
An INNER JOIN excludes any row that doesn't have a corresponding match in the other table. A LEFT JOIN keeps every row from the left table regardless of whether a match exists on the right, filling unmatched columns with NULL -- useful for 'find all X, with optional related Y' queries, like all customers and their orders (including customers with zero orders).
Common Mistakes
Using INNER JOIN when the intent was actually 'show all rows from table A even without a match,' silently dropping rows that have no corresponding match.
WHERE filters individual rows before grouping; HAVING filters groups after a GROUP BY aggregation.
Detailed Answer
WHERE is applied first, row by row, before any grouping or aggregation happens, and it can't reference an aggregate function like COUNT() or SUM() directly. HAVING is applied after GROUP BY, so it can filter based on aggregate results, e.g. `HAVING COUNT(*) > 5` to find groups with more than five rows.
Common Mistakes
Trying to filter on an aggregate value (like COUNT(*)) using WHERE instead of HAVING, which either errors or doesn't do what's intended.
A primary key uniquely identifies each row in its own table; a foreign key is a column (or set of columns) in one table that references a primary key in another table, enforcing referential integrity.
Detailed Answer
A primary key must be unique and non-null for every row, and a table can have only one. A foreign key doesn't have to be unique in the child table (many rows can reference the same parent row) but the database enforces that any value inserted must exist in the referenced table's primary (or unique) key -- preventing orphaned references.
Normalization organizes data into related tables to eliminate redundancy and prevent update anomalies, typically following normal forms (1NF, 2NF, 3NF).
Detailed Answer
An unnormalized table that repeats the same customer name and address on every one of their orders wastes space and risks inconsistency if one copy gets updated and another doesn't. Normalizing splits this into a Customers table and an Orders table linked by a foreign key, so each customer's details are stored exactly once. Third normal form (3NF) is the common practical target for transactional systems -- every non-key column depends on the whole primary key and nothing but the key.
Common Mistakes
Over-normalizing to the point that simple, common queries require many joins across many tiny tables, hurting both readability and performance.
Atomicity, Consistency, Isolation, and Durability -- the guarantees a transaction provides: it fully completes or fully rolls back, leaves the database in a valid state, doesn't interfere with concurrent transactions in unexpected ways, and survives a crash once committed.
Detailed Answer
Atomicity means a multi-statement transaction is all-or-nothing -- if any part fails, everything rolls back. Consistency means the database moves from one valid state to another, respecting constraints. Isolation controls how concurrent transactions see each other's uncommitted changes (governed by isolation levels like READ COMMITTED or SERIALIZABLE, which trade consistency guarantees for concurrency/performance). Durability means once a transaction commits, the change survives even a subsequent crash, typically via a write-ahead log.
From loosest to strictest: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, and SERIALIZABLE -- each closes off one more class of concurrency anomaly (dirty reads, non-repeatable reads, and phantom reads) at the cost of more locking/lower concurrency.
Detailed Answer
READ UNCOMMITTED allows dirty reads (seeing another transaction's uncommitted changes). READ COMMITTED (MySQL's InnoDB default alongside REPEATABLE READ variations) prevents dirty reads but still allows a row's value to change between two reads in the same transaction (non-repeatable read). REPEATABLE READ prevents that but can still allow phantom rows to appear in a repeated range query. SERIALIZABLE prevents all of these by effectively serializing transactions, at the cost of the most locking and lowest concurrency.
Best Practices
Default to your database's standard isolation level (often READ COMMITTED or REPEATABLE READ) and only raise it to SERIALIZABLE for the specific transactions that truly need that guarantee, since it costs concurrency.
A clustered index determines the physical storage order of the table's rows (one per table); a non-clustered index is a separate structure that points back to the row's location, and a table can have several.
Detailed Answer
Because a clustered index dictates row storage order, lookups by that key are extremely fast since the data itself is organized that way -- in InnoDB (MySQL's default engine), the primary key is always the clustered index. A non-clustered index is a separate lookup structure mapping key values to row locations (or to the primary key, in InnoDB's case), requiring an extra lookup step to fetch the full row unless the query is covered entirely by the index's own columns.
Best Practices
Design a covering index (including all columns a frequent query needs) when that query is performance-critical, avoiding the extra lookup back to the full row.
A deadlock occurs when two transactions each hold a lock the other needs, and neither can proceed -- the database detects this and forcibly rolls back one of them.
Detailed Answer
For example, transaction A locks row 1 then waits for row 2, while transaction B locks row 2 then waits for row 1 -- neither can complete. MySQL's InnoDB detects this cycle and aborts one transaction with a deadlock error, which the application should catch and retry. Prevention strategies include always acquiring locks (or updating rows) in a consistent order across all transactions, and keeping transactions short so locks are held as briefly as possible.
Best Practices
Access tables/rows in a consistent order across all transactions in the codebase, and keep transactions as short as possible to minimize the window for lock contention.
Common Mistakes
Not handling deadlock errors with a retry in application code, causing an otherwise-recoverable transient error to surface as a hard failure to the user.
Architecture 2
Sharding splits a large dataset across multiple database instances (shards), typically by a shard key, so no single instance holds the entire dataset -- improving write scalability at the cost of cross-shard query complexity.
Detailed Answer
As a single database instance's write throughput or storage becomes the bottleneck, sharding distributes rows across several instances based on a chosen key (e.g. user_id), so each shard only holds a fraction of the data. The trade-off is that queries needing data across multiple shards (like a global aggregate or a join across differently-sharded tables) become significantly harder and often require an application-level fan-out and merge, plus rebalancing shards as data grows unevenly becomes an ongoing operational concern.
Best Practices
Choose a shard key that matches your dominant query pattern (so most queries stay within a single shard) before sharding, not after.
Common Mistakes
Sharding on a key that doesn't align with common query patterns, forcing most queries to fan out across every shard anyway and losing most of the benefit.
Stored procedures make sense for logic that must run atomically close to the data, needs to minimize round-trips for performance-critical batch operations, or must be enforced consistently regardless of which application calls the database.
Detailed Answer
Pushing logic into the database can reduce network round-trips for multi-step operations and centralizes rules that every consumer of the database must follow. The trade-offs are real, though: stored procedure logic is harder to version-control and test compared to application code, harder to unit test in isolation, and ties business logic to a specific database vendor's procedural language, complicating a future migration.
Best Practices
Reserve stored procedures for genuinely data-proximate, performance-critical, or cross-application-enforced logic -- keep general business logic in the application layer where it's easier to test and evolve.
Common Mistakes
Pushing significant business logic into stored procedures by default, making it hard to unit test, code-review, and version alongside the rest of the application.
Performance 2
An index is an auxiliary data structure (typically a B-tree) that lets the database find rows matching a condition without scanning the entire table.
Detailed Answer
Without an index, a query filtering on a column requires a full table scan -- checking every row. An index on that column lets the database navigate directly to matching rows in roughly O(log n) time instead of O(n), which is the difference between milliseconds and seconds (or worse) on large tables. Indexes aren't free, though -- they add overhead to every INSERT/UPDATE/DELETE since the index structure must be maintained alongside the table.
Best Practices
Index columns that are frequently used in WHERE clauses, JOIN conditions, and ORDER BY -- but avoid indexing every column reflexively, since each index adds write overhead.
Common Mistakes
Adding an index to every column 'just in case,' which slows down writes without meaningfully helping reads that never filter on those columns.
Run EXPLAIN (or EXPLAIN ANALYZE) to see the query's execution plan, look for full table scans or missing index usage on large tables, and check whether the query is doing more work than necessary (unnecessary joins, SELECT *, or non-sargable predicates).
Detailed Answer
EXPLAIN reveals whether the database is using an index or falling back to a full table scan, how many rows it estimates it'll examine, and the join order/strategy chosen. Common fixes include adding a missing index on a filtered/joined column, rewriting a non-sargable predicate (like wrapping a column in a function, e.g. `WHERE YEAR(created_at) = 2024`, which prevents index use) into a sargable form (`WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'`), and avoiding SELECT * when only a few columns are actually needed.
Best Practices
Always check the execution plan before guessing at a fix -- adding an index blindly doesn't help if the planner isn't even using indexes for a different reason (like a function wrapped around the column).
Common Mistakes
Wrapping an indexed column in a function in the WHERE clause (e.g. LOWER(email) = ...), which silently disables index usage for that predicate unless a functional index exists.
Behavioral 1
A strong answer identifies the specific symptom (slow endpoint, timeout, lock contention), traces it to a concrete cause (missing index, N+1 query pattern, unnormalized/over-normalized schema), and explains the fix plus how it was verified.
Detailed Answer
Interviewers want a real, specific story: how the problem was noticed (monitoring, user complaints, slow query logs), how it was diagnosed (EXPLAIN plans, query logs, APM traces), what the actual fix was (added an index, batched N+1 queries into one, denormalized a hot-path table), and how the improvement was confirmed (before/after query times, reduced database load).
Scenario-Based 1
Separate Customers, Products, Orders, and OrderItems into distinct normalized tables, with OrderItems as a join table capturing per-line-item quantity and price-at-time-of-purchase (not a live reference to the product's current price).
Detailed Answer
Orders holds order-level data (customer_id, status, created_at). OrderItems holds one row per product in that order (order_id, product_id, quantity, unit_price_at_purchase) -- capturing the price at the time of purchase rather than joining live to Products.price is important, since product prices change over time and historical orders must reflect what was actually charged. Indexes on customer_id (for order history lookups) and order_id (for fetching an order's items) support the most common access patterns.
Best Practices
Store a snapshot of price-sensitive fields (like unit price) at transaction time rather than relying on a live join to a mutable Products table, so historical records stay accurate as prices change.
Common Mistakes
Computing an order's total by joining to the current Products.price at read time, which silently changes historical order totals whenever a product's price is updated later.
No questions match your filters.