Standard SQL plus the PostgreSQL specifics interviewers probe; every snippet below was run on PostgreSQL 16.
Commands & query order
Family
Commands
Note
DDL
CREATE, ALTER, DROP, TRUNCATE
Transactional in PG: BEGIN; DROP TABLE t; ROLLBACK; restores t
DML
SELECT, INSERT, UPDATE, DELETE, MERGE
MERGE since PG 15
DCL
GRANT, REVOKE
GRANT SELECT ON employees TO analyst;
TCL
BEGIN, COMMIT, ROLLBACK, SAVEPOINT
ROLLBACK TO SAVEPOINT sp1 undoes part
Logical evaluation order (not the written order):
WITH, then FROM + JOIN
WHERE → GROUP BY → HAVING
SELECT list, including window functions (aliases are born here)
DISTINCT → UNION / INTERSECT / EXCEPT → ORDER BY → LIMIT / OFFSET
So WHERE annual > 1000 fails with column "annual" does not exist, and so does HAVING n > 1: repeat the expression. ORDER BY accepts a bare alias or a position (ORDER BY 2), not an alias inside an expression.
Aggregates and window functions are not allowed in WHERE; filter window results in an outer query or CTE.
Every selected column must be grouped or aggregated. PostgreSQL relaxes this when you group by the primary key.
Joins
With a(id) = {1,2,3} and b(id) = {2,3,4}:
Join
Keeps
Result (a.id, b.id)
INNER JOIN
matching pairs only
(2,2) (3,3)
LEFT JOIN
all of a, NULLs where no match
(1, NULL) (2,2) (3,3)
RIGHT JOIN
all of b
(2,2) (3,3) (NULL, 4)
FULL JOIN
all rows of both
(1, NULL) (2,2) (3,3) (NULL, 4)
CROSS JOIN
every combination
9 rows
Semi: WHERE EXISTS (…)
a rows with a match, once each
2, 3
Anti: NOT EXISTS / LEFT JOIN … WHERE b.id IS NULL
a rows with no match
1
LATERAL
subquery that sees earlier FROM items
per-row top-N
Self join: employees e JOIN employees m ON m.id = e.manager_id.
A filter on the right table in WHERE turns a LEFT JOIN into an inner join; put it in ON.
One-to-many joins multiply rows, so sum() after them double counts.
Filtering & aggregation
WHERE filters rows before grouping; HAVING filters groups after it. Put non-aggregate conditions in WHERE.
Other aggregates ignore NULLs. Over zero rows sum, avg and max return NULL but count returns 0: use coalesce(sum(x), 0).
UNION removes duplicates (extra sort or hash); UNION ALL keeps them and is cheaper.
SELECT dept_id, count(*) AS headcount, count(*) FILTER (WHERE salary >= 150) AS senior, round(avg(salary), 2) AS avg_salaryFROM employeesWHERE created_at > now() - interval '1 year'GROUP BY dept_idHAVING count(*) > 1ORDER BY headcount DESC;
sql
Subqueries, EXISTS & IN
A scalar subquery returning two rows fails: more than one row returned by a subquery used as an expression.
A correlated subquery references the outer row, so it logically runs once per row.
EXISTS stops at the first match; PostgreSQL usually plans both IN (subquery) and EXISTS as a semi-join.
SELECT 1 NOT IN (2, NULL); -- NULL, not trueSELECT id FROM a WHERE id NOT IN (SELECT id FROM b); -- 0 rows if b.id has a NULLSELECT id FROM aWHERE NOT EXISTS (SELECT 1 FROM b WHERE b.id = a.id); -- correct: 1
sql
Gotcha
NOT IN against a subquery that returns even one NULL yields no rows at all. Use NOT EXISTS for anti-joins.
CTEs & recursive CTEs
WITH x AS (…) names a subquery; later CTEs can use earlier ones.
Since PG 12, a non-recursive, side-effect-free CTE used once is inlined. AS MATERIALIZED forces one evaluation (an optimization fence); AS NOT MATERIALIZED forces inlining.
Recursive CTE = anchor UNION ALL recursive term; it stops when the recursive term returns no rows. UNION drops duplicate rows, which can stop cycles; PG 14 added CYCLE and SEARCH clauses.
CTEs can modify data: WITH moved AS (DELETE … RETURNING *) INSERT INTO archive SELECT * FROM moved;
WITH RECURSIVE chain AS ( SELECT id, name, manager_id, 1 AS depth FROM employees WHERE manager_id IS NULL -- anchor 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 -- step)SELECT name, depth FROM chain ORDER BY depth, name;
sql
Window functions
fn() OVER (PARTITION BY … ORDER BY … frame) computes across related rows without collapsing them. Results for salaries 200, 150, 150, 130 DESC:
Function
Result
Typical use
row_number()
1, 2, 3, 4
dedupe, top-N per group
rank()
1, 2, 2, 4
ties share, then a gap
dense_rank()
1, 2, 2, 3
ties share, no gap: “Nth highest”
ntile(n)
bucket 1..n
quartiles
lag(col, n, default) / lead(…)
previous / next row’s value
deltas, month over month
first_value / last_value / nth_value
value at a frame position
last_value needs a full frame
percent_rank() / cume_dist()
relative position 0–1
percentiles
sum / avg / countOVER (…)
running or partition totals
running total, % of total
With ORDER BY the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so tied rows (peers) share a running total. Use ROWS for strictly row by row.
Without ORDER BY the frame is the whole partition. With the default frame, last_value() returns the current row (or its last peer).
Reuse a definition: WINDOW w AS (ORDER BY salary DESC), then rank() OVER w.
SELECT day, amount, sum(amount) OVER (ORDER BY day) AS running_total, avg(amount) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS avg_7d, amount - lag(amount) OVER (ORDER BY day) AS delta, round(100.0 * amount / sum(amount) OVER (), 1) AS pct_of_totalFROM daily_revenue;
sql
Classic interview queries
-- Nth highest distinct salary (N = 2): OFFSET N-1SELECT DISTINCT salary FROM employees WHERE salary IS NOT NULLORDER BY salary DESC OFFSET 1 LIMIT 1;-- Second highest, NULL when there is noneSELECT max(salary) FROM employeesWHERE salary < (SELECT max(salary) FROM employees);-- Duplicates, then delete them keeping the lowest idSELECT email, count(*) FROM employees GROUP BY email HAVING count(*) > 1;DELETE FROM employees a USING employees bWHERE a.email = b.email AND a.id > b.id;
sql
-- Top 2 earners per departmentSELECT dept_id, name, salary FROM ( SELECT *, row_number() OVER (PARTITION BY dept_id ORDER BY salary DESC NULLS LAST) AS rn FROM employees) tWHERE rn <= 2;-- Top 1 per group, PostgreSQL onlySELECT DISTINCT ON (dept_id) dept_id, name, salaryFROM employees ORDER BY dept_id, salary DESC NULLS LAST;
sql
Earns more than their manager: self join plus WHERE e.salary > m.salary. Departments with no employees: the anti-join in Joins.
Keys, constraints & normal forms
PRIMARY KEY = UNIQUE + NOT NULL, one per table, backed by a unique B-tree index. Surrogate keys (id) carry no business meaning; natural keys (email) do.
Prefer id bigint GENERATED ALWAYS AS IDENTITY to serial: it is standard SQL and rejects explicit ids unless you write OVERRIDING SYSTEM VALUE.
REFERENCES t(id) ON DELETE CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION (default NO ACTION).
PostgreSQL does not index the referencing (child) column of a foreign key; add one for joins and fast parent deletes.
UNIQUE allows many NULLs (NULLs are distinct); PG 15 added UNIQUE NULLS NOT DISTINCT.
Also CHECK (salary > 0), DEFAULT, and EXCLUDE USING gist (room WITH =, during WITH &&) against overlapping bookings (= on an integer needs the btree_gist extension).
Form
Rule
Violation
1NF
atomic values, no repeating groups
phones = '111,222'
2NF
no column depends on only part of a composite key
order_items(order_id, product_id, product_name)
3NF
no transitive dependency (non-key → non-key)
employees(dept_id, dept_name)
BCNF
every determinant is a candidate key
(student, course, instructor) with instructor → course
Each form includes the previous ones. OLTP schemas aim for 3NF and denormalize deliberately (materialized views, counters, analytics star schemas).
Indexes
Type
Good for
Notes
B-tree (default)
=, <, >, BETWEEN, IN, IS NULL, ORDER BY, LIKE 'ab%'
Only type that can be UNIQUE; prefix LIKE needs C collation or text_pattern_ops
Hash
= only
Single column; crash-safe since PG 10
GIN
many values per row: jsonb, arrays, full-text, pg_trgm for LIKE '%x%'
huge tables whose values follow physical order (append-only time)
Min/max per block range; tiny
Partial
CREATE INDEX … WHERE kind = 'purchase'
The query must imply the predicate
Expression
CREATE INDEX … ON employees (lower(email))
The query must use the same expression
Covering
CREATE INDEX … ON events (kind) INCLUDE (user_id)
Enables index-only scans (PG 11+)
Leftmost prefix: (a, b, c) serves a, a, b and a, b, c. A filter on b alone cannot seek; at best it scans the whole index (PG 18 added B-tree skip scan, which helps when a has few distinct values).
Equality columns first, then the range or sort column: (user_id, created_at) serves WHERE user_id = ? AND created_at > ? and WHERE user_id = ? ORDER BY created_at DESC LIMIT 10 with no sort. Columns after the first range condition cannot narrow the seek.
Every index slows writes; unused ones show idx_scan = 0 in pg_stat_user_indexes.
Plain CREATE INDEX takes a SHARE lock that blocks writes. CREATE INDEX CONCURRENTLY does not, but is slower, cannot run inside a transaction block, and leaves an INVALID index if it fails.
Reading EXPLAIN
EXPLAIN shows the estimated plan; EXPLAIN ANALYZEruns the query (wrap writes in BEGIN … ROLLBACK). BUFFERS adds page I/O.
cost=startup..total is in planner units; actual time is ms per loop, times loops.
Node
Meaning
Typical cause
Seq Scan
reads the whole table
no usable index, or most rows match
Index Scan
walks the index, fetches each row
selective filter, ORDER BY … LIMIT
Index Only Scan
answers from the index alone
all columns indexed, pages all-visible (VACUUM)
Bitmap Index + Bitmap Heap Scan
collects row locations, reads pages in order
medium selectivity, OR / AND of indexes
Nested Loop
probes the inner side per outer row
small outer side, indexed inner
Hash Join
builds a hash table from one input
equi-joins on larger unsorted inputs
Merge Join
merges two sorted inputs
both sides already sorted
Troubleshooting a slow query:
Estimated vs actual rows far apart → stale statistics → ANALYZE t.
Seq Scan on a selective filter → missing index, a function or cast on the column (date(created_at) = …), or a type mismatch.
Large Rows Removed by Filter → the index does not cover the predicate.
Sort Method: external merge Disk → raise work_mem (default 4MB) or index the sort key.
Transactions & isolation
Atomic: all or nothing. Consistent: constraints hold. Isolated: no seeing others’ partial work. Durable: commits survive a crash (write-ahead log).
Each statement autocommits unless you BEGIN. After an error, every command fails with current transaction is aborted until ROLLBACK (or ROLLBACK TO SAVEPOINT).
BEGIN ISOLATION LEVEL SERIALIZABLE; sets the level; SET TRANSACTION must precede the first query.
Level
Dirty read
Non-repeatable read
Phantom read
Serialization anomaly
Read uncommitted
not in PG (acts as RC)
possible
possible
possible
Read committed (PG default)
no
possible
possible
possible
Repeatable read
no
no
not in PG
possible (write skew)
Serializable
no
no
no
no
Dirty read: seeing uncommitted data. Non-repeatable: a re-read row changed. Phantom: a re-run predicate returns new rows. Write skew: two transactions read the same rows, update different ones and break an invariant (both on-call doctors go off call).
Read committed takes a snapshot per statement. An UPDATE that waited on a locked row re-checks its WHERE on the new version, so SET qty = qty - 1 is safe; read-then-write in application code can still lose updates.
Repeatable read keeps one snapshot per transaction; updating a row another transaction changed fails with could not serialize access due to concurrent update (SQLSTATE 40001).
Serializable (SSI) also aborts dependency cycles such as write skew, again with 40001.
Interview tip
At repeatable read and serializable, 40001 is expected, not a bug: retry the whole transaction.
MVCC, VACUUM & locking
MVCC: each row version has xmin (creating transaction) and xmax (deleting one). UPDATE writes a new version and marks the old one dead, so readers and writers never block each other.
VACUUM makes dead rows’ space reusable (the file only shrinks by empty trailing pages), updates the visibility map for index-only scans, and freezes old transaction IDs to prevent wraparound. Long-running or idle in transaction sessions block this cleanup and cause bloat.
Autovacuum is on by default and vacuums a table once dead rows exceed 50 + 20% of its rows. VACUUM FULL rewrites the table and returns space to the OS, under an ACCESS EXCLUSIVE lock.
Row locks, strongest first: FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE. NOWAIT errors instead of waiting; SKIP LOCKED skips locked rows.
Table locks: SELECT takes ACCESS SHARE, writes ROW EXCLUSIVE; ALTER TABLE, DROP, TRUNCATE and VACUUM FULL take ACCESS EXCLUSIVE, which blocks reads too. Set lock_timeout before DDL on busy tables.
Deadlocks are checked after deadlock_timeout (default 1s); one transaction fails with deadlock detected (40P01). Lock rows in a consistent order and keep transactions short.
Optimistic locking: UPDATE … SET version = version + 1 WHERE id = $1 AND version = $2, then check the row count. Pessimistic: SELECT … FOR UPDATE.
WITH next AS ( -- job queue: workers never double-pick SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED)UPDATE jobs SET status = 'running'FROM next WHERE jobs.id = next.idRETURNING jobs.id;
sql
UPSERT, JSONB & views
INSERT INTO inventory (sku, qty) VALUES ('A1', 5)ON CONFLICT (sku) DO UPDATE SET qty = inventory.qty + EXCLUDED.qty, updated_at = now()RETURNING sku, qty;-- or: ON CONFLICT (sku) DO NOTHING
sql
ON CONFLICT needs a unique index or constraint on the target columns; EXCLUDED is the proposed row. MERGE (PG 15+) is the standard alternative.
Operator
Returns
Example
->
jsonb by key or array index
doc -> 'addr', doc -> 'tags' -> 0
->>
text
doc ->> 'name'
#> / #>>
jsonb / text at a path
doc #>> '{addr,city}'
@> / <@
contains / is contained by
doc @> '{"tags":["sql"]}'
? / ?| / ?&
key exists / any / all
doc ? 'age'
||
shallow merge
doc || '{"age":31}'
- / #-
delete a key / a path
doc - 'tags'
@? / @@
jsonpath exists / predicate
doc @@ '$.age > 18'
Cast before comparing: (doc ->> 'age')::int > 18. Also jsonb_set, jsonb_agg, jsonb_array_elements, and subscripts doc['addr']['city'] (PG 14+).
json stores the text as is; jsonb is parsed binary, keeps the last duplicate key, and is indexable. Default to jsonb.
GIN with the default jsonb_ops supports ?, ?|, ?&, @>, @?, @@; jsonb_path_ops only @>, @?, @@, but is smaller and faster.
View
Materialized view
Stores rows
no, re-runs the query
yes, a snapshot
Freshness
always current
stale until REFRESH MATERIALIZED VIEW
Own indexes
no
yes
Writes
simple single-table views are updatable
read-only
REFRESH MATERIALIZED VIEW CONCURRENTLY keeps it readable during the refresh but needs a unique index on it.
Pagination & psql
OFFSET n still reads and discards n rows (page 5,001 of 20 reads 100,020 index entries), and rows shift between pages as data arrives.
Keyset (seek) pagination filters on the last row’s sort key plus a unique tiebreaker: same cost for every page, but no jumping to page N.
-- index: (created_at, id); values come from the previous page's last rowSELECT id, created_at FROM eventsWHERE (created_at, id) < ('2026-09-28 10:00+00', 5000)ORDER BY created_at DESC, id DESCLIMIT 20;
\copy (query) TO 'f.csv' WITH (FORMAT csv, HEADER)
client-side export (FROM imports)
\h VACUUM, \q
SQL syntax help, quit
Quick answers
DELETE vs TRUNCATE vs DROP?DELETE removes chosen rows one by one (row triggers, dead rows); TRUNCATE empties the table at once (RESTART IDENTITY resets ids); DROP removes the table. All can be rolled back in PostgreSQL.
Primary key vs unique? One PK, never NULL; many unique constraints, NULLs allowed.
Clustered index? PostgreSQL tables are heaps; CLUSTER reorders a table once and is not maintained.
Why isn’t my index used? Low selectivity, a function or cast on the column, a missing leading column, stale statistics, or a tiny table.
text vs varchar(n) vs char(n)? Same speed in PostgreSQL; varchar(n) only for a real limit; char(n) pads with spaces.
timestamp vs timestamptz?timestamptz stores an absolute instant shown in the session time zone; use it for events.
Function vs procedure? Functions return a value inside the caller’s transaction; procedures (PG 11+) run with CALL and may COMMIT.
Partitioning? Declarative PARTITION BY RANGE | LIST | HASH; the planner prunes partitions, and old data goes by DETACH or DROP, not DELETE.
Scaling reads? Indexes, connection pooling (each connection is a process; max_connections is 100 by default), caching, replicas.
Fast row count?count(*) scans the table (MVCC keeps no stored count); pg_class.reltuples is an estimate.
Gotchas & traps
NULL = NULL is NULL, not true: use IS NULL or IS DISTINCT FROM.
NULLs sort as larger than any value, so ORDER BY salary DESC LIMIT 1 returns a NULL first. Add NULLS LAST or filter.
'a' || NULL is NULL, but concat('a', NULL) is 'a'.
5 / 2 is 2 (integer division); write 5 / 2.0 or cast.
Without ORDER BY, row order is undefined, even with LIMIT.
BETWEEN includes both ends; for time ranges use >= start AND < end.
Unquoted identifiers fold to lowercase, so "User" needs quotes forever. Strings take single quotes.
now() is frozen at transaction start; clock_timestamp() is the wall clock.
LIKE is case-sensitive (ILIKE is not), and a leading % cannot use a B-tree.
Identity values have gaps: rolled-back inserts still consume them.