SQL reference
PostgreSQL reference
Syntax, behaviour and runnable examples for everything SQL Quest teaches, the errors you'll meet and how to fix them, and snippets for everyday analysis. Every example runs on the SQL Quest datasets.
Querying
- SELECTReads rows from one or more tables and returns the columns or expressions you list.
- ORDER BYSorts the result rows by one or more expressions, ascending or descending.
- LIMIT / OFFSET / FETCH FIRSTReturns only the first n rows of the (sorted) result, optionally after skipping some.
- DISTINCT and DISTINCT ONRemoves duplicate result rows; DISTINCT ON keeps the first row of each group.
- Logical order of a queryThe order PostgreSQL logically evaluates clauses in, which decides what each clause can see.
Filtering
- WHEREKeeps only the rows for which a condition is TRUE.
- NULL and IS NULLNULL means a missing or unknown value; it needs IS NULL, not =, and it propagates through expressions.
- LIKE, ILIKE and pattern matchingMatches text against a pattern with % (any run of characters) and _ (exactly one character).
- IN and BETWEENIN tests membership in a list or subquery; BETWEEN tests an inclusive range.
- Comparison, logical and arithmetic operatorsThe operators you use in conditions and calculations, and how PostgreSQL evaluates them.
Functions
- CASEReturns a value chosen by the first matching condition: SQL's if / else if / else.
- COALESCEReturns the first argument that is not NULL — the standard way to supply a default.
- NULLIFReturns NULL when its two arguments are equal, otherwise the first argument.
- CAST and ::Converts a value to another data type.
- String functionsFunctions for concatenating, cleaning, slicing and searching text.
- Date and time functionsTruncating, extracting, adding and subtracting dates, timestamps and intervals.
- Math functionsRounding, absolute values, powers, logarithms and related numeric functions.
- generate_seriesA set-returning function that produces a sequence of numbers or timestamps, e.g. a calendar.
Aggregation
- Aggregate functionsFunctions that reduce many rows to one value: COUNT, SUM, AVG, MIN, MAX and friends.
- GROUP BYGroups rows that share values so aggregates are computed per group — one output row per group.
- HAVINGFilters groups after GROUP BY, using aggregate values.
- FILTER (WHERE …) on aggregatesRestricts which rows a single aggregate sees, so one query can compute several conditional totals.
- Percentiles and medianOrdered-set aggregates that return the median or any percentile of a column.
Joins
- INNER JOINCombines rows from two tables where the join condition is true; unmatched rows are dropped.
- LEFT JOINKeeps every row of the left table, with NULLs where the right table has no match.
- FULL OUTER JOINKeeps all rows from both tables, matching where possible and padding the rest with NULLs.
- CROSS JOIN and LATERALCROSS JOIN pairs every row with every row; LATERAL lets a subquery refer to the row on its left.
- Self-joinJoins a table to itself, using two aliases, to relate rows of the same table.
Subqueries and CTEs
- SubqueriesA query nested inside another: as a single value, a list, or a derived table.
- EXISTS and NOT EXISTSTests whether a correlated subquery returns at least one row: semi-joins and anti-joins.
- WITH (common table expressions)Names one or more intermediate results at the top of a query so it reads top-down in steps.
- WITH RECURSIVEA CTE that refers to itself to walk hierarchies or generate sequences.
- UNION, INTERSECT, EXCEPTCombine the results of two queries vertically: all rows, common rows, or rows only in the first.
Window functions
- Window functions (OVER)Compute a value over a set of related rows while keeping every row in the result.
- ROW_NUMBER, RANK, DENSE_RANK, NTILENumber or rank rows within a partition by an ordering.
- LAG, LEAD, FIRST_VALUE, LAST_VALUERead a value from a previous, next, first or last row of the window without a self-join.
- Window frames (ROWS / RANGE / GROUPS)Define exactly which rows around the current row an aggregate window function covers.
Changing data
- INSERTAdds new rows to a table, from literal values or from a query.
- UPDATEChanges column values in the rows that match a condition.
- DELETE and TRUNCATERemoves rows that match a condition (DELETE) or all rows quickly (TRUNCATE).
- INSERT … ON CONFLICT and MERGEInsert a row, or update/skip it if a row with the same key already exists.
- RETURNINGMakes INSERT, UPDATE, DELETE and MERGE return the rows they changed.
- Transactions (BEGIN, COMMIT, ROLLBACK)Group several statements so they succeed or fail together.
Schema
- CREATE TABLEDefines a new table: its columns, types, defaults and constraints.
- Data typesThe PostgreSQL types you will use most and when to choose each.
- ConstraintsRules the database enforces on every write: NOT NULL, UNIQUE, PRIMARY KEY, FOREIGN KEY, CHECK.
- ALTER TABLE and DROPChange an existing table's columns, types, defaults and constraints, or remove objects.
- Views and materialized viewsSave a query under a name so it can be queried like a table.
Performance
Dialects
Common errors
The exact messages PostgreSQL prints, what they mean, and the usual fix.
- column "custmer_id" does not exist
A name in the query is not a column of any table in FROM. Usually a typo, a column from a table you did not join, or a name with capitals that was created quoted.
Fix: Check the spelling against the table's columns, make sure the table that owns the column is in FROM/JOIN, and qualify it with the right alias. PostgreSQL often adds a HINT with the closest match.
- column "total" does not exist
You used a select-list alias in WHERE (or HAVING). WHERE runs before SELECT, so the alias is not defined yet.
Fix: Repeat the expression in WHERE, or compute it in a subquery/CTE and filter in the outer query. Aliases are fine in ORDER BY.
- column "Riyadh" does not exist
Double quotes mean an identifier (a column or table name) in PostgreSQL, so "Riyadh" is read as a column name.
Fix: Use single quotes for text values: WHERE city = 'Riyadh'.
- relation "order" does not exist
The table or view name is wrong, in another schema, or was created with quoted capitals.
Fix: Check the exact table name (here: orders). List tables with SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'.
- column "customers.customer_name" must appear in the GROUP BY clause or be used in an aggregate function
The query groups rows, so each output row represents many input rows; a non-grouped column could have several values per group.
Fix: Add the column to GROUP BY, wrap it in an aggregate (min, max, string_agg), or group by the table's primary key so its other columns are allowed.
- aggregate functions are not allowed in WHERE
WHERE filters individual rows before grouping, so aggregates are not available there yet.
Fix: Move the condition to HAVING (after GROUP BY), or compute the aggregate in a subquery: WHERE price > (SELECT avg(price) FROM products).
- window functions are not allowed in WHERE
Window functions are computed after WHERE, GROUP BY and HAVING.
Fix: Compute the window function in a subquery or CTE, then filter on its alias in the outer query.
- aggregate function calls cannot be nested
An aggregate cannot directly contain another aggregate, e.g. avg(count(*)).
Fix: Aggregate in a subquery/CTE first (count per group), then aggregate that result in the outer query. Or use a window over the aggregate: avg(count(*)) OVER ().
- syntax error at or near "FROM"
The parser hit FROM where it did not expect it, most often because of a trailing comma after the last select-list item.
Fix: Remove the comma before FROM. In general, the error points at the first token that does not fit; the actual mistake is usually just before it.
- syntax error at end of input
The statement ended while something was still open: a missing closing parenthesis, an unfinished condition, or a trailing AND/comma.
Fix: Check that parentheses balance and that the last clause is complete.
- unterminated quoted string at or near "'Riyadh;"
A single-quoted string literal was opened but never closed — or the text itself contains an apostrophe.
Fix: Close the quote. Write an apostrophe inside a string by doubling it: 'Al''Ula'.
- division by zero
A denominator evaluated to 0 for at least one row.
Fix: Use NULLIF(denominator, 0) so the division returns NULL instead, and COALESCE the result only if 0 is a meaningful answer.
- more than one row returned by a subquery used as an expression
A subquery used as a single value (in SELECT, or after =, <, >) returned several rows.
Fix: Make it return one row (aggregate it, filter more tightly, or ORDER BY ... LIMIT 1), or use IN / EXISTS / a join if several values are legitimate.
- invalid input syntax for type integer: "abc"
A text value could not be converted to the target type — in a cast, a comparison with a typed column, or an INSERT.
Fix: Fix the literal (quote dates as 'YYYY-MM-DD', numbers without thousands separators) or clean/filter the text before casting (e.g. WHERE col ~ '^[0-9]+$').
- date/time field value out of range: "2025-02-30"
The date literal has the right shape but is not a real calendar date (or uses an unexpected day/month order).
Fix: Use a valid ISO date (YYYY-MM-DD). For month ends, use date_trunc('month', d) + interval '1 month' - interval '1 day', or a half-open range (< first day of next month).
- operator does not exist: integer = text
The two sides of an operator have types PostgreSQL will not compare or combine automatically (here a number and a text value).
Fix: Cast one side so the types match (col::text or text_col::integer), or fix a column that is stored with the wrong type.
- function round(double precision, integer) does not exist
No function with this name accepts these argument types. round(x, n) exists only for numeric, not double precision.
Fix: Cast the argument: round(x::numeric, 2). For other functions check the name and argument types (the HINT lists this).
- argument of WHERE must be type boolean, not type integer
WHERE needs a condition that is TRUE/FALSE, but the expression is a number (or text).
Fix: Write a full comparison: WHERE stock > 0 instead of WHERE stock.
- column reference "customer_id" is ambiguous
Two or more tables in FROM have a column with this name, and PostgreSQL cannot tell which one you mean.
Fix: Qualify the column with a table alias (o.customer_id), or join with USING (customer_id), which merges the column into one.
- missing FROM-clause entry for table "p"
The query uses an alias or table name that is not defined in FROM/JOIN at that level.
Fix: Add the table to FROM/JOIN with that alias, or correct the alias.
- each UNION query must have the same number of columns
The two queries combined with UNION/INTERSECT/EXCEPT return different numbers of columns.
Fix: Select the same number of columns in each branch; add NULL::type placeholders where a branch has no value.
- UNION types text and integer cannot be matched
Columns in the same position of a UNION have incompatible types.
Fix: Reorder the columns so they line up, or cast them to a common type (e.g. id::text).
- for SELECT DISTINCT, ORDER BY expressions must appear in select list
After DISTINCT removes duplicates, rows can only be sorted by values that are part of the output.
Fix: Add the sort expression to the select list, or de-duplicate with GROUP BY and sort by an aggregate (e.g. min(order_date)).
- duplicate key value violates unique constraint "customers_pkey"
The INSERT or UPDATE would create a second row with a value that a PRIMARY KEY or UNIQUE constraint requires to be unique. The DETAIL line shows the key.
Fix: Use a new key (or let an identity column generate it), skip duplicates with ON CONFLICT DO NOTHING, or update instead with ON CONFLICT ... DO UPDATE.
- insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey"
The row references a parent key that does not exist (DETAIL: Key (customer_id)=(999) is not present in table "customers").
Fix: Insert the parent row first, or correct the referenced id.
- update or delete on table "customers" violates foreign key constraint "orders_customer_id_fkey" on table "orders"
You tried to delete (or change the key of) a parent row that child rows still reference.
Fix: Delete or reassign the child rows first, or define the foreign key with ON DELETE CASCADE / SET NULL if that is the intended rule. Often a status column is better than deleting.
- null value in column "customer_name" of relation "customers" violates not-null constraint
A NOT NULL column received NULL, often because it was left out of the INSERT column list and has no default.
Fix: Supply a value for the column (or give the column a DEFAULT).
- new row for relation "products" violates check constraint "products_price_check"
The new or updated row makes a CHECK condition FALSE (here, a negative price).
Fix: Correct the value; read the constraint with SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = '...'.
- there is no unique or exclusion constraint matching the ON CONFLICT specification
ON CONFLICT (cols) must name columns that have a unique index or constraint, exactly.
Fix: Use the primary-key or unique columns, or create a unique index on the columns you want to de-duplicate on.
- current transaction is aborted, commands ignored until end of transaction block
An earlier statement in the same BEGIN ... block failed. PostgreSQL rejects everything else until the transaction ends.
Fix: Run ROLLBACK (or ROLLBACK TO SAVEPOINT), fix the failing statement, and start again. In a client, the first error message (not this one) tells you what actually went wrong.
Snippets
WITH ranked AS (
SELECT c.*,
row_number() OVER (PARTITION BY lower(trim(email)) ORDER BY signup_date, customer_id) AS rn
FROM customers c
WHERE email IS NOT NULL
)
SELECT customer_id, first_name, last_name, email, signup_date
FROM ranked
WHERE rn = 1
ORDER BY customer_id
LIMIT 20;SELECT lower(trim(email)) AS email_key,
count(*) AS copies,
array_agg(customer_id ORDER BY customer_id) AS customer_ids
FROM customers
WHERE email IS NOT NULL
GROUP BY lower(trim(email))
HAVING count(*) > 1
ORDER BY email_key;SELECT DISTINCT ON (employee_id)
employee_id, effective_date, base_salary, reason
FROM salary_history
ORDER BY employee_id, effective_date DESC
LIMIT 20;WITH spend AS (
SELECT c.region, c.customer_id, sum(oi.quantity * oi.unit_price - oi.discount_amount) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'delivered'
JOIN order_items oi ON oi.order_id = o.order_id
WHERE c.region IS NOT NULL
GROUP BY c.region, c.customer_id
), ranked AS (
SELECT *, row_number() OVER (PARTITION BY region ORDER BY revenue DESC, customer_id) AS rn
FROM spend
)
SELECT region, rn AS position, customer_id, round(revenue, 2) AS revenue
FROM ranked
WHERE rn <= 3
ORDER BY region, rn;WITH monthly AS (
SELECT date_trunc('month', o.ordered_at)::date AS month,
sum(oi.quantity * oi.unit_price - oi.discount_amount) AS revenue
FROM orders o
JOIN order_items oi USING (order_id)
WHERE o.status = 'delivered'
GROUP BY month
)
SELECT month, round(revenue, 2) AS revenue,
round(sum(revenue) OVER (PARTITION BY extract(year FROM month) ORDER BY month), 2) AS ytd_revenue
FROM monthly
ORDER BY month;SELECT d::date AS day, count(t.ticket_id) AS tickets_opened
FROM generate_series(DATE '2025-01-01', DATE '2025-01-31', INTERVAL '1 day') AS g(d)
LEFT JOIN support_tickets t
ON t.opened_at >= d AND t.opened_at < d + INTERVAL '1 day'
GROUP BY d
ORDER BY d;SELECT channel,
count(*) FILTER (WHERE ordered_at >= '2023-01-01' AND ordered_at < '2024-01-01') AS orders_2023,
count(*) FILTER (WHERE ordered_at >= '2024-01-01' AND ordered_at < '2025-01-01') AS orders_2024,
count(*) FILTER (WHERE ordered_at >= '2025-01-01' AND ordered_at < '2026-01-01') AS orders_2025
FROM orders
WHERE status <> 'cancelled'
GROUP BY channel
ORDER BY channel;SELECT cat.category_name,
round(sum(oi.quantity * oi.unit_price - oi.discount_amount), 2) AS revenue,
round(100 * sum(oi.quantity * oi.unit_price - oi.discount_amount)
/ sum(sum(oi.quantity * oi.unit_price - oi.discount_amount)) OVER (), 2) AS pct_of_total
FROM order_items oi
JOIN products p USING (product_id)
JOIN categories cat ON cat.category_id = p.category_id
GROUP BY cat.category_name
ORDER BY revenue DESC;SELECT device,
count(*) AS sessions,
count(*) FILTER (WHERE added_to_cart) AS carts,
count(*) FILTER (WHERE purchased) AS purchases,
round(100.0 * count(*) FILTER (WHERE purchased)
/ NULLIF(count(*) FILTER (WHERE added_to_cart), 0), 2) AS cart_to_purchase_pct
FROM fact_web_sessions
GROUP BY device
ORDER BY device;WITH monthly AS (
SELECT date_trunc('month', ordered_at)::date AS month, count(*) AS orders
FROM orders
WHERE status <> 'cancelled'
GROUP BY month
)
SELECT cur.month, cur.orders, prev.orders AS orders_last_year,
round(100.0 * (cur.orders - prev.orders) / NULLIF(prev.orders, 0), 1) AS yoy_pct
FROM monthly cur
LEFT JOIN monthly prev ON prev.month = cur.month - INTERVAL '1 year'
WHERE cur.month >= '2024-01-01'
ORDER BY cur.month;WITH daily AS (
SELECT ordered_at::date AS day, count(*) AS orders
FROM orders
WHERE ordered_at >= '2025-11-01' AND ordered_at < '2026-01-01'
GROUP BY day
)
SELECT day, orders,
round(avg(orders) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 1) AS moving_avg_7d
FROM daily
ORDER BY day;WITH current_salary AS (
SELECT DISTINCT ON (employee_id) employee_id, job_id, base_salary
FROM salary_history
ORDER BY employee_id, effective_date DESC
)
SELECT j.job_family,
count(*) AS employees,
percentile_cont(0.25) WITHIN GROUP (ORDER BY cs.base_salary) AS p25,
percentile_cont(0.5) WITHIN GROUP (ORDER BY cs.base_salary) AS median,
percentile_cont(0.75) WITHIN GROUP (ORDER BY cs.base_salary) AS p75
FROM current_salary cs
JOIN jobs j ON j.job_id = cs.job_id
GROUP BY j.job_family
ORDER BY median DESC;WITH active AS (
SELECT DISTINCT customer_id,
(extract(year FROM ordered_at) * 12 + extract(month FROM ordered_at) - 1)::int AS month_no
FROM orders
WHERE status <> 'cancelled'
), grouped AS (
SELECT customer_id, month_no,
month_no - row_number() OVER (PARTITION BY customer_id ORDER BY month_no) AS island
FROM active
)
SELECT customer_id,
make_date(min(month_no) / 12, min(month_no) % 12 + 1, 1) AS streak_start,
count(*) AS months_in_a_row
FROM grouped
GROUP BY customer_id, island
HAVING count(*) >= 4
ORDER BY months_in_a_row DESC, customer_id
LIMIT 20;SELECT c.customer_id, c.first_name, c.last_name, c.signup_date
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id)
ORDER BY c.signup_date, c.customer_id
LIMIT 20;WITH items AS (
SELECT order_id, sum(quantity * unit_price - discount_amount) AS items_value
FROM order_items
GROUP BY order_id
), paid AS (
SELECT order_id, sum(amount) FILTER (WHERE status = 'captured') AS captured
FROM payments
GROUP BY order_id
)
SELECT o.order_id, o.status, i.items_value + o.shipping_fee AS order_value, COALESCE(p.captured, 0) AS captured
FROM orders o
JOIN items i USING (order_id)
LEFT JOIN paid p USING (order_id)
WHERE o.status IN ('pending', 'paid', 'shipped')
ORDER BY o.order_id
LIMIT 20;WITH firsts AS (
SELECT customer_id, date_trunc('month', min(ordered_at)) AS cohort
FROM orders
GROUP BY customer_id
), activity AS (
SELECT DISTINCT o.customer_id, f.cohort,
(extract(year FROM age(date_trunc('month', o.ordered_at), f.cohort)) * 12
+ extract(month FROM age(date_trunc('month', o.ordered_at), f.cohort)))::int AS month_offset
FROM orders o
JOIN firsts f USING (customer_id)
)
SELECT cohort::date AS cohort,
count(*) FILTER (WHERE month_offset = 0) AS customers,
count(*) FILTER (WHERE month_offset = 1) AS m1,
count(*) FILTER (WHERE month_offset = 2) AS m2,
count(*) FILTER (WHERE month_offset = 3) AS m3
FROM activity
WHERE cohort >= '2025-01-01'
GROUP BY cohort
ORDER BY cohort;SELECT step, sessions, round(100.0 * sessions / first_value(sessions) OVER (ORDER BY step_no), 1) AS pct_of_sessions
FROM (
SELECT 1 AS step_no, 'session' AS step, count(*) AS sessions FROM fact_web_sessions
UNION ALL SELECT 2, 'product page', count(*) FILTER (WHERE reached_product_page) FROM fact_web_sessions
UNION ALL SELECT 3, 'added to cart', count(*) FILTER (WHERE added_to_cart) FROM fact_web_sessions
UNION ALL SELECT 4, 'checkout', count(*) FILTER (WHERE started_checkout) FROM fact_web_sessions
UNION ALL SELECT 5, 'purchase', count(*) FILTER (WHERE purchased) FROM fact_web_sessions
) f
ORDER BY step_no;SELECT t.account_id, t.posted_at, t.direction, t.amount,
a.opening_balance
+ sum(CASE WHEN t.direction = 'credit' THEN t.amount ELSE -t.amount END)
OVER (PARTITION BY t.account_id ORDER BY t.posted_at, t.transaction_id) AS balance_after
FROM transactions t
JOIN accounts a ON a.account_id = t.account_id
WHERE t.account_id = (SELECT min(account_id) FROM transactions)
ORDER BY t.posted_at, t.transaction_id
LIMIT 30;SELECT je.entry_id, je.entry_number, je.status,
sum(jl.debit) AS total_debit, sum(jl.credit) AS total_credit,
sum(jl.debit) - sum(jl.credit) AS difference
FROM journal_entries je
JOIN journal_lines jl ON jl.entry_id = je.entry_id
GROUP BY je.entry_id, je.entry_number, je.status
HAVING sum(jl.debit) <> sum(jl.credit)
ORDER BY abs(sum(jl.debit) - sum(jl.credit)) DESC, je.entry_id;SELECT a.employee_id, a.leave_id AS leave_a, a.status AS status_a, b.leave_id AS leave_b, b.status AS status_b,
a.start_date AS a_start, a.end_date AS a_end, b.start_date AS b_start, b.end_date AS b_end
FROM leave_requests a
JOIN leave_requests b
ON b.employee_id = a.employee_id
AND a.leave_id < b.leave_id
AND daterange(a.start_date, a.end_date, '[]') && daterange(b.start_date, b.end_date, '[]')
ORDER BY a.employee_id, a.leave_id, b.leave_id;WITH RECURSIVE chain AS (
SELECT employee_id, manager_id, 0 AS level, (first_name || ' ' || last_name) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.employee_id, e.manager_id, c.level + 1, c.path || ' > ' || e.first_name || ' ' || e.last_name
FROM employees e
JOIN chain c ON e.manager_id = c.employee_id
)
SELECT employee_id, level, path
FROM chain
ORDER BY path
LIMIT 25;WITH order_values AS (
SELECT o.order_id, sum(oi.quantity * oi.unit_price - oi.discount_amount) + o.shipping_fee AS order_value
FROM orders o
JOIN order_items oi USING (order_id)
GROUP BY o.order_id
)
SELECT width_bucket(order_value, 0, 3000, 12) AS bucket,
(width_bucket(order_value, 0, 3000, 12) - 1) * 250 AS from_sar,
count(*) AS orders
FROM order_values
GROUP BY bucket
ORDER BY bucket;WITH actual AS (
SELECT jl.account_code,
sum(CASE WHEN g.account_type IN ('asset', 'expense') THEN jl.debit - jl.credit
ELSE jl.credit - jl.debit END) AS actual
FROM journal_lines jl
JOIN gl_accounts g ON g.account_code = jl.account_code
JOIN journal_entries je ON je.entry_id = jl.entry_id AND je.status = 'posted'
JOIN fiscal_periods fp ON fp.period_id = je.period_id AND fp.fiscal_year = 2025
GROUP BY jl.account_code
), budget AS (
SELECT account_code, sum(amount) AS budget
FROM budgets
WHERE fiscal_year = 2025
GROUP BY account_code
)
SELECT b.account_code, g.account_name, b.budget, COALESCE(a.actual, 0) AS actual,
COALESCE(a.actual, 0) - b.budget AS variance,
round(100 * (COALESCE(a.actual, 0) - b.budget) / NULLIF(b.budget, 0), 1) AS variance_pct
FROM budget b
JOIN gl_accounts g ON g.account_code = b.account_code
LEFT JOIN actual a ON a.account_code = b.account_code
ORDER BY abs(COALESCE(a.actual, 0) - b.budget) DESC
LIMIT 15;