Twenty-five SQL practice exercises | by Michael Boles | Towards Data Science

Structured query language (SQL) is used to retrieve and manipulate data stored in relational databases. Gaining working proficiency in SQL is an important prerequisite for many technology jobs and requires a bit of practice.
To complement SQL training resources (PGExercises, LeetCode, HackerRank, Mode) available on the web, I’ve compiled a list of my favorite questions that you can tackle by hand or solve with a PostgreSQL instance.
These questions cover the following critical concepts:
Basic retrieval (SELECT, FROM)
Creating and aliasing (WITH, AS, GENERATE_SERIES)
Filtering (DISTINCT, WHERE, HAVING, AND, OR, IN, NOT IN)
Aggregation (GROUP BY with COUNT, SUM, AVERAGE)
Joins (INNER JOIN, LEFT JOIN, FULL OUTER JOIN on one or multiple (in)equalities, CROSS JOIN, UNION and UNION ALL)
Conditional statements (CASE - WHEN - THEN - ELSE - END)
Window functions (RANK, DENSE_RANK, ROW_NUMBER, SUM with PARTITION BY - ORDER BY)
Formatting (LIMIT, ORDER BY, casting as an integer, float, or date, CONCAT, COALESCE)
Arithmetic operations and comparisons (+, -, *, /, //, ^, <, >, =, !=)
Datetime operations (EXTRACT(month/day/year))
You can try these out yourself by downloading PostgreSQL and PSequel (see this tutorial for a step-by-step installation guide) and then running the queries shown in the grey boxes in the text below. PSequel is only available for Mac — if you’re using a PC, you can try one of these Windows alternatives.
The first block of text in each query shown below establishes the input table and follows the format:
WITH input_table (column_1, column_2) AS (VALUES (1, 'A'), (2, 'B'))
You can query against the input table using PSequel (shown above) and easily construct new tables for your own problems using this template.
Web-based SQL training resources fall short along a few dimensions. LeetCode, for instance, doesn’t support the use of window functions and hides its most interesting questions behind a paywall. In addition, running SQL queries in your browser can be extremely slow — the data sets are large and retrieval speed is often throttled for non-premium users. Locally executing a query, on the other hand, is instantaneous and allows for rapid iteration through syntax bugs and intermediate tables. I’ve found this to be a more satisfying learning experience.
The questions outlined below include example solutions confirmed to work in PostgreSQL. Keep in mind there is usually more than one way to obtain the correct answer to a SQL problem. My preference is to use common table expressions (CTEs) rather than nested subqueries — CTEs allow for a more linear illustration of the data wrangling sequence. Both approaches, however, can yield identical solutions. I also like to follow the convention of keeping SQL operators in all caps (SELECT, FROM, WHERE, etc.), column names in lowercase (user_id, date, etc.), and simple table aliasing (t1, t2, etc.) where possible.
The code snippets shown below can be run in PSequel as-is to yield the displayed result. Note one quirk of Postgres: fractions must be multiplied by 1.0 to convert from integer to float format. This is not needed in other implementations of SQL and is not expected in interviews.
Feel free to leave your alternative answers in the comments!
From the following table of user IDs, actions, and dates, write a query to return the publication and cancellation rate for each user.
WITH users (user_id, action, date) AS (VALUES (1,'start', CAST('01-01-20' AS date)), (1,'cancel', CAST('01-02-20' AS date)), (2,'start', CAST('01-03-20' AS date)), (2,'publish', CAST('01-04-20' AS date)), (3,'start', CAST('01-05-20' AS date)), (3,'cancel', CAST('01-06-20' AS date)), (1,'start', CAST('01-07-20' AS date)), (1,'publish', CAST('01-08-20' AS date))),-- retrieve count of starts, cancels, and publishes for each usert1 AS (SELECT    user_id,   SUM(CASE WHEN action = 'start' THEN 1 ELSE 0 END) AS starts,    SUM(CASE WHEN action = 'cancel' THEN 1 ELSE 0 END) AS cancels,    SUM(CASE WHEN action = 'publish' THEN 1 ELSE 0 END) AS publishesFROM usersGROUP BY 1ORDER BY 1)-- calculate publication, cancelation rate for each user by dividing by number of starts, casting as float by multiplying by 1.0 (default floor division is a quirk of some SQL tools, not always needed)SELECT    user_id,    1.0*publishes/starts AS publish_rate,    1.0*cancels/starts AS cancel_rateFROM t1
From the following table of transactions between two users, write a query to return the change in net worth for each user, ordered by decreasing net change.
WITH transactions (sender, receiver, amount, transaction_date) AS (VALUES (5, 2, 10, CAST('2-12-20' AS date)),(1, 3, 15, CAST('2-13-20' AS date)), (2, 1, 20, CAST('2-13-20' AS date)), (2, 3, 25, CAST('2-14-20' AS date)), (3, 1, 20, CAST('2-15-20' AS date)), (3, 2, 15, CAST('2-15-20' AS date)), (1, 4, 5, CAST('2-16-20' AS date))),-- sum amounts for each sender (debits) and receiver (credits)debits AS (SELECT    sender,    SUM(amount) AS debitedFROM transactionsGROUP BY 1 ),credits AS (SELECT    receiver,    SUM(amount) AS creditedFROM transactionsGROUP BY 1 )-- full (outer) join debits and credits tables on user id, taking net change as difference between credits and debits, coercing nulls to zeros with coalesce()SELECT    COALESCE(sender, receiver) AS user,    COALESCE(credited, 0) - COALESCE(debited, 0) AS net_change FROM debits dFULL JOIN credits cON d.sender = c.receiverORDER BY 2 DESC
From the following table containing a list of dates and items ordered, write a query to return the most frequent item ordered on each date. Return multiple items in the case of a tie.
WITH items (date, item) AS (VALUES (CAST('01-01-20' AS date),'apple'), (CAST('01-01-20' AS date),'apple'), (CAST('01-01-20' AS date),'pear'), (CAST('01-01-20' AS date),'pear'), (CAST('01-02-20' AS date),'pear'), (CAST('01-02-20' AS date),'pear'), (CAST('01-02-20' AS date),'pear'), (CAST('01-02-20' AS date),'orange')),-- add an item count column to existing table, grouping by date and item columnst1 AS (SELECT    date,    item,    COUNT(*) AS item_countFROM itemsGROUP BY 1, 2ORDER BY 1),-- add a rank column in descending order, partitioning by datet2 AS (SELECT    *,    RANK() OVER (PARTITION BY date ORDER BY item_count DESC) AS date_rankFROM t1)-- return all dates and items where rank = 1SELECT    date,    itemFROM t2WHERE date_rank = 1
From the following table of user actions, write a query to return for each user the time elapsed between the last action and the second-to-last action, in ascending order by user ID.
WITH users (user_id, action, action_date) AS (VALUES (1, 'start', CAST('2-12-20' AS date)), (1, 'cancel', CAST('2-13-20' AS date)), (2, 'start', CAST('2-11-20' AS date)), (2, 'publish', CAST('2-14-20' AS date)), (3, 'start', CAST('2-15-20' AS date)), (3, 'cancel', CAST('2-15-20' AS date)), (4, 'start', CAST('2-18-20' AS date)), (1, 'publish', CAST('2-19-20' AS date))),-- create a date rank column, partitioned by user ID, using the ROW_NUMBER() window function t1 AS (SELECT    *,    ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY action_date DESC) AS date_rankFROM users ),-- filter on date rank column to pull latest and next latest actions from this tablelatest AS (SELECT *FROM t1 WHERE date_rank = 1 ),next_latest AS (SELECT *FROM t1 WHERE date_rank = 2 )-- left join these two tables, subtracting latest from second latest to get time elapsed SELECT    l1.user_id,    l1.action_date - l2.action_date AS days_elapsedFROM latest l1LEFT JOIN next_latest l2ON l1.user_id = l2.user_idORDER BY 1
A company defines its super users as those who have made at least two transactions. From the following table, write a query to return, for each user, the date when they become a super user, ordered by oldest super users first. Users who are not super users should also be present in the table.
WITH users (user_id, product_id, transaction_date) AS (VALUES (1, 101, CAST('2-12-20' AS date)), (2, 105, CAST('2-13-20' AS date)), (1, 111, CAST('2-14-20' AS date)), (3, 121, CAST('2-15-20' AS date)), (1, 101, CAST('2-16-20' AS date)), (2, 105, CAST('2-17-20' AS date)),(4, 101, CAST('2-16-20' AS date)), (3, 105, CAST('2-15-20' AS date))),-- create a transaction number column using ROW_NUMBER(), partitioning by user IDt1 AS (SELECT    *,    ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY transaction_date) AS transaction_numberFROM users),-- filter resulting table on transaction_number = 2t2 AS (SELECT    user_id,    transaction_dateFROM t1WHERE transaction_number = 2 ),-- left join super users onto full user table, order by date t3 AS (SELECT DISTINCT user_idFROM users )SELECT    t3.user_id,    transaction_date AS superuser_dateFROM t3LEFT JOIN t2ON t3.user_id = t2.user_idORDER BY 2
Using the following two tables, write a query to return page recommendations to a social media user based on the pages that their friends have liked, but that they have not yet marked as liked. Order the result by ascending user ID. Source.
WITH friends (user_id, friend) AS (VALUES (1, 2), (1, 3), (1, 4), (2, 1), (3, 1), (3, 4), (4, 1), (4, 3)),likes (user_id, page_likes) AS (VALUES (1, 'A'), (1, 'B'), (1, 'C'), (2, 'A'), (3, 'B'), (3, 'C'), (4, 'B')),-- inner join friends and page likes tables on user_idt1 AS (SELECT    l.user_id,    l.page_likes,    f.friendFROM likes lJOIN friends fON l.user_id = f.user_id ),-- left join likes on this, requiring user = friend and user likes = friend likes t2 AS (SELECT    t1.user_id,   t1.page_likes,    t1.friend,    l.page_likes AS friend_likesFROM t1LEFT JOIN likes lON t1.friend = l.user_idAND t1.page_likes = l.page_likes )-- if a friend pair doesn’t share a common page like, friend likes column will be null - pull out these entries SELECT DISTINCT    friend AS user_id,    page_likes AS recommended_pageFROM t2WHERE friend_likes IS NULLORDER BY 1
With the following two tables, return the fraction of users who only visited mobile, only visited web, and visited both.
WITH mobile (user_id, page_url) AS (VALUES (1, 'A'), (2, 'B'), (3, 'C'), (4, 'A'), (9, 'B'), (2, 'C'), (10, 'B')),web (user_id, page_url) AS (VALUES (6, 'A'), (2, 'B'), (3, 'C'), (7, 'A'), (4, 'B'), (8, 'C'), (5, 'B')),-- outer join mobile and web users on user IDt1 AS (SELECT DISTINCT    m.user_id AS mobile_user,    w.user_id AS web_userFROM mobile mFULL JOIN web wON m.user_id = w.user_id)-- calculate fraction of mobile-only, web-only, and both as average of values (ones and zeros) specified in case statement conditionSELECT    AVG(CASE WHEN mobile_user IS NOT NULL AND web_user IS NULL THEN 1   ELSE 0 END) AS mobile_fraction,   AVG(CASE WHEN web_user IS NOT NULL AND mobile_user IS NULL THEN 1 ELSE 0 END) AS web_fraction,   AVG(CASE WHEN web_user IS NOT NULL AND mobile_user IS NOT NULL THEN 1 ELSE 0 END) AS both_fractionFROM t1
Given the following two tables, return the fraction of users, rounded to two decimal places, who accessed feature two (type: F2 in events table) and upgraded to premium within the first 30 days of signing up.
WITH users (user_id, name, join_date) AS (VALUES (1, 'Jon', CAST('2-14-20' AS date)), (2, 'Jane', CAST('2-14-20' AS date)), (3, 'Jill', CAST('2-15-20' AS date)), (4, 'Josh', CAST('2-15-20' AS date)), (5, 'Jean', CAST('2-16-20' AS date)), (6, 'Justin', CAST('2-17-20' AS date)),(7, 'Jeremy', CAST('2-18-20' AS date))),events (user_id, type, access_date) AS (VALUES (1, 'F1', CAST('3-1-20' AS date)), (2, 'F2', CAST('3-2-20' AS date)), (2, 'P', CAST('3-12-20' AS date)),(3, 'F2', CAST('3-15-20' AS date)), (4, 'F2', CAST('3-15-20' AS date)), (1, 'P', CAST('3-16-20' AS date)), (3, 'P', CAST('3-22-20' AS date))),-- get feature 2 users and their date of feature 2 accesst1 AS (SELECT    user_id,    type,    access_date AS f2_dateFROM eventsWHERE type = 'F2' ),-- get premium users and their date of premium upgradet2 AS (SELECT    user_id,    type,    access_date AS premium_dateFROM eventsWHERE type = 'P' ),-- for each feature 2 user, get time between joining and premium upgrade (or null if no upgrade) by inner joining full users table with feature 2 users on user ID and left joining premium users on user ID, then subtracting premium upgrade date from join datet3 AS (SELECT t2.premium_date - u.join_date AS upgrade_timeFROM users uJOIN t1ON u.user_id = t1.user_idLEFT JOIN t2ON u.user_id = t2.user_id )-- calculate fraction of users with upgrade time less than 30 days as average of values (ones and zeros) specified in case statement condition, rounding to two decimal places SELECT    ROUND(AVG(CASE WHEN upgrade_time < 30 THEN 1 ELSE 0 END), 2) AS upgrade_rateFROM t3
Given the following table, return a list of users and their corresponding friend count. Order the result by descending friend count, and in the case of a tie, by ascending user ID. Assume that only unique friendships are displayed (i.e., [1, 2] will not show up again as [2, 1] ). From LeetCode.
WITH friends (user1, user2) AS (VALUES (1, 2), (1, 3), (1, 4), (2, 3)),-- compile all user appearances into one column, preserving duplicate entries with UNION ALL t1 AS (SELECT user1 AS user_idFROM friendsUNION ALLSELECT user2 AS user_idFROM friends)-- grouping by user ID, count up all appearances of that userSELECT    user_id,    COUNT(*) AS friend_countFROM t1GROUP BY 1ORDER BY 2 DESC
The projects table contains three columns: task_id, start_date, and end_date. The difference between end_date and start_date is 1 day for each row in the table. If task end dates are consecutive they are part of the same project. Projects do not overlap.
Write a query to return the start and end dates of each project, and the number of days it took to complete. Order by ascending project duration, and ascending start date in the case of a tie. From HackerRank.
WITH projects (task_id, start_date, end_date) AS (VALUES (1, CAST('10-01-20' AS date), CAST('10-02-20' AS date)), (2, CAST('10-02-20' AS date), CAST('10-03-20' AS date)), (3, CAST('10-03-20' AS date), CAST('10-04-20' AS date)), (4, CAST('10-13-20' AS date), CAST('10-14-20' AS date)), (5, CAST('10-14-20' AS date), CAST('10-15-20' AS date)), (6, CAST('10-28-20' AS date), CAST('10-29-20' AS date)), (7, CAST('10-30-20' AS date), CAST('10-31-20' AS date))),-- get start dates not present in end date column (these are “true” project start dates) t1 AS (SELECT start_dateFROM projectsWHERE start_date NOT IN (SELECT end_date FROM projects) ),-- get end dates not present in start date column (these are “true” project end dates) t2 AS (SELECT end_dateFROM projectsWHERE end_date NOT IN (SELECT start_date FROM projects) ),-- filter to plausible start-end pairs (start < end), then find correct end date for each start date (the minimum end date, since there are no overlapping projects)t3 AS (SELECT    start_date,    MIN(end_date) AS end_dateFROM t1, t2WHERE start_date < end_dateGROUP BY 1 )SELECT    *,    end_date - start_date AS project_durationFROM t3ORDER BY 3, 1
Given the following two tables, write a query to return the fraction of students, rounded to two decimal places, who attended school (attendance = 1) on their birthday. Source.
WITH attendance (student_id, school_date, attendance)AS (VALUES(1, CAST('2020-04-03' AS date), 0),(2, CAST('2020-04-03' AS date), 1),(3, CAST('2020-04-03' AS date), 1), (1, CAST('2020-04-04' AS date), 1), (2, CAST('2020-04-04' AS date), 1), (3, CAST('2020-04-04' AS date), 1), (1, CAST('2020-04-05' AS date), 0), (2, CAST('2020-04-05' AS date), 1), (3, CAST('2020-04-05' AS date), 1), (4, CAST('2020-04-05' AS date), 1)),students (student_id, school_id, grade_level, date_of_birth)AS (VALUES(1, 2, 5, CAST('2012-04-03' AS date)),(2, 1, 4, CAST('2013-04-04' AS date)),(3, 1, 3, CAST('2014-04-05' AS date)), (4, 2, 4, CAST('2013-04-03' AS date)))-- join attendance and students table on student ID, and day and month of school day = day and month of birthday, taking average of attendance column values and roundingSELECT ROUND(AVG(attendance), 2) AS birthday_attendanceFROM attendance aJOIN students sON a.student_id = s.student_id AND EXTRACT(MONTH FROM school_date) = EXTRACT(MONTH FROM date_of_birth)AND EXTRACT(DAY FROM school_date) = EXTRACT(DAY FROM date_of_birth)
Given the following two tables, write a query to return the hacker ID, name, and total score (the sum of maximum scores for each challenge completed) ordered by descending score, and by ascending hacker ID in the case of score tie. Do not display entries for hackers with a score of zero. From HackerRank.
WITH hackers (hacker_id, name)AS (VALUES(1, 'John'),(2, 'Jane'),(3, 'Joe'),(4, 'Jim')),submissions (submission_id, hacker_id, challenge_id, score)AS (VALUES(101, 1, 1, 10),(102, 1, 1, 12),(103, 2, 1, 11),(104, 2, 1, 9),(105, 2, 2, 13),(106, 3, 1, 9),(107, 3, 2, 12),(108, 3, 2, 15),(109, 4, 1, 0)),-- from submissions table, get maximum score for each hacker-challenge pairt1 AS (SELECT    hacker_id,    challenge_id,    MAX(score) AS max_scoreFROM submissions GROUP BY 1, 2 )-- inner join this with the hackers table, sum up all maximum scores, filter to exclude hackers with total score of zero, and order result by total score and hacker IDSELECT    t1.hacker_id,    h.name,    SUM(t1.max_score) AS total_scoreFROM t1JOIN hackers hON t1.hacker_id = h.hacker_idGROUP BY 1, 2HAVING SUM(max_score) > 0ORDER BY 3 DESC, 1
Write a query to rank scores in the following table without using a window function. If there is a tie between two scores, both should have the same rank. After a tie, the following rank should be the next consecutive integer value. From LeetCode.
WITH scores (id, score)AS (VALUES(1, 3.50),(2, 3.65),(3, 4.00),(4, 3.85),(5, 4.00),(6, 3.65))-- self-join on inequality produces a table with one score and all scores as large as this joined to it, grouping by first id and score, and counting up all unique values of joined scores yields the equivalent of DENSE_RANK()   [check join output to understand]SELECT    s1.score,    COUNT(DISTINCT s2.score) AS score_rankFROM scores s1 JOIN scores s2ON s1.score <= s2.scoreGROUP BY s1.id, s1.scoreORDER BY 1 DESC
The following table holds monthly salary information for several employees. Write a query to get, for each month, the cumulative sum of an employee’s salary over a period of 3 months, excluding the most recent month. The result should be ordered by ascending employee ID and month. From LeetCode.
WITH employee (id, pay_month, salary)AS (VALUES(1, 1, 20),(2, 1, 20),(1, 2, 30),(2, 2, 30),(3, 2, 40),(1, 3, 40),(3, 3, 60),(1, 4, 60),(3, 4, 70)),-- add column for descending month rank (latest month = 1) for each employeet1 AS (SELECT *,    RANK() OVER (PARTITION BY id ORDER BY pay_month DESC) AS month_rankFROM employee )-- filter to exclude latest month and months 5+, create cumulative salary sum using SUM() as window function, order by ID and monthSELECT    id,    pay_month,    salary,    SUM(salary) OVER (PARTITION BY id ORDER BY month_rank DESC) AS cumulative_sumFROM t1 WHERE month_rank != 1AND month_rank <= 4ORDER BY 1, 2
Write a query to return the scores of each team in the teams table after all matches displayed in the matches table. Points are awarded as follows: zero points for a loss, one point for a tie, and three points for a win. The result should include team name and points, and be ordered by decreasing points. In case of a tie, order by alphabetized team name.
WITH teams (team_id, team_name)AS (VALUES(1, 'New York'),(2, 'Atlanta'),(3, 'Chicago'),(4, 'Toronto'),(5, 'Los Angeles'),(6, 'Seattle')),matches (match_id, host_team, guest_team, host_goals, guest_goals)AS (VALUES(1, 1, 2, 3, 0),(2, 2, 3, 2, 4),(3, 3, 4, 4, 3),(4, 4, 5, 1, 1),(5, 5, 6, 2, 1),(6, 6, 1, 1, 2)),-- add host points and guest points columns to matches table, using case-when-then to tally up points for wins, ties, and lossest1 AS (SELECT    *,    CASE WHEN host_goals > guest_goals THEN 3         WHEN host_goals = guest_goals THEN 1         ELSE 0 END AS host_points,    CASE WHEN host_goals < guest_goals THEN 3    WHEN host_goals = guest_goals THEN 1    ELSE 0 END AS guest_pointsFROM matches )-- join result onto teams table twice to add up for each team the points earned as host team and guest team, then order as requestedSELECT    t.team_name,    a.host_points + b.guest_points AS total_pointsFROM teams tJOIN t1 aON t.team_id = a.host_teamJOIN t1 bON t.team_id = b.guest_teamORDER BY 2 DESC, 1
From the following table, write a query to display the ID and name of customers who bought products A and B, but didn’t buy product C, ordered by ascending customer ID.
WITH customers (id, name)AS (VALUES(1, 'Daniel'),(2, 'Diana'),(3, 'Elizabeth'),(4, 'John')),orders (order_id, customer_id, product_name)AS (VALUES(1, 1, 'A'),(2, 1, 'B'),(3, 2, 'A'),(4, 2, 'B'),(5, 2, 'C'),(6, 3, 'A'), (7, 3, 'A'),(8, 3, 'B'),(9, 3, 'D'))-- join customers and orders tables on customer ID, filtering to those who bought both products A and B, removing those who bought product C, returning ID and name columns ordered by ascending IDSELECT DISTINCT    id,    nameFROM orders oJOIN customers cON o.customer_id = c.idWHERE customer_id IN (SELECT customer_id                       FROM orders                       WHERE product_name = 'A') AND customer_id IN (SELECT customer_id                     FROM orders                     WHERE product_name = 'B') AND customer_id NOT IN (SELECT customer_id                         FROM orders                         WHERE product_name = 'C')ORDER BY 1
Write a query to return the median latitude of weather stations from each state in the following table, rounding to the nearest tenth of a degree. Note that there is no MEDIAN() function in SQL! From HackerRank.
WITH stations (id, city, state, latitude, longitude)AS (VALUES(1, 'Asheville', 'North Carolina', 35.6, 82.6),(2, 'Burlington', 'North Carolina', 36.1, 79.4),(3, 'Chapel Hill', 'North Carolina', 35.9, 79.1),(4, 'Davidson', 'North Carolina', 35.5, 80.8),(5, 'Elizabeth City', 'North Carolina', 36.3, 76.3),(6, 'Fargo', 'North Dakota', 46.9, 96.8),(7, 'Grand Forks', 'North Dakota', 47.9, 97.0),(8, 'Hettinger', 'North Dakota', 46.0, 102.6),(9, 'Inkster', 'North Dakota', 48.2, 97.6)),-- assign latitude-ordered row numbers for each state, and get total row count for each statet1 AS (SELECT    *,    ROW_NUMBER() OVER (PARTITION BY state ORDER BY latitude ASC) AS row_number_state,             count(*) OVER (PARTITION BY state) AS row_countFROM stations )-- filter to middle row (for odd total row number) or middle two rows (for even total row number), then get average value of those, grouping by stateSELECT    state,    AVG(latitude) AS median_latitudeFROM t1WHERE row_number_state >= 1.0*row_count/2 AND row_number_state <= 1.0*row_count/2 + 1GROUP BY 1
From the same table in question 17, write a query to return the furthest-separated pair of cities for each state, and the corresponding distance (in degrees, rounded to 2 decimal places) between those two cities. From HackerRank.
WITH stations (id, city, state, latitude, longitude)AS (VALUES(1, 'Asheville', 'North Carolina', 35.6, 82.6),(2, 'Burlington', 'North Carolina', 36.1, 79.4),(3, 'Chapel Hill', 'North Carolina', 35.9, 79.1),(4, 'Davidson', 'North Carolina', 35.5, 80.8),(5, 'Elizabeth City', 'North Carolina', 36.3, 76.3),(6, 'Fargo', 'North Dakota', 46.9, 96.8),(7, 'Grand Forks', 'North Dakota', 47.9, 97.0),(8, 'Hettinger', 'North Dakota', 46.0, 102.6),(9, 'Inkster', 'North Dakota', 48.2, 97.6)),-- self-join on matching states and city < city (avoids identical and double-counted city pairs), pulling state, city pair, and latitude/longitude coordinates for each cityt1 AS (SELECT    s1.state,    s1.city AS city1,    s2.city AS city2,    s1.latitude AS city1_lat,    s1.longitude AS city1_long,    s2.latitude AS city2_lat,    s2.longitude AS city2_longFROM stations s1JOIN stations s2ON s1.state = s2.state AND s1.city < s2.city ),-- add a column displaying rounded Euclidean distance t2 AS (SELECT *, ROUND(( (city1_lat - city2_lat)^2 + (city1_long - city2_long)^2 ) ^ 0.5, 2) AS distanceFROM t1 ),-- rank each city pair by descending distance for each statet3 AS (SELECT *, RANK() OVER (PARTITION BY state ORDER BY distance DESC) AS dist_rankFROM t2 )-- return the city pair with maximium separationSELECT    state,    city1,    city2,    distanceFROM t3WHERE dist_rank = 1
Write a query to return the average cycle time across each month. Cycle time is the time elapsed between one user joining and their invitees joining. Users who joined without an invitation have a zero in the “invited by” column.
WITH users (user_id, join_date, invited_by) AS (VALUES (1, CAST('01-01-20' AS date), 0), (2, CAST('01-10-20' AS date), 1), (3, CAST('02-05-20' AS date), 2), (4, CAST('02-12-20' AS date), 3), (5, CAST('02-25-20' AS date), 2), (6, CAST('03-01-20' AS date), 0), (7, CAST('03-01-20' AS date), 4),(8, CAST('03-04-20' AS date), 7)),-- self-join on invited by = user ID, extract join month from inviter join date, and calculate cycle time as difference between join dates of inviter and inviteet1 AS (SELECT    CAST(EXTRACT(MONTH FROM u2.join_date) AS int) AS month,   u1.join_date - u2.join_date AS cycle_timeFROM users u1JOIN users u2ON u1.invited_by = u2.user_id )-- group by join month, take average of cycle times within each monthSELECT    month,    AVG(cycle_time) AS cycle_time_month_avgFROM t1GROUP BY 1ORDER BY 1
The attendance table logs the number of people counted in a crowd each day an event is held. Write a query to return a table showing the date and visitor count of high-attendance periods, defined as three consecutive entries (not necessarily consecutive dates) with more than 100 visitors. From LeetCode.
WITH attendance (event_date, visitors) AS (VALUES (CAST('01-01-20' AS date), 10), (CAST('01-04-20' AS date), 109), (CAST('01-05-20' AS date), 150), (CAST('01-06-20' AS date), 99), (CAST('01-07-20' AS date), 145), (CAST('01-08-20' AS date), 1455), (CAST('01-11-20' AS date), 199),(CAST('01-12-20' AS date), 188)),-- add row numbers to identify consecutive entries, since date column has some gapst1 AS (SELECT *,    ROW_NUMBER() OVER (ORDER BY event_date) AS day_numFROM attendance ),-- filter this to exclude days with > 100 visitorst2 AS (SELECT *FROM t1WHERE visitors > 100 ),-- self-join (inner) twice on offset = 1 day and offset = 2 dayst3 AS (SELECT    a.day_num AS day1,    b.day_num AS day2,    c.day_num AS day3FROM t2 aJOIN t2 bON a.day_num = b.day_num - 1JOIN t2 cON a.day_num = c.day_num - 2 )-- pull date and visitor count for consecutive days surfaced in previous tableSELECT    event_date,    visitorsFROM t1WHERE day_num IN (SELECT day1 FROM t3)   OR day_num IN (SELECT day2 FROM t3)   OR day_num IN (SELECT day3 FROM t3)
Using the following two tables, write a query to return the names and purchase frequency of the top three pairs of products most often bought together. The names of both products should appear in one column. Source.
WITH orders (order_id, customer_id, product_id) AS (VALUES (1, 1, 1),(1, 1, 2),(1, 1, 3),(2, 2, 1),(2, 2, 2),(2, 2, 4),(3, 1, 5)),products (id, name) AS (VALUES (1, 'A'),(2, 'B'),(3, 'C'),(4, 'D'),(5, 'E')),-- get unique product pairs from same order by self-joining orders table on order ID and product ID < product ID (avoids identical and double-counted product pairs)t1 AS (SELECT    o1.product_id AS prod_1,    o2.product_id AS prod_2FROM orders o1JOIN orders o2ON o1.order_id = o2.order_idAND o1.product_id < o2.product_id ),-- join products table onto this to get product names, concatenate to get product pairs in one columnt2 AS (SELECT CONCAT(p1.name, ' ', p2.name) AS product_pairFROM t1JOIN products p1ON t1.prod_1 = p1.idJOIN products p2ON t1.prod_2 = p2.id )-- grouping by product pair, return top 3 entries sorted by purchase frequencySELECT *,    COUNT(*) AS purchase_freqFROM t2GROUP BY 1ORDER BY 2 DESC LIMIT 3
From the following table summarizing the results of a study, calculate the average treatment effect as well as upper and lower bounds of the 95% confidence interval. Round these numbers to 3 decimal places.
WITH study (participant_id, assignment, outcome) AS (VALUES (1, 0, 0),(2, 1, 1),(3, 0, 1),(4, 1, 0),(5, 0, 1),(6, 1, 1),(7, 0, 0),(8, 1, 1),(9, 1, 1)),-- get average outcomes, standard deviations, and group sizes for control and treatment groupscontrol AS (SELECT    AVG(outcome) AS avg_outcome,    STDDEV(outcome) AS std_dev,    COUNT(*) AS group_sizeFROM studyWHERE assignment = 0 ),treatment AS (SELECT    AVG(outcome) AS avg_outcome,    STDDEV(outcome) AS std_dev,   COUNT(*) AS group_sizeFROM studyWHERE assignment = 1 ),-- get average treatment effect sizeeffect_size AS (SELECT t.avg_outcome - c.avg_outcome AS effect_sizeFROM control c, treatment t ),-- construct 95% confidence interval using z* = 1.96 and magnitude of individual standard errors [ std dev / sqrt(sample size) ]conf_interval AS (SELECT 1.96 * (t.std_dev^2 / t.group_size              + c.std_dev^2 / c.group_size)^0.5 AS conf_intFROM treatment t, control c )SELECT round(es.effect_size, 3) AS point_estimate,         round(es.effect_size - ci.conf_int, 3) AS lower_bound,         round(es.effect_size + ci.conf_int, 3) AS upper_boundFROM effect_size es, conf_interval ci
The following table shows the monthly salary for an employee for the first nine months in a given year. From this, write a query to return a table that displays, for each month in the first half of the year, the rolling sum of the employee’s salary for that month and the following two months, ordered chronologically.
WITH salaries (month, salary) AS (VALUES (1, 2000),(2, 3000),(3, 5000),(4, 4000),(5, 2000),(6, 1000),(7, 2000),(8, 4000),(9, 5000))-- self-join to match month n with months n, n+1, and n+2, then sum salary across those months, filter to first half of year, and sortSELECT    s1.month,    SUM(s2.salary) AS salary_3mosFROM salaries s1JOIN salaries s2ON s1.month <= s2.month AND s1.month > s2.month - 3GROUP BY 1HAVING s1.month < 7ORDER BY 1
From the given trips and users tables for a taxi service, write a query to return the cancellation rate in the first two days in October, rounded to two decimal places, for trips not involving banned riders or drivers. From LeetCode.
WITH trips (trip_id, rider_id, driver_id, status, request_date)AS (VALUES(1, 1, 10, 'completed', CAST('2020-10-01' AS date)),(2, 2, 11, 'cancelled_by_driver', CAST('2020-10-01' AS date)),(3, 3, 12, 'completed', CAST('2020-10-01' AS date)),(4, 4, 10, 'cancelled_by_rider', CAST('2020-10-02' AS date)),(5, 1, 11, 'completed', CAST('2020-10-02' AS date)),(6, 2, 12, 'completed', CAST('2020-10-02' AS date)),(7, 3, 11, 'completed', CAST('2020-10-03' AS date))),users (user_id, banned, type)AS (VALUES(1, 'no', 'rider'),(2, 'yes', 'rider'),(3, 'no', 'rider'),(4, 'no', 'rider'),(10, 'no', 'driver'),(11, 'no', 'driver'),(12, 'no', 'driver'))-- filter trips table to exclude banned riders and drivers, then calculate cancellation rate as 1 - fraction of trips completed, filtering to first two days of the monthSELECT    request_date,    1 - AVG(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS cancel_rateFROM tripsWHERE rider_id NOT IN (SELECT user_id                        FROM users                       WHERE banned = 'yes' )AND driver_id NOT IN (SELECT user_id                       FROM users                      WHERE banned = 'yes' )GROUP BY 1HAVING EXTRACT(DAY FROM request_date) <= 2
From the following user activity table, write a query to return the fraction of users who are retained (show some activity) a given number of days after joining. By convention, users are considered active on their join day (day 0).
WITH users (user_id, action_date, action) AS (VALUES (1, CAST('01-01-20' AS date), 'Join'), (1, CAST('01-02-20' AS date), 'Access'), (2, CAST('01-02-20' AS date), 'Join'), (3, CAST('01-02-20' AS date), 'Join'), (1, CAST('01-03-20' AS date), 'Access'), (3, CAST('01-03-20' AS date), 'Access'),(1, CAST('01-04-20' AS date), 'Access')),-- get join dates for each userjoin_dates AS (SELECT    user_id,    action_date AS join_dateFROM usersWHERE action = 'Join' ),-- create vector containing all dates in date rangedate_vector AS (SELECT CAST(GENERATE_SERIES(MIN(action_date), MAX(action_date),             '1 day'::interval) AS date) AS datesFROM users ),-- cross join to get all possible user-date combinationsall_users_dates AS (SELECT DISTINCT    user_id,    d.datesFROM usersCROSS JOIN date_vector d ),-- left join users table onto all user-date combinations on matching user ID and date (null on days where user didn't engage), join onto this each user's signup date, exclude user-date combinations falling before user signupt1 AS (SELECT    a.dates - c.join_date AS day_no,    b.user_idFROM all_users_dates aLEFT JOIN users bON a.user_id = b.user_idAND a.dates = b.action_dateJOIN join_dates cON a.user_id = c.user_id WHERE a.dates - c.join_date >= 0 )-- grouping by days since signup, count (non-null) user IDs as active users, total users, and the quotient as retention rateSELECT    day_no,    COUNT(*) AS n_total,    COUNT(DISTINCT user_id) AS n_active,    ROUND(1.0*COUNT(DISTINCT user_id)/COUNT(*), 2) AS retentionFROM t1GROUP BY 1
A note on one common problem: if you see a syntax error when using CTEs, check that you have commas between CTEs and no comma after the last CTE.
WITH input_table (column_1, column_2) AS (VALUES (1, 'A'), (2, 'B')),     -- comma between CTEst1 AS (SELECT *FROM input_tableWHERE column_2 = 'A')    -- no comma after last CTESELECT *FROM t1
Thanks to Ben Lacar and Minting Ye.