- Alex0:02
Hi, I'm Alex. This is the SQL and pandas round. The editor runs SQL on DuckDB and Python with pandas, against the data that comes with each question. You can run a query on its own to look at the result, or run it against the examples, which compares your output with the expected one. There are three questions; I'll keep an eye on time. Talk me through what you're doing as you go. First question. Given the logins table in your workspace, calculate day-1 retention: the share of users who logged in on the day after their first login, for each signup date.
- You0:42
Okay. Um, so the signup date is just the first login, there's no separate signups table? And day 1 means exactly the next calendar day, not within twenty-four hours?
- Alex0:56
Right. The cohort is each user's first login date, and day 1 is exactly one calendar day later. Return cohort_date, cohort_size and day1_retention as a fraction, one row per cohort, ordered by date. The newest cohort is included even though it has no day-1 data yet.
- You1:14
And the table is one row per user per day, so no duplicates within a day?
- Alex1:24
That's what the schema comment says: one row per user per login day. Comments in schemas are not always true, though. How would you check?
- You1:33
I'd just look at it first. It's small.
- You1:58
Sent to the workspace, sql
1SELECT * FROM logins ORDER BY user_id, login_date;2
- You2:05
Okay, so eight rows, five users. User 1 logs in on the first, second and fifth. User 2 only the first. User 3 the second and third. 4 and 5 once each. So by hand, the January first cohort is users 1 and 2, and only 1 comes back on the second, so that's a half. January second is 3 and 4, 3 comes back, also a half. And January third is just user 5, zero. Good, that gives me something to check against.
- You2:53
Plan: a CTE with the minimum login date per user, that's the cohort. Then I join back to logins on the same user and the cohort date plus one. Retention is the distinct users I find on that day divided by the cohort size.
- Alex3:15
Go ahead.
- You3:53
Okay, so, the CTE first. Select user id, min of login date as cohort date, from logins, group by user. Um, then the main query from that CTE, joined to logins on the user id and the login date equals cohort date plus one. In DuckDB I think adding an integer to a date works, it gives the next day. Group by cohort date and order by it.
- You5:25
Sent to the workspace, sql
1WITH first_login AS (2 SELECT user_id, MIN(login_date) AS cohort_date3 FROM logins4 GROUP BY user_id5)6SELECT f.cohort_date,7 COUNT(DISTINCT f.user_id) AS cohort_size,8 COUNT(DISTINCT l.user_id) * 1.0 / COUNT(DISTINCT f.user_id) AS day1_retention9FROM first_login f10JOIN logins l11 ON l.user_id = f.user_id12 AND l.login_date = f.cohort_date + 113GROUP BY f.cohort_date14ORDER BY f.cohort_date;15
- You5:33
That runs. Let me check it against the example.
- You5:47
Hmm, it fails. It expects three rows and I have two, and my retention is one everywhere. Which doesn't match what I did by hand. Oh. The inner join only keeps users who came back on day 1, so the cohort becomes just the retained users. And the third of January disappears completely because nobody from it came back. I need to keep everyone in the cohort, so a left join.
- You6:54
Sent to the workspace, sql
1WITH first_login AS (2 SELECT user_id, MIN(login_date) AS cohort_date3 FROM logins4 GROUP BY user_id5)6SELECT f.cohort_date,7 COUNT(DISTINCT f.user_id) AS cohort_size,8 COUNT(DISTINCT l.user_id) * 1.0 / COUNT(DISTINCT f.user_id) AS day1_retention9FROM first_login f10LEFT JOIN logins l11 ON l.user_id = f.user_id12 AND l.login_date = f.cohort_date + 113GROUP BY f.cohort_date14ORDER BY f.cohort_date;15
- You7:01
With the left join all three pass. Half, half and zero, like by hand. And the empty table gives no rows, and the user who only came back on day three counts as not retained.
- Alex7:21
Walk me through what the left join produces for user 4, the one who never came back.
- You7:27
User 4's first login is January second, so the join looks for a login on the third. There isn't one, so with the left join I get one row for user 4 with the logins columns all null. COUNT DISTINCT on f.user_id counts them in the cohort, and COUNT DISTINCT on l.user_id skips the null, so they're in the denominator but not the numerator. That's exactly what I want.
- Alex8:01
Why COUNT DISTINCT on the logins side?
- You8:04
The table is one row per user per day, so today it can't duplicate, but if it were raw login events a user could log in three times on day 1 and be counted three times. Distinct keeps it a count of users. It's cheap insurance.
- Alex8:26
Okay. Other edge cases. What about the denominator, can it ever be zero or null here, and what would happen if it were?
- You8:35
Um, no, every cohort row exists because at least one user has that first login, so the cohort size is at least one. If I were dividing by something from a different table, I'd wrap it in a NULLIF.
- Alex8:57
Next step. Product wants the standard retention table, not just day one. Extend it to day 1, 7 and 30 retention in one query.
- You9:06
Okay. Same first-login CTE. Then instead of putting the day in the join, I join all of the user's logins and use conditional counts, count distinct user when the login date is the cohort date plus one, plus seven, plus thirty. And, um, the newest cohorts can't have day-30 data yet, so those should be null, not zero, otherwise it looks like everybody churned. I'll use the max date in the table as today.
- Alex9:41
Go ahead and write it.
- You10:15
So the join is now only on user id, every login of the user. Then three counts with case when inside the count distinct. And for the censoring, um, I'll cross join a one-row CTE with the max date, and wrap d7 and d30 in a case: only compute it if the max date is at least the cohort date plus seven, or plus thirty. Otherwise it falls through to null.
- You12:12
Sent to the workspace, sql
1WITH first_login AS (2 SELECT user_id, MIN(login_date) AS cohort_date3 FROM logins4 GROUP BY user_id5),6last_day AS (7 SELECT MAX(login_date) AS max_date FROM logins8)9SELECT f.cohort_date,10 COUNT(DISTINCT f.user_id) AS cohort_size,11 COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 1 THEN l.user_id END) * 1.012 / COUNT(DISTINCT f.user_id) AS d1,13 CASE WHEN MAX(d.max_date) >= f.cohort_date + 714 THEN COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 7 THEN l.user_id END) * 1.015 / COUNT(DISTINCT f.user_id) END AS d7,16 CASE WHEN MAX(d.max_date) >= f.cohort_date + 3017 THEN COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 30 THEN l.user_id END) * 1.018 / COUNT(DISTINCT f.user_id) END AS d3019FROM first_login f20CROSS JOIN last_day d21LEFT JOIN logins l ON l.user_id = f.user_id22GROUP BY f.cohort_date23ORDER BY f.cohort_date;24
- You12:19
So d1 is the same as before, a half, a half, zero. And d7 and d30 are null for every cohort, which is right here, the data only covers five days. If I'd written zero there it would be misleading.
- Alex12:43
Mm-hm. You'll hear two definitions of retention on most teams. What is the difference between classic and rolling retention?
- You12:50
Um. Classic is what I did, active exactly on day N. Rolling is, I think, active on day N or any day after it? So it's, kind of, always higher than classic, and it doesn't go down when someone skips a day. I'm fairly sure that's it, but I've only seen it in one course.
- Alex13:26
When would you report rolling instead of classic?
- You13:29
Um, maybe for a product people don't use every day. Like a tax app or a travel app, being active exactly on day seven means nothing, so day seven or later is closer to did we keep them. For a daily app like messaging I'd stay with classic. I'd at least say which one in the chart title, because they're easy to mix up.
- Alex14:04
Okay. Next question. You have an orders DataFrame and a customers DataFrame. Compute the average order value per customer segment for the last 90 days, handling missing values sensibly.
- You14:15
What counts as today, the real date or the latest order? And orders whose customer isn't in the customers table, do I drop them?
- Alex14:29
Today is the latest order_date in the data, and the last 90 days means after that date minus 90. Orders without a matching customer go under unknown, not
- You14:39
Sorry, and a customer who exists but has an empty segment, is that unknown too?
- Alex14:46
Treat it the same way. Missing amounts are left out of the average, but count them. Print CSV: segment, avg_amount, n_orders and n_missing_amount, sorted by segment.
- You14:56
And n_orders includes the ones with a missing amount?
- Alex15:04
Yes. All orders of the segment.
- You15:07
Okay. So filter to the last ninety days first, then merge in the segment, then group by segment with three aggregations: the mean of amount, which skips NaN by default, the count of orders, and a count of the nulls in amount.
- Alex15:28
Okay.
- You15:56
Reading both files, order date is already parsed as a date. Cutoff is the max minus a Timedelta of ninety days, keep strictly greater. Then merge on customer id. Um, group by segment, named aggregations, mean of amount, count of order id, and for the missing ones a lambda with isna sum. Reset index, sort, and print to_csv without the index.
- You17:47
Sent to the workspace, python
1import pandas as pd2 3orders = pd.read_csv("data/orders.csv", parse_dates=["order_date"])4customers = pd.read_csv("data/customers.csv")5 6cutoff = orders["order_date"].max() - pd.Timedelta(days=90)7recent = orders[orders["order_date"] > cutoff]8 9df = recent.merge(customers, on="customer_id")10out = (11 df.groupby("segment")12 .agg(avg_amount=("amount", "mean"),13 n_orders=("order_id", "count"),14 n_missing_amount=("amount", lambda s: s.isna().sum()))15 .reset_index()16 .sort_values("segment")17)18print(out.to_csv(index=False), end="")19
- You17:54
Um, the unknown row is missing. Right, merge is an inner join by default, so the order with no customer id and the one from customer 4, who isn't in the customers table, both disappear. Left merge.
- Alex18:21
Before you change it, what do you expect the unknown row to be?
- You18:26
Um, the order with no customer id is forty dollars, and customer 4's order is sixty. Both recent. So unknown should be an average of fifty, two orders, nothing missing. Order 6 is from January, so it's out of the window, it shouldn't show up anywhere. That's the nine ninety-nine one, which would really move enterprise if the filter were wrong.
- You19:23
Sent to the workspace, python
1import pandas as pd2 3orders = pd.read_csv("data/orders.csv", parse_dates=["order_date"])4customers = pd.read_csv("data/customers.csv")5 6cutoff = orders["order_date"].max() - pd.Timedelta(days=90)7recent = orders[orders["order_date"] > cutoff]8 9df = recent.merge(customers, on="customer_id", how="left")10out = (11 df.groupby("segment")12 .agg(avg_amount=("amount", "mean"),13 n_orders=("order_id", "count"),14 n_missing_amount=("amount", lambda s: s.isna().sum()))15 .reset_index()16 .sort_values("segment")17)18print(out.to_csv(index=False), end="")19
- You19:32
Hmm. Still no unknown row. Enterprise and smb are right. So the left merge kept the rows, I think, but... they don't show up. Um, maybe the merge on customer id fails because, uh, one side is float? The orders column has a NaN so it's float, and customers is int. Um.
- Alex20:25
What does groupby do with NaN keys by default?
- You20:29
Oh. It drops them. The segment is NaN for those two orders after the left merge, and groupby throws away NaN keys unless you pass dropna false. So the float thing was a red herring. I'll fill the segment with unknown before grouping, that also covers customer 5, whose segment is empty.
- You21:17
Sent to the workspace, python
1import pandas as pd2 3orders = pd.read_csv("data/orders.csv", parse_dates=["order_date"])4customers = pd.read_csv("data/customers.csv")5 6cutoff = orders["order_date"].max() - pd.Timedelta(days=90)7recent = orders[orders["order_date"] > cutoff]8 9df = recent.merge(customers, on="customer_id", how="left")10df["segment"] = df["segment"].fillna("unknown")11out = (12 df.groupby("segment")13 .agg(avg_amount=("amount", "mean"),14 n_orders=("order_id", "count"),15 n_missing_amount=("amount", lambda s: s.isna().sum()))16 .reset_index()17 .sort_values("segment")18)19print(out.to_csv(index=False), end="")20
- You21:24
Both examples pass now. Enterprise 200, smb 75 with one missing amount, unknown 50.
- Alex21:34
Smb has three orders and an average of 75. Walk me through that row.
- You21:39
Customers 1 and 3 are smb. Customer 1 has two recent orders, a hundred and fifty, and customer 3 has one order with no amount. So three orders, one missing, and the mean is over the two that have an amount, a hundred and fifty over two, seventy-five. If I'd filled the missing amount with zero, it would be fifty, which would be wrong.
- Alex22:10
Earlier you suspected the customer_id types. After reading the CSV, what is the dtype on each side, and does it matter here?
- You22:19
In orders it's float, because one id is empty and NaN forces float. In customers it's int. Pandas still matches one point oh with one, so the merge works, it just might warn. In a bigger pipeline I'd cast both to the nullable Int64 type so ids stay integers and the missing one stays missing.
- Alex22:48
Okay. One thing about the merge itself. Nothing in the data guarantees customer_id is unique in customers. How would you check the merge did not duplicate orders?
- You22:58
If a customer id appeared twice in customers, every one of their orders would be doubled and the counts would be wrong without any error. So I'd add validate many to one on the merge, pandas raises if the right side has duplicate keys. And an assert that the row count after the merge equals the row count before. Let me put both in.
- You23:57
Sent to the workspace, python
1import pandas as pd2 3orders = pd.read_csv("data/orders.csv", parse_dates=["order_date"])4customers = pd.read_csv("data/customers.csv")5 6cutoff = orders["order_date"].max() - pd.Timedelta(days=90)7recent = orders[orders["order_date"] > cutoff]8 9df = recent.merge(customers, on="customer_id", how="left", validate="many_to_one")10assert len(df) == len(recent) # the merge must not duplicate orders11df["segment"] = df["segment"].fillna("unknown")12out = (13 df.groupby("segment")14 .agg(avg_amount=("amount", "mean"),15 n_orders=("order_id", "count"),16 n_missing_amount=("amount", lambda s: s.isna().sum()))17 .reset_index()18 .sort_values("segment")19)20print(out.to_csv(index=False), end="")21
- You24:05
Still passes, so no duplicates in this data, but now it would fail loudly.
- Alex24:15
Now the same thing in production, where orders is a fact table that keeps growing. How would you scale this if orders has 2 billion rows?
- You24:25
I wouldn't load that into pandas. I'd push it down to the warehouse as SQL: filter the date first, which should prune partitions if the table is partitioned by date, aggregate, then join the small customers table. Um, or Spark if it has to be Python, with a broadcast join for customers since it's small. I'm less sure how I'd pick the partitioning if I had to set it up myself.
- Alex25:03
Say you write it in SQL in the warehouse. Where could the SQL version quietly differ from your pandas one?
- You25:11
Hmm. NULLs, mostly. AVG in SQL also skips nulls, so that's the same. But COUNT of amount skips nulls and COUNT star doesn't, so n_orders has to be COUNT star or COUNT of order id. A NULL segment groups into its own NULL group in SQL instead of disappearing, which is actually the opposite of pandas, so I'd still COALESCE it to unknown. And the date cutoff, whether it's strictly greater, and timestamps versus dates, that's where off-by-one-day bugs come from.
- Alex25:55
Okay. Last question. You have exposure and purchase data from an A/B test. Compute conversion rate per variant, the difference with a confidence interval, and check whether the assignment looks healthy.
- You26:07
Okay, a few questions. Which purchases count, any purchase after exposure, or within some window? And was the split meant to be fifty-fifty?
- Alex26:21
Only purchases after the user's first exposure, within 7 days. The design intended a 50/50 split. Also, some users appear in both variants because of a bug. The exposures table should be one row per user, but it isn't always.
- You26:36
Then those users have to go, they saw both, so I can't attribute anything. Do you want the SQL part here and the interval separately?
- Alex26:51
The SQL part: per variant, users, converters and conversion rate, ordered by variant, after excluding users who appear in both variants. The interval and the assignment check can be in Python.
- You27:03
Okay. First a clean CTE: group exposures by user, keep only users with exactly one distinct variant, and take their first exposure time. Then left join purchases inside the seven-day window, and count distinct users and distinct converters per variant. The user is the unit, not the purchase.
- Alex27:26
Go ahead.
- You27:55
Clean CTE: group by user id, having count distinct variant equals one, and then min of variant is just the variant, and min of exposed at is the first exposure. Then left join purchases on user id and purchased at less than exposed at plus an interval of seven days. Count distinct both sides, and the rate is converters times one point oh over users.
- You29:33
Sent to the workspace, sql
1WITH clean AS (2 SELECT user_id, MIN(variant) AS variant, MIN(exposed_at) AS exposed_at3 FROM exposures4 GROUP BY user_id5 HAVING COUNT(DISTINCT variant) = 16)7SELECT c.variant,8 COUNT(DISTINCT c.user_id) AS users,9 COUNT(DISTINCT p.user_id) AS converters,10 COUNT(DISTINCT p.user_id) * 1.0 / COUNT(DISTINCT c.user_id) AS conversion_rate11FROM clean c12LEFT JOIN purchases p13 ON p.user_id = c.user_id14 AND p.purchased_at < c.exposed_at + INTERVAL 7 DAY15GROUP BY c.variant16ORDER BY c.variant;17
- You29:41
Control is wrong. It expects one converter out of four, I have two. Treatment matches. So one control user converts in my query and shouldn't. Let me think. User 2 buys on the twelfth, that's outside the seven days, so not them. User 8... buys on December thirty-first, before the exposure. I only put the upper bound in the join. I need purchased at greater or equal to exposed at as well.
- You30:51
Sent to the workspace, sql
1WITH clean AS (2 SELECT user_id, MIN(variant) AS variant, MIN(exposed_at) AS exposed_at3 FROM exposures4 GROUP BY user_id5 HAVING COUNT(DISTINCT variant) = 16)7SELECT c.variant,8 COUNT(DISTINCT c.user_id) AS users,9 COUNT(DISTINCT p.user_id) AS converters,10 COUNT(DISTINCT p.user_id) * 1.0 / COUNT(DISTINCT c.user_id) AS conversion_rate11FROM clean c12LEFT JOIN purchases p13 ON p.user_id = c.user_id14 AND p.purchased_at >= c.exposed_at15 AND p.purchased_at < c.exposed_at + INTERVAL 7 DAY16GROUP BY c.variant17ORDER BY c.variant;18
- You30:58
Both pass. Control a quarter, treatment two thirds, and the case where nobody converts gives zeros instead of dropping the rows, because of the left join.
- Alex31:12
Is the DISTINCT on the purchases side doing anything in this data?
- You31:17
Yes, user 1 buys twice, on the third and the fourth, both inside the window. Without distinct, the join gives two rows for user 1 and control would have two converters out of four, which is the same wrong number I had before, but for a different reason. Funny. So yes, it matters.
- Alex31:42
Why count users and not exposures as the unit?
- You31:46
Because the randomization is by user. If one user is exposed five times and I count exposures, their behavior counts five times, and the observations aren't independent anymore, so the variance would be wrong. The user is what was assigned, so that's what I count.
- Alex32:06
Okay. The question also asks for the difference with a confidence interval, and whether the assignment looks healthy. How do you want to do those?
- You32:16
I'll do it in Python with the counts from the query. Um, two-proportion, normal approximation, so the standard error is, uh, the square root of p times one minus p over n for each group, added. And, you know, a chi-square on the group sizes against fifty-fifty for the assignment check.
- Alex32:38
Go ahead.
- You33:02
I'll just hard-code the counts from the query, four and one, three and two. Proportions, the difference, the standard error, plus and minus one point nine six. Then the chi-square for the split by hand, it's two cells.
- You34:10
Sent to the workspace, python
1from math import sqrt2 3n_c, x_c = 4, 1 # control: users, converters (from the query)4n_t, x_t = 3, 2 # treatment5p_c, p_t = x_c / n_c, x_t / n_t6diff = p_t - p_c7se = sqrt(p_c * (1 - p_c) / n_c + p_t * (1 - p_t) / n_t)8print(f"control {p_c:.3f} treatment {p_t:.3f} diff {diff:.3f}")9print(f"95% CI {diff - 1.96 * se:.3f} to {diff + 1.96 * se:.3f}")10 11# sample ratio check against the planned 50/50 split, chi-square with 1 df12n = n_c + n_t13chi2 = (n_c - n / 2) ** 2 / (n / 2) + (n_t - n / 2) ** 2 / (n / 2)14print(f"SRM chi-square {chi2:.3f} (cutoff 3.84 at 5%)")15
- You34:20
Okay, so the difference is about point four two, and the interval goes from, uh, minus point two seven to one point one. Which, um, is above one, so it's nonsense. That's the normal approx- the normal approximation breaking with, you know, three and four users. With this sample I'd say: no conclusion, and, I mean, if I had to test something this small I'd use, um, Fisher's exact test. The chi-square for the split is point one four, so nothing wrong with the assignment, but again, seven users.
- Alex34:53
If a product manager asked you for one sentence about this test, based on this data, what would you say?
- You35:01
Um, treatment looks higher, but, uh, with seven users we can't tell it apart from chance, and one user had to be dropped because they saw both versions. We need the full test before deciding anything.
- Alex35:19
Different scenario. The real test ran for two weeks on a lot more users. The treatment group is 52% of users. Is that a problem and how do you decide?
- You35:33
It depends on the total. If it's a hundred users, fifty-two to forty-eight is noise. If it's a hundred thousand... uh, the expected is fifty thousand each, observed fifty-two thousand, so each cell is off by two thousand. Two thousand squared over fifty thousand is eighty, times two cells, a chi-square of a hundred and sixty. That's way past three point eight four. So at that size it's a sample ratio mismatch and I wouldn't trust the result until I found why, like a bug that drops control users or a redirect that loses some.
- Alex36:11
Mm-hm. Conversion is a yes or no per user. Product also wants revenue. How would you handle revenue per user, which is heavy tailed?
- You36:20
Um, the mean gets dominated by a few big spenders, so the variance is huge. I'd probably bootstrap the difference in means, or cap the top like the ninety-ninth percentile. Or test the median with Mann-Whitney, but that's a different question than the mean, and the business usually cares about total revenue. I'm honestly not sure which one a team would pick by default.
- Alex37:01
Suppose the real test is large and conversion is up two points with a tight interval. What else do you look at before recommending a launch?
- You37:11
Um, guardrails. Like refunds, or, uh, revenue per user, in case people convert more but spend less. Whether the lift holds over the weeks or it's a novelty effect that's fading, so plot it by day. And, you know, if we looked at a lot of metrics, some will be significant by chance, so I'd only trust the one we picked as primary before the test. And I'd check how many users we lost by dropping the ones in both variants. If that's a lot, the bug itself could bias things.
- Alex37:50
We have a few minutes. Any questions for me?
- You37:53
Yeah, one. How much of a data scientist's day is SQL like this versus modeling, usually?
- Alex38:03
It depends a lot on the team and the company. Analytics-heavy roles are mostly SQL, metrics and experiments, with a lot of time spent checking data before anyone models anything. Others are closer to machine learning. The title alone doesn't tell you much, so it's worth asking each team directly. The hiring team can describe the specific role. Anything else?
- You38:25
Yes, one more. Would something like the experiment question come up more in a later round, like a case interview?
- Alex38:37
Often, yes. Many processes have a separate product or experimentation round where you design the test and interpret results without code. The exact rounds differ by company; the recruiter can tell you the format.
- You38:49
Okay. That's it, thank you.
- Alex38:54
That's time. Thanks. Your report will be ready in a few minutes.