SQL interviews test your ability to write correct queries, explain database concepts, and reason about performance. This guide covers 50 of the most common SQL interview questions — with concise answers and concrete examples.
Quick reference
| Topic | Most asked questions |
|---|---|
| SELECT basics | WHERE, DISTINCT, ORDER BY, LIMIT |
| JOINs | INNER vs LEFT, multiple joins, self-join |
| Aggregation | GROUP BY, HAVING, COUNT/SUM/AVG/MAX/MIN |
| Subqueries & CTEs | correlated, EXISTS, WITH clause |
| Window functions | ROW_NUMBER, RANK, LAG/LEAD, PARTITION BY |
| Indexes | B-tree, composite, covering, when to index |
| Transactions | ACID, isolation levels, deadlocks |
| Normalization | 1NF/2NF/3NF, denormalization trade-offs |
| Performance | EXPLAIN, N+1, query optimisation |
| Tricky questions | NULL behaviour, HAVING vs WHERE, UNION vs UNION ALL |
SELECT and filtering
1. What is the difference between WHERE and HAVING?
WHERE filters rows before aggregation. HAVING filters after aggregation.
-- WHERE: filter individual rows
SELECT department, COUNT(*) AS headcount
FROM employees
WHERE salary > 50000 -- applied before GROUP BY
GROUP BY department;
-- HAVING: filter aggregated groups
SELECT department, COUNT(*) AS headcount
FROM employees
GROUP BY department
HAVING COUNT(*) > 10; -- applied after GROUP BY
Rule: use WHERE whenever possible — it reduces rows early and is faster. Use HAVING only when filtering on an aggregate value.
2. What does DISTINCT do, and what is its performance cost?
DISTINCT removes duplicate rows from the result set. It forces the database to sort or hash the result, which is O(n log n) — more expensive than a plain SELECT.
-- Without DISTINCT: may return duplicates
SELECT country FROM customers;
-- With DISTINCT: each country appears once
SELECT DISTINCT country FROM customers;
-- Alternative with GROUP BY (same result, sometimes faster)
SELECT country FROM customers GROUP BY country;
Prefer GROUP BY when you need aggregates alongside the distinct column; prefer DISTINCT for simple deduplication.
3. What is the difference between UNION and UNION ALL?
UNION removes duplicates across both result sets. UNION ALL keeps all rows including duplicates.
-- UNION: sorted + dedup — slower
SELECT city FROM customers
UNION
SELECT city FROM suppliers;
-- UNION ALL: no dedup — faster
SELECT city FROM customers
UNION ALL
SELECT city FROM suppliers;
Use UNION ALL unless you specifically need deduplication — it avoids the extra sort/hash step.
4. How does NULL behave in SQL?
NULL is not a value — it represents the absence of a value. Comparisons with NULL using = or <> always return NULL (unknown), not TRUE or FALSE.
-- Wrong — returns no rows even if salary IS NULL
SELECT * FROM employees WHERE salary = NULL;
-- Correct
SELECT * FROM employees WHERE salary IS NULL;
SELECT * FROM employees WHERE salary IS NOT NULL;
-- NULL in aggregates
SELECT AVG(salary) FROM employees; -- NULLs are ignored
SELECT COUNT(*) FROM employees; -- counts all rows including NULLs
SELECT COUNT(salary) FROM employees; -- NULLs not counted
-- NULL in expressions
SELECT 100 + NULL; -- NULL
SELECT NULL = NULL; -- NULL (not TRUE)
SELECT NULL OR TRUE; -- TRUE
SELECT NULL AND FALSE; -- FALSE
Use COALESCE(value, default) or NULLIF(a, b) to handle NULLs in expressions.
5. What is the difference between DELETE, TRUNCATE, and DROP?
| Statement | Removes | Rollback? | Resets AUTO_INCREMENT? | Triggers? |
|---|---|---|---|---|
DELETE FROM t |
Rows (with WHERE optional) | Yes | No | Yes |
TRUNCATE TABLE t |
All rows | Usually no (DDL) | Yes | No |
DROP TABLE t |
Entire table + structure | No | N/A | No |
-- DELETE: slow, logged row-by-row, can use WHERE
DELETE FROM orders WHERE created_at < '2023-01-01';
-- TRUNCATE: fast, resets sequence, no WHERE
TRUNCATE TABLE session_logs;
-- DROP: removes table completely
DROP TABLE temp_import;
JOINs
6. What is the difference between INNER JOIN, LEFT JOIN, RIGHT JOIN, and FULL OUTER JOIN?
| JOIN type | Returns |
|---|---|
| INNER JOIN | Only rows with a match in both tables |
| LEFT JOIN | All rows from left + matching rows from right (NULLs for no match) |
| RIGHT JOIN | All rows from right + matching rows from left |
| FULL OUTER JOIN | All rows from both tables, NULLs where no match |
-- INNER JOIN: only matched customers with orders
SELECT c.name, o.total
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id;
-- LEFT JOIN: all customers, even those without orders
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id;
-- Find customers with NO orders (anti-join pattern)
SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;
7. What is a self-join and when do you use it?
A self-join joins a table to itself, typically to find relationships within the same table (hierarchies, pairs).
-- Employees table with manager_id referencing same table
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- Find pairs of employees in the same department
SELECT a.name AS emp1, b.name AS emp2
FROM employees a
JOIN employees b ON a.department = b.department AND a.id < b.id;
8. What is a CROSS JOIN?
A CROSS JOIN produces the Cartesian product — every row from the left table combined with every row from the right table.
SELECT colors.name, sizes.label
FROM colors
CROSS JOIN sizes;
-- 5 colors × 3 sizes = 15 rows
Use cases: generating combinations (size/colour grids), populating calendar tables, test data.
9. How do you join more than two tables?
Chain joins sequentially. The result of each JOIN becomes the left side of the next.
SELECT
o.id AS order_id,
c.name AS customer,
p.name AS product,
oi.quantity
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.status = 'completed';
Index every join column (customer_id, order_id, product_id) to avoid full-table scans.
Aggregation and grouping
10. What is GROUP BY and what are the rules for using it?
GROUP BY collapses rows with the same values into summary rows. Every column in SELECT must either be in GROUP BY or wrapped in an aggregate function.
-- Correct
SELECT department, COUNT(*) AS headcount, AVG(salary) AS avg_salary
FROM employees
GROUP BY department;
-- Wrong in strict SQL: name is not aggregated or grouped
-- SELECT department, name, COUNT(*) FROM employees GROUP BY department;
MySQL with sql_mode=ONLY_FULL_GROUP_BY enforces this strictly (as does PostgreSQL by default).
11. What aggregate functions does SQL provide?
| Function | Description |
|---|---|
COUNT(*) |
Count all rows |
COUNT(col) |
Count non-NULL values |
COUNT(DISTINCT col) |
Count unique non-NULL values |
SUM(col) |
Total of numeric column |
AVG(col) |
Average (NULLs excluded) |
MIN(col) |
Minimum value |
MAX(col) |
Maximum value |
GROUP_CONCAT(col) |
Concatenate values (MySQL) |
STRING_AGG(col, sep) |
Concatenate values (PostgreSQL) |
SELECT
department,
COUNT(*) AS total,
COUNT(DISTINCT job_title) AS unique_roles,
SUM(salary) AS payroll,
AVG(salary) AS avg_sal,
MIN(hired_at) AS first_hire,
MAX(hired_at) AS last_hire
FROM employees
GROUP BY department;
12. Difference between COUNT(*) and COUNT(column)?
COUNT(*)counts all rows, including those with NULLs.COUNT(column)counts only rows where that column is NOT NULL.
CREATE TABLE example (id INT, score INT);
INSERT INTO example VALUES (1, 90), (2, NULL), (3, 75);
SELECT COUNT(*) FROM example; -- 3
SELECT COUNT(score) FROM example; -- 2 (NULL excluded)
SELECT COUNT(DISTINCT score) FROM example; -- 2
Subqueries and CTEs
13. What is a subquery? What are the types?
A subquery is a SELECT nested inside another query.
| Type | Description |
|---|---|
| Scalar | Returns a single value |
| Row | Returns a single row |
| Table (derived) | Returns a result set used as a table |
| Correlated | References the outer query — re-executed per row |
-- Scalar subquery
SELECT name, salary,
(SELECT AVG(salary) FROM employees) AS company_avg
FROM employees;
-- Table subquery (derived table)
SELECT dept, avg_sal
FROM (
SELECT department AS dept, AVG(salary) AS avg_sal
FROM employees
GROUP BY department
) AS dept_stats
WHERE avg_sal > 60000;
-- Correlated subquery — runs once per outer row (slow on large tables)
SELECT name, salary
FROM employees e
WHERE salary > (
SELECT AVG(salary) FROM employees WHERE department = e.department
);
14. What is the difference between IN and EXISTS?
Both test membership, but they behave differently:
INevaluates the subquery once and compares values. Returns FALSE if the subquery is empty.EXISTSchecks whether the subquery returns any rows. Stops at the first match (short-circuit). Better for large outer tables.
-- IN: good when subquery returns few rows
SELECT name FROM customers
WHERE id IN (SELECT customer_id FROM orders WHERE total > 1000);
-- EXISTS: better when subquery returns many rows, short-circuits
SELECT name FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.total > 1000
);
-- NOT EXISTS: anti-join
SELECT name FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
15. What is a CTE (Common Table Expression)?
A CTE is a named temporary result set defined with WITH. It makes complex queries more readable and can be referenced multiple times.
WITH high_value_customers AS (
SELECT customer_id, SUM(total) AS lifetime_value
FROM orders
GROUP BY customer_id
HAVING SUM(total) > 5000
),
customer_details AS (
SELECT c.id, c.name, c.email, h.lifetime_value
FROM customers c
JOIN high_value_customers h ON c.id = h.customer_id
)
SELECT * FROM customer_details ORDER BY lifetime_value DESC;
CTEs are not materialised by default in most databases — the optimiser may inline them. Use MATERIALIZED hint in PostgreSQL if you need caching.
16. What is a recursive CTE?
A recursive CTE calls itself to traverse hierarchical data like org charts or category trees.
-- Print all employees under manager ID 1
WITH RECURSIVE org_chart AS (
-- Base case: the top manager
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive case: direct reports
SELECT e.id, e.name, e.manager_id, oc.depth + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT depth, name FROM org_chart ORDER BY depth, name;
Most databases support this (PostgreSQL, MySQL 8+, SQL Server, SQLite 3.35+). Always include a termination condition or a depth/iteration limit to prevent infinite loops.
Window functions
17. What are window functions?
Window functions compute a value for each row using a set of rows (the "window") without collapsing them into a single result like GROUP BY does.
SELECT
name,
department,
salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY department) AS diff_from_avg
FROM employees;
Available in PostgreSQL, MySQL 8+, SQL Server, SQLite 3.25+, Oracle.
18. What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?
All three number rows within a partition, but they handle ties differently:
| Function | Ties | Gap after ties |
|---|---|---|
| ROW_NUMBER | Arbitrary unique number | — |
| RANK | Same rank | Yes (skips numbers) |
| DENSE_RANK | Same rank | No (no gaps) |
SELECT name, score,
ROW_NUMBER() OVER (ORDER BY score DESC) AS row_num,
RANK() OVER (ORDER BY score DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rnk
FROM scores;
-- score 100, 100, 90, 80
-- ROW_NUMBER: 1, 2, 3, 4
-- RANK: 1, 1, 3, 4 (gap after tie)
-- DENSE_RANK: 1, 1, 2, 3 (no gap)
19. How do you get the top N rows per group?
Use ROW_NUMBER() with PARTITION BY, then filter in an outer query.
-- Top 3 earners per department
WITH ranked AS (
SELECT
name, department, salary,
ROW_NUMBER() OVER (
PARTITION BY department ORDER BY salary DESC
) AS rn
FROM employees
)
SELECT name, department, salary
FROM ranked
WHERE rn <= 3;
20. What are LAG and LEAD?
LAG accesses a previous row's value; LEAD accesses a future row's value within the same window.
SELECT
order_date,
total,
LAG(total, 1, 0) OVER (ORDER BY order_date) AS prev_total,
total - LAG(total, 1, 0) OVER (ORDER BY order_date) AS day_over_day_change,
LEAD(total) OVER (ORDER BY order_date) AS next_total
FROM daily_sales;
Both accept an optional offset (default 1) and a default value when no row exists.
Indexes
21. What is an index and how does it work?
An index is a data structure (usually a B-tree) that stores column values alongside pointers to rows, enabling the database to find rows without scanning the entire table.
- Without index: full table scan O(n)
- With index: B-tree lookup O(log n) + row fetch
-- Create a basic index
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
-- Unique index (enforces constraint + enables fast lookup)
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- Composite index (order matters — leftmost prefix rule)
CREATE INDEX idx_orders_status_date ON orders(status, created_at);
22. What is the leftmost prefix rule for composite indexes?
A composite index on (a, b, c) can be used for queries that filter on a, a + b, or a + b + c — but NOT on b alone or c alone.
CREATE INDEX idx ON orders(status, created_at, customer_id);
-- Uses index ✓
WHERE status = 'pending'
WHERE status = 'pending' AND created_at > '2024-01-01'
WHERE status = 'pending' AND created_at > '2024-01-01' AND customer_id = 42
-- Does NOT use index ✗
WHERE created_at > '2024-01-01' -- skips leftmost column
WHERE customer_id = 42 -- skips first two columns
23. What is a covering index?
A covering index includes all columns needed by a query, so the database can answer the query entirely from the index without accessing the table rows.
-- Query
SELECT email, status FROM users WHERE created_at > '2024-01-01';
-- Covering index for that query
CREATE INDEX idx_users_covering ON users(created_at, email, status);
-- All needed columns (created_at filter + email, status select) are in the index
The EXPLAIN output shows "Using index" (MySQL) or "Index Only Scan" (PostgreSQL) for covering index usage.
24. When should you NOT add an index?
- Tables with very few rows (full scan is faster)
- Columns with very low cardinality (e.g. boolean flags)
- Columns that are rarely used in WHERE/JOIN
- Tables with heavy write workloads (every write must update all indexes)
- Columns that are frequently updated (index maintenance cost)
25. What is the difference between a clustered and non-clustered index?
| Clustered | Non-clustered | |
|---|---|---|
| Stores | Actual row data in index order | Pointers to row data |
| Per table | One only | Many |
| MySQL InnoDB | Primary key is always clustered | Secondary indexes store PK value |
| PostgreSQL | No clustered index (heap-based) | All indexes are non-clustered |
In MySQL InnoDB, the primary key IS the table — rows are stored in B-tree order by PK.
Transactions and ACID
26. What does ACID stand for?
| Property | Meaning |
|---|---|
| Atomicity | All operations in a transaction succeed or all roll back |
| Consistency | Database moves from one valid state to another |
| Isolation | Concurrent transactions don't interfere |
| Durability | Committed data survives crashes (written to disk) |
BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE id = 1;
UPDATE accounts SET balance = balance + 500 WHERE id = 2;
-- If anything fails, ROLLBACK undoes both updates
COMMIT;
27. What are the SQL transaction isolation levels?
| Level | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible |
| READ COMMITTED | Prevented | Possible | Possible |
| REPEATABLE READ | Prevented | Prevented | Possible (but prevented in InnoDB) |
| SERIALIZABLE | Prevented | Prevented | Prevented |
- Most databases default to READ COMMITTED (PostgreSQL) or REPEATABLE READ (MySQL InnoDB).
- Higher isolation = fewer anomalies but more contention and lower concurrency.
28. What is a deadlock?
A deadlock occurs when two transactions each hold a lock the other needs, causing them to wait forever.
-- Session 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- locks row 1
-- waits for row 2 locked by Session 2...
-- Session 2
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2; -- locks row 2
-- waits for row 1 locked by Session 1...
Databases detect deadlocks and automatically roll back one transaction. Prevention: always acquire locks in the same order, keep transactions short.
Normalization
29. What are the normal forms?
| NF | Rule |
|---|---|
| 1NF | Atomic columns — no arrays or repeating groups |
| 2NF | 1NF + no partial dependency on a composite primary key |
| 3NF | 2NF + no transitive dependencies (non-key column depending on another non-key) |
| BCNF | Stricter 3NF — every determinant is a candidate key |
Un-normalised:
order_id | customer_name | customer_email | product1 | product2
1NF (atomic):
order_id | customer_name | customer_email | product
2NF (remove partial dependency):
orders(order_id, customer_id)
customers(customer_id, customer_name, customer_email)
order_items(order_id, product_id)
3NF (remove transitive):
products(product_id, product_name, category_id)
categories(category_id, category_name)
30. What is denormalisation and when do you use it?
Denormalisation intentionally introduces redundancy to improve read performance. Common techniques:
- Storing pre-computed aggregates (e.g.
order_counton thecustomerstable) - Duplicating columns to avoid expensive joins (e.g.
product_nameinorder_items) - Read-optimised reporting tables (star/snowflake schema in data warehouses)
Trade-off: faster reads, more storage, risk of data inconsistency on writes.
Performance and query optimisation
31. How do you read an EXPLAIN plan?
EXPLAIN shows how the database executes a query — table scan vs index, join method, estimated rows.
-- MySQL
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
-- PostgreSQL
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
Key things to look for:
| Output | Meaning |
|---|---|
type: ALL (MySQL) / Seq Scan (PG) |
Full table scan — usually bad on large tables |
type: ref / Index Scan |
Index used |
type: eq_ref / Index Seek |
Unique index lookup — fastest |
rows |
Estimated number of rows scanned |
Using filesort |
Sort without an index — slow |
Using temporary |
Temp table created — slow |
32. What is the N+1 query problem?
N+1 happens when you execute 1 query to fetch a list, then N more queries to fetch related data for each item — instead of a single JOIN.
-- N+1 (bad): 1 query for customers, then 1 per customer for orders
SELECT * FROM customers; -- returns 100 rows
SELECT * FROM orders WHERE customer_id = 1; -- repeated 100 times
-- Fixed: single JOIN
SELECT c.name, o.total
FROM customers c
JOIN orders o ON o.customer_id = c.id;
-- Or: two queries + in-app join (when JOIN is impractical)
SELECT * FROM customers;
SELECT * FROM orders WHERE customer_id IN (1, 2, 3, ...100);
33. How would you optimise a slow query?
- Run
EXPLAIN ANALYZE— identify full table scans or bad estimates - Check missing indexes on WHERE/JOIN/ORDER BY columns
- Rewrite correlated subqueries as JOINs or CTEs
- Use covering indexes to avoid row lookups
- Move filter conditions to WHERE (not HAVING when possible)
- Avoid
SELECT *— fetch only needed columns - Use pagination (
LIMIT/OFFSETor keyset pagination) instead of loading all rows - Partition large tables by date or range
- Cache results at the application layer for read-heavy queries
34. What is keyset pagination and why is it better than OFFSET?
OFFSET n requires the database to scan and discard n rows — O(n) cost that grows with page number.
Keyset (cursor) pagination filters on the last seen value, using an indexed column — O(log n) regardless of page.
-- OFFSET pagination (slow on large pages)
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 10000;
-- Keyset pagination (fast)
SELECT * FROM orders
WHERE id > 10020 -- last id from previous page
ORDER BY id
LIMIT 20;
Common interview gotchas
35. What does SELECT * do to performance?
It fetches all columns, preventing covering index usage, increasing network payload, and tying queries to the current schema. Always list columns explicitly in production queries.
36. Why does this query return unexpected results?
SELECT name FROM employees WHERE department != 'Engineering';
If department contains NULL values for some rows, those rows are excluded — because NULL != 'Engineering' evaluates to NULL (unknown), not TRUE. To include them:
SELECT name FROM employees
WHERE department != 'Engineering' OR department IS NULL;
37. What is the difference between a primary key and a unique key?
| Primary Key | Unique Key | |
|---|---|---|
| NULLs allowed | No | Yes (one NULL in most databases) |
| Per table | One | Many |
| Clustered (MySQL InnoDB) | Yes | No |
| Purpose | Row identity | Uniqueness constraint |
38. What is a foreign key and what does ON DELETE CASCADE do?
A foreign key enforces referential integrity — it ensures a value in one table exists in another.
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
FOREIGN KEY (customer_id) REFERENCES customers(id)
ON DELETE CASCADE -- delete orders when customer is deleted
ON UPDATE CASCADE -- update customer_id if PK changes
);
| Action | Behaviour |
|---|---|
| RESTRICT / NO ACTION | Block delete if child rows exist |
| CASCADE | Delete/update child rows automatically |
| SET NULL | Set FK column to NULL |
| SET DEFAULT | Set FK column to its default value |
39. What is a view and when would you use one?
A view is a stored SELECT query that acts like a virtual table.
CREATE VIEW active_customer_orders AS
SELECT c.name, c.email, COUNT(o.id) AS order_count
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.status = 'active'
GROUP BY c.id, c.name, c.email;
-- Use like a table
SELECT * FROM active_customer_orders WHERE order_count > 5;
Uses: simplify complex queries, restrict column access (security), provide stable API over changing schema.
40. What is the difference between a stored procedure and a function?
| Stored Procedure | Function | |
|---|---|---|
| Returns | Nothing (or OUT params) | A value |
| Usable in SELECT | No | Yes |
| Can modify data | Yes | Depends (pure functions cannot in PostgreSQL) |
| Transaction control | Yes (COMMIT/ROLLBACK) | No |
-- PostgreSQL function
CREATE OR REPLACE FUNCTION get_order_count(cust_id INT)
RETURNS INT AS $$
SELECT COUNT(*) FROM orders WHERE customer_id = cust_id;
$$ LANGUAGE SQL;
SELECT get_order_count(42);
Advanced topics
41. What is the difference between a hash join and a nested loop join?
| Join strategy | Best for | Time complexity |
|---|---|---|
| Nested loop | Small outer table, indexed inner | O(n × log m) |
| Hash join | Large tables with no useful index | O(n + m) |
| Merge join | Both inputs sorted on join key | O(n log n + m log m) |
The optimiser chooses based on table size, available indexes, and statistics. EXPLAIN shows which strategy was chosen.
42. What are partitioned tables?
Table partitioning splits a large table into smaller physical segments (partitions) while presenting a single logical table. The database can scan only relevant partitions (partition pruning).
-- PostgreSQL range partitioning by year
CREATE TABLE orders (
id BIGINT,
created_at DATE,
total NUMERIC
) PARTITION BY RANGE (created_at);
CREATE TABLE orders_2023 PARTITION OF orders
FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');
CREATE TABLE orders_2024 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
Common strategies: RANGE (dates), LIST (categories), HASH (even distribution).
43. What is a materialised view?
A materialised view stores the query result physically on disk (unlike a regular view that re-runs the query). It must be refreshed explicitly or on a schedule.
-- PostgreSQL
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue
FROM orders
GROUP BY 1;
-- Refresh (blocks reads in older PG versions)
REFRESH MATERIALIZED VIEW monthly_revenue;
-- Non-blocking refresh
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;
44. What is SQL injection and how do you prevent it?
SQL injection occurs when user input is concatenated into a SQL string, allowing attackers to modify the query.
-- Vulnerable (never do this)
$query = "SELECT * FROM users WHERE email = '" . $_GET['email'] . "'";
-- Input: ' OR '1'='1 -- makes WHERE always true
-- Safe: parameterised query
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$_GET['email']]);
Prevention: always use parameterised queries / prepared statements. Never build SQL by string concatenation with user input.
45. What is the difference between CHAR and VARCHAR?
| CHAR(n) | VARCHAR(n) | |
|---|---|---|
| Storage | Fixed n bytes (padded with spaces) | Variable (actual length + 1-2 bytes overhead) |
| Performance | Slightly faster for fixed-size data | More storage-efficient |
| Use case | ISO codes, fixed-format IDs | Names, emails, descriptions |
CHAR(2) -- country codes: 'US', 'DE', 'ME'
VARCHAR(255) -- email addresses of varying length
TEXT -- unlimited length (PostgreSQL, MySQL)
Tricky questions
46. Write a query to find duplicate rows.
-- Find emails that appear more than once
SELECT email, COUNT(*) AS count
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- Show all duplicate rows (including originals)
SELECT * FROM users
WHERE email IN (
SELECT email FROM users GROUP BY email HAVING COUNT(*) > 1
)
ORDER BY email;
-- Delete duplicates, keep lowest id
DELETE FROM users
WHERE id NOT IN (
SELECT MIN(id) FROM users GROUP BY email
);
47. Write a query to get the second highest salary.
-- Using OFFSET
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;
-- Using subquery (works everywhere)
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- Using DENSE_RANK
WITH ranked AS (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
)
SELECT salary FROM ranked WHERE rnk = 2;
48. Write a query to pivot rows into columns.
-- Source: monthly_sales(month, product, revenue)
-- Target: product | Jan | Feb | Mar
-- MySQL
SELECT product,
SUM(CASE WHEN month = 'Jan' THEN revenue ELSE 0 END) AS Jan,
SUM(CASE WHEN month = 'Feb' THEN revenue ELSE 0 END) AS Feb,
SUM(CASE WHEN month = 'Mar' THEN revenue ELSE 0 END) AS Mar
FROM monthly_sales
GROUP BY product;
-- PostgreSQL with crosstab (requires tablefunc extension)
SELECT * FROM crosstab(
'SELECT product, month, revenue FROM monthly_sales ORDER BY 1, 2',
'VALUES (''Jan''), (''Feb''), (''Mar'')'
) AS t(product TEXT, Jan NUMERIC, Feb NUMERIC, Mar NUMERIC);
49. What is the difference between optimistic and pessimistic locking?
| Pessimistic locking | Optimistic locking | |
|---|---|---|
| Mechanism | Lock rows on read (SELECT FOR UPDATE) |
Version column checked on write |
| Concurrency | Low (locks held until commit) | High (no locks held) |
| Contention handling | Wait or timeout | Detect conflict on write, retry |
| Best for | High-contention data | Low-contention data |
-- Pessimistic: lock row during read
BEGIN;
SELECT * FROM inventory WHERE id = 1 FOR UPDATE;
UPDATE inventory SET quantity = quantity - 1 WHERE id = 1;
COMMIT;
-- Optimistic: check version on write
UPDATE inventory
SET quantity = quantity - 1, version = version + 1
WHERE id = 1 AND version = 5; -- fails if another transaction updated first
-- If rows_affected = 0, retry with fresh data
50. What is a surrogate key vs a natural key?
| Natural key | Surrogate key | |
|---|---|---|
| Source | Real-world data (email, SSN, ISBN) | System-generated (SERIAL, UUID) |
| Stability | May change over time | Never changes |
| Privacy | Exposes business data | Opaque |
| Join cost | Wider (string) | Narrow (integer) |
Most production schemas use surrogate PKs (auto-increment or UUID) and add unique constraints on natural keys.
-- Surrogate PK + natural key constraint
CREATE TABLE users (
id SERIAL PRIMARY KEY, -- surrogate
email VARCHAR(255) NOT NULL UNIQUE -- natural key as unique constraint
);
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
WHERE col = NULL |
Always returns empty | Use IS NULL |
| Missing index on JOIN column | Full table scan on every join | Index FK columns |
SELECT * in production |
Breaks on schema change, blocks covering indexes | List columns explicitly |
| N+1 queries | O(n) round trips | Use JOIN or batch IN |
OFFSET for large pages |
Scans and discards rows | Use keyset pagination |
| Aggregate in WHERE | Syntax error | Move to HAVING |
| Ignoring NULL in aggregates | Miscounted averages | Use COALESCE or filter |
| No transaction on multi-step writes | Partial failure leaves corrupt data | Wrap in BEGIN/COMMIT |
FAQ
Q: What is the execution order of SQL clauses?
FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT. This explains why you can't use a SELECT alias in WHERE (alias not yet assigned).
Q: What is the difference between a subquery and a CTE?
A CTE is syntactic sugar for a named subquery, defined with WITH. CTEs improve readability and can be referenced multiple times. Performance is generally identical — the optimiser may inline either.
Q: Can a table have multiple primary keys?
No. A table has at most one primary key, but it can be a composite primary key — spanning multiple columns. You can have many unique keys.
Q: When should I use NoSQL instead of SQL?
Consider NoSQL when you need horizontal scale-out (sharding across many nodes), document/graph/time-series data models, or schema flexibility. Use SQL when you need joins, ACID transactions, complex querying, and strong consistency.
Q: How do I handle large text in SQL?
Use TEXT (PostgreSQL/MySQL) or NVARCHAR(MAX) (SQL Server). Avoid indexing large text columns directly — use full-text indexes (FULLTEXT in MySQL, tsvector in PostgreSQL) or a dedicated search engine (Elasticsearch).
Q: What is the difference between CHAR(36) and UUID type for storing UUIDs?
Storing as CHAR(36) uses 36 bytes and requires string comparison. Native UUID types (UUID in PostgreSQL, BINARY(16) in MySQL) use 16 bytes and binary comparison — significantly faster for joins and indexes.
Related: SQL Cheat Sheet · SQL Joins Guide · MySQL Cheat Sheet · PostgreSQL Cheat Sheet