LearnThatStack Ace your next interview

SQL Fundamentals

Standard SQL on PostgreSQL, MySQL 8 and SQL Server Checked Oct 2026 75 commands

The model

  • SQL is declarative. You describe the result, and the optimizer picks how to get it. EXPLAIN shows its plan.
  • Clauses run in a logical order. WHERE filters rows before grouping, HAVING filters groups after, and SELECT comes sixth.
  • NULL means unknown. A comparison with it, even NULL = NULL, is never true, so WHERE skips the row.
  • A transaction is all or nothing. COMMIT keeps every change, and ROLLBACK undoes them all.
  • An index, usually a B-tree, finds a row in O(log n) steps instead of an O(n) scan. Writes to its columns must update it.
1 FROM2 JOIN3 WHERE4 GROUP BY5 HAVING6 SELECT7 DISTINCT8 ORDER BY9 LIMIT
logical orderSQL evaluates the clauses in this order, not in the order you write them.
WHERE runs before grouping, so it can't use an aggregate. SELECT runs sixth, so WHERE can't see its aliases, but ORDER BY can.

How SQL works

the statement types, and how NULL behaves

Four kinds of statements

Define or change table structureDDL, with CREATE, ALTER, DROP and TRUNCATE.
Read and change rowsDML, with SELECT, INSERT, UPDATE and DELETE.
Give or take away accessDCL, with GRANT and REVOKE.
Save or undo a group of changesTCL, with COMMIT, ROLLBACK and SAVEPOINT.

TipDDL is Data Definition Language, DML is Data Manipulation, DCL is Data Control and TCL is Transaction Control.

Work with NULL

Find rows with a missing valueWHERE bonus IS NULL
Show a default in place of NULLCOALESCE(bonus, 0)Returns its first argument that isn't NULL.
Count rows, or count valuesCOUNT(*), COUNT(bonus)The first counts rows. The second skips NULL.

TrapWHERE bonus = NULL returns no rows, because a comparison with NULL is never true. Write IS NULL.

Tables, keys & constraints

define tables and the rules that guard their data

Create a table with constraints

Identify each rowid INT PRIMARY KEYUnique and never NULL. One per table, indexed for you.
Require a value in a columnname VARCHAR(50) NOT NULL
Reject duplicate valuesemail VARCHAR(255) UNIQUEPostgreSQL and MySQL allow many NULLs, SQL Server only one. A table can have several unique columns.
Require a matching row in another tableFOREIGN KEY (cust_id) REFERENCES customers(id)
Reject values that break a ruleCHECK (total > 0)
Fill in a value the insert leaves outDEFAULT (CURRENT_DATE)

TrapA CHECK passes when its condition is NULL. Add NOT NULL when the column must have a value.

CHAR or VARCHAR

Store a code that always has the same lengthcountry CHAR(2)Pads shorter values with spaces.
Store text of varying length, like namesname VARCHAR(100)Stores the value plus a length prefix.

TrapDon't pick CHAR for speed. PostgreSQL's docs say it is usually the slowest of its string types.

Relationships and normal forms

Model one-to-many, like a customer and their ordersA foreign key on the many side, such as orders.cust_id.
Model one-to-one, like a person and a passportA foreign key that is also UNIQUE.
Model many-to-many, like students and coursesA junction table, such as enrollments, with a foreign key to each side.
Reach first normal form (1NF)One atomic value per column, no repeating groups.
Reach second normal form (2NF)1NF, and no non-key column depends on part of a composite key.
Reach third normal form (3NF)2NF, and no non-key column depends on another non-key column.

TipBCNF is a stricter 3NF. Every column that determines another must be a candidate key, a column set that could be the primary key.

TrapNormalizing too far slows reads, because queries need more joins.

Insert, update & delete

change rows, and know which changes you can undo

Insert and update rows

Add a rowINSERT INTO users (name) VALUES ('Ana');
Change some rowsUPDATE emp SET salary = salary * 1.1 WHERE id = 7;

TrapAn UPDATE or DELETE with no WHERE changes every row in the table.

DELETE, TRUNCATE or DROP

Remove chosen rowsDELETE FROM emp WHERE id = 7;Fires delete triggers, and it is slow on big tables.
Empty a table but keep itTRUNCATE TABLE emp;Much faster, with no delete triggers. MySQL also resets auto-increment.
Remove the table itselfDROP TABLE emp;

TrapMySQL commits TRUNCATE and DROP at once, so ROLLBACK can't bring them back. PostgreSQL and SQL Server can roll back a TRUNCATE in a transaction.

Insert or update in one statement

Upsert in PostgreSQL or SQLiteON CONFLICT (id) DO UPDATE SET qty = EXCLUDED.qtyIt needs a primary key or unique constraint.
Upsert in MySQL (8.0.19+)AS new ON DUPLICATE KEY UPDATE qty = new.qtynew names the row in VALUES.
Upsert with standard MERGEMERGE INTO stock t USING src s ON t.id = s.idSQL Server and PostgreSQL 15+. SQL Server needs HOLDLOCK to be race-free.

TrapSelect-then-insert leaves a gap. Another session can insert the same key between your check and your write.

Filter, group & sort

pick rows, summarize them and page through them

Text search and built-in functions

Match a text patternWHERE name LIKE 'J%'% matches any run of characters, _ exactly one.
Compare text ignoring case, on any engineUPPER(name) = UPPER('ana')
Join textCONCAT(first, ' ', last)
Get the year from a dateEXTRACT(YEAR FROM hire_date)SQL Server uses YEAR(hire_date).
Pick a value by conditionCASE WHEN salary > 9000 THEN 'High' ELSE 'Low' END

TipMySQL's default collation already ignores case. PostgreSQL doesn't, but it has ILIKE.

TrapUPPER(name) hides the column from an ordinary index. An expression index on UPPER(name) fixes that.

Grouping and clause order

Count rows per departmentSELECT dept_id, COUNT(*) FROM emp GROUP BY dept_id;
Filter rows before groupingWHERE salary > 5000
Filter groups after groupingHAVING COUNT(*) > 5
Filter on a SELECT aliasWHERE runs before SELECT, so repeat the expression instead.
Sort by a SELECT aliasORDER BY avg_sal DESCThis works, because ORDER BY runs after SELECT.

TrapEvery column in SELECT must appear in GROUP BY or sit inside an aggregate.

Find duplicate rows

List each repeated email with its countGROUP BY email HAVING COUNT(*) > 1
Mark the extra copies, ready to deleteROW_NUMBER() OVER (PARTITION BY email ORDER BY id)Rows numbered above 1 are the extra copies.

TrapA self-join on email finds copies too, but = never matches NULL, so it misses the NULL emails.

Page through results

OFFSETskipped 20pagekeysetpagerows read

Page 3 with OFFSET reads 30 rows and throws away 20. Keyset pagination seeks straight to the page through an index.

Get page 3 at 10 rows a pageLIMIT 10 OFFSET 20OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLYMySQL and PostgreSQL use the first. SQL Server uses the second, standard form.
Page deep into a large tableWHERE id > ? ORDER BY id LIMIT 10Keyset pagination. Every page costs the same.

TrapSort by a unique key. Without a stable ORDER BY, pages can repeat or skip rows.

Joins & set operators

combine rows from two tables or two queries

Join types

INNER JOINKeep only rows that match
LEFT JOINKeep every row of the left table
RIGHT JOINKeep every row of the right table
FULL OUTER JOINMySQL has none. Combine a left and a right join with UNION.Keep every row of both tables
in the resultdropped

TipCROSS JOIN has no condition. It pairs every row with every row, the Cartesian product.

TrapA WHERE test like d.name = 'Sales' drops the unmatched rows, so the LEFT JOIN acts like an inner join.

Self-joins

Pair each employee with their managerLEFT JOIN emp m ON e.manager_id = m.idLEFT JOIN keeps the CEO, whose manager_id is NULL.
List each pair of colleagues oncee1.id < e2.idNot !=, which returns each pair twice, once in each order.

UNION, INTERSECT and EXCEPT

Combine two results and drop duplicatesUNION
Combine two results and keep every rowUNION ALLFaster, with no sort or hash to find repeats.
Keep rows found in bothINTERSECTMySQL supports it, and EXCEPT, from 8.0.31.
Keep rows of the first query that the second lacksEXCEPTOrder matters. Oracle calls it MINUS.

TipBoth queries need the same number of columns, with compatible types.

TrapUNION drops repeats even within one query. Use UNION ALL when repeats matter.

Subqueries & CTEs

nest one query in another, or name its steps

Subqueries

Compare each salary with the company averageWHERE salary > (SELECT AVG(salary) FROM emp)Non-correlated. It runs once.
Compare each salary with its department's averageWHERE e2.dept_id = e1.dept_idCorrelated. It reads the outer row, so it runs once per row.
Name the four subquery shapesScalar returns one value, row one row, column a list, and table rows and columns.

TipOptimizers often turn a correlated subquery into a join, so read the plan first.

EXISTS, IN and NOT IN

Keep customers that have an orderWHERE EXISTS (SELECT 1 FROM orders o WHERE o.cust_id = c.id)Stops at the first match.
Do the same with INWHERE id IN (SELECT cust_id FROM orders)Also fits a short list, such as IN (1, 2, 3).
Find customers with no orderWHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.cust_id = c.id)

TipThe old rule that EXISTS is faster than IN is not reliable. Check the plan.

TrapIf the subquery returns even one NULL, NOT IN returns no rows. Use NOT EXISTS when the column can be NULL.

Common table expressions

WITH RECURSIVE tree AS ( SELECT id, parent_id, 0 AS levelthe anchor FROM nodes WHERE parent_id IS NULL UNION ALLthen once per level SELECT n.id, n.parent_id, t.level + 1 FROM nodes n JOIN tree t ON n.parent_id = t.id)SELECT * FROM tree;
Name a step of a queryPut it in a WITH clause. The main query then reads it like a table.
Reuse a costly result across statementsUse a temporary table. A CTE lasts for one statement.
Store a tree for faster readsUse a nested set, a materialized path such as /1/4/9/, or a closure table.

TipPostgreSQL and MySQL need WITH RECURSIVE. SQL Server takes plain WITH.

TrapA CTE is for readability, not speed. SQL Server runs its query again for each reference.

Window functions

rank, compare and total rows without collapsing them

Window functions and ranking

salary 100K 90K 90K 80KROW_NUMBER 1 2 3 4RANK 1 2 2 4skips 3DENSE_RANK 1 2 2 3no gap
Show the department average on every rowAVG(salary) OVER (PARTITION BY dept_id)GROUP BY would collapse each department to one row.
Filter on a window function's resultFilter in an outer query or CTE. WHERE runs before window functions.
Pick the top row, or top N, in each groupROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnKeep rn = 1, or rn <= 3, in an outer query.
Find the top three distinct salariesDENSE_RANK() OVER (ORDER BY salary DESC)
Rank with a gap after each tieRANK() OVER (ORDER BY salary DESC)

TrapTied rows get their ROW_NUMBER in any order. Add a column to ORDER BY that breaks the tie.

LAG, LEAD and running totals

Read the previous row's valueLAG(sales) OVER (ORDER BY sale_date)Returns NULL on the first row, unless you pass a default.
Read the next row's value, with 0 at the endLEAD(sales, 1, 0) OVER (ORDER BY sale_date)Its arguments are the column, the offset and the default.
Keep a running totalSUM(sales) OVER (ORDER BY sale_date ROWS UNBOUNDED PRECEDING)
Average the last 3 rowsAVG(sales) OVER (ORDER BY sale_date ROWS 2 PRECEDING)The current row and the 2 before it.

TrapWith ORDER BY and no frame, the default includes rows tied with the current one. Two sales on one date then share a total, so write ROWS.

Indexes & query speed

find rows without reading the whole table

Indexes

Index two columns togetherCREATE INDEX ix_name ON emp (last_name, first_name);Serves last_name alone. For first_name alone, it needs a skip scan, in MySQL 8.0.13+ or PostgreSQL 18.
List what an index costsInserts, deletes and updates to indexed columns must update it. It also takes disk and memory.
Store the rows themselves in key orderA clustered index, at most one per table. Ranges on its key are fast.
Add more indexes to a tableNon-clustered. A lookup follows a row locator to fetch the row.
Answer a query from the index aloneCREATE INDEX ix_cust ON orders (cust_id, total);A covering index holds every column the query reads.

TipMySQL's InnoDB always clusters a table. PostgreSQL keeps no clustered order.

TrapWhen a query returns a large share of the table, a full scan can be cheaper. The optimizer may then skip your index.

Speed up a slow query

See how the database runs a queryEXPLAIN SELECT * FROM orders WHERE cust_id = 42;Look for a full scan where you expected an index seek.
Make a date filter sargableorder_date >= '2024-01-01' AND order_date < '2025-01-01'Sargable means an index can serve it. YEAR(order_date) = 2024 can't use one.
Read less dataSELECT id, total FROM ordersNot SELECT *. An index may then cover the query.
Speed up queries on a huge tablePartition it by a key such as date. A filter on that key skips the other partitions.

TrapA query hint forces one plan even after the data changes. Use one only when the optimizer keeps choosing badly.

Transactions & locks

keep data correct when many sessions write at once

Transactions and ACID

Start a transactionSTART TRANSACTION;You can also write BEGIN. SQL Server uses BEGIN TRANSACTION.
Keep every change, or undo them allCOMMIT;ROLLBACK;
Undo back to a marked pointSAVEPOINT sp1;ROLLBACK TO SAVEPOINT sp1;
Make a transfer all or nothingAtomicity. Every step succeeds, or the database undoes them all.
Keep the data valid before and afterConsistency. Each transaction moves from one valid state to another.
Stop concurrent transactions from interferingIsolation, through locks or multiversion concurrency control (MVCC).
Make a commit survive a crashDurability. The database replays a write-ahead log after a crash.

TrapWith autocommit on, each statement commits on its own. A ROLLBACK after it has nothing to undo.

Isolation levels

READ UNCOMMITTEDRead even uncommitted changes
READ COMMITTEDPostgreSQL's default.See only committed data
REPEATABLE READMySQL InnoDB's default. PostgreSQL's blocks new rows too.Get the same values when you read a row again
SERIALIZABLEAct as if transactions ran one at a time
preventedcan happen

TipA dirty read sees uncommitted changes. A non-repeatable read gets a new value when it reads a row again. A phantom is a new row in a rerun query.

TrapAt REPEATABLE READ and SERIALIZABLE, PostgreSQL may abort a transaction with a serialization failure. Your code must retry it.

Locks and deadlocks

Let many transactions read the same dataA shared lock.
Let only one transaction change the dataAn exclusive lock.
Handle two transactions waiting on each otherThat's a deadlock. The database detects it and rolls one back.

TipWith MVCC, as in PostgreSQL, readers see an older row version, so reads and writes don't block each other.

Views, procedures & security

logic stored in the database, and keeping data safe

Views and row-level security

Hide sensitive columns behind a viewCREATE VIEW emp_public AS SELECT id, name FROM emp;
Speed up a query with a viewIt can't. A view stores no data, so its query runs on every read.
Update rows through a viewSimple views allow it. Joins, grouping or DISTINCT usually prevent it.
Show each user only their department's rowsCREATE POLICY p ON emp USING (dept_id = my_dept());PostgreSQL syntax. Then turn RLS on with ENABLE ROW LEVEL SECURITY.
Say why RLS is safer than filters in the appThe database adds the rule to every query, so a missed WHERE can't leak rows.

TipSuperusers bypass RLS, and so do table owners unless you FORCE ROW LEVEL SECURITY.

TrapA view depends on its tables. Changing or dropping a column can break it.

Procedures, functions and triggers

Say why teams use stored proceduresLess network traffic, logic in one place, and callers need no rights on its tables.
Use your own function inside a querydbo.GetAge(birth_date)A function returns a value. A procedure runs as its own statement.
Change data or control a transactionUse a procedure. A SQL Server function can do neither.
Log every change to a tableAn AFTER UPDATE trigger that writes to an audit table.
Check or adjust a row before it is savedA BEFORE trigger.

TipAn INSTEAD OF trigger replaces the change, mainly on views. MySQL has only BEFORE and AFTER.

TrapProcedure and trigger code ties you to one engine and hides logic from anyone reading the app.

Stop SQL injection

Pass user input into a query safelySELECT * FROM users WHERE name = ?A parameterized query. The value is never parsed as SQL.
Spot a login bypass' OR '1'='1 closes the string early and makes the test always true.
Let users choose the sort columnCheck it against an allowlist. A placeholder can't stand for a column name.
Limit the damage of an attackGRANT SELECT ON orders TO app_user;Least privilege. The app's account gets only what it needs.

TrapEscaping special characters is a last resort that OWASP strongly discourages. Use parameters.

Found this useful? Pass it on.
Pro · $10/mo

This sheet stays free. With Pro, you can:

  • Every answer on this sheet. Read the full answer to all 44 questions it links, not just the 6 free ones.
  • My sheet. The cards you star and the ones you miss in Quiz me, from every sheet, on one page to quiz and print.
  • PDFs. Download this sheet and My sheet, laid out for A4, with every question a link to its answer.
  • The rest of Pro. Every answer in the question library, and 1,000 AI credits a month.