SQL from Basics to Advanced.
Complete SQL reference — database design, queries, joins, window functions, indexes, normalization, and optimization. 40+ interview questions tagged for FAANG and startups. Free.
What's covered
Six topic areas — from first query to query optimization.
What is a Database?
A database is a structured collection of related data. In MySQL, a database holds one or more tables — think of it as a folder, where each table is a file inside it.
| Analogy | Database | Table | Row |
|---|---|---|---|
| File System | Folder | File | Line in the file |
| Excel | Workbook | Sheet | Excel row |
| Real world | Company records | Department list | One employee |
CREATE DATABASE techforge_db; USE techforge_db; SHOW DATABASES; -- List all databases DROP DATABASE techforge_db; -- Permanent!
USE database_name; or set it as default schema in your SQL client before running table queries.DDL, DML, DCL & TCL
Every SQL statement belongs to one of four sub-languages. Interviewers ask this to check you know which statements can be rolled back and which cannot.
| Family | Stands for | Statements | Rollback? |
|---|---|---|---|
| DDL | Data Definition | CREATE, ALTER, DROP, TRUNCATE | No — auto-commits in MySQL and Oracle |
| DML | Data Manipulation | INSERT, UPDATE, DELETE, SELECT | Yes |
| DCL | Data Control | GRANT, REVOKE | No |
| TCL | Transaction Control | COMMIT, ROLLBACK, SAVEPOINT | — |
TRUNCATE is DDL, so it cannot be rolled back and it resets identity counters. DELETE is DML — slower, logs every row, but recoverable inside a transaction.MySQL Data Types
| Data Type | Description | Example | Interview Note |
|---|---|---|---|
INT | Whole numbers | age INT | Use BIGINT for IDs at scale |
VARCHAR(n) | Variable-length string (up to n chars) | name VARCHAR(100) | Prefer over CHAR for varying lengths |
TEXT | Long text (65,535 chars) | bio TEXT | Cannot be indexed directly |
DECIMAL(p,s) | Exact numeric (p digits, s decimal) | price DECIMAL(10,2) | Use for money — never FLOAT |
DATE | Date only (YYYY-MM-DD) | dob DATE | DATETIME includes time component |
TIMESTAMP | Date + time, auto-sets to now | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | Stored in UTC |
BOOLEAN | TRUE / FALSE (stored as 1/0) | is_active BOOLEAN DEFAULT TRUE | Alias for TINYINT(1) |
ENUM | One value from a defined set | gender ENUM('Male','Female','Other') | Validates at DB level |
DECIMAL for financial data to avoid rounding errors.Constraints
Constraints enforce rules on column data — they protect data integrity at the database level, not just the application level.
| Constraint | Purpose | Example |
|---|---|---|
PRIMARY KEY | Uniquely identifies each row — NOT NULL + UNIQUE | id INT PRIMARY KEY |
AUTO_INCREMENT | Auto-generates sequential integers | id INT AUTO_INCREMENT |
NOT NULL | Column must always have a value | name VARCHAR(100) NOT NULL |
UNIQUE | All values must be distinct (allows NULL) | email VARCHAR(100) UNIQUE |
DEFAULT | Uses this value if none provided | is_active BOOLEAN DEFAULT TRUE |
CHECK | Values must satisfy a condition | CHECK (salary > 0) |
FOREIGN KEY | References a primary key in another table | FOREIGN KEY(user_id) REFERENCES users(id) |
Create Table
CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(100) UNIQUE NOT NULL, gender ENUM('Male', 'Female', 'Other'), salary DECIMAL(10,2) DEFAULT 0, date_of_birth DATE, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Verify structure DESCRIBE users; -- Remove table entirely (irreversible) DROP TABLE IF EXISTS users;
Alter Table
-- Add a column ALTER TABLE users ADD COLUMN phone VARCHAR(15); -- Drop a column ALTER TABLE users DROP COLUMN phone; -- Modify column type ALTER TABLE users MODIFY COLUMN name VARCHAR(150) NOT NULL; -- Rename column (MySQL 8.0+) ALTER TABLE users RENAME COLUMN name TO full_name; -- Rename table RENAME TABLE users TO members;
Insert Data
-- Single row INSERT INTO users (name, email, gender, salary, date_of_birth) VALUES ('Alice', 'alice@example.com', 'Female', 85000, '1995-04-12'); -- Multiple rows at once (more efficient) INSERT INTO users (name, email, gender, salary, date_of_birth) VALUES ('Bob', 'bob@example.com', 'Male', 72000, '1990-11-23'), ('Charlie', 'charlie@example.com', 'Other', 91000, '1988-02-17'), ('Divya', 'divya@example.com', 'Female', 67000, '2000-08-09');
SELECT Query
-- Basic SELECT SELECT * FROM users; SELECT name, email, salary FROM users; -- WHERE with operators SELECT * FROM users WHERE gender = 'Female'; SELECT * FROM users WHERE salary BETWEEN 60000 AND 90000; SELECT * FROM users WHERE name LIKE 'A%'; -- starts with A SELECT * FROM users WHERE email IS NOT NULL; -- AND / OR / NOT SELECT * FROM users WHERE salary > 70000 AND gender = 'Male'; SELECT * FROM users WHERE department IN ('Eng', 'Design'); -- ORDER and LIMIT SELECT * FROM users ORDER BY salary DESC LIMIT 10; SELECT * FROM users ORDER BY name ASC LIMIT 5 OFFSET 10; -- pagination -- Aliases SELECT name AS full_name, salary * 12 AS annual_salary FROM users;
UPDATE & DELETE
-- UPDATE specific rows UPDATE users SET salary = 95000 WHERE id = 1; UPDATE users SET salary = salary * 1.10 WHERE gender = 'Female'; -- 10% raise -- DELETE specific rows DELETE FROM users WHERE id = 3; DELETE FROM users WHERE is_active = FALSE; -- TRUNCATE: delete all rows, keep table structure (faster than DELETE) TRUNCATE TABLE users;
SQL Functions
SELECT COUNT(*) AS total_users FROM users; SELECT COUNT(DISTINCT department) AS dept_count FROM users; SELECT MIN(salary), MAX(salary) AS max_sal FROM users; SELECT SUM(salary) AS total_payroll FROM users; SELECT ROUND(AVG(salary), 2) AS avg_salary FROM users;
-- String functions SELECT UPPER(name), LOWER(email), LENGTH(name) FROM users; SELECT CONCAT(first_name, ' ', last_name) AS full_name FROM users; SELECT SUBSTRING(email, 1, INSTR(email, '@') - 1) AS username FROM users; -- Date functions SELECT YEAR(date_of_birth), MONTH(date_of_birth) FROM users; SELECT DATEDIFF(NOW(), date_of_birth) / 365 AS age FROM users; SELECT DATE_FORMAT(created_at, '%Y-%m') AS month FROM users;
NULLs & the CASE Expression
NULL is not a value — it is the absence of one. That is why it never equals anything, not even itself, and why comparisons against it need IS NULL rather than =.
SELECT NULL = NULL; -- NULL, not true SELECT * FROM users WHERE deleted_at IS NULL; -- correct -- Aggregates skip NULLs; COUNT(*) does not SELECT COUNT(*), COUNT(phone), AVG(score) FROM users;
| Function | Does | Portable? |
|---|---|---|
COALESCE(a, b, c) | Returns the first non-NULL argument | Standard SQL — use this |
ISNULL(a, b) | Two arguments only | SQL Server |
NVL(a, b) | Two arguments only | Oracle |
NULLIF(a, b) | NULL when a equals b, else a | Standard — good for guarding division |
CASE is SQL's if/else, usable anywhere an expression is allowed — in SELECT, in ORDER BY, even inside an aggregate to count conditionally.
SELECT name, CASE WHEN score >= 90 THEN 'A' WHEN score >= 80 THEN 'B' ELSE 'C' END AS grade, COUNT(CASE WHEN status = 'paid' THEN 1 END) AS paid_orders FROM students LEFT JOIN orders USING(id) GROUP BY name, grade;
CASE with no matching WHEN and no ELSE returns NULL — a common source of silently missing rows once that column is filtered on later.Window Functions
Window functions perform calculations across rows related to the current row — without collapsing them into groups like GROUP BY does. They're one of the most-tested advanced SQL topics at FAANG companies.
-- ROW_NUMBER: unique rank per partition SELECT name, department, salary, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num FROM users; -- RANK vs DENSE_RANK: ties behave differently -- RANK skips numbers after ties; DENSE_RANK does not SELECT name, salary, RANK() OVER (ORDER BY salary DESC) AS rank_val, DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank_val FROM users; -- LAG/LEAD: compare current row with previous/next SELECT name, salary, LAG(salary) OVER (ORDER BY salary) AS prev_salary, LEAD(salary) OVER (ORDER BY salary) AS next_salary FROM users; -- Running total SELECT name, salary, SUM(salary) OVER (ORDER BY id ROWS UNBOUNDED PRECEDING) AS running_total FROM users;
GROUP BY collapses rows into one per group. Window functions keep all rows and add a calculated column alongside them.Transactions & ACID
A transaction is a sequence of SQL statements treated as a single unit — either all succeed or all fail (atomicity).
| ACID Property | Meaning |
|---|---|
| Atomicity | All operations succeed or all are rolled back |
| Consistency | DB moves from one valid state to another |
| Isolation | Concurrent transactions don't interfere |
| Durability | Committed data persists even after crashes |
START TRANSACTION; UPDATE accounts SET balance = balance - 500 WHERE id = 1; -- debit UPDATE accounts SET balance = balance + 500 WHERE id = 2; -- credit -- If both succeed: COMMIT; -- If something fails, undo everything: ROLLBACK;
Isolation Levels & Deadlocks
The I in ACID is a dial, not a switch. Weaker isolation lets more transactions run at once and admits specific anomalies; stronger isolation removes them at the cost of blocking.
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible |
| READ COMMITTED | Prevented | Possible | Possible |
| REPEATABLE READ | Prevented | Prevented | Possible* |
| SERIALIZABLE | Prevented | Prevented | Prevented |
A dirty read sees another transaction's uncommitted change. A non-repeatable read gets a different value when it reads the same row twice. A phantom read gets a different set of rows when it repeats the same query. (*InnoDB's REPEATABLE READ blocks phantoms too, via next-key locking — the standard does not require that.)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 1; SAVEPOINT after_debit; -- a partial rollback point UPDATE accounts SET balance = balance + 100 WHERE id = 2; ROLLBACK TO after_debit; -- undo the credit, keep the debit COMMIT;
Primary & Foreign Keys
- Uniquely identifies each row
- Cannot be NULL
- Only one per table
- Often combined with AUTO_INCREMENT
- References a PK in another table
- Enforces referential integrity
- Can be NULL (optional relationship)
- Multiple FKs allowed per table
CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, total DECIMAL(10,2), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); -- ON DELETE CASCADE: deleting a user also deletes their orders -- ON DELETE SET NULL: sets user_id to NULL when user is deleted
A natural key is data that is already unique and meaningful — an ISBN, an email. A surrogate key is a meaningless identifier the database generates, usually an auto-increment or identity column. Surrogates win in practice because business meaning changes: people change email addresses, and a key that changes has to be updated everywhere it was referenced.
${table(['Term', 'Meaning'], [ ['Composite key', 'A primary key made of two or more columns together'], ['Candidate key', 'Any column set that could serve as the primary key'], ['Surrogate key', 'A generated identifier with no business meaning'], ['Orphan record', 'A row whose foreign key points at something that no longer exists'], ])}Referential integrity is what prevents orphans, and ON DELETE decides what happens when a parent goes away.
ON DELETE CASCADE is convenient and dangerous: one delete can silently remove rows several tables away. Use it where the child genuinely cannot exist without the parent, and RESTRICT everywhere else so the deletion has to be deliberate.SQL JOINs
JOINs combine rows from two or more tables based on a related column. The most tested SQL concept in interviews.
-- INNER JOIN: users who have placed orders SELECT u.name, o.total FROM users u INNER JOIN orders o ON u.id = o.user_id; -- LEFT JOIN: ALL users, with orders if they exist SELECT u.name, o.total FROM users u LEFT JOIN orders o ON u.id = o.user_id; -- Find users with NO orders SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL; -- SELF JOIN: find users in the same department SELECT a.name, b.name, a.department FROM users a JOIN users b ON a.department = b.department AND a.id != b.id;
UNION & Subqueries
-- UNION: combines results, removes duplicates SELECT name FROM users UNION SELECT name FROM admin_users; -- UNION ALL: keeps duplicates (faster) SELECT name FROM users UNION ALL SELECT name FROM admin_users; -- Subquery in WHERE SELECT name, salary FROM users WHERE salary > (SELECT AVG(salary) FROM users); -- Subquery in FROM (derived table) SELECT dept, avg_sal FROM ( SELECT department AS dept, AVG(salary) AS avg_sal FROM users GROUP BY department ) dept_avg WHERE avg_sal > 80000;
Advanced Query Patterns
Four shapes that come up constantly once the basics are behind you.
Correlated subquery — an inner query that references the outer row, so it re-evaluates per row. Readable, but often slower than the join or window function that replaces it.
-- Correlated: runs once per employee SELECT name, salary FROM employees e WHERE salary > (SELECT AVG(salary) FROM employees WHERE dept = e.dept); -- Same answer with a window function, one pass SELECT name, salary FROM ( SELECT name, salary, AVG(salary) OVER (PARTITION BY dept) AS dept_avg FROM employees ) t WHERE salary > dept_avg;
Recursive CTE — a query that refers to itself, which is how you walk hierarchical data (org charts, category trees, bills of materials) to arbitrary depth.
WITH RECURSIVE chain AS ( SELECT id, name, manager_id, 1 AS depth FROM employees WHERE manager_id IS NULL -- anchor: the root UNION ALL SELECT e.id, e.name, e.manager_id, c.depth + 1 FROM employees e JOIN chain c ON e.manager_id = c.id -- recursive step ) SELECT * FROM chain ORDER BY depth;
Lateral join (LATERAL, or CROSS APPLY in SQL Server) lets the right-hand subquery see columns from the left — the standard way to fetch "the top N rows per group".
SELECT c.name, o.total, o.placed_at FROM customers c CROSS JOIN LATERAL ( SELECT total, placed_at FROM orders WHERE customer_id = c.id ORDER BY placed_at DESC LIMIT 3 ) o;
Pivot and unpivot rotate rows into columns and back. PIVOT exists in SQL Server and Oracle; elsewhere the portable form is conditional aggregation, which is the CASE-inside-SUM pattern above.
Indexes & Performance
An index is a data structure (typically a B-Tree) that speeds up data retrieval at the cost of extra storage and slower writes. Think of it like a book's index — you find the page number without reading the whole book.
-- Create index on frequently queried column CREATE INDEX idx_email ON users(email); -- Composite index: column order matters! CREATE INDEX idx_dept_salary ON users(department, salary); -- EXPLAIN: see if index is being used EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com'; -- Drop index DROP INDEX idx_email ON users;
Reading the Execution Plan
The optimiser turns your SQL into a plan: which indexes to use, which join algorithm, in what order. EXPLAIN shows the plan it chose, and EXPLAIN ANALYZE runs the query and shows what actually happened alongside the estimate.
EXPLAIN ANALYZE SELECT u.name, COUNT(o.id) FROM users u JOIN orders o ON o.user_id = u.id WHERE u.created_at > '2026-01-01' GROUP BY u.name;
| What you see | Means | Usually fine when |
|---|---|---|
| Seq / full table scan | Every row was read | The table is small, or you really do want most of it |
| Index scan | The index was walked | A range query |
| Index seek / unique scan | Straight to the rows | Almost always what you want |
| Nested loop join | For each left row, look up the right | The left side is small and the right is indexed |
| Hash join | Build a hash of one side, probe with the other | Both sides are large, no useful index |
Two numbers matter most. Cost is the optimiser's estimate in arbitrary units — useful for comparing two plans for the same query, meaningless across queries. The gap between estimated and actual rows is the real signal: when they differ by orders of magnitude, statistics are stale and every choice downstream was made on bad information. Refreshing statistics fixes more "slow query" tickets than rewriting the SQL does.
A tuning pass, in order: confirm the query is the problem, read the plan for the biggest actual-time node, check whether an index would turn a scan into a seek, and only then consider rewriting. Databases cache plans so repeated statements skip planning — one reason parameterised queries are faster as well as safer, since a literal baked into the text produces a new plan every time.
An indexing strategy follows from all this: index the columns you filter and join on, put the most selective column first in a composite index, cover the query where you can so the index alone answers it, and drop indexes nothing uses — each one is paid for on every insert and update.
GROUP BY & HAVING
-- Count employees per department SELECT department, COUNT(*) AS headcount FROM users GROUP BY department ORDER BY headcount DESC; -- HAVING: only departments with avg salary > 75k SELECT department, ROUND(AVG(salary),2) AS avg_sal FROM users GROUP BY department HAVING AVG(salary) > 75000 ORDER BY avg_sal DESC; -- WHERE + GROUP BY + HAVING together SELECT department, COUNT(*) AS cnt FROM users WHERE is_active = TRUE -- filter rows first GROUP BY department -- then group HAVING COUNT(*) > 5 -- then filter groups ORDER BY cnt DESC; -- then sort
Normalization
Normalization is the process of organizing a database to reduce redundancy and improve data integrity by dividing data into related tables.
Views & Materialized Views
A view is a stored query that behaves like a table. It holds no data of its own — every read re-runs the underlying query — so it is a way to name a complicated join once and hide columns a caller should not see.
CREATE VIEW active_customers AS SELECT id, name, email FROM customers WHERE status = 'active'; SELECT * FROM active_customers; -- queried like a table -- Materialized: stores the result, must be refreshed CREATE MATERIALIZED VIEW sales_by_month AS SELECT date_trunc('month', sold_at) AS m, SUM(total) FROM orders GROUP BY 1; REFRESH MATERIALIZED VIEW sales_by_month;
| View | Materialized view | |
|---|---|---|
| Storage | None — query re-runs each time | Result is stored on disk |
| Freshness | Always current | Stale until refreshed |
| Read cost | Cost of the underlying query | Cost of reading a table |
| Use when | Simplifying access, hiding columns | Expensive aggregate read far more often than it changes |
DROP or ALTER that would break it. Worth turning on for views other systems depend on.Stored Procedures, Functions & Triggers
SQL can hold logic as well as data. The three forms differ in what invokes them and what they are allowed to do.
| Called by | Returns | Can modify data | |
|---|---|---|---|
| Stored procedure | Explicitly, with CALL | Nothing, or output params | Yes |
| Function (UDF) | Inside an expression | A single value, or a table | Usually no |
| Trigger | The database, on an event | Nothing | Yes |
-- Procedure: called explicitly CREATE PROCEDURE give_raise(IN emp_id INT, IN pct DECIMAL(4,2)) BEGIN UPDATE employees SET salary = salary * (1 + pct) WHERE id = emp_id; END; CALL give_raise(42, 0.10); -- Trigger: the database calls it for you CREATE TRIGGER audit_salary AFTER UPDATE ON employees FOR EACH ROW INSERT INTO salary_audit(emp_id, old_val, new_val, changed_at) VALUES(OLD.id, OLD.salary, NEW.salary, NOW());
A scalar function returns one value per call (UPPER, ROUND); a table-valued function returns a result set you can join against. A cursor walks a result set one row at a time — occasionally necessary for row-by-row procedural work, but almost always slower than the set-based statement that would replace it.
SQL Injection
If user input is concatenated into a query string, the user can end your statement and start their own. It remains one of the most exploited web vulnerabilities, and the fix has been known for decades.
-- Vulnerable: input becomes part of the statement query = "SELECT * FROM users WHERE email = '" + input + "'"; -- input: ' OR '1'='1 SELECT * FROM users WHERE email = '' OR '1'='1'; -- returns every row -- Safe: the value is sent separately from the statement SELECT * FROM users WHERE email = ?;
The defence is parameterised queries (prepared statements): the SQL text is parsed once, and the value can never be read as syntax however it is spelled. Escaping input by hand is not equivalent — it is a blocklist, and blocklists lose. Add least-privilege database accounts so an application that only reads cannot DROP, and validate input for shape as a second layer, not the first.
Partitioning, Sharding & Replication
When one table or one machine stops being enough, these are the four moves — and the vocabulary interviewers expect you to keep straight.
| Technique | What it splits | Across |
|---|---|---|
| Partitioning | One table into segments by key or range | One database |
| Sharding | The dataset into independent databases | Many servers |
| Replication | Nothing — it copies | A primary and its replicas |
| Clustering | Nothing — it groups servers | Nodes behind one endpoint |
Partitioning lets the planner skip whole segments that cannot match — partition pruning — so a query filtered by date reads one month rather than ten years. Sharding buys write throughput but costs you cross-shard joins and transactions. Replication gives read scale-out and a failover target, at the price of replica lag: a read straight after a write may not see it.
JSON, Text Search & Collation
Relational databases stopped being purely relational some time ago. Three capabilities come up often enough to know by name.
JSON columns store a document in a single field and let you query inside it. Postgres has json and jsonb (binary, indexable — use this one); MySQL has JSON. The older XML type does the same job with XPath, and survives mostly in enterprise systems.
ALTER TABLE events ADD COLUMN payload JSONB; -- query inside the document SELECT id FROM events WHERE payload->>'type' = 'signup'; -- and index it, or every read is a full scan CREATE INDEX idx_events_type ON events ((payload->>'type'));
Full-text search indexes words rather than whole strings, so it can rank by relevance and handle stemming — LIKE '%term%' can do neither, and cannot use a normal index at all. Postgres has tsvector and tsquery; MySQL has FULLTEXT indexes with MATCH ... AGAINST.
Collation decides how text sorts and compares: whether 'a' equals 'A', where accented characters fall, which language's alphabet order applies. It is set per database, table or column, and a mismatch between two columns is a classic cause of a join that refuses to use its index.
Quick Quiz
Five questions on the parts interviewers reach for first: joins, filtering, indexes and normal forms.
LEFT JOIN returns which rows?"php,mysql,react" as a single comma-separated value. Which normal form does that break?INDEX idx_dept_salary ON users(department, salary). Which query can use it?