SQL Quest

Lessons, hints, explained solutions, the in-browser PostgreSQL editor and practice answer checks in your browser are available. Graded assessments, recorded progress from answer checks and new certificates aren't available yet.

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

Filtering

Functions

Aggregation

Joins

Subqueries and CTEs

Window functions

Changing data

Schema

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

Deduplicate with ROW_NUMBERKeep one row per normalised key (here: email ignoring case and spaces), preferring the oldest registration.
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;
Find duplicate keysList every key that occurs more than once, with the ids involved — check this before deduplicating.
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;
Latest row per key (DISTINCT ON)Each employee's current salary record: the row with the latest effective date.
SELECT DISTINCT ON (employee_id)
       employee_id, effective_date, base_salary, reason
FROM salary_history
ORDER BY employee_id, effective_date DESC
LIMIT 20;
Top N per groupThe three biggest customers by item revenue in each region (ties broken by id).
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;
Running total (resets each year)Monthly revenue and year-to-date revenue, restarting every January.
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;
Date spine with zero-filled daysEvery day of a period appears, including days with no activity, so charts and averages are honest.
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;
Pivot with FILTERTurn rows into columns: order counts per channel, one column per year.
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;
Percent of totalEach category's share of item revenue, using a window sum over the grouped totals.
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;
Safe divisionConversion rates that return NULL instead of failing when a group has no sessions reaching a step.
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;
Year-over-year comparisonEach month's orders next to the same month a year earlier, and the growth rate.
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;
Moving averageA 7-day moving average of daily orders (assumes one row per day; use a date spine if days can be missing).
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;
Median and quartiles per groupMedian and interquartile range of current base salaries per job family.
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;
Gaps and islands (streaks)Find each customer's runs of consecutive months with at least one order: consecutive months share the same (month number − row number).
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;
Anti-join: rows with no matchCustomers who never placed an order. NOT EXISTS is NULL-safe, unlike NOT IN.
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;
Avoid fan-out: pre-aggregate before joiningOrder value and captured payments per order, each aggregated separately, then joined one-to-one.
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;
Cohort retentionCustomers grouped by first-order month, and how many ordered again 1, 2 and 3 months later.
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;
Funnel conversionHow many sessions reach each step of the purchase funnel, and the share of the first step.
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;
Running balance per accountOpening balance plus signed transactions in posting order (credit adds, debit subtracts).
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;
Unbalanced journal entriesData-quality check: every journal entry's debits should equal its credits.
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;
Overlapping date rangesLeave requests of the same employee whose dates overlap, with both statuses. daterange(..., '[]') includes both end dates; && tests overlap.
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;
Org chart path (recursive)Every employee with their level and the chain of managers above them.
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;
Histogram of valuesDistribution of order values in SAR 250 buckets, with an open-ended top bucket.
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;
Budget vs actual variancePosted actuals against budget per account for FY2025. Actuals are signed by account type (assets/expenses debit − credit, others credit − debit) so they compare with the positive budgets; for revenue a positive variance is good, for costs it is overspend.
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;