SQL interview questions with queries you can run and edit in real PostgreSQL in your browser: joins, aggregation, window functions, classic problems, indexes and performance, transactions and database design.
- 50 questions
- 15 easy
- 28 medium
- 7 hard
- Free, no sign-up
Sample database for the runnable queriesShow
Every query on this page runs in real PostgreSQL inside your browser. Each question starts with a fresh copy of these tables, so you can edit and run freely.
-- A small company: staff, customers and their orders.
CREATE TABLE departments (
id int PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE employees (
id int PRIMARY KEY,
name text NOT NULL,
department_id int REFERENCES departments (id),
manager_id int REFERENCES employees (id),
salary numeric(10, 2) NOT NULL,
hired_on date NOT NULL
);
CREATE TABLE customers (
id int PRIMARY KEY,
name text NOT NULL,
city text,
signed_up date NOT NULL
);
CREATE TABLE products (
id int PRIMARY KEY,
name text NOT NULL,
category text NOT NULL,
price numeric(10, 2) NOT NULL
);
CREATE TABLE orders (
id int PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers (id),
ordered_on date NOT NULL,
status text NOT NULL
);
CREATE TABLE order_items (
order_id int REFERENCES orders (id),
product_id int REFERENCES products (id),
quantity int NOT NULL,
unit_price numeric(10, 2) NOT NULL,
PRIMARY KEY (order_id, product_id)
);
INSERT INTO departments VALUES
(1, 'Engineering'), (2, 'Sales'), (3, 'Support'), (4, 'Legal');
INSERT INTO employees VALUES
(1, 'Maya', 1, NULL, 185000, '2018-02-01'),
(2, 'Arjun', 1, 1, 142000, '2019-06-15'),
(3, 'Lena', 1, 1, 142000, '2021-03-08'),
(4, 'Tom', 1, 2, 98000, '2023-01-09'),
(5, 'Sofia', 2, 1, 120000, '2019-11-20'),
(6, 'Kofi', 2, 5, 76000, '2022-04-04'),
(7, 'Priya', 2, 5, 81000, '2021-09-13'),
(8, 'Diego', 3, 1, 64000, '2020-07-27'),
(9, 'Hana', 3, 8, 52000, '2024-02-19'),
(10, 'Sam', NULL, 1, 70000, '2024-05-06');
INSERT INTO customers VALUES
(1, 'Acme Corp', 'London', '2023-01-15'),
(2, 'Globex', 'Berlin', '2023-03-02'),
(3, 'Initech', 'London', '2023-07-21'),
(4, 'Umbrella', 'Paris', '2024-01-10'),
(5, 'Hooli', NULL, '2024-02-28'),
(6, 'Stark Supply', 'Berlin', '2024-06-05');
INSERT INTO products VALUES
(1, 'Laptop', 'Hardware', 1200.00),
(2, 'Monitor', 'Hardware', 300.00),
(3, 'Keyboard', 'Hardware', 80.00),
(4, 'Office 365', 'Software', 100.00),
(5, 'Antivirus', 'Software', 40.00),
(6, 'Setup', 'Services', 150.00);
INSERT INTO orders VALUES
(101, 1, '2024-01-05', 'shipped'),
(102, 2, '2024-01-18', 'shipped'),
(103, 1, '2024-02-02', 'shipped'),
(104, 3, '2024-02-14', 'cancelled'),
(105, 4, '2024-02-20', 'shipped'),
(106, 1, '2024-03-03', 'pending'),
(107, 2, '2024-03-11', 'shipped'),
(108, 5, '2024-03-25', 'shipped'),
(109, 3, '2024-03-28', 'pending');
INSERT INTO order_items VALUES
(101, 1, 2, 1200.00), (101, 4, 2, 100.00),
(102, 2, 3, 300.00), (102, 3, 3, 80.00),
(103, 1, 1, 1150.00), (103, 6, 1, 150.00),
(104, 5, 10, 40.00),
(105, 1, 1, 1200.00), (105, 2, 2, 300.00), (105, 5, 1, 40.00),
(106, 3, 5, 80.00),
(107, 4, 4, 100.00), (107, 6, 2, 150.00),
(108, 2, 1, 300.00),
(109, 1, 1, 1200.00), (109, 4, 1, 100.00);- Link to this question
You write clauses in one order, but the database evaluates them in another:
Diagram - FROM / JOIN: build the set of rows.
- WHERE: filter individual rows.
- GROUP BY: collapse rows into groups.
- HAVING: filter groups.
- SELECT: compute output columns and aliases (window functions run here too).
- DISTINCT: remove duplicate output rows.
- ORDER BY: sort.
- LIMIT / OFFSET: cut the result.
This explains common errors. A column alias from
SELECTcan be used inORDER BY(which runs later), but not inWHERE(which runs earlier):query.sql · editableExpected resultname | yearly -------+------------ Maya | 2220000.00 Arjun | 1704000.00 Lena | 1704000.00 (3 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
error.sql · editablefails on purposeExpected resultERROR: column "yearly" does not exist
Press Run to execute this on a real PostgreSQL database in your browser.
Fix it by repeating the expression in
WHERE, or by wrapping the query in a subquery or CTE.It's the logical order. The optimiser may physically do things differently (for example, pushing filters down), but the result must match this order.
- Link to this question
WHEREfilters rows, before grouping. It can't use aggregates likesum()orcount().HAVINGfilters groups, afterGROUP BY. It's where conditions on aggregates go.
Departments with more than two employees, counting only staff hired before 2024:
query.sql · editableExpected resultname | staff | avg_salary -------------+-------+------------ Engineering | 4 | 141750 Sales | 3 | 92333 (2 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Rule of thumb: put every condition that doesn't need an aggregate in
WHERE. Filtering rows early means less data to group, which is faster. - Link to this question
Join Returns INNER JOINonly rows that match in both tables LEFT JOINall rows from the left table, plus matches from the right ( NULLwhere none)RIGHT JOINall rows from the right table, plus matches from the left FULL JOINall rows from both, matched where possible CROSS JOINevery combination of rows (a Cartesian product) Self join a table joined to itself (see the employee/manager question) Customers and how many orders each has.
LEFT JOINkeeps Stark Supply, who has never ordered; anINNER JOINwould drop them:query.sql · editableExpected resultname | orders --------------+-------- Acme Corp | 3 Globex | 2 Initech | 2 Hooli | 1 Umbrella | 1 Stark Supply | 0 (6 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Note
count(o.id), notcount(*): for Stark Supply the joined row haso.id = NULL, andcount(column)skipsNULLs, giving 0 instead of 1.Follow-up trap: a condition on the right table in
WHEREturns aLEFT JOINinto an inner join, becauseNULLrows fail the test. Put such conditions in theONclause instead:query.sql · editableExpected resultname | shipped_orders --------------+---------------- Acme Corp | 2 Globex | 2 Hooli | 1 Initech | 0 Stark Supply | 0 Umbrella | 1 (6 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
- Link to this question
NULLmeans unknown (or missing), not zero or empty. That leads to three-valued logic: a comparison can be true, false or unknown.NULL = NULLis unknown, not true. Test withIS NULL/IS NOT NULL.WHEREkeeps only rows where the condition is true, so unknown rows disappear.- Arithmetic with
NULLgivesNULL:5 + NULLisNULL. - Aggregates like
sum,avgandcount(column)ignoreNULLs;count(*)counts rows.
query.sql · editableExpected resultall_customers | with_city | equals_null | is_null ---------------+-----------+-------------+--------- 6 | 5 | 0 | 1 (1 row)Press Run to execute this on a real PostgreSQL database in your browser.
Useful tools:
query.sql · editableExpected resultname | city | not_london --------------+---------+------------ Acme Corp | London | Globex | Berlin | Berlin Initech | London | Umbrella | Paris | Paris Hooli | Unknown | Stark Supply | Berlin | Berlin (6 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Other gotchas interviewers like:
NOT INwith aNULLin the list returns no rows (see theNOT INquestion).NULLs sort last in ascending order in PostgreSQL and Oracle, but first in SQL Server and MySQL. UseNULLS FIRST/NULLS LASTto be explicit.- A
UNIQUEcolumn can usually hold manyNULLs, since they aren't equal to each other.
- Link to this question
Expression Counts count(*)rows, including ones full of NULLscount(column)rows where columnis notNULLcount(DISTINCT column)distinct non- NULLvaluesquery.sql · editableExpected resultemployees | in_a_department | departments_used -----------+-----------------+------------------ 10 | 9 | 3 (1 row)Press Run to execute this on a real PostgreSQL database in your browser.
Sam has no department (
NULL), socount(department_id)is one less thancount(*).count(1)is the same ascount(*)in every major database; neither is faster. - Link to this question
Both stack the results of two queries (same number of columns, compatible types).
UNIONremoves duplicates, which needs a sort or hash step.UNION ALLkeeps every row and is faster.
query.sql · editableExpected resultcity -------- Berlin London (2 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
query.sql · editableExpected resultcity -------- Berlin Berlin Berlin London London (5 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Use
UNION ALLby default, unless you actually need duplicates removed. It's a common performance fix.Related set operators:
INTERSECT(rows in both) andEXCEPT(rows in the first but not the second;MINUSin Oracle). - Link to this question
DELETETRUNCATEDROPRemoves chosen rows ( WHERE), or all rowsall rows the whole table: data, structure, indexes Speed on big tables slow: row by row, logged very fast: frees the data pages fast Fires row triggers yes no no Resets identity counters no optionally ( RESTART IDENTITY)— Can be rolled back yes yes in PostgreSQL and SQL Server; no in MySQL and Oracle (auto-commits) yes in PostgreSQL; no in MySQL and Oracle query.sql · editableExpected resultBEGIN DELETE 2 items_left ------------ 14 (1 row) ROLLBACK items_after_rollback ---------------------- 16 (1 row)Press Run to execute this on a real PostgreSQL database in your browser.
Good habits: run
SELECTwith the sameWHEREbefore aDELETE, and do risky changes inside a transaction so you canROLLBACK.TRUNCATEfails if other tables reference this one with foreign keys (unless you useCASCADE). - Link to this question
Constraints are rules the database enforces, so bad data can't get in even when application code has a bug.
Constraint Rule PRIMARY KEYuniquely identifies each row: unique and not NULL; one per tableFOREIGN KEYa value must exist in another table's key (e.g. orders.customer_id→customers.id)UNIQUEno two rows share the value (e.g. email) NOT NULLa value is required CHECKany condition, e.g. CHECK (quantity > 0)DEFAULTthe value used when none is given (not strictly a constraint) The foreign key on
orders.customer_idstops an order for a customer that doesn't exist:error.sql · editablefails on purposeExpected resultERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" DETAIL: Key (customer_id)=(999) is not present in table "customers".
Press Run to execute this on a real PostgreSQL database in your browser.
Follow-ups:
- Natural vs surrogate keys: a natural key has business meaning (an ISBN, an email); a surrogate key is generated (
id bigint GENERATED ALWAYS AS IDENTITY, or a UUID). Surrogate keys are usually preferred, because business values change. Keep aUNIQUEconstraint on the natural key. - ON DELETE options for foreign keys:
RESTRICT/NO ACTION(block),CASCADE(delete children too),SET NULL. - Index foreign key columns: PostgreSQL doesn't do it automatically, and joins and deletes on the parent need it.
- Natural vs surrogate keys: a natural key has business meaning (an ISBN, an email); a surrogate key is generated (
- Link to this question
Normalization organises tables so each fact is stored once. That avoids anomalies: updating a customer's city in one row but not another, or losing a product when its last order is deleted.
Form Rule Breaks it 1NF each column holds one value; no repeating groups phones = '555-1, 555-2'2NF 1NF, and every non-key column depends on the whole key in order_items(order_id, product_id, quantity, product_name),product_namedepends only onproduct_id3NF 2NF, and non-key columns depend only on the key, not on other non-key columns orders(id, customer_id, customer_city): city depends on the customerThe short version: "every non-key column depends on the key, the whole key, and nothing but the key".
Our sample schema is in 3NF: customer details live in
customers, product details inproducts, andorder_itemslinks them.Denormalization deliberately duplicates data for read speed or simplicity:
- analytics and data warehouses (star schemas, wide tables);
- storing computed totals (
orders.total) to avoid summing items on every page view; - caching data that rarely changes.
The cost is keeping copies in sync (triggers, application code, or rebuild jobs). Note that
order_items.unit_pricehere is not a normalization mistake: it records the price at the time of the order, which is a different fact from today's price. - Link to this question
Use a self join: join
employeesto itself, with different aliases for the two roles. ALEFT JOINkeeps Maya, who has no manager:query.sql · editableExpected resultemployee | manager ----------+--------- Maya | Arjun | Maya Lena | Maya Tom | Arjun Sofia | Maya Kofi | Sofia Priya | Sofia Diego | Maya Hana | Diego Sam | Maya (10 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Follow-up: "Find employees who earn more than their manager."
query.sql · editableExpected resultname | salary | manager | manager_salary ------+--------+---------+---------------- (0 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
No rows: nobody here out-earns their manager. Add
UPDATE employees SET salary = 150000 WHERE id = 4;above the query and run it again to see Tom appear (his manager Arjun earns 142,000).For the whole chain of managers (manager's manager, and so on), use a recursive CTE.
- Link to this question
This is an anti-join. For "customers who have never placed an order", there are three common ways:
query.sql · editableExpected resultname -------------- Stark Supply (1 row)
Press Run to execute this on a real PostgreSQL database in your browser.
query.sql · editableExpected resultname -------------- Stark Supply (1 row)
Press Run to execute this on a real PostgreSQL database in your browser.
query.sql · editableExpected resultname -------------- Stark Supply (1 row)
Press Run to execute this on a real PostgreSQL database in your browser.
All three agree here, but prefer
NOT EXISTS:- It's always correct with
NULLs.NOT INreturns nothing if the subquery contains a singleNULL(see the next question). - Optimisers turn both
NOT EXISTSandLEFT JOIN ... IS NULLinto an efficient anti-join. - It states the intent clearly.
The opposite, a semi-join ("customers who have ordered"), is
EXISTS, which returns each customer once even if they have many orders. - It's always correct with
- Link to this question
Because of
NULL.x NOT IN (a, b, NULL)meansx <> a AND x <> b AND x <> NULL. The last part is unknown for everyx, so the whole condition is never true, andWHEREdrops every row.Departments that no employee belongs to. Sam's
department_idisNULL:query.sql · editableExpected resultname ------ (0 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
No rows, even though Legal clearly has no employees. The fixes:
query.sql · editableExpected resultname ------- Legal (1 row)
Press Run to execute this on a real PostgreSQL database in your browser.
query.sql · editableExpected resultname ------- Legal (1 row)
Press Run to execute this on a real PostgreSQL database in your browser.
INdoesn't have this problem:x IN (a, NULL)is true whenx = a. Only the negated form breaks. That's why many teams have a rule: useNOT EXISTS, neverNOT INwith a subquery. - Link to this question
A CTE (common table expression) is a named, temporary result defined with
WITHand used in the query that follows. It makes multi-step queries readable, top to bottom:query.sql · editableExpected resultname | orders | spent -----------+--------+--------- Acme Corp | 3 | 4300.00 Globex | 2 | 1840.00 Umbrella | 1 | 1840.00 Initech | 1 | 1300.00 Hooli | 1 | 300.00 (5 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
CTE Subquery Temporary table Readability best for multi-step logic fine for small, one-off pieces good Reuse within one query yes, by name, several times no, repeat it yes Reuse across queries no no yes, for the session Can be indexed no no yes Recursion yes ( WITH RECURSIVE)no no Performance: modern PostgreSQL (12+) and SQL Server inline CTEs like subqueries, so they usually cost nothing extra. In PostgreSQL,
WITH x AS MATERIALIZED (...)forces it to be computed once. For big intermediate results reused by several queries, a temporary table with an index can be much faster. - Link to this question
A classic. Note that Arjun and Lena share the second-highest salary, so be clear whether you want the second-highest value or the second person.
query.sql · editableExpected resultsecond_highest ---------------- 142000.00 (1 row)Press Run to execute this on a real PostgreSQL database in your browser.
query.sql · editableExpected resultsalary ----------- 142000.00 (1 row)
Press Run to execute this on a real PostgreSQL database in your browser.
query.sql · editableExpected resultsalary ----------- 142000.00 (1 row)
Press Run to execute this on a real PostgreSQL database in your browser.
Things interviewers check:
- Ties:
DISTINCT/DENSE_RANKhandle them.ROW_NUMBERwould pick one of Arjun and Lena arbitrarily. - Empty case: the
max()version returnsNULLwhen there's no second salary, which is often what's asked for. - Dialect: SQL Server uses
TOPorOFFSET ... FETCH, Oracle usesFETCH FIRST.
- Ties:
- Link to this question
Number the rows within each group with a window function, then filter:
query.sql · editableExpected resultdepartment | name | salary -------------+-------+----------- Engineering | Maya | 185000.00 Engineering | Arjun | 142000.00 Sales | Sofia | 120000.00 Sales | Priya | 81000.00 Support | Diego | 64000.00 Support | Hana | 52000.00 (6 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Choose the numbering function by how you want ties handled:
Function Ties Engineering, <= 2Engineering, <= 3ROW_NUMBERalways exactly N rows (ties broken by the extra sort column) Maya, Arjun Maya, Arjun, Lena RANKtied rows share a rank, then it skips (1, 2, 2, 4); may return more than N Maya, Arjun, Lena Maya, Arjun, Lena DENSE_RANKtop N distinct values (1, 2, 2, 3) Maya, Arjun, Lena Maya, Arjun, Lena, Tom PostgreSQL alternatives:
DISTINCT ON (department_id) ... ORDER BY department_id, salary DESCfor the top 1 per group.- A
LATERALjoin (CROSS APPLYin SQL Server) withLIMIT 2, which can use an index on(department_id, salary)and is fast when there are few groups.
- Link to this question
Both compute values over a set of rows, but:
GROUP BYcollapses each group into one row.- A window function keeps every row and adds a computed value next to it.
query.sql · editableExpected resultname | department_id | salary | dept_avg | vs_avg -------+---------------+-----------+----------+----------- Maya | 1 | 185000.00 | 141750 | 43250.00 Arjun | 1 | 142000.00 | 141750 | 250.00 Lena | 1 | 142000.00 | 141750 | 250.00 Tom | 1 | 98000.00 | 141750 | -43750.00 Sofia | 2 | 120000.00 | 92333 | 27667.00 Priya | 2 | 81000.00 | 92333 | -11333.00 Kofi | 2 | 76000.00 | 92333 | -16333.00 Diego | 3 | 64000.00 | 58000 | 6000.00 Hana | 3 | 52000.00 | 58000 | -6000.00 (9 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
The
OVER (...)clause defines the window:Part Meaning PARTITION BYsplit rows into groups (like GROUP BY, without collapsing)ORDER BYorder rows inside each partition (needed for ranking, running totals, LAG)frame ( ROWS BETWEEN ...)which rows around the current one to include, e.g. the last 7 Families of window functions:
- Ranking:
ROW_NUMBER,RANK,DENSE_RANK,NTILE. - Offsets:
LAG,LEAD,FIRST_VALUE,LAST_VALUE. - Aggregates as windows:
sum,avg,count,min,maxwithOVER.
Window functions run after
WHERE,GROUP BYandHAVING, so you can't filter on them directly inWHERE. Wrap the query in a subquery or CTE (or useQUALIFYin Snowflake, BigQuery and DuckDB). - Link to this question
They differ only in how they handle ties (Arjun and Lena both earn 142,000):
query.sql · editableExpected resultname | salary | row_number | rank | dense_rank -------+-----------+------------+------+------------ Maya | 185000.00 | 1 | 1 | 1 Arjun | 142000.00 | 2 | 2 | 2 Lena | 142000.00 | 3 | 2 | 2 Sofia | 120000.00 | 4 | 4 | 3 Tom | 98000.00 | 5 | 5 | 4 (5 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Function Ties get After a tie Use for ROW_NUMBERdifferent numbers (arbitrary order unless you add a tie-breaker) continues: 1, 2, 3 de-duplicating, paging, "exactly one per group" RANKthe same number skips: 1, 2, 2, 4 competition-style rankings DENSE_RANKthe same number no gap: 1, 2, 2, 3 "Nth highest value" questions Always add a tie-breaker column to
ORDER BYwhen usingROW_NUMBER(for exampleORDER BY salary DESC, id), or results can change from run to run. - Link to this question
Use
sum()as a window function with anORDER BY. Monthly revenue from non-cancelled orders, and the running total:query.sql · editableExpected resultmonth | revenue | running_total ------------+---------+--------------- 2024-01-01 | 3740.00 | 3740.00 2024-02-01 | 3140.00 | 6880.00 2024-03-01 | 2700.00 | 9580.00 (3 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Details interviewers probe:
- The default frame. With
ORDER BYand no frame, the window isRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. WithRANGE, rows with the same sort value are added together, which surprises people when dates repeat. WriteROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWto add strictly row by row. - Per group: add
PARTITION BY customer_idfor a running total per customer. - Moving average:
avg(revenue) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)gives a 3-month moving average.
Before window functions, this needed a self join or correlated subquery, which is O(n²).
- The default frame. With
- Link to this question
LAG(value)reads from the previous row in the window;LEAD(value)from the next one.query.sql · editableExpected resultmonth | revenue | previous | change | pct_change ------------+---------+----------+---------+------------ 2024-01-01 | 3740.00 | | | 2024-02-01 | 3140.00 | 3740.00 | -600.00 | -16.0 2024-03-01 | 2700.00 | 3140.00 | -440.00 | -14.0 (3 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
The first month has no previous row, so
LAGreturnsNULL. You can give a default:LAG(revenue, 1, 0). The second argument is the offset, soLAG(revenue, 12)compares with the same month last year.Another common use: time between events, like days since a customer's previous order:
query.sql · editableExpected resultcustomer_id | id | ordered_on | days_since_last -------------+-----+------------+----------------- 1 | 101 | 2024-01-05 | 1 | 103 | 2024-02-02 | 28 1 | 106 | 2024-03-03 | 30 2 | 102 | 2024-01-18 | 2 | 107 | 2024-03-11 | 53 3 | 104 | 2024-02-14 | 3 | 109 | 2024-03-28 | 43 4 | 105 | 2024-02-20 | 5 | 108 | 2024-03-25 | (9 rows)Press Run to execute this on a real PostgreSQL database in your browser.
Watch out for gaps: if a month has no orders,
LAGcompares with the last month that does have data. Generate the full list of months first (see the question on missing dates). - Link to this question
Group by the columns that define a duplicate and keep groups with more than one row:
query.sql · editableExpected resultCREATE TABLE INSERT 0 6 email | copies | first_id -----------------+--------+---------- ana@example.com | 3 | 1 bo@example.com | 2 | 2 (2 rows)Press Run to execute this on a real PostgreSQL database in your browser.
To see the full duplicate rows, not just the groups, use a window count:
query.sql · editableExpected resultid | email | signed_up ----+-----------------+------------ 1 | ana@example.com | 2024-01-02 3 | ana@example.com | 2024-01-09 5 | ANA@example.com | 2024-02-01 2 | bo@example.com | 2024-01-03 6 | bo@example.com | 2024-02-03 (5 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Talk about what counts as a duplicate: exact match, case-insensitive email, trimmed spaces? Here
lower(email)catchesANA@example.com. Then prevent new duplicates with a unique index, e.g.CREATE UNIQUE INDEX ON signups (lower(email));. - Link to this question
Number the rows within each duplicate group, keep number 1, delete the rest:
query.sql · editableExpected resultCREATE TABLE INSERT 0 6 DELETE 3 id | email | signed_up ----+-----------------+------------ 1 | ana@example.com | 2024-01-02 2 | bo@example.com | 2024-01-03 4 | cy@example.com | 2024-01-10 (3 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
The
ORDER BYinside the window decides which copy survives: here the earliest signup.No unique id? Each database has a hidden row identifier:
ctidin PostgreSQL,ROWIDin Oracle. In SQL Server, you can delete through a CTE directly:-- SQL Server WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY email ORDER BY signed_up) AS rn FROM signups ) DELETE FROM ranked WHERE rn > 1;For a huge table with many duplicates, it's often faster to copy the distinct rows into a new table (
CREATE TABLE ... AS SELECT DISTINCT ON ...), swap the tables, and add a unique constraint so it can't happen again. - Link to this question
A recursive CTE has two parts joined by
UNION ALL:- the anchor: the starting rows (the CEO, who has no manager);
- the recursive part: joins the table to the rows found in the previous step, repeating until no new rows appear.
query.sql · editableExpected resultorg_chart | level | path -----------+-------+---------------------- Maya | 1 | Maya Arjun | 2 | Maya > Arjun Tom | 3 | Maya > Arjun > Tom Diego | 2 | Maya > Diego Hana | 3 | Maya > Diego > Hana Lena | 2 | Maya > Lena Sam | 2 | Maya > Sam Sofia | 2 | Maya > Sofia Kofi | 3 | Maya > Sofia > Kofi Priya | 3 | Maya > Sofia > Priya (10 rows)Press Run to execute this on a real PostgreSQL database in your browser.
Other uses: category trees, bill of materials (parts made of parts), graph walks, and generating series of numbers or dates.
Safety points:
- A cycle in the data (A manages B, B manages A) makes it loop forever. Guard with a depth limit (
WHERE org.level < 20) or, in PostgreSQL 14+, theCYCLEclause. - SQL Server uses the same syntax without the word
RECURSIVE, and stops at 100 levels by default (OPTION (MAXRECURSION n)).
- Link to this question
Use conditional aggregation: one aggregate per output column, each counting only matching rows. Revenue per product category, with one column per month:
query.sql · editableExpected resultcategory | jan | feb | mar ----------+---------+---------+--------- Hardware | 3540.00 | 2950.00 | 1900.00 Services | | 150.00 | 300.00 Software | 200.00 | 40.00 | 500.00 (3 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
FILTER (WHERE ...)is PostgreSQL (and SQLite) syntax. The portable form usesCASE:SUM(CASE WHEN ordered_on >= '2024-01-01' AND ordered_on < '2024-02-01' THEN quantity * unit_price ELSE 0 END) AS janNotes:
- SQL Server and Oracle have a
PIVOToperator, and Snowflake hasPIVOTtoo, but conditional aggregation works everywhere. - SQL needs the output columns to be known when the query is written. A pivot with dynamic columns (one per month, however many there are) needs dynamic SQL or pivoting in the reporting tool.
- The reverse, unpivot, can be done with
UNION ALLor aLATERAL (VALUES ...)join.
- SQL Server and Oracle have a
- Link to this question
CASEis SQL's if/else. It returns the value of the first matching branch, or theELSEvalue (orNULLif there's noELSE).query.sql · editableExpected resultname | salary | band -------+-----------+-------- Maya | 185000.00 | senior Arjun | 142000.00 | senior Lena | 142000.00 | senior Sofia | 120000.00 | mid Tom | 98000.00 | mid Priya | 81000.00 | mid Kofi | 76000.00 | junior Sam | 70000.00 | junior Diego | 64000.00 | junior Hana | 52000.00 | junior (10 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
It works anywhere an expression does:
SELECT,WHERE,ORDER BY,GROUP BYand inside aggregates:query.sql · editableExpected resultshipped | pending | total ---------+---------+------- 6 | 2 | 9 (1 row)Press Run to execute this on a real PostgreSQL database in your browser.
query.sql · editableExpected resultid | status -----+----------- 106 | pending 109 | pending 101 | shipped 102 | shipped 103 | shipped 105 | shipped 107 | shipped 108 | shipped 104 | cancelled (9 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Branches are checked in order, so put the most specific conditions first. In the salary example, checking
>= 80000first would label everyone above 80,000 as "mid". - Link to this question
GROUP BYcan only produce groups that exist in the data. To show zeros for empty periods, generate the full calendar andLEFT JOINthe data to it.Orders per week in February and March 2024. Several weeks have no orders:
query.sql · editableExpected resultweek | orders ------------+-------- 2024-02-05 | 0 2024-02-12 | 1 2024-02-19 | 1 2024-02-26 | 1 2024-03-04 | 0 2024-03-11 | 1 2024-03-18 | 0 2024-03-25 | 2 (8 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Key points:
generate_seriesis PostgreSQL. Other options: a recursive CTE, a numbers table, or a permanent calendar (date dimension) table, which data warehouses usually have anyway (with columns likeis_weekend,fiscal_quarter).- Use
count(o.id), notcount(*), so empty weeks show 0 rather than 1. - Use
COALESCE(sum(...), 0)for sums, since an empty week's sum isNULL. - Use half-open ranges (
>= start AND < end) so timestamps at midnight aren't counted twice.
- Link to this question
Islands are runs of consecutive values (days in a row a user was active); gaps are the breaks between them. Typical questions: "longest login streak", "periods a machine was down".
The trick: subtract a
ROW_NUMBERfrom the date. Within a run of consecutive days, both go up by 1 each row, so the difference stays constant and identifies the island.query.sql · editableExpected resultCREATE TABLE INSERT 0 9 user_id | streak_start | streak_end | days ---------+--------------+------------+------ 1 | 2024-03-01 | 2024-03-03 | 3 1 | 2024-03-05 | 2024-03-06 | 2 1 | 2024-03-09 | 2024-03-09 | 1 2 | 2024-03-01 | 2024-03-01 | 1 2 | 2024-03-03 | 2024-03-04 | 2 (5 rows)Press Run to execute this on a real PostgreSQL database in your browser.
Then the longest streak per user is just
max(days)over that result.If the data can contain the same day twice, de-duplicate first (
SELECT DISTINCT user_id, day) or useDENSE_RANK.An alternative approach uses
LAGto flag where a new island starts (when the gap to the previous row is more than 1 day), then a runningsumof those flags numbers the islands. It's handy for timestamps with a tolerance, like "sessions with no more than 30 minutes between events". - Link to this question
Divide by a window
sumwith an emptyOVER (), which covers all rows:query.sql · editableExpected resultcategory | revenue | pct_of_total ----------+---------+-------------- Hardware | 8390.00 | 87.6 Software | 740.00 | 7.7 Services | 450.00 | 4.7 (3 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
sum(sum(...)) OVER ()looks odd but is normal: the innersumis theGROUP BYaggregate per category, and the outersum ... OVER ()adds those up across all groups.Add
PARTITION BYfor "percent within a group", e.g. each employee's share of their department's payroll:query.sql · editableExpected resultdepartment_id | name | salary | pct_of_dept ---------------+-------+-----------+------------- 1 | Maya | 185000.00 | 32.6 1 | Arjun | 142000.00 | 25.0 1 | Lena | 142000.00 | 25.0 1 | Tom | 98000.00 | 17.3 2 | Sofia | 120000.00 | 43.3 2 | Priya | 81000.00 | 29.2 2 | Kofi | 76000.00 | 27.4 3 | Diego | 64000.00 | 55.2 3 | Hana | 52000.00 | 44.8 (9 rows)Press Run to execute this on a real PostgreSQL database in your browser.
Integer division trap: in PostgreSQL and SQL Server,
5 / 2is2. Multiply by100.0(a decimal) first, as above, to get a decimal result. - Link to this question
Use the ordered-set aggregates
percentile_cont(interpolates between values) andpercentile_disc(returns an actual value from the data):query.sql · editableExpected resultdepartment_id | staff | mean | median | median_disc ---------------+-------+--------+--------+------------- 1 | 4 | 141750 | 142000 | 142000.00 2 | 3 | 92333 | 81000 | 81000.00 3 | 2 | 58000 | 58000 | 52000.00 (3 rows)Press Run to execute this on a real PostgreSQL database in your browser.
Sales shows why the median matters: Sofia's 120,000 pulls the mean up to 92,333, while the median of 81,000 better represents a typical salary in that team. Medians are robust to outliers, which is why they're used for incomes, response times and house prices.
Other percentiles:
percentile_cont(0.95)for p95 latency.percentile_cont(ARRAY[0.25, 0.5, 0.75])returns quartiles at once.Dialects: SQL Server has
PERCENTILE_CONTas a window function (OVER (PARTITION BY ...)), Snowflake and BigQuery haveMEDIAN/APPROX_QUANTILES. On huge data, approximate functions are much cheaper. - Link to this question
"Rows that match all of several values" is a classic. Filter to the products you care about, group per customer, and require that each product appears:
query.sql · editableExpected resultname ---------- Umbrella (1 row)
Press Run to execute this on a real PostgreSQL database in your browser.
Why the other attempts fail:
WHERE p.name = 'Laptop' AND p.name = 'Monitor'returns nothing: one row can't have two names.WHERE p.name IN (...)alone returns customers who bought either.count(*) = 2would wrongly count a customer who bought two laptops; hencecount(DISTINCT ...).
Variations:
query.sql · editableExpected resultname ----------- Acme Corp Initech (2 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
The fully general version, "customers who bought every product in a category", is called relational division: compare
count(DISTINCT product)with the number of products in that category. - Link to this question
Several correct approaches. The most portable is
ROW_NUMBER:query.sql · editableExpected resultcustomer_id | order_id | ordered_on -------------+----------+------------ 1 | 101 | 2024-01-05 2 | 102 | 2024-01-18 3 | 104 | 2024-02-14 4 | 105 | 2024-02-20 5 | 108 | 2024-03-25 (5 rows)Press Run to execute this on a real PostgreSQL database in your browser.
PostgreSQL's
DISTINCT ONis shorter: keep the first row of eachcustomer_idaccording to theORDER BY:query.sql · editableExpected resultcustomer_id | order_id | ordered_on -------------+----------+------------ 1 | 101 | 2024-01-05 2 | 102 | 2024-01-18 3 | 104 | 2024-02-14 4 | 105 | 2024-02-20 5 | 108 | 2024-03-25 (5 rows)Press Run to execute this on a real PostgreSQL database in your browser.
A common wrong answer:
SELECT customer_id, min(ordered_on), id -- error: id isn't grouped FROM orders GROUP BY customer_id;min(ordered_on)gives the date but not which order it was. Joining back on the date breaks when a customer has two orders on the same day. That's why the window-function version, with a tie-breaker (id), is the safe answer.Follow-up: "customer's latest order" is the same with
ORDER BY ordered_on DESC. With an index on(customer_id, ordered_on), all of these are fast. - Link to this question
In PostgreSQL (and SQLite), use
INSERT ... ON CONFLICT. It needs a unique constraint or primary key to detect the conflict:query.sql · editableExpected resultCREATE TABLE INSERT 0 2 INSERT 0 2 product_id | quantity ------------+---------- 1 | 10 2 | 12 3 | 4 (3 rows)Press Run to execute this on a real PostgreSQL database in your browser.
EXCLUDEDis the row you tried to insert.ON CONFLICT DO NOTHINGskips rows that already exist.Other databases:
Database Syntax PostgreSQL 15+, SQL Server, Oracle, Snowflake MERGE INTO target USING source ON ... WHEN MATCHED THEN UPDATE ... WHEN NOT MATCHED THEN INSERT ...MySQL INSERT ... ON DUPLICATE KEY UPDATEWhy not "SELECT, then INSERT or UPDATE" in application code? Two requests can both see "doesn't exist" and both insert, causing a duplicate or a constraint error.
ON CONFLICTis atomic. (MERGEin SQL Server can still hit race conditions under concurrency unless you add locking hints likeHOLDLOCK.) - Link to this question
OFFSET pagination skips rows:
query.sql · editableExpected resultid | ordered_on -----+------------ 106 | 2024-03-03 105 | 2024-02-20 104 | 2024-02-14 (3 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Keyset pagination remembers the last row of the previous page and continues after it:
query.sql · editableExpected resultid | ordered_on -----+------------ 106 | 2024-03-03 105 | 2024-02-20 104 | 2024-02-14 (3 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
OFFSET Keyset Speed on deep pages slow: OFFSET 100000still reads and throws away 100,000 rowsfast: an index seek straight to the spot Rows added or deleted between page loads rows get skipped or shown twice stable Jump to page 57 easy not directly; only next/previous Best for small tables, admin screens with page numbers infinite scroll, APIs, large tables Keyset needs a unique, ordered key: add
idas a tie-breaker so rows with the same date aren't lost. An index on(ordered_on, id)makes it efficient. The comparison(a, b) < (x, y)is a row value comparison; in databases without it, writea < x OR (a = x AND b < y). - Link to this question
An index is a separate, sorted data structure that maps column values to the rows that contain them, like the index at the back of a book. Without one, the database must read every row (a full table scan) to find matches.
Most indexes are B-trees: balanced trees whose leaves hold sorted keys and pointers to rows. Finding a value takes a few page reads, even among millions of rows (O(log n)).
Diagram A B-tree index on a column helps:
- equality and range filters:
=,<,>,BETWEEN,LIKE 'abc%'; ORDER BYandmin/max(the data is already sorted);- joins on that column.
The costs:
- slower writes: every
INSERT,UPDATEandDELETEmust update every index; - disk and memory space;
- the optimiser may still ignore it if the query returns a large share of the table, because reading the table straight through is cheaper.
Other index types: hash (equality only), GIN (arrays, JSON, full-text search), GiST (geometry, ranges), BRIN (huge, naturally ordered tables like logs), plus partial and expression indexes.
Good candidates: columns in frequent
WHERE,JOINandORDER BYclauses that are selective (many distinct values). Foreign keys usually deserve one. - equality and range filters:
- Link to this question
- A clustered index is the table: rows are stored in the order of the index key. There can be only one per table.
- A non-clustered (secondary) index is a separate structure holding the key plus a pointer to the row. There can be many.
Clustered Non-clustered Per table one many Leaf level contains the full rows keys + row locator Range scans on the key very fast (rows are adjacent) fast for the key, then a lookup per row Extra lookup to get other columns no yes, unless the index covers the query How databases differ:
- SQL Server: the primary key is clustered by default. Choose a narrow, ever-increasing clustered key (like an identity column); a random GUID causes page splits and fragmentation.
- MySQL (InnoDB): the table is always clustered on the primary key, and secondary indexes store the primary key as their pointer, so a long primary key makes every index bigger.
- PostgreSQL: tables are heaps (no clustered index). Every index is secondary. The
CLUSTERcommand reorders a table once, but the order isn't maintained.
Covering index: if a non-clustered index contains every column a query needs, the database answers from the index alone (an "index-only scan"). SQL Server and PostgreSQL let you add extra columns with
INCLUDE (...). - Link to this question
Yes, a lot. An index on
(customer_id, ordered_on)is sorted bycustomer_idfirst, then byordered_onwithin each customer, like a phone book sorted by last name, then first name.CREATE INDEX orders_customer_date ON orders (customer_id, ordered_on);Query Can use the index well? WHERE customer_id = 1yes (the leftmost column) WHERE customer_id = 1 AND ordered_on >= '2024-02-01'yes, both columns WHERE customer_id = 1 ORDER BY ordered_onyes, and no sort is needed WHERE ordered_on >= '2024-02-01'no (not efficiently): the dates are scattered across all customers This is the leftmost prefix rule: an index helps queries that filter on its first column, or its first two, and so on.
How to order the columns:
- Columns compared with equality (
=,IN) first. - Then the column used for a range or for sorting. After a range condition, later columns can't be used for seeking.
- Among equality columns, the order matters less than people think. Put the one shared by the most queries first, so one index serves several queries.
Don't create both
(a)and(a, b): the second already serves queries onaalone. - Columns compared with equality (
- Link to this question
Work through it step by step:
- Measure: confirm it's the query (not the network or the app), and how slow, with real parameters. Use slow-query logs or
pg_stat_statementsto find the worst offenders. - Read the execution plan:
EXPLAIN ANALYZE(PostgreSQL, MySQL 8), "Include Actual Execution Plan" (SQL Server). - Look for the usual suspects:
Plan shows Likely cause Fix Seq Scan / Table Scan on a big table with a selective filter missing index, or a non-sargable condition add an index; rewrite the condition Estimated rows far from actual rows stale statistics, correlated columns ANALYZE, extended statisticsNested loop over many rows bad estimate, missing index on the join column index the join column, fix statistics Sort or hash spilling to disk not enough memory, or sorting too much index matching the ORDER BY, raisework_mem, return fewer rowsSame query run hundreds of times N+1 queries from the app batch with a join or INquery.sql · editableExpected resultQUERY PLAN -------------------------------------------------------- Seq Scan on orders (cost=0.00..24.12 rows=6 width=44) Filter: (customer_id = 1) (2 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
On our tiny table, a sequential scan is the right plan. On a large table you'd want an index scan.
- Reduce the work: select only needed columns, filter early, paginate with keyset, pre-aggregate with materialized views.
- Verify the fix with the plan and timings, and watch that it doesn't slow down writes or other queries.
Good answers mention statistics and cardinality estimates: most bad plans come from the optimiser guessing row counts wrong.
- Measure: confirm it's the query (not the network or the app), and how slow, with real parameters. Use slow-query logs or
- Link to this question
A condition is sargable (Search ARGument ABLE) when the database can use an index to find matching rows. Wrapping the column in a function or calculation usually makes it non-sargable, because the index is sorted by the raw column value, not by the function's result.
Non-sargable Sargable rewrite WHERE EXTRACT(year FROM ordered_on) = 2024WHERE ordered_on >= '2024-01-01' AND ordered_on < '2025-01-01'WHERE salary * 12 > 1200000WHERE salary > 100000WHERE lower(email) = 'ana@example.com'store emails lower-case, or index the expression: CREATE INDEX ON users (lower(email))WHERE name LIKE '%son'a trigram index (PostgreSQL pg_trgm) or full-text searchWHERE COALESCE(city, '') = 'London'WHERE city = 'London'WHERE customer_id::text = '42'(type mismatch)compare with the right type: customer_id = 42Both versions return the same rows:
query.sql · editableExpected resultnon_sargable -------------- 3 (1 row) sargable ---------- 3 (1 row)Press Run to execute this on a real PostgreSQL database in your browser.
The rule: keep the column bare on one side of the comparison and move the calculation to the other side.
LIKE 'abc%'(prefix) can use a B-tree index;LIKE '%abc'can't.Implicit type conversions are a sneaky cause, especially in SQL Server comparing
varcharcolumns withnvarcharparameters. - Link to this question
ACID is the set of guarantees a database transaction gives:
Letter Property Meaning Example A Atomicity all of the transaction happens, or none of it a transfer debits one account and credits the other, never just one C Consistency a transaction moves the database from one valid state to another; constraints always hold a foreign key can't point to a missing row I Isolation concurrent transactions don't see each other's half-finished work two people booking the last seat D Durability once committed, the data survives crashes and power loss written to the write-ahead log on disk before COMMITreturnsquery.sql · editableExpected resultBEGIN UPDATE 1 UPDATE 1 id | name | price ----+---------+--------- 1 | Laptop | 1150.00 2 | Monitor | 350.00 (2 rows) ROLLBACK id | name | price ----+---------+--------- 1 | Laptop | 1200.00 2 | Monitor | 300.00 (2 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
How databases provide it: a write-ahead log (durability and crash recovery), locks and/or MVCC (multi-version concurrency control, for isolation), and constraint checks (consistency).
Follow-up: many NoSQL and distributed systems trade some ACID guarantees for availability and scale, often described as BASE (basically available, soft state, eventually consistent).
- Link to this question
Isolation levels trade correctness for concurrency. Each prevents more anomalies:
Anomaly What happens Dirty read you read another transaction's uncommitted change, which may be rolled back Non-repeatable read you read a row twice and get different values, because someone committed an update in between Phantom read you run the same query twice and new rows appear, because someone inserted matching rows Lost update / write skew two transactions read, then both write based on what they read, and one's work is lost or a rule is broken Level Dirty read Non-repeatable read Phantom Write skew Read Uncommitted possible possible possible possible Read Committed (default in PostgreSQL, SQL Server, Oracle) prevented possible possible possible Repeatable Read (default in MySQL InnoDB) prevented prevented possible in the standard; prevented in PostgreSQL and InnoDB possible Serializable prevented prevented prevented prevented BEGIN ISOLATION LEVEL SERIALIZABLE; -- ... COMMIT;Points that impress interviewers:
- Most databases use MVCC: readers see a snapshot and don't block writers. In PostgreSQL, Read Uncommitted behaves like Read Committed.
- Serializable transactions may fail with a serialization error, so the application must retry them.
- Write skew example: two doctors both check "at least one other doctor is on call", both go off call, and now nobody is. Only Serializable (or explicit locking with
SELECT ... FOR UPDATE) prevents it. - Higher isolation means more blocking or more retries. Most apps use Read Committed plus targeted locks or constraints.
- Link to this question
A deadlock happens when two transactions each hold a lock the other needs, so neither can continue:
Diagram The database detects the cycle and kills one transaction (the "victim") with a deadlock error. The other continues.
How to prevent or reduce them:
- Lock in a consistent order: e.g. always update the lower account id first. That removes the cycle.
- Keep transactions short: no user input or slow API calls while holding locks.
- Touch fewer rows: good indexes so updates lock only the rows they need, not big ranges.
- Lock up front when you'll update later:
SELECT ... FOR UPDATEon all the rows at the start, in order. - Retry: deadlocks can't be ruled out completely, so catch the error and retry the whole transaction.
How to investigate: PostgreSQL logs both queries on a deadlock; SQL Server has deadlock graphs (system_health Extended Events); MySQL has
SHOW ENGINE INNODB STATUS.Don't confuse a deadlock with blocking: a long transaction holding locks makes others wait, but they continue once it finishes.
- Link to this question
Both stop two users from overwriting each other's changes (a lost update).
Pessimistic locking: lock the row when you read it, so others must wait.
BEGIN; SELECT quantity FROM stock WHERE product_id = 1 FOR UPDATE; -- others wait here UPDATE stock SET quantity = quantity - 1 WHERE product_id = 1; COMMIT;Optimistic locking: don't lock. Keep a version number, and only update if it hasn't changed since you read it:
query.sql · editableExpected resultCREATE TABLE INSERT 0 1 UPDATE 1 UPDATE 0 id | body | version ----+----------+--------- 1 | A's edit | 2 (1 row)
Press Run to execute this on a real PostgreSQL database in your browser.
The second update affects 0 rows, so the app tells user B "this document changed; reload and try again".
Pessimistic Optimistic Best when conflicts are frequent, and the transaction is short conflicts are rare; long "think time" (forms, web apps) Cost blocking, possible deadlocks retries when conflicts happen Typical use stock counts, seat booking, queues ( FOR UPDATE SKIP LOCKED)editing records in a web app; ORMs (EF Core [ConcurrencyCheck], JPA@Version)For simple counters, a single atomic statement avoids the problem entirely:
UPDATE stock SET quantity = quantity - 1 WHERE product_id = 1 AND quantity > 0. - Link to this question
- A view is a saved query. It stores no data; every time you select from it, the query runs again, so results are always current.
- A materialized view stores the result like a table. Reads are fast, but the data is only as fresh as the last refresh.
query.sql · editableExpected resultCREATE MATERIALIZED VIEW customer_id | revenue -------------+--------- 1 | 4300.00 2 | 1840.00 (2 rows) INSERT 0 1 INSERT 0 1 customer_id | revenue -------------+--------- 1 | 4300.00 2 | 1840.00 (2 rows) REFRESH MATERIALIZED VIEW customer_id | revenue -------------+--------- 1 | 4300.00 2 | 4240.00 (2 rows)Press Run to execute this on a real PostgreSQL database in your browser.
View Materialized view Stores data no yes Freshness always current as of the last refresh Read speed same as the underlying query fast; can be indexed Uses simplify complex queries, hide columns for security, stable interface for apps dashboards, expensive aggregations, reporting Notes: PostgreSQL's
REFRESH MATERIALIZED VIEW CONCURRENTLYavoids blocking readers (needs a unique index). SQL Server's equivalent is an indexed view, kept up to date automatically. Snowflake, BigQuery and Oracle can refresh materialized views automatically or incrementally. - Link to this question
OLTP (transactional) OLAP (analytical) Purpose run the business: orders, payments, sign-ups analyse the business: reports, dashboards, trends Queries many small reads and writes of a few rows few large queries scanning millions of rows Schema normalized (3NF) denormalized: star or snowflake schema Storage row-oriented usually column-oriented Examples PostgreSQL, MySQL, SQL Server Snowflake, BigQuery, Redshift, Databricks, ClickHouse A star schema has a central fact table of measurable events, surrounded by dimension tables that describe them:
Diagram - Facts are numbers you add up (revenue, quantity) at a stated grain ("one row per order line").
- Dimensions are the "by" in "revenue by month by category".
- A snowflake schema normalizes dimensions further (category in its own table), trading simpler storage for more joins.
Related ideas: ETL/ELT pipelines copy data from OLTP systems into the warehouse; slowly changing dimensions (type 2) keep history when, say, a customer moves city.
- Link to this question
SQL injection happens when user input is pasted into the SQL text, so the input can change the query itself.
# Vulnerable: input becomes part of the SQL query = f"SELECT * FROM users WHERE email = '{email}'" # email = "' OR '1'='1" -> WHERE email = '' OR '1'='1' (returns every user) # email = "'; DROP TABLE users; --" -> runs a second statementPrevention:
- Parameterized queries (prepared statements), always. The SQL and the values are sent separately, so values can never be read as SQL:
cur.execute("SELECT * FROM users WHERE email = %s", (email,))cmd.CommandText = "SELECT * FROM users WHERE email = @email"; cmd.Parameters.AddWithValue("@email", email);- Use an ORM or query builder correctly. They parameterize for you, but raw-SQL escape hatches can still be misused with string concatenation.
- Allow-list identifiers. Table and column names can't be parameters. If users choose a sort column, map their choice to a fixed list of known names.
- Least privilege: the app's database user shouldn't be able to drop tables or read other schemas.
- Defence in depth: input validation, a WAF, and avoiding detailed database errors in responses.
Escaping quotes by hand is not a reliable fix; parameters are.
- Link to this question
Both split a large table into smaller pieces, but in different places:
- Partitioning: one table is split into parts inside one database server, e.g. one partition per month. The database routes queries automatically.
- Sharding: data is split across several database servers, e.g. customers 1 to 1M on server A, the rest on server B. Each shard is a separate database.
CREATE TABLE events ( id bigint, occurred_at timestamptz NOT NULL, payload jsonb ) PARTITION BY RANGE (occurred_at); CREATE TABLE events_2024_03 PARTITION OF events FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');Partitioning Sharding Main benefits partition pruning (queries skip irrelevant partitions); dropping old data instantly ( DROPa partition instead of a hugeDELETE); smaller indexesscale writes and storage beyond one machine Complexity low to moderate high: cross-shard queries and joins, distributed transactions, rebalancing Schemes range (dates), list (region), hash by key range, hash of the key, or a lookup directory Choosing a partition or shard key is the critical decision: it should match the most common filter (e.g.
occurred_at,tenant_id) and spread data evenly. A bad key creates hot spots.Interview advice: suggest sharding last. First try indexes, query fixes, caching, read replicas and partitioning. Or use a database that shards for you (Citus, CockroachDB, Vitess, cloud warehouses).
- Link to this question
Stored procedure Function Trigger What saved code you CALLsaved code that returns a value, usable inside queries code that runs automatically on INSERT,UPDATEorDELETETransaction control can COMMIT/ROLLBACKinside (PostgreSQL 11+, SQL Server)no runs inside the triggering statement's transaction Typical use multi-step batch jobs, data loads calculations, reusable logic in SELECTaudit logs, keeping derived data in sync, enforcing complex rules query.sql · editableExpected resultCREATE TABLE CREATE FUNCTION CREATE TRIGGER UPDATE 2 product_id | old_price | new_price ------------+-----------+----------- 4 | 100.00 | 90.00 5 | 40.00 | 36.00 (2 rows)Press Run to execute this on a real PostgreSQL database in your browser.
Trade-offs:
Pros Cons logic runs next to the data: fewer round trips harder to version, test, review and debug than app code one place to enforce rules for every app that writes ties you to one database's language can be granted permissions independently of tables triggers are hidden: a simple UPDATEdoes surprising extra workA balanced answer: keep most business logic in the application; use the database for data integrity (constraints first, triggers when constraints can't express the rule), auditing, and heavy set-based processing.
- Link to this question
Type Stores Notes CHAR(n)fixed length, padded with spaces good only for truly fixed codes (country 'GB'); the padding causes surprising comparisonsVARCHAR(n)variable length, up to n characters the limit is a business rule enforced by the database TEXTvariable length, no declared limit in PostgreSQL, same performance as VARCHAR; in SQL Server useVARCHAR(MAX)/NVARCHAR(MAX)(oldTEXTis deprecated)In SQL Server,
NVARCHARstores Unicode (UTF-16); plainVARCHARneeds a UTF-8 collation for other languages. Mixing the two in comparisons can stop index use.General rules for picking types:
- Money:
numeric/decimal, neverfloat. Floating point can't represent 0.1 exactly. - Dates and times: real
date/timestamptypes, not strings. Store instants astimestamptz(or UTC). - IDs:
bigintidentity for most tables; UUIDs when IDs are generated in many places (prefer time-ordered UUIDv7 for index friendliness). - Booleans:
boolean, not'Y'/'N'strings. - Smallest type that safely fits: smaller rows mean more rows per page and faster scans, but don't use
intfor something that may pass 2.1 billion.
query.sql · editableExpected resultfloat_sum | numeric_sum ---------------------+------------- 0.30000000000000004 | 0.3 (1 row)
Press Run to execute this on a real PostgreSQL database in your browser.
- Money:
- Link to this question
Use a string-aggregate function. In PostgreSQL and SQL Server 2017+, that's
string_agg:query.sql · editableExpected resultdepartment | employees | staff -------------+------------------------+------- Engineering | Arjun, Lena, Maya, Tom | 4 Sales | Kofi, Priya, Sofia | 3 Support | Diego, Hana | 2 (3 rows)
Press Run to execute this on a real PostgreSQL database in your browser.
Database Function PostgreSQL string_agg(col, ', ' ORDER BY col)SQL Server 2017+ STRING_AGG(col, ', ') WITHIN GROUP (ORDER BY col)MySQL GROUP_CONCAT(col ORDER BY col SEPARATOR ', ')(watch the default length limit)Oracle, Snowflake LISTAGG(col, ', ') WITHIN GROUP (ORDER BY col)PostgreSQL can also build an array (
array_agg) or JSON (json_agg), which is often better for APIs than a comma-separated string.The reverse, splitting a string into rows, is
string_to_tableorunnest(string_to_array(...))in PostgreSQL, andSTRING_SPLITin SQL Server. - Link to this question
They compute several levels of grouping in one query, such as per-month totals, per-category totals and a grand total, which would otherwise need several queries combined with
UNION ALL.query.sql · editableExpected resultcategory | month | revenue ----------+---------+--------- Hardware | 2024-01 | 3540.00 Hardware | 2024-02 | 2950.00 Hardware | 2024-03 | 1900.00 Hardware | | 8390.00 Services | 2024-02 | 150.00 Services | 2024-03 | 300.00 Services | | 450.00 Software | 2024-01 | 200.00 Software | 2024-02 | 40.00 Software | 2024-03 | 500.00 Software | | 740.00 | | 9580.00 (12 rows)Press Run to execute this on a real PostgreSQL database in your browser.
The rows with
NULLinmonthare subtotals per category, and the row withNULLin both columns is the grand total.Clause Groupings produced for (a, b) ROLLUP (a, b)(a, b), (a), () — a hierarchy, like year > month CUBE (a, b)(a, b), (a), (b), () — every combination GROUPING SETS ((a), (b))exactly the sets you list Use the
GROUPING(col)function to tell a subtotal'sNULLapart from a realNULLvalue in the data. It returns 1 when the column was rolled up.