Toolmingo
Guides24 min read

50 SQL Interview Questions (With Answers)

Top SQL interview questions with clear answers and examples — covering SELECT, JOINs, aggregation, subqueries, indexes, window functions, transactions, and performance.

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:

  • IN evaluates the subquery once and compares values. Returns FALSE if the subquery is empty.
  • EXISTS checks 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_count on the customers table)
  • Duplicating columns to avoid expensive joins (e.g. product_name in order_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?

  1. Run EXPLAIN ANALYZE — identify full table scans or bad estimates
  2. Check missing indexes on WHERE/JOIN/ORDER BY columns
  3. Rewrite correlated subqueries as JOINs or CTEs
  4. Use covering indexes to avoid row lookups
  5. Move filter conditions to WHERE (not HAVING when possible)
  6. Avoid SELECT * — fetch only needed columns
  7. Use pagination (LIMIT/OFFSET or keyset pagination) instead of loading all rows
  8. Partition large tables by date or range
  9. 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

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools