SQL Interview Questions
97 tricky SQL questions with answers: joins, NULLs, aggregation, window functions, subqueries, types and dates, ordering, DML, performance, and the classic puzzles asked in live rounds.
1. Joins
Most join bugs are about row counts: rows multiplying through duplicate keys, or rows disappearing through NULLs and filters.
SELECT e.first_name, e.salary, m.first_name AS manager, m.salary AS manager_salary FROM employees e JOIN employees m ON m.employee_id = e.manager_id WHERE e.salary > m.salary;
SELECT e.* FROM employees e JOIN jobs j ON j.job_id = e.job_id WHERE e.salary NOT BETWEEN j.min_salary AND j.max_salary;
SELECT a.name AS emp1, b.name AS emp2, a.dept_id FROM employees a JOIN employees b ON a.dept_id = b.dept_id AND a.emp_id < b.emp_id;
NOT EXISTS, LEFT JOIN … WHERE right.key IS NULL, or EXCEPT on keys. Avoid NOT IN when the subquery can contain NULLs. Semi join (rows with at least one match): EXISTS or IN. A plain JOIN returns the left row once per match, so customers with three orders appear three times; EXISTS returns each customer once without needing DISTINCT.SELECT a.id, a.val, b.val FROM a LEFT JOIN b ON a.id = b.id UNION ALL SELECT b.id, NULL, b.val FROM a RIGHT JOIN b ON a.id = b.id WHERE a.id IS NULL;
SELECT s.store_id, d.day, COALESCE(SUM(f.amount), 0) AS sales FROM stores s CROSS JOIN calendar d LEFT JOIN fact_sales f ON f.store_id = s.store_id AND f.sale_date = d.day GROUP BY s.store_id, d.day;
2. Self-Joins & Hierarchies
Five join patterns cover almost every employee/manager and hierarchy question. Every example below runs against this sample employees table. Note the planted edge cases: Asha is the CEO (no manager), Chen was hired before his manager, and Gus reports to a manager ID that does not exist.
| emp_id | name | manager_id | salary | hire_date | dept |
|---|---|---|---|---|---|
| 1 | Asha | NULL | 300 | 2015-01-10 | Exec |
| 2 | Ben | 1 | 200 | 2016-03-01 | Data |
| 3 | Chen | 1 | 210 | 2014-06-15 | Data |
| 4 | Dia | 2 | 150 | 2017-02-20 | Data |
| 5 | Eli | 2 | 220 | 2018-07-01 | Data |
| 6 | Fay | 4 | 120 | 2019-09-09 | Data |
| 7 | Gus | 99 | 100 | 2020-01-05 | Ops |
| Pattern | Typical interview requirement | Question below |
|---|---|---|
| Self-join | Employee vs. manager, peers, salary comparisons | Q1 |
| Left anti-join | Find records without a match | Q2 |
Self-join with < | Generate unique pairs without mirrored duplicates | Q3 |
| Multiple self-joins | Traverse a fixed-depth hierarchy | Q4 |
| Recursive CTE | Traverse an arbitrary-depth hierarchy | Q5 |
SELECT e.name, e.hire_date, m.name AS manager, m.hire_date AS manager_hired FROM employees e JOIN employees m ON m.emp_id = e.manager_id -- alias the same table twice WHERE e.hire_date < m.hire_date; -- Chen | 2014-06-15 | Asha | 2015-01-10
COALESCE(m.name, '(no manager)'), or you silently lose the top of the org chart. A related variant: peers are employees with the same manager, ON a.manager_id = b.manager_id AND a.emp_id <> b.emp_id.SELECT e.name FROM employees e LEFT JOIN employees m ON m.emp_id = e.manager_id WHERE m.emp_id IS NULL; -- Asha, Gus (wrong: Asha has no manager at all)A missing match has two causes, no manager (NULL) and a broken reference, so exclude the legitimate case explicitly:
SELECT e.name, e.manager_id FROM employees e LEFT JOIN employees m ON m.emp_id = e.manager_id WHERE m.emp_id IS NULL AND e.manager_id IS NOT NULL; -- Gus | 99
NOT EXISTS gives the same result and is equally safe; NOT IN (SELECT emp_id …) is fine here only because emp_id is never NULL.SELECT a.name, b.name AS colleague, ABS(a.salary - b.salary) AS diff FROM employees a JOIN employees b ON a.dept = b.dept AND a.emp_id < b.emp_id -- each unordered pair once, never a row with itself AND ABS(a.salary - b.salary) <= 20; -- Ben–Chen 10 · Ben–Eli 20 · Chen–Eli 10
<> instead of < the same query returns 6 rows: every pair twice, mirrored. With no inequality at all, each employee also pairs with themselves. For n matching rows, < gives n(n−1)/2 pairs. The same pattern finds possible duplicate customers (same email or phone, different IDs).SELECT e.name,
m.name AS manager,
gm.name AS skip_level
FROM employees e
LEFT JOIN employees m ON m.emp_id = e.manager_id
LEFT JOIN employees gm ON gm.emp_id = m.manager_id;
-- Asha | NULL | NULL
-- Ben | Asha | NULL
-- Dia | Ben | Asha
-- Fay | Dia | Ben …WITH RECURSIVE chain AS ( SELECT emp_id, name, manager_id, 0 AS lvl FROM employees WHERE name = 'Fay' UNION ALL SELECT m.emp_id, m.name, m.manager_id, c.lvl + 1 FROM employees m JOIN chain c ON m.emp_id = c.manager_id ) SELECT lvl, name FROM chain ORDER BY lvl; -- 0 Fay · 1 Dia · 2 Ben · 3 AshaWalking down (descendants): the anchor is the manager, and each step finds direct reports. Carry the visited path so a cycle cannot recurse forever:
WITH RECURSIVE sub AS (
SELECT emp_id, name, 0 AS depth, ',' || emp_id || ',' AS path
FROM employees WHERE emp_id = 2 -- Ben
UNION ALL
SELECT e.emp_id, e.name, s.depth + 1, s.path || e.emp_id || ','
FROM employees e
JOIN sub s ON e.manager_id = s.emp_id
WHERE s.path NOT LIKE '%,' || e.emp_id || ',%' -- skip anyone already on this path
AND s.depth < 20 -- hard safety limit
)
SELECT depth, name FROM sub ORDER BY depth, name;m.emp_id = c.manager_id, down joins e.manager_id = s.emp_id. Postgres 14+ also has a built-in CYCLE emp_id SET is_cycle USING path clause, and SQL Server caps recursion at 100 levels by default (OPTION (MAXRECURSION n)). In Spark, which lacks recursive CTEs in older versions, iterate with a loop of joins until no new rows appear.3. NULL Traps
NULL means "unknown", and SQL uses three-valued logic: a comparison with NULL is UNKNOWN, and WHERE keeps only TRUE rows. Almost every NULL trick question comes back to that rule.
WHERE col = NULL never matches. Use IS NULL / IS NOT NULL, or the null-safe a IS NOT DISTINCT FROM b (Postgres, Spark SQL, Snowflake; a <=> b in MySQL and Spark).NULL <> 'A' is UNKNOWN, so NULL rows are silently excluded, a very common reporting bug. Use WHERE status IS DISTINCT FROM 'A' or WHERE status <> 'A' OR status IS NULL. The same applies to NOT (amount > 100): it does not return NULL amounts.AVG(COALESCE(x, 0)), which returns 1.revenue - refunds becomes NULL when there were no refunds. Wrap it: COALESCE(SUM(amount), 0).NULL NOT IN (1, 2) is UNKNOWN, so the row is excluded. 3 IN (1, NULL) expands to 3 = 1 OR 3 = NULL = FALSE OR UNKNOWN = UNKNOWN, so no match (though 1 IN (1, NULL) is TRUE). Compare the classic trap in the SQL section, where a NULL inside a NOT IN subquery returns zero rows.SELECT c.customer_id, o.order_id FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id WHERE o.status = 'SHIPPED';
o column, and NULL = 'SHIPPED' is UNKNOWN, so the WHERE clause removes exactly the rows the LEFT JOIN was meant to keep. Move the condition into the join: LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'SHIPPED'. The exception is WHERE o.order_id IS NULL, which is the deliberate anti-join pattern.NULL = NULL is UNKNOWN, so NULL keys never match in an equi-join (and are dropped by INNER joins). If NULLs should match, use a null-safe join: ON a.k IS NOT DISTINCT FROM b.k or a.k <=> b.k. Avoid COALESCE(k, -1) sentinels unless -1 can never be a real value; they also block index use.SELECT COUNT(DISTINCT x) ignores NULLs, but SELECT DISTINCT x returns one NULL row.UNIQUE NULLS NOT DISTINCT. CHECK: no, it accepts NULL, because a CHECK fails only when the result is FALSE, and UNKNOWN passes. Add NOT NULL if the value is required.ORDER BY amount DESC NULLS LAST; in MySQL or SQL Server emulate it with ORDER BY amount IS NULL, amount or a CASE expression. This matters for "top N" and ROW_NUMBER queries.SELECT CASE region WHEN NULL THEN 'missing' ELSE region END FROM sales;
=, so region = NULL is never TRUE. Use a searched CASE: CASE WHEN region IS NULL THEN 'missing' ELSE region END, or just COALESCE(region, 'missing').|| concatenation. CONCAT varies: SQL Server and Postgres CONCAT treat NULL as an empty string, but MySQL and Spark CONCAT return NULL if any argument is NULL. CONCAT_WS skips NULLs in MySQL, Postgres and Spark. Oracle is the odd one: it treats an empty string as NULL, and its || ignores NULLs.COALESCE(a, b, c) is standard, takes any number of arguments and returns the first non-NULL. ISNULL (SQL Server), NVL (Oracle) and IFNULL (MySQL, SQLite) take two. Gotcha: SQL Server's ISNULL returns the first argument's type, so a longer replacement string can be truncated. NULLIF(a, b) returns NULL when a = b, so revenue / NULLIF(orders, 0) gives NULL instead of an error when orders is 0.SELECT ts, device_id,
LAST_VALUE(reading IGNORE NULLS) OVER (
PARTITION BY device_id ORDER BY ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS reading_filled
FROM sensor_readings;COUNT(reading) OVER (PARTITION BY device_id ORDER BY ts) (it only increments on non-NULL rows), then take MAX(reading) over each group. Also note LAG/LEAD return NULL at the edges unless you pass a default: LAG(amount, 1, 0).SELECT COUNT(*) AS total_rows,
COUNT(*) - COUNT(email) AS null_emails,
SUM(CASE WHEN phone IS NULL THEN 1 ELSE 0 END) AS null_phones
FROM customers;x NOT IN (a, b, NULL) expands to x <> a AND x <> b AND x <> NULL; the last comparison is UNKNOWN, so no row passes. Use NOT EXISTS or a LEFT JOIN … IS NULL anti join:-- employees who manage nobody (manager_id contains NULL for the CEO) SELECT * FROM employees e WHERE NOT EXISTS (SELECT 1 FROM employees r WHERE r.manager_id = e.employee_id);
4. Aggregation & GROUP BY
Know the logical order of execution: FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT. It explains most of these.
WHERE filters rows before grouping; HAVING filters groups after aggregation, so it can reference aggregates like COUNT(*) > 5. Push filters into WHERE whenever possible so less data reaches the aggregation.COUNT(*) counts rows, so an empty department still shows 1 (the row of NULLs). COUNT(e.employee_id) skips NULLs and correctly shows 0.name is neither grouped nor aggregated. MySQL with ONLY_FULL_GROUP_BY disabled returns an arbitrary name from each group, not necessarily the top earner. SQLite happens to return the row holding the MAX, but that is a quirk. To get the top earner per department use a window function (RANK() … = 1) or join back to the per-department MAX.SELECT COUNT(*) FROM orders HAVING COUNT(*) > 1000 returns one row if there are more than 1,000 orders and no rows otherwise. Handy as a data-quality assertion.SELECT region,
SUM(CASE WHEN quarter = 'Q1' THEN revenue ELSE 0 END) AS q1,
SUM(CASE WHEN quarter = 'Q2' THEN revenue ELSE 0 END) AS q2,
COUNT(*) FILTER (WHERE revenue > 1000) AS big_deals -- Postgres / SQLite
FROM sales
GROUP BY region;COUNT(DISTINCT a, b) works only in MySQL (and Spark). Portable: SELECT COUNT(*) FROM (SELECT DISTINCT a, b FROM t) x. Do not concatenate the columns: a || b makes ('ab','c') equal to ('a','bc'), and a NULL in either column turns the whole value NULL.5. Window Functions
Window functions compute across related rows without collapsing them. The tricky part is almost always the frame.
ROW_NUMBER is always unique (1,2,3,4). RANK gives ties the same number and leaves gaps (1,2,2,4). DENSE_RANK gives ties the same number with no gaps (1,2,2,3). Use DENSE_RANK for "Nth highest salary" questions.SELECT order_date, revenue,
SUM(revenue) OVER (ORDER BY order_date) AS running_total,
AVG(revenue) OVER (ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7d
FROM daily_revenue;RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so the "last" row of the frame is the current row (or its last peer). Specify the full frame:LAST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW and add a unique tiebreaker (ORDER BY order_date, order_id) for a row-by-row running total.QUALIFY where supported (Snowflake, BigQuery, Databricks/Spark, DuckDB, Teradata):SELECT * FROM employees QUALIFY ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) = 1;
SELECT dept_id, name, salary,
salary * 100.0 / SUM(salary) OVER () AS pct_of_company,
salary * 100.0 / SUM(salary) OVER (PARTITION BY dept_id) AS pct_of_dept
FROM employees;SELECT month, revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS change,
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 1) AS pct_change
FROM monthly_revenue;WITH first_seen AS (
SELECT user_id, MIN(activity_date) AS first_day
FROM activity GROUP BY user_id
)
SELECT first_day,
SUM(COUNT(*)) OVER (ORDER BY first_day) AS cumulative_users
FROM first_seen
GROUP BY first_day;6. Subqueries & CTEs
MAX), a unique key, or LIMIT 1 with an ORDER BY that makes the choice deterministic.SELECT e.* FROM employees e WHERE e.salary > (SELECT AVG(salary) FROM employees x WHERE x.dept_id = e.dept_id);Window function (one pass):
SELECT * FROM ( SELECT e.*, AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg FROM employees e ) t WHERE salary > dept_avg;
IN and EXISTS return the same rows and most optimizers turn both into a semi join. The difference is negation: NOT IN returns nothing if the subquery yields a NULL, while NOT EXISTS behaves as expected. EXISTS also handles multi-column matches naturally.MATERIALIZED, and a CTE referenced twice may be evaluated twice. Use a temp table when an expensive intermediate result is reused several times or needs its own index or statistics.WITH RECURSIVE org(employee_id, lvl, path) AS ( SELECT employee_id, 1, first_name FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, o.lvl + 1, o.path || ' > ' || e.first_name FROM employees e JOIN org o ON e.manager_id = o.employee_id ) SELECT * FROM org ORDER BY path;
WITH RECURSIVE days(d) AS ( SELECT DATE '2026-01-01' UNION ALL SELECT d + INTERVAL '1 day' FROM days WHERE d < DATE '2026-01-31' ) SELECT d FROM days WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.order_date = d);
7. Views & Materialized Views
A view is a saved query, not saved data. Most view questions test whether you know when the query actually runs, what a view can and cannot do, and how materialized views trade freshness for speed.
CREATE VIEW active_customers AS SELECT customer_id, name, city FROM customers WHERE status = 'ACTIVE'; SELECT * FROM active_customers WHERE city = 'Pune'; -- runs the view's query with your filter added
| Stores data? | Lifetime | Freshness | Use for | |
|---|---|---|---|---|
| Table | Yes | Permanent | What you load | Source of truth |
| View | No (stored query) | Permanent definition | Always current | Reusable logic, security, stable interfaces |
| Materialized view | Yes (precomputed result) | Permanent | As of the last refresh | Expensive aggregates read often |
| CTE (WITH) | No | One statement | Current | Readability inside a single query |
| Temp table | Yes | Session or transaction | As of creation | Reusing an intermediate result many times |
CREATE VIEW pune_customers AS SELECT customer_id, name, city FROM customers WHERE city = 'Pune' WITH CHECK OPTION; INSERT INTO pune_customers VALUES (9, 'Ravi', 'Pune'); -- OK INSERT INTO pune_customers VALUES (10, 'Meera', 'Delhi'); -- ERROR: violates the view's WHERE
* into a fixed column list when the view is created, so new columns are ignored until you recreate the view (SQL Server: sp_refreshview). Dropping or renaming a column the view uses either fails (Postgres refuses unless you use CASCADE) or leaves the view broken (SQL Server without SCHEMABINDING). SQLite and Spark store the view text and re-expand it at query time, so behaviour differs. List columns explicitly in views to avoid surprises.CREATE OR REPLACE VIEW must keep the existing columns with the same names, types and order; it can only add new columns at the end. Renaming or removing columns needs DROP VIEW and CREATE VIEW (and dependent views must be dropped too, or use CASCADE and recreate them). Plan view changes like schema migrations.ORDER BY inside a view is not guaranteed to be respected by the outer query.-- Postgres CREATE MATERIALIZED VIEW daily_sales AS SELECT order_date, region, SUM(amount) AS revenue FROM orders GROUP BY order_date, region; CREATE UNIQUE INDEX ON daily_sales (order_date, region); -- required for CONCURRENTLY REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales; -- readers are not blocked
| Platform | Refresh behaviour |
|---|---|
| Postgres | Manual full refresh; CONCURRENTLY needs a unique index and keeps the old data readable during refresh |
| Oracle | COMPLETE or FAST (incremental, using materialized view logs); ON COMMIT or ON DEMAND |
| Redshift / Snowflake / BigQuery | Auto or incremental refresh for supported query shapes; queries may be rewritten to use the MV automatically |
| Databricks | Materialized views refreshed incrementally where possible, on a schedule or as part of a pipeline |
WITH (security_invoker = true) to check the caller's permissions instead. Know which model your database uses before relying on views for security.df.createOrReplaceTempView("txn") # visible only in this SparkSession
spark.sql("SELECT channel, COUNT(*) FROM txn GROUP BY channel")
df.createOrReplaceGlobalTempView("txn_shared") # visible to all sessions of this application
spark.sql("SELECT * FROM global_temp.txn_shared") # note the global_temp database
spark.sql("""CREATE OR REPLACE VIEW gold.v_daily_txn AS
SELECT dt, SUM(amount) AS total FROM silver.transactions GROUP BY dt""") # permanent, in the catalog8. Dates, Types & Strings
Implicit conversions and boundary conditions cause wrong answers that look plausible.
order_ts is a timestamp, '2026-01-31' means midnight at the start of the 31st, so almost the whole last day is excluded. Use a half-open range: order_ts >= '2026-01-01' AND order_ts < '2026-02-01'. BETWEEN is inclusive on both ends, which also double-counts boundary rows when you chain monthly ranges.1.0 * a / b or CAST(a AS DECIMAL(10,2)) / b. Related trap: in SQL Server AVG() of an INT column returns an INT, so AVG of 1 and 2 is 1.DECIMAL/NUMERIC for money and exact values, or compare floats with a tolerance (ABS(a - b) < 1e-9).CAST(code AS INT)) or fix the column type._ matches any single character and % any sequence, so LIKE '%_%' matches every non-empty string. Escape it: LIKE '%!_%' ESCAPE '!'. Case sensitivity also varies: Postgres LIKE is case-sensitive (use ILIKE), while MySQL and SQL Server usually follow a case-insensitive collation.TRIM, UPPER, consistent types) and check LENGTH when values look equal.CAST(event_ts AS DATE) uses the session or server timezone, so the same event can land on different days in different tools. Store timestamps in UTC (or with timezone), convert explicitly to the business timezone before taking the date, and remember daylight-saving days have 23 or 25 hours.9. Ordering, Pagination & Set Operations
ORDER BY created_at DESC, id DESC. OFFSET pagination also shifts when rows are inserted between page requests.OFFSET 100000 still reads and discards 100,000 rows. Keyset (seek) pagination remembers the last row seen and uses an index:SELECT * FROM orders WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 50;
UNION deduplicates (sort/hash cost); UNION ALL just appends and is faster. DELETE removes rows (can filter, is logged, can be rolled back); TRUNCATE removes all rows quickly without row-level logging; DROP removes the table and its definition.unionByName).SELECT * FROM alerts
ORDER BY CASE risk_level WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 WHEN 'LOW' THEN 3 ELSE 4 END,
created_at DESC;10. UPDATE, DELETE & MERGE
Data-changing statements have database-specific behaviour that interviewers like to probe.
-- Postgres / SQL Server / Oracle style (SQL Server can DELETE from the CTE directly)
DELETE FROM customers
WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
FROM customers
) t
WHERE rn > 1
);UPDATE employees SET gender = CASE gender WHEN 'M' THEN 'F' WHEN 'F' THEN 'M' ELSE gender END;
INSERT … ON CONFLICT (key) DO UPDATE (Postgres, SQLite), INSERT … ON DUPLICATE KEY UPDATE (MySQL), or MERGE.11. Performance & Query Plans
Expect at least one "why is this slow?" question.
order_date >= '2026-01-01' AND order_date < '2027-01-01'. The same applies to LIKE '%abc' (a leading wildcard cannot use a B-tree index) and to col + 1 = 10.country, or on country and city, can seek into it. A filter on city alone generally cannot (leftmost-prefix rule), apart from engines that support index skip scans. Put equality columns first and the range or sort column last.EXPLAIN (or EXPLAIN ANALYZE, which executes the query) and look for full scans on large tables, estimated vs actual row counts that differ widely (stale statistics), nested-loop joins over large inputs, sorts or hashes spilling to disk, and filters applied late. Then fix the cause: an index, a sargable rewrite, updated statistics, earlier filtering, or pre-aggregation.12. Classic Puzzles
These come up again and again in live SQL rounds. Practise writing them from memory.
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY customer_id
ORDER BY updated_at DESC) AS rn
FROM customers_raw
)
SELECT * FROM ranked WHERE rn = 1;SELECT dept_id, salary
FROM (
SELECT dept_id, salary,
DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk
FROM employees
) t
WHERE rnk = 2;SELECT ( SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 2 -- N - 1, here N = 3 ) AS third_highest;
SELECT email, COUNT(*) AS occurrences FROM customers GROUP BY email HAVING COUNT(*) > 1;
WITH d AS (
SELECT DISTINCT user_id, login_date FROM logins
), g AS (
SELECT user_id, login_date,
login_date - ROW_NUMBER() OVER (PARTITION BY user_id
ORDER BY login_date) * INTERVAL '1 day' AS grp
FROM d
)
SELECT user_id, MIN(login_date) AS start_day, COUNT(*) AS streak
FROM g GROUP BY user_id, grp
HAVING COUNT(*) >= 3;SELECT department_id, AVG(salary) AS median_salary
FROM (
SELECT department_id, salary,
ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary) AS rn,
COUNT(*) OVER (PARTITION BY department_id) AS cnt
FROM employees
) t
WHERE rn IN ((cnt + 1) / 2, (cnt + 2) / 2)
GROUP BY department_id;SELECT dept_id, name, salary FROM ( SELECT e.*, RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk FROM employees e ) t WHERE rnk = 1;
SELECT customer_id FROM purchases GROUP BY customer_id HAVING COUNT(DISTINCT product_id) = (SELECT COUNT(*) FROM products);
SELECT user_id, order_id, order_ts FROM ( SELECT o.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_ts, order_id) AS rn FROM orders o ) t WHERE rn = 2;
SELECT u.signup_date,
COUNT(*) AS signups,
COUNT(a.user_id) AS retained_d7,
ROUND(100.0 * COUNT(a.user_id) / COUNT(*), 1) AS retention_pct
FROM users u
LEFT JOIN (SELECT DISTINCT user_id, activity_date FROM activity) a
ON a.user_id = u.user_id
AND a.activity_date = u.signup_date + INTERVAL '7 day'
GROUP BY u.signup_date;