Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Analysis with Python (pandas, polars, DuckDB)  Module 17 – Analyst interviews and take-home assignments

Data analyst interviews: pandas and SQL patterns

The twelve tasks that recur in data analyst coding rounds, each solved in pandas and in SQL on the same data, plus a take-home write-up and two drills.

  • Intermediate
  • 50 minutes
  • Examples run with Python 3.14.8, pandas 3.0.6, duckdb 1.5.6, polars 1.44.2, numpy 2.5.3, pyarrow 25.0.1 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 50 minutes

What you will learn

  • Recognise the twelve task patterns that recur in analyst coding rounds and take-home assignments
  • Solve each pattern in pandas and in SQL, and explain its edge case aloud
  • State the grain, the keys, the meaning of missing values and the tie rule before writing code
  • Structure a take-home assignment and its README write-up
  • Practise two timed drills against sample tests

Before you start

Data analyst coding rounds and take-home assignments keep returning to the same twelve kinds of task: the top N rows of each group, the latest record per key, duplicates, running totals, growth from one period to the next, gaps in a sequence, sessions in an event log, retention by sign-up cohort, funnel conversion, rows with no match in another table, the median of each group, and pivoting and unpivoting. Each rests on one of four moves, and each has an edge case that interviewers like to ask about: a tie, a missing month, two rows with the same timestamp, a month that has not happened yet.

This lesson solves all twelve on small tables, once in pandas and once in SQL run by DuckDB, and each of those examples checks that the two answers hold the same rows. Then it shows how to write up a take-home assignment, with a sample README whose numbers come from a script, and ends with two timed drills.

Say four things before you write code

Interviewers listen to how you set a problem up as closely as they read your code. Before the first line, say four things in a sentence each:

  1. The grain. What one row of each table stands for, and what one row of your answer will be: “one row per order in, one row per city and month out”. The first lesson of this track explains why the grain decides what a count means.
  2. The keys. Which columns identify a row, and whether they are really unique. df.duplicated(["customer"]).sum() answers that in one line, and a nonzero answer changes your plan.
  3. Missing values. What an empty cell means here: not delivered yet, unknown, or zero. It decides whether the row is counted, skipped or filled.
  4. Ties and order. What decides between rows that tie, and what order the answer comes in.

“Two restaurants tie for second place in Pune, so I’ll break ties by name and every city gets exactly two rows” takes ten seconds to say and answers the follow-up question before it is asked. Then test the smallest input that would break your solution, a tie, a missing month or two events in the same minute, which is what every example below does.

Twelve patterns, four moves

The twelve analyst interview patterns in four families, each with the move that solves it in pandas and in SQL.The task, in the interviewer's words:which move does it need?1. Rank inside a group2. Compare with the row before3. Match across tables or time4. Summarise or reshapetop N per grouplatest record per key · duplicatespandas: sort, then keep by positionSQL: ROW_NUMBER() OVER (…)running totals · growthgaps and islands · sessionspandas: shift, diff, cumsum per groupSQL: LAG(), SUM() OVER (… ROWS …)retention cohorts · funnelsrows with no matchpandas: merge, isinSQL: JOIN, LEFT JOIN, NOT EXISTSmedian by grouppivot and unpivotpandas: agg, pivot_table, meltSQL: GROUP BY, conditional SUM,UNPIVOT

Twelve task patterns rest on four moves

Text description of the diagram

The diagram starts from a box that reads "The task, in the interviewer's words: which move does it need?". An arrow leads down to four families, one under the other.

  1. Rank inside a group: top N per group, the latest record per key, and duplicates. In pandas: sort, then keep rows by their position. In SQL: ROW_NUMBER() OVER (…).
  2. Compare with the row before: running totals, growth, gaps and islands, and sessions. In pandas: shift, diff and cumsum per group. In SQL: LAG() and SUM() OVER (… ROWS …).
  3. Match across tables or time: retention cohorts, funnels, and rows with no match. In pandas: merge and isin. In SQL: JOIN, LEFT JOIN and NOT EXISTS.
  4. Summarise or reshape: the median by group, and pivot and unpivot. In pandas: agg, pivot_table and melt. In SQL: GROUP BY, a conditional SUM and UNPIVOT.

Read a task for the move it needs, not for its business story. “The two best restaurants in each city” and “each customer’s current plan” are the same move: order the rows inside a group and keep some of them. “Revenue so far”, “growth on last month” and “a new session after 30 minutes of quiet” all compare a row with the one before it.

The twelve at a glance, each with the words that usually signal it and the core of the answer in each tool:

  • Top N per group (“the three best … in each …”). pandas: sort_values, then groupby().head(n). SQL: ROW_NUMBER() OVER (PARTITION BY …).
  • Latest record per key (“current”, “most recent”). pandas: sort_values, then drop_duplicates(keep="last"). SQL: ROW_NUMBER(), keeping row 1.
  • Duplicates (“more than once”, “repeated”). pandas: duplicated(keep=False). SQL: GROUP BY … HAVING count(*) > 1.
  • Running totals (“so far”, “cumulative”, “to date”). pandas: groupby().cumsum(). SQL: SUM() OVER (… ROWS BETWEEN …).
  • Growth between periods (“compared with last month”). pandas: complete the calendar, then shift(). SQL: a calendar LEFT JOIN, then LAG().
  • Gaps and islands (“missing numbers”, “consecutive”). pandas: diff(), shift(-1) and cumsum(). SQL: LEAD() and n - ROW_NUMBER().
  • Sessions (“within 30 minutes”, “visits”). pandas: diff(), a comparison and cumsum(). SQL: LAG(), CASE and a running SUM().
  • Retention cohorts (“came back”, “still active after”). pandas: months since sign-up, then nunique(). SQL: date_diff() and COUNT(DISTINCT …).
  • Funnels (“went on to”, “conversion from … to …”). pandas: the first time of each step after the step before. SQL: MIN(ts) per step, with a time condition.
  • Rows with no match (“never”, “did not”, “without”). pandas: merge(how="left_anti") or ~isin(). SQL: NOT EXISTS or LEFT JOIN … IS NULL.
  • Median by group (“typical”, “middle”). pandas: groupby().median(). SQL: median() in DuckDB, percentile_cont(0.5) in PostgreSQL.
  • Pivot and unpivot (“one column per month”, “back to rows”). pandas: pivot_table() and melt(). SQL: a conditional SUM() and UNPIVOT.

Every pattern example below builds a small DataFrame, answers the task in pandas, runs the SQL answer on the same DataFrame through DuckDB, which reads a pandas DataFrame by its variable name, and passes both to this helper. It prints the pandas rows and stops with an error unless SQL returned the same rows in the same order, so each recorded output below is also the proof that the two answers agree:

agree.py: the check every pattern example ends with Python · patterns/agree.py
"""Used by every pattern example: print the pandas answer, then check that the SQL answer
holds the same rows."""
import pandas as pd


def same_rows(pandas_answer, sql_answer):
    """Print pandas' rows; raise an error unless SQL returned them in the same order."""
    a = pandas_answer.reset_index(drop=True)
    b = sql_answer.reset_index(drop=True)
    print(a.to_string(index=False))
    pd.testing.assert_frame_equal(a, b, check_dtype=False)
    print(f"DuckDB's SQL returns the same {len(b)} rows.")
Run SQL on CSV & Excel Files Run the SQL half of any pattern on a CSV or Excel file of your own, in the browser.

Rank inside a group: top N, latest record, duplicates

Top N per group

The task: the two highest-earning restaurants in each city. Two Pune restaurants tie for second place, and Indore has only one restaurant.

The two best restaurants per city, ties broken by name Python · patterns/top_n_per_group.py
# Top N per group: the two highest-earning restaurants in each city, ties broken by name.
import duckdb
import pandas as pd
from agree import same_rows

sales = pd.DataFrame({
    "city": ["Pune", "Pune", "Pune", "Pune", "Kochi", "Kochi", "Kochi", "Indore"],
    "restaurant": ["Masala Hub", "Dosa Den", "Chai Point", "Vada Corner",
                   "Fish Fry", "Appam House", "Biryani Bay", "Poha Stop"],
    "revenue": [5200, 4100, 4100, 3000, 3900, 3900, 2800, 2500],
})

# pandas: sort so the wanted rows come first in each city, then keep the first two of each
top2 = (sales.sort_values(["city", "revenue", "restaurant"], ascending=[True, False, True])
        .groupby("city").head(2))

sql = duckdb.sql("""
    SELECT city, restaurant, revenue
    FROM (SELECT *, row_number() OVER (PARTITION BY city
                                       ORDER BY revenue DESC, restaurant) AS rn
          FROM sales)
    WHERE rn <= 2
    ORDER BY city, revenue DESC, restaurant
""").df()
same_rows(top2, sql)

# Keeping ties instead: rank(method="min") numbers like SQL's RANK(), so a tie for second
# place keeps both rows
place = sales.groupby("city")["revenue"].rank(method="min", ascending=False)
kept = sales[place <= 2]
pune = ", ".join(kept.loc[kept["city"] == "Pune", "restaurant"])
print(f"With ties kept: {len(kept)} rows. Pune keeps", pune)

Output

  city  restaurant  revenue
Indore   Poha Stop     2500
 Kochi Appam House     3900
 Kochi    Fish Fry     3900
  Pune  Masala Hub     5200
  Pune  Chai Point     4100
DuckDB's SQL returns the same 5 rows.
With ties kept: 6 rows. Pune keeps Masala Hub, Dosa Den, Chai Point

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 top_n_per_group.py

Sort so that the rows you want come first in each group, then take the first two: groupby("city").head(2) returns the first two rows of every city with their order and index kept. SQL numbers the rows inside each city with ROW_NUMBER() and keeps numbers 1 and 2. Both put a tie-breaker in the sort, the restaurant’s name after the revenue. Without it, which of Chai Point and Dosa Den comes second depends on the order the rows happen to be in, and pandas and SQL, or two runs of the same query, can disagree. Indore gets one row: a group smaller than N is not an error.

“Exactly two per city” is one reading of the task. “Every restaurant with one of the two highest revenues” keeps ties: rank(method="min") numbers like SQL’s RANK(), gives Chai Point and Dosa Den both place 2, and Pune keeps three rows. Ask which reading is wanted. Filtering, sorting and ranking compares all five rank methods on one tied column.

The latest record per key

The task: each customer’s current plan, from a log of plan changes. Customer C2 changed plan twice in the same minute.

Each customer's current plan Python · patterns/latest_per_key.py
# Latest record per key: each customer's current plan, from a log of plan changes.
import duckdb
import pandas as pd
from agree import same_rows

changes = pd.DataFrame({
    "change_id": [1, 2, 3, 4, 5],
    "customer": ["C1", "C2", "C2", "C1", "C3"],
    "changed_at": pd.to_datetime(["2026-03-01 09:00", "2026-03-01 11:00",
                                  "2026-03-01 11:00", "2026-03-02 10:30",
                                  "2026-03-02 08:15"]),
    "plan": ["basic", "basic", "silver", "gold", "gold"],
})

# pandas: order each customer's rows oldest to newest (change_id breaks the tie at 11:00),
# then keep the last
latest = (changes.sort_values(["customer", "changed_at", "change_id"])
          .drop_duplicates("customer", keep="last")
          [["customer", "changed_at", "plan"]])

sql = duckdb.sql("""
    SELECT customer, changed_at, plan
    FROM (SELECT *, row_number() OVER (PARTITION BY customer
                                       ORDER BY changed_at DESC, change_id DESC) AS rn
          FROM changes)
    WHERE rn = 1
    ORDER BY customer
""").df()
same_rows(latest, sql)

# The max-and-join-back version returns both of C2's rows: they share the latest time
joined = duckdb.sql("""
    SELECT c.customer, c.plan
    FROM changes AS c
    JOIN (SELECT customer, max(changed_at) AS last_at FROM changes GROUP BY customer) AS m
      ON c.customer = m.customer AND c.changed_at = m.last_at
""").df()
customers = joined["customer"].nunique()
print("max() and join back:", len(joined), "rows for", customers, "customers")

Output

customer          changed_at   plan
      C1 2026-03-02 10:30:00   gold
      C2 2026-03-01 11:00:00 silver
      C3 2026-03-02 08:15:00   gold
DuckDB's SQL returns the same 3 rows.
max() and join back: 4 rows for 3 customers

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 latest_per_key.py

Sort each customer’s rows from oldest to newest and keep the last one with drop_duplicates("customer", keep="last"). In SQL, number each customer’s rows from newest to oldest and keep row 1. The change ID decides between C2’s two changes at 11:00; sorting by time alone would leave that choice to row order.

A common shortcut, a GROUP BY with max(changed_at) joined back to the table, returns every row at the latest time, so C2 comes back twice and the answer has 4 rows for 3 customers. Say what breaks a tie, or say that the data cannot have one and check that it does not.

Duplicates: find them before you drop them

The task: a payments export repeats some payment IDs. Report them before cleaning anything.

Repeated payment IDs: copies and conflicts Python · patterns/duplicates.py
# Duplicates: find them, and tell harmless copies from conflicts, before dropping anything.
import duckdb
import pandas as pd
from agree import same_rows

payments = pd.DataFrame({
    "payment_id": ["P1", "P1", "P2", "P3", "P3", "P4"],
    "amount": [500, 500, 1200, 300, 350, 800],
    "method": ["UPI", "UPI", "card", "UPI", "UPI", "cash"],
})
print("Rows that copy an earlier row exactly:", payments.duplicated().sum())

# pandas: every payment ID that occurs more than once, with its rows and distinct amounts
repeated = payments[payments.duplicated("payment_id", keep=False)]
report = (repeated.groupby("payment_id", as_index=False)
          .agg(rows=("amount", "size"), amounts=("amount", "nunique")))

sql = duckdb.sql("""
    SELECT payment_id, count(*) AS rows, count(DISTINCT amount) AS amounts
    FROM payments
    GROUP BY payment_id
    HAVING count(*) > 1
    ORDER BY payment_id
""").df()
same_rows(report, sql)

clean = payments.drop_duplicates()
p3_rows = (clean["payment_id"] == "P3").sum()
print("After dropping exact copies:", len(clean), "rows; P3 still has", p3_rows)

Output

Rows that copy an earlier row exactly: 1
payment_id  rows  amounts
        P1     2        1
        P3     2        2
DuckDB's SQL returns the same 2 rows.
After dropping exact copies: 5 rows; P3 still has 2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 duplicates.py

Two kinds of duplicate need different answers. An exact copy, every column the same, is safe to drop: duplicated() counts one here, and drop_duplicates() removes it. A repeated key with different values is a conflict. P3 has two amounts, 300 and 350, and dropping either one without a rule changes revenue. The report counts both kinds: rows and distinct amounts per repeated ID, which is GROUP BY payment_id HAVING count(*) > 1 in SQL. Then choose the rule for conflicts, such as the latest row or the amount the payment provider confirms, and write down how many rows it touched.

Compare with the row before: totals, growth, gaps, sessions

Running totals

The task: each city’s revenue so far, order by order. Two Pune orders share the first day.

Revenue so far per city, and what the default frame does Python · patterns/running_totals.py
# Running totals: revenue so far in each city, order by order.
import duckdb
import pandas as pd
from agree import same_rows

orders = pd.DataFrame({
    "order_id": [101, 102, 103, 104, 105, 106],
    "city": ["Pune", "Pune", "Kochi", "Pune", "Kochi", "Pune"],
    "day": pd.to_datetime(["2026-03-01", "2026-03-01", "2026-03-01",
                           "2026-03-02", "2026-03-03", "2026-03-03"]),
    "amount": [400, 250, 300, 500, 200, 150],
})

# pandas: cumsum adds row by row, so sort first, with order_id ordering the rows of a day
o = orders.sort_values(["city", "day", "order_id"]).copy()
o["so_far"] = o.groupby("city")["amount"].cumsum()
o = o[["city", "day", "order_id", "amount", "so_far"]]

sql = duckdb.sql("""
    SELECT city, day, order_id, amount,
           sum(amount) OVER (PARTITION BY city ORDER BY day, order_id
                             ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS so_far
    FROM orders
    ORDER BY city, day, order_id
""").df()
same_rows(o, sql)

# Without a frame clause, the frame ends with the current row's peers, so orders on the
# same day share a total
default = duckdb.sql("""
    SELECT order_id, sum(amount) OVER (PARTITION BY city ORDER BY day) AS so_far_default
    FROM orders WHERE city = 'Pune' ORDER BY day, order_id
""").df()
print("Pune with the default frame:", default["so_far_default"].astype("int64").tolist())

Output

 city        day  order_id  amount  so_far
Kochi 2026-03-01       103     300     300
Kochi 2026-03-03       105     200     500
 Pune 2026-03-01       101     400     400
 Pune 2026-03-01       102     250     650
 Pune 2026-03-02       104     500    1150
 Pune 2026-03-03       106     150    1300
DuckDB's SQL returns the same 6 rows.
Pune with the default frame: [650, 650, 1150, 1300]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 running_totals.py

cumsum adds row by row in the order the rows are in, so sort first, with something that orders the rows inside a day, here the order ID. In SQL the frame clause decides the answer. ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW adds row by row, like pandas. Leave the frame out and DuckDB, like PostgreSQL, uses RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, in which the current row brings its peers, every row with the same ORDER BY value (DuckDB window functions). Pune’s two first-day orders then both show 650 instead of 400 and 650. The total at the end of each day is right either way; the running total of each order is not.

Growth from one period to the next

The task: month-on-month revenue growth for each city. Indore had no orders in March.

Month-on-month growth with a month missing Python · patterns/period_growth.py
# Growth between periods: month-on-month revenue growth per city, with a month missing.
import duckdb
import pandas as pd
from agree import same_rows

monthly = pd.DataFrame({
    "city": ["Pune", "Pune", "Pune", "Pune", "Indore", "Indore", "Indore"],
    "month": ["2026-01", "2026-02", "2026-03", "2026-04", "2026-01", "2026-02", "2026-04"],
    # thousand rupees; Indore had no orders in March
    "revenue": [100, 120, 150, 120, 80, 100, 140],
})

# The trap: pct_change compares each row with the row above it, so Indore's April is
# compared with February
naive = monthly.groupby("city")["revenue"].pct_change()
print("Row-to-row growth for Indore in April:", f"{naive.iloc[-1]:+.0%}")

# pandas: one row for every city and month first (revenue 0 in a month without orders),
# then compare
months = [str(p) for p in pd.period_range("2026-01", "2026-04", freq="M")]
grid = pd.MultiIndex.from_product([["Indore", "Pune"], months], names=["city", "month"])
full = monthly.set_index(["city", "month"]).reindex(grid, fill_value=0).reset_index()
full["prev"] = full.groupby("city")["revenue"].shift(1)
growth = full["revenue"] / full["prev"].where(full["prev"] > 0) - 1
full["growth_pct"] = (100 * growth).round(1)

sql = duckdb.sql("""
    WITH months AS (SELECT strftime(m, '%Y-%m') AS month
                    FROM range(DATE '2026-01-01', DATE '2026-05-01', INTERVAL 1 MONTH)
                         AS t(m)),
         grid AS (SELECT c.city, m.month
                  FROM (SELECT DISTINCT city FROM monthly) AS c CROSS JOIN months AS m),
         filled AS (SELECT g.city, g.month, coalesce(r.revenue, 0) AS revenue
                    FROM grid AS g LEFT JOIN monthly AS r USING (city, month)),
         with_prev AS (SELECT *,
                              lag(revenue) OVER (PARTITION BY city ORDER BY month) AS prev
                       FROM filled)
    SELECT city, month, revenue, prev,
           round(100 * (revenue / nullif(prev, 0) - 1), 1) AS growth_pct
    FROM with_prev
    ORDER BY city, month
""").df()
same_rows(full, sql)

# Without the guard, growth from zero comes out as infinity in both tools
unguarded_pandas = (full["revenue"] / full["prev"] - 1).iloc[3]
unguarded_sql = duckdb.sql("SELECT 140 / 0 - 1").fetchone()[0]
print("Indore's April without the guard:", unguarded_pandas, "in pandas,",
      unguarded_sql, "in DuckDB")

Output

Row-to-row growth for Indore in April: +40%
  city   month  revenue  prev  growth_pct
Indore 2026-01       80   NaN         NaN
Indore 2026-02      100  80.0        25.0
Indore 2026-03        0 100.0      -100.0
Indore 2026-04      140   0.0         NaN
  Pune 2026-01      100   NaN         NaN
  Pune 2026-02      120 100.0        20.0
  Pune 2026-03      150 120.0        25.0
  Pune 2026-04      120 150.0       -20.0
DuckDB's SQL returns the same 8 rows.
Indore's April without the guard: inf in pandas, inf in DuckDB

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 period_growth.py

pct_change computes the change from the previous row, not from the previous month (pandas pct_change). Indore has no March row, so its April row sits right after February, and the +40 % it prints compares April with February. Build the complete calendar first: one row for every city and month, with revenue 0 where there were no orders. pandas does it with reindex on a MultiIndex.from_product, SQL with a range of months cross-joined with the cities and left-joined to the data. Then March shows −100 %, and April has no growth at all, because growth from zero is undefined: divide by prev.where(prev > 0) in pandas and by nullif(prev, 0) in SQL. Without that guard both tools return infinity, as the last line shows.

Two more things to say aloud. A month that has not finished always looks like a fall, so leave it out or label it as partial. And division differs between SQL dialects: DuckDB’s / turns two integers into a decimal result, while PostgreSQL truncates integer division, so 120 / 100 is 1 there (DuckDB, PostgreSQL). Cast one side to a decimal type when the query may run on another database.

Gaps and islands

The task: an invoice series should have no holes. Which numbers are missing, and which unbroken runs were issued? One invoice number was sent twice.

Missing invoice numbers and unbroken runs Python · patterns/gaps_islands.py
# Gaps and islands: which invoice numbers are missing, and which unbroken runs were issued.
import duckdb
import pandas as pd
from agree import same_rows

# Invoice 1010 was sent twice
invoices = pd.DataFrame({"n": [1001, 1002, 1003, 1006, 1007, 1009, 1010, 1010, 1011, 1012]})

# pandas, gaps: sort the distinct numbers; wherever the next number is more than 1 away,
# the numbers in between are missing
s = invoices["n"].drop_duplicates().sort_values().reset_index(drop=True)
nxt = s.shift(-1)
gap = nxt - s > 1
gaps = pd.DataFrame({"missing_from": s[gap] + 1,
                     "missing_to": (nxt[gap] - 1).astype("int64")})

sql_gaps = duckdb.sql("""
    SELECT n + 1 AS missing_from, next_n - 1 AS missing_to
    FROM (SELECT n, lead(n) OVER (ORDER BY n) AS next_n
          FROM (SELECT DISTINCT n FROM invoices))
    WHERE next_n - n > 1
    ORDER BY n
""").df()
same_rows(gaps, sql_gaps)

# pandas, islands: a new run starts wherever the step from the previous number is not 1
run = (s.diff() != 1).cumsum()
islands = s.groupby(run).agg(first="min", last="max", issued="size").reset_index(drop=True)

# SQL, islands: inside a run, n minus its row number stays the same
sql_islands = duckdb.sql("""
    SELECT min(n) AS first, max(n) AS last, count(*) AS issued
    FROM (SELECT n, n - row_number() OVER (ORDER BY n) AS run
          FROM (SELECT DISTINCT n FROM invoices))
    GROUP BY run
    ORDER BY first
""").df()
same_rows(islands, sql_islands)

# Without DISTINCT, the second 1010 shifts the row numbers after it: the runs overlap
broken = duckdb.sql("""
    SELECT min(n) AS first, max(n) AS last, count(*) AS issued
    FROM (SELECT n, n - row_number() OVER (ORDER BY n) AS run FROM invoices)
    GROUP BY run
    ORDER BY first
""").df()
runs = "; ".join(f"{r.first}-{r.last} ({r.issued})" for r in broken.itertuples())
print("Without DISTINCT:", runs)

Output

 missing_from  missing_to
         1004        1005
         1008        1008
DuckDB's SQL returns the same 2 rows.
 first  last  issued
  1001  1003       3
  1006  1007       2
  1009  1012       4
DuckDB's SQL returns the same 3 rows.
Without DISTINCT: 1001-1003 (3); 1006-1012 (5); 1009-1010 (2)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gaps_islands.py

These are two questions about one sequence. The gaps are the missing numbers: wherever the next number is more than 1 away, the numbers in between are missing, which shift(-1) finds in pandas and LEAD() in SQL. The islands are the unbroken runs. In pandas a new run starts wherever the step from the previous number is not 1, and a cumulative sum of those starts labels each run. In SQL, n - ROW_NUMBER() stays the same along a run, so you can group by it. Both methods need the distinct numbers first. The repeated 1010 shifts every row number after it, and the last line shows what comes out without DISTINCT: runs that overlap, which no real sequence has. The same idea finds streaks, such as a customer’s run of consecutive active days.

Sessions

The task: split each user’s events into sessions, where more than 30 minutes without an event starts a new session.

Sessions from an event log, with a 30-minute rule Python · patterns/sessions.py
# Sessions: split each user's events; over 30 minutes without an event starts a new one.
import duckdb
import pandas as pd
from agree import same_rows

events = pd.DataFrame({
    "user_id": ["u1", "u2", "u1", "u1", "u1", "u2", "u1"],
    "ts": pd.to_datetime(["2026-03-01 09:00", "2026-03-01 09:05", "2026-03-01 09:10",
                          "2026-03-01 09:40", "2026-03-01 10:11", "2026-03-01 11:00",
                          "2026-03-01 10:20"]),
})

# pandas: sort, take the gap to the user's previous event, flag the starts, count the flags
e = events.sort_values(["user_id", "ts"]).copy()
gap = e.groupby("user_id")["ts"].diff()
starts = gap.isna() | (gap > pd.Timedelta(minutes=30))
e["session"] = starts.astype("int64").groupby(e["user_id"]).cumsum()
per_session = (e.groupby(["user_id", "session"], as_index=False)
               .agg(start=("ts", "min"), end=("ts", "max"), events=("ts", "size")))

sql = duckdb.sql("""
    WITH flagged AS (
        SELECT user_id, ts,
               CASE WHEN lag(ts) OVER w IS NULL OR ts - lag(ts) OVER w > INTERVAL 30 MINUTE
                    THEN 1 ELSE 0 END AS starts
        FROM events
        WINDOW w AS (PARTITION BY user_id ORDER BY ts)),
    numbered AS (
        SELECT *, sum(starts) OVER (PARTITION BY user_id ORDER BY ts
                                    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
                  AS session
        FROM flagged)
    SELECT user_id, session, min(ts) AS start, max(ts) AS "end", count(*) AS events
    FROM numbered
    GROUP BY user_id, session
    ORDER BY user_id, session
""").df()
same_rows(per_session, sql)

Output

user_id  session               start                 end  events
     u1        1 2026-03-01 09:00:00 2026-03-01 09:40:00       3
     u1        2 2026-03-01 10:11:00 2026-03-01 10:20:00       2
     u2        1 2026-03-01 09:05:00 2026-03-01 09:05:00       1
     u2        2 2026-03-01 11:00:00 2026-03-01 11:00:00       1
DuckDB's SQL returns the same 4 rows.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sessions.py

Sort each user’s events by time, measure the gap to the same user’s previous event, flag the events that start a session (the user’s first event, or one after a gap of more than 30 minutes), and number the sessions with a running count of the flags. In pandas that is diff, a comparison and cumsum; in SQL, LAG, a CASE and a running SUM with a ROWS frame. u1’s event at 09:40 comes exactly 30 minutes after the one before and stays in the first session, while the next, 31 minutes later, starts the second. Say which side of the boundary exactly 30 minutes falls on before you write > or >=.

Match across tables or time: cohorts, funnels, no match

Retention by cohort

The task: of the people who signed up in each month, what share ordered one, two and three months later? The data ends in April.

A monthly retention triangle Python · patterns/retention_cohorts.py
# Retention by cohort: of each month's sign-ups, what share ordered 1, 2 and 3 months later?
import duckdb
import pandas as pd
from agree import same_rows

users = pd.DataFrame({
    "user_id": ["a1", "a2", "a3", "a4", "b1", "b2", "b3", "c1", "c2", "d1", "d2"],
    "signup": pd.to_datetime(["2026-01-03", "2026-01-10", "2026-01-20", "2026-01-28",
                              "2026-02-02", "2026-02-14", "2026-02-25", "2026-03-05",
                              "2026-03-18", "2026-04-02", "2026-04-09"]),
})
orders = pd.DataFrame({
    "user_id": ["a1", "a1", "a1", "a2", "a3", "b1", "c1", "d1"],
    "day": pd.to_datetime(["2026-01-15", "2026-02-05", "2026-03-10", "2026-02-20",
                           "2026-04-02", "2026-04-11", "2026-04-01", "2026-04-20"]),
})
data_ends = pd.Period("2026-04", freq="M")  # the last month in the data

# pandas: each order's month number since sign-up, then distinct users per cohort and month
u = users.assign(cohort=users["signup"].dt.to_period("M"))
o = orders.merge(u, on="user_id")
o["k"] = ((o["day"].dt.year - o["signup"].dt.year) * 12
          + (o["day"].dt.month - o["signup"].dt.month))
active = (o[o["k"].between(1, 3)].groupby(["cohort", "k"])["user_id"].nunique()
          .rename("active").reset_index())

# Only the cells the data can see: month k of a cohort exists once cohort + k has happened
size = u.groupby("cohort").size()
seen = [(c, k) for c in size.index for k in (1, 2, 3) if c + k <= data_ends]
cells = pd.DataFrame(seen, columns=["cohort", "k"])
cells = cells.merge(active, on=["cohort", "k"], how="left")
cells["active"] = cells["active"].fillna(0).astype("int64")
cells["users"] = cells["cohort"].map(size)
cells["retained_pct"] = (100 * cells["active"] / cells["users"]).round(1)
cells["cohort"] = cells["cohort"].astype(str)

sql = duckdb.sql("""
    WITH cohorts AS (SELECT user_id, signup, strftime(signup, '%Y-%m') AS cohort
                     FROM users),
         sizes AS (SELECT cohort, count(*) AS users FROM cohorts GROUP BY cohort),
         active AS (SELECT c.cohort, date_diff('month', c.signup, o.day) AS k,
                           count(DISTINCT o.user_id) AS active
                    FROM orders AS o JOIN cohorts AS c USING (user_id)
                    GROUP BY c.cohort, k),
         cells AS (SELECT s.cohort, s.users, t.k
                   FROM sizes AS s CROSS JOIN range(1, 4) AS t(k)
                   WHERE date_diff('month', strptime(s.cohort, '%Y-%m'), DATE '2026-04-01')
                         >= t.k)
    SELECT cells.cohort, cells.k, coalesce(a.active, 0) AS active, cells.users,
           round(100 * coalesce(a.active, 0) / cells.users, 1) AS retained_pct
    FROM cells LEFT JOIN active AS a ON a.cohort = cells.cohort AND a.k = cells.k
    ORDER BY cells.cohort, cells.k
""").df()
same_rows(cells[["cohort", "k", "active", "users", "retained_pct"]], sql)

triangle = (cells.pivot(index="cohort", columns="k", values="retained_pct")
            .reindex(size.index.astype(str)))
print(triangle.to_string())

Output

 cohort  k  active  users  retained_pct
2026-01  1       2      4          50.0
2026-01  2       1      4          25.0
2026-01  3       1      4          25.0
2026-02  1       0      3           0.0
2026-02  2       1      3          33.3
2026-03  1       1      2          50.0
DuckDB's SQL returns the same 6 rows.
k           1     2     3
cohort                   
2026-01  50.0  25.0  25.0
2026-02   0.0  33.3   NaN
2026-03  50.0   NaN   NaN
2026-04   NaN   NaN   NaN

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retention_cohorts.py

A cohort is everyone who signed up in the same month, and its month-k retention is the share of the cohort that ordered k calendar months after the sign-up month. Three details decide whether the table is right:

  • The month number is the difference in calendar months: year × 12 + month of the order, minus the same for the sign-up. DuckDB’s date_diff('month', …) counts the same thing, the month boundaries between two dates (DuckDB date functions).
  • The denominator is everyone in the cohort, including the people who never ordered again: a4, b2 and b3 count.
  • Zero or not yet. The February cohort’s month 1 is 0.0 %: March is in the data and nobody from February ordered then. Its month 3 is empty, because that month comes after the data ends. The code first lists the cells the data can see, then fills those without orders with 0. Filling every empty cell with 0 would say that the April cohort is already lost.

The result is the familiar triangle: each newer cohort has one month fewer to show. Expect the follow-up question: why is the bottom-right corner empty?

Cohort Retention Analyzer Build the same retention triangle from your own export of orders or logins.

Funnel conversion

The task: of the users who visited, how many then added something to the cart, and how many of those then paid?

An ordered funnel, and why separate counts mislead Python · patterns/funnel.py
# Funnel conversion: of the users who visited, how many then added to cart, and then paid?
import duckdb
import pandas as pd
from agree import same_rows

events = pd.DataFrame({
    "user_id": ["u1", "u1", "u1", "u2", "u2", "u3", "u4",
                "u4", "u4", "u5", "u5", "u6", "u6"],
    "event": ["visit", "cart", "pay", "visit", "cart", "visit", "cart",
              "visit", "pay", "visit", "pay", "visit", "pay"],
    "ts": pd.to_datetime(["10:00", "10:05", "10:09", "10:01", "10:07", "10:02", "09:58",
                          "10:03", "10:06", "10:04", "10:12", "10:05", "10:15"],
                         format="%H:%M"),
})
separately = events.groupby("event")["user_id"].nunique().to_dict()
print("Users with each event, counted separately:", separately)

# pandas: each step counts only if it happened after the user reached the step before
def first_after(step, earlier):
    """The first time each user did `step` at or after their time in `earlier`."""
    e = events[events["event"] == step]
    e = e.merge(earlier.rename("after"), left_on="user_id", right_index=True)
    return e[e["ts"] >= e["after"]].groupby("user_id")["ts"].min()

visit = events[events["event"] == "visit"].groupby("user_id")["ts"].min()
cart = first_after("cart", visit)
pay = first_after("pay", cart)
steps = pd.DataFrame({"step": ["visit", "cart", "pay"],
                      "users": [len(visit), len(cart), len(pay)]})
steps["pct_of_previous"] = (100 * steps["users"] / steps["users"].shift(1)).round(1)

sql = duckdb.sql("""
    WITH v AS (SELECT user_id, min(ts) AS t FROM events WHERE event = 'visit'
               GROUP BY user_id),
         c AS (SELECT e.user_id, min(e.ts) AS t FROM events AS e JOIN v USING (user_id)
               WHERE e.event = 'cart' AND e.ts >= v.t GROUP BY e.user_id),
         p AS (SELECT e.user_id, min(e.ts) AS t FROM events AS e JOIN c USING (user_id)
               WHERE e.event = 'pay' AND e.ts >= c.t GROUP BY e.user_id),
         counts AS (SELECT 1 AS n, 'visit' AS step, count(*) AS users FROM v
                    UNION ALL SELECT 2, 'cart', count(*) FROM c
                    UNION ALL SELECT 3, 'pay', count(*) FROM p)
    SELECT step, users,
           round(100 * users / lag(users) OVER (ORDER BY n), 1) AS pct_of_previous
    FROM counts ORDER BY n
""").df()
same_rows(steps, sql)

Output

Users with each event, counted separately: {'cart': 3, 'pay': 4, 'visit': 6}
 step  users  pct_of_previous
visit      6              NaN
 cart      2             33.3
  pay      1             50.0
DuckDB's SQL returns the same 3 rows.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 funnel.py

Counting the users of each event separately gives 6 visits, 3 carts and 4 payments: more payments than carts, which no funnel can have. u4 added to the cart before visiting, and u5 and u6 paid without a cart event in the data. An ordered funnel counts a step only when it happens after the step before: each user’s first visit, then their first cart at or after that visit, then their first payment at or after that cart. That gives 6, 2 and 1. A third of the visitors reached the cart, half of those paid, and one visitor in six did both.

Agree on three choices before you count: whether the steps must happen in order, whether they must fall in one session or within a time limit, and whether the funnel counts users or sessions. Each choice changes the numbers, and the interviewer usually has one of them in mind.

Rows with no match

The task: customers who ordered in March but not in April.

Customers who ordered in March and not in April Python · patterns/anti_join.py
# Rows with no match: customers who ordered in March but not in April.
import duckdb
import pandas as pd
from agree import same_rows

orders = pd.DataFrame({
    "order_id": [1, 2, 3, 4, 5, 6, 7, 8],
    "customer_id": ["C1", "C2", "C2", "C3", "C1", "C4", "C5", "C2"],
    "month": ["2026-03", "2026-03", "2026-03", "2026-03",
              "2026-04", "2026-04", "2026-03", "2026-04"],
})

# pandas: March's customers, once each, minus every customer with an April order
# (how="left_anti" is new in pandas 3.0)
march = orders.loc[orders["month"] == "2026-03", ["customer_id"]].drop_duplicates()
april = orders.loc[orders["month"] == "2026-04", ["customer_id"]]
lapsed = march.merge(april, on="customer_id", how="left_anti").sort_values("customer_id")

sql = duckdb.sql("""
    SELECT DISTINCT m.customer_id
    FROM orders AS m
    WHERE m.month = '2026-03'
      AND NOT EXISTS (SELECT 1 FROM orders AS a
                      WHERE a.customer_id = m.customer_id AND a.month = '2026-04')
    ORDER BY m.customer_id
""").df()
same_rows(lapsed, sql)

# The same filter with isin, and what an inner join would have answered instead
no_april = ~march["customer_id"].isin(april["customer_id"])
print("isin agrees:", sorted(march.loc[no_april, "customer_id"]))
stayed = march.merge(april.drop_duplicates())["customer_id"]
print("An inner join gives the customers who stayed:", sorted(stayed))

Output

customer_id
         C3
         C5
DuckDB's SQL returns the same 2 rows.
isin agrees: ['C3', 'C5']
An inner join gives the customers who stayed: ['C1', 'C2']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 anti_join.py

This is an anti-join: the rows of one set that have no partner in another. pandas 3.0 added how="left_anti" to merge (pandas merge), and ~isin() gives the same list when the key is one column. In SQL, NOT EXISTS with a correlated subquery says it directly, and LEFT JOIN … WHERE the right-hand key IS NULL is the other common form. Take March’s customers once each first: C2 ordered twice in March, and an answer is a list of customers, not of orders. An inner join answers the opposite question, the customers who came back.

Summarise or reshape: medians and pivots

Median by group

The task: the typical delivery time in each city. One Kochi order has no delivery time yet, and one took 90 minutes.

Median delivery time per city Python · patterns/median_by_group.py
# Median by group: typical delivery time per city, with one time missing and one extreme.
import duckdb
import numpy as np
import pandas as pd
from agree import same_rows

deliveries = pd.DataFrame({
    "city": ["Pune", "Pune", "Pune", "Pune", "Kochi", "Kochi", "Kochi", "Kochi", "Indore"],
    # one Kochi order has no delivery time yet
    "minutes": [22, 35, 41, 58, 30, 33, np.nan, 90, 25],
})

# pandas: size counts rows, count counts times; median and mean skip the missing time
summary = (deliveries.groupby("city", as_index=False)
           .agg(orders=("minutes", "size"), timed=("minutes", "count"),
                median=("minutes", "median"), mean=("minutes", "mean"))
           .round(1))

sql = duckdb.sql("""
    SELECT city, count(*) AS orders, count(minutes) AS timed,
           round(median(minutes), 1) AS median, round(avg(minutes), 1) AS mean
    FROM deliveries
    GROUP BY city
    ORDER BY city
""").df()
same_rows(summary, sql)

Output

  city  orders  timed  median  mean
Indore       1      1    25.0  25.0
 Kochi       4      3    33.0  51.0
  Pune       4      4    38.0  39.0
DuckDB's SQL returns the same 3 rows.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 median_by_group.py

The median is the middle of the sorted values, and the mean of the middle two when the count is even: Pune’s 35 and 41 give 38.0. pandas and DuckDB both leave the missing time out, so Kochi’s median is the middle of three values, 33.0. Say which count you report: size counts all four Kochi orders, count only the three with a time. The mean shows why the task asks for a median: one 90-minute delivery pulls Kochi’s mean to 51.0 while the median stays at 33.0. DuckDB has a median function. PostgreSQL has none, and there you write percentile_cont(0.5) WITHIN GROUP (ORDER BY minutes) (PostgreSQL aggregate functions).

Pivot and unpivot

The task: one column per month for a report, then back to one row per city and month.

Rows to columns and back Python · patterns/pivot_unpivot.py
# Pivot and unpivot: a column per month for a report, then back to a row per city and month.
import duckdb
import pandas as pd
from agree import same_rows

long = pd.DataFrame({
    "city": ["Pune", "Pune", "Pune", "Kochi", "Kochi", "Pune"],
    "month": ["2026-01", "2026-02", "2026-03", "2026-01", "2026-03", "2026-03"],
    # Pune has two March rows; Kochi has no February row
    "revenue": [100, 120, 90, 70, 80, 60],
})

# pandas: pivot_table sums repeated city-month pairs (pivot would raise an error on them)
wide = (long.pivot_table(index="city", columns="month", values="revenue", aggfunc="sum")
        .reset_index())
wide.columns.name = None

# SQL: one conditional sum per month, which every SQL database understands
sql = duckdb.sql("""
    SELECT city,
           sum(CASE WHEN month = '2026-01' THEN revenue END) AS "2026-01",
           sum(CASE WHEN month = '2026-02' THEN revenue END) AS "2026-02",
           sum(CASE WHEN month = '2026-03' THEN revenue END) AS "2026-03"
    FROM long GROUP BY city ORDER BY city
""").df()
same_rows(wide, sql)

# Back to long rows: melt keeps Kochi's empty February, and DuckDB's UNPIVOT leaves it out
# unless told otherwise
melted = wide.melt(id_vars="city", var_name="month", value_name="revenue")
months = '"2026-01", "2026-02", "2026-03"'
dropped = duckdb.sql(f"SELECT * FROM wide UNPIVOT (revenue FOR month IN ({months}))").df()
kept = duckdb.sql(
    f"SELECT * FROM wide UNPIVOT INCLUDE NULLS (revenue FOR month IN ({months}))").df()
print(f"melt: {len(melted)} rows; UNPIVOT: {len(dropped)} rows;",
      f"UNPIVOT INCLUDE NULLS: {len(kept)} rows")

Output

 city  2026-01  2026-02  2026-03
Kochi     70.0      NaN     80.0
 Pune    100.0    120.0    150.0
DuckDB's SQL returns the same 2 rows.
melt: 6 rows; UNPIVOT: 5 rows; UNPIVOT INCLUDE NULLS: 6 rows

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pivot_unpivot.py

pivot_table turns the rows into one column per month and sums Pune’s two March rows. pivot would stop with “Index contains duplicate entries, cannot reshape”, because it takes exactly one value per cell. Kochi has no February row, so its cell is empty (NaN), which also turns the month columns into floats. Use fill_value=0 only when a missing cell really means no sales. In SQL, one sum(CASE WHEN month = … THEN revenue END) per column works in every database, and DuckDB also has a PIVOT statement.

Going back to rows, melt keeps the empty cell as a row with NaN, 6 rows. DuckDB’s UNPIVOT left it out in this run, 5 rows, and kept it once INCLUDE NULLS was added (DuckDB UNPIVOT). Check the row count after every reshape.

Pivot Table Generator Make a pivot of your own file in the browser and compare it with pivot_table.

The same moves in polars

When you may choose the tool, polars is a third option, and it writes the same moves as expressions:

Top N, sessions and an anti-join in polars Python · polars/polars_moves.py
# Three patterns in polars: group_by().head(), window expressions with over(), an anti-join.
import polars as pl

sales = pl.DataFrame({
    "city": ["Pune", "Pune", "Pune", "Pune", "Kochi", "Kochi", "Kochi", "Indore"],
    "restaurant": ["Masala Hub", "Dosa Den", "Chai Point", "Vada Corner",
                   "Fish Fry", "Appam House", "Biryani Bay", "Poha Stop"],
    "revenue": [5200, 4100, 4100, 3000, 3900, 3900, 2800, 2500],
})
# Top N per group: sort with the tie-breaker, then keep the first two rows of each city
top2 = (sales.sort(["city", "revenue", "restaurant"], descending=[False, True, False])
        .group_by("city", maintain_order=True).head(2))
print(top2)

events = pl.DataFrame({
    "user_id": ["u1", "u2", "u1", "u1", "u1", "u2", "u1"],
    "ts": ["09:00", "09:05", "09:10", "09:40", "10:11", "11:00", "10:20"],
}).with_columns(pl.col("ts").str.to_time("%H:%M"))
# Sessions: diff() and cum_sum() inside over("user_id") work per user, as groupby does
starts = pl.col("gap").is_null() | (pl.col("gap") > pl.duration(minutes=30))
sessions = (events.sort(["user_id", "ts"])
            .with_columns(gap=pl.col("ts").diff().over("user_id"))
            .with_columns(session=starts.cast(pl.Int64).cum_sum().over("user_id"))
            .group_by("user_id", "session", maintain_order=True)
            .agg(start=pl.col("ts").min(), events=pl.len()))
print(sessions)

orders = pl.DataFrame({
    "customer_id": ["C1", "C2", "C2", "C3", "C1", "C4", "C5", "C2"],
    "month": ["2026-03", "2026-03", "2026-03", "2026-03",
              "2026-04", "2026-04", "2026-03", "2026-04"],
})
# Rows with no match: an anti-join keeps March customers with no April order
march = orders.filter(pl.col("month") == "2026-03").select("customer_id").unique()
april = orders.filter(pl.col("month") == "2026-04")
lapsed = march.join(april, on="customer_id", how="anti").sort("customer_id")
print(lapsed["customer_id"].to_list())

Output

shape: (5, 3)
┌────────┬─────────────┬─────────┐
│ city   ┆ restaurant  ┆ revenue │
│ ---    ┆ ---         ┆ ---     │
│ str    ┆ str         ┆ i64     │
╞════════╪═════════════╪═════════╡
│ Indore ┆ Poha Stop   ┆ 2500    │
│ Kochi  ┆ Appam House ┆ 3900    │
│ Kochi  ┆ Fish Fry    ┆ 3900    │
│ Pune   ┆ Masala Hub  ┆ 5200    │
│ Pune   ┆ Chai Point  ┆ 4100    │
└────────┴─────────────┴─────────┘
shape: (4, 4)
┌─────────┬─────────┬──────────┬────────┐
│ user_id ┆ session ┆ start    ┆ events │
│ ---     ┆ ---     ┆ ---      ┆ ---    │
│ str     ┆ i64     ┆ time     ┆ u32    │
╞═════════╪═════════╪══════════╪════════╡
│ u1      ┆ 1       ┆ 09:00:00 ┆ 3      │
│ u1      ┆ 2       ┆ 10:11:00 ┆ 2      │
│ u2      ┆ 1       ┆ 09:05:00 ┆ 1      │
│ u2      ┆ 2       ┆ 11:00:00 ┆ 1      │
└─────────┴─────────┴──────────┴────────┘
['C3', 'C5']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 polars_moves.py

After a sort, group_by("city", maintain_order=True).head(2) keeps the first two rows of each city. over("user_id") runs diff and cum_sum once per user, as groupby does in pandas (Polars window functions), and join(how="anti") is the anti-join (Polars joins). The answers match the pandas ones above: the same five restaurants, the same four sessions, C3 and C5.

A take-home, start to finish

A take-home gives you a dataset, a few questions and a deadline of a day or more. The write-up is judged as closely as the code: whether the answer is easy to find, whether every number can be traced back to the data, and whether you say what the data cannot show. A README in this order covers all three:

  1. The question, restated in your own words, with the decision it serves.
  2. The answer first: two to four sentences with the numbers that matter.
  3. Data and checks: the rows received, each cleaning rule with the rows it touched, and the rows analysed.
  4. Method: the definitions (what counts as late, active or a session), the grain and the time window.
  5. Results: one table or chart per question, each with its definition.
  6. Limitations and next steps: what the data cannot tell, how uncertain small groups are, what to check next.
  7. How to run: one command, the versions used, and where the raw data goes.

Here is a short README for a synthetic food-delivery dataset, followed by the script whose output it quotes:

README.md Markdown · take_home/README.md
# Late deliveries: where and when

## The question

Operations believe late deliveries are a Kochi problem. They asked where and when orders
arrive after the promised 35 minutes, and how many orders that affects, to decide where
the next riders should work.

## The answer

Late deliveries are mostly an evening problem, in Kochi and in Pune. Between 7 and 11 pm,
55.2 % of Kochi's orders and 35.6 % of Pune's arrived late, against 18.2 % and 11.7 % at
other hours. Indore stays at 15 to 16 % all day. Evening orders are 41 % of all orders but
63 % of the late ones. Overall, 23.4 % of 1,769 delivered orders were late.

## Data and checks

| Step                                            |  Rows |
| ----------------------------------------------- | ----: |
| Rows received                                   | 1,812 |
| Exact duplicate rows removed (an export glitch) |    12 |
| Cancelled, no delivery time: left out           |    27 |
| Delivered before they were ordered: left out    |     4 |
| Orders analysed                                 | 1,769 |

## Method

- Late: delivered more than 35 minutes after the order was placed, the time the app
  promises.
- Evening: ordered between 19:00 and 22:59.
- Each share is late orders divided by delivered orders, per city and time slot.

## Limitations

- The smallest cell, Indore in the evening, has 189 orders, so its share is uncertain by
  about 5 points either way (a 95 % interval); the 0.6-point gap between Indore's two
  slots is well inside that.
- The data has no distances, weather or kitchen times, so it shows when orders are late,
  not why.
- The four rows delivered before they were ordered suggest a clock problem that may also
  shift other times.

## Next steps

Compare riders on shift with orders per hour in Kochi and Pune evenings, and ask whether
35 minutes is the right promise for evening orders.

## How to run

`python analysis.py` with Python 3.14.8 and pandas 3.0.6. It rebuilds the synthetic
orders from a fixed seed and prints every number in this file.
analysis.py: every number in the README Python · take_home/analysis.py
# A take-home in miniature: where and when are food deliveries late? The orders are
# synthetic, made by a seeded generator so that every run prints the same numbers; in a real
# take-home this block reads the file you were given.
import numpy as np
import pandas as pd

rng = np.random.default_rng(2026)
n = 1800
city = rng.choice(["Pune", "Kochi", "Indore"], size=n, p=[0.45, 0.30, 0.25])
by_hour = np.array([2, 4, 9, 10, 8, 4, 3, 4, 6, 11, 12, 10, 6, 3]) / 92  # 10:00 to 23:00
hour = rng.choice(np.arange(10, 24), size=n, p=by_hour)
ordered_at = (pd.Timestamp("2026-03-02") + pd.to_timedelta(rng.integers(0, 56, n), unit="D")
              + pd.to_timedelta(hour, unit="h")
              + pd.to_timedelta(rng.integers(0, 60, n), unit="min"))
dinner = (hour >= 19) & (hour <= 22)
extra = np.where(dinner & (city == "Kochi"), 9, np.where(dinner & (city == "Pune"), 4, 0))
minutes = np.round(rng.normal(28, 7, n) + extra)
orders = pd.DataFrame({"order_id": np.arange(1, n + 1), "city": city,
                       "ordered_at": ordered_at,
                       "delivered_at": ordered_at + pd.to_timedelta(minutes, unit="min"),
                       "promised_min": 35})
# Cancelled orders, a clock error on four rows, and an export glitch that repeats 12 rows
orders.loc[rng.choice(n, 27, replace=False), "delivered_at"] = pd.NaT
orders.loc[[101, 707, 1203, 1650], "delivered_at"] -= pd.Timedelta(hours=1)
orders = pd.concat([orders, orders.sample(12, random_state=7)]).reset_index(drop=True)

# The checks, each logged with the rows it touched
log = [("rows received", len(orders))]
orders = orders.drop_duplicates()
log.append(("exact duplicate rows removed", log[0][1] - len(orders)))
cancelled = orders["delivered_at"].isna()
log.append(("cancelled, no delivery time (left out)", int(cancelled.sum())))
orders = orders[~cancelled].copy()
orders["minutes"] = (orders["delivered_at"] - orders["ordered_at"]).dt.total_seconds() / 60
impossible = orders["minutes"] <= 0
log.append(("delivered before ordered (left out)", int(impossible.sum())))
orders = orders[~impossible]
log.append(("orders analysed", len(orders)))
for step, rows in log:
    print(f"{step:<40}{rows:>6,}")

# The answer: the share of orders delivered after the promised time, by city and time of day
orders["late"] = orders["minutes"] > orders["promised_min"]
evening_hours = orders["ordered_at"].dt.hour.between(19, 22)
orders["slot"] = np.where(evening_hours, "7-11 pm", "other hours")
by = orders.groupby(["city", "slot"]).agg(orders=("late", "size"), late=("late", "sum"))
by["late_pct"] = (100 * by["late"] / by["orders"]).round(1)
print(f"\nLate overall: {orders['late'].mean():.1%} of {len(orders):,} orders")
print(by.to_string())
small = by.loc[by["orders"].idxmin()]  # the cell with the fewest orders
p = small["late"] / small["orders"]
# A 95 % interval with the normal approximation, in percentage points
margin = 100 * 1.96 * np.sqrt(p * (1 - p) / small["orders"])
print(f"Smallest cell: {int(small['orders'])} orders,",
      f"95 % margin about ±{margin:.1f} points")
evening = orders["slot"].eq("7-11 pm")
share_late = evening[orders["late"]].mean()
print(f"Placed between 7 and 11 pm: {evening.mean():.0%} of orders,",
      f"{share_late:.0%} of late orders")

Output

rows received                            1,812
exact duplicate rows removed                12
cancelled, no delivery time (left out)      27
delivered before ordered (left out)          4
orders analysed                          1,769

Late overall: 23.4% of 1,769 orders
                    orders  late  late_pct
city   slot                               
Indore 7-11 pm         189    30      15.9
       other hours     275    42      15.3
Kochi  7-11 pm         212   117      55.2
       other hours     319    58      18.2
Pune   7-11 pm         320   114      35.6
       other hours     454    53      11.7
Smallest cell: 189 orders, 95 % margin about ±5.2 points
Placed between 7 and 11 pm: 41% of orders, 63% of late orders

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 analysis.py

The script logs each check with the number of rows it touched, so the “Data and checks” table is copied from its output, never retyped. The answer contradicts the brief, which expected a Kochi problem, and says so in its first sentence. The limitations give the uncertainty of the smallest group, 189 orders and a 95 % margin of about 5 points, which stops a reader from treating the 0.6-point gap between Indore’s two time slots as a finding. Code that rebuilds every number from the raw data with one command is what makes the rest believable.

Two timed drills

Set a timer for each. Both drills are plain Python, so they run in the browser against their sample tests, and each is a pattern from this lesson in the form an interviewer can ask without any library.

Exercise · Medium · Python

Number each user's sessions from an event log

A timed drill: give yourself 10 minutes. Write sessionise(events, gap_minutes=30) in sessions.py, in plain Python: the same steps the lesson takes with diff, a comparison and cumsum in pandas, or LAG and a running SUM in SQL.

events is a list of (user, minute) pairs in any order, where minute is a whole number of minutes since the start of the day. Return a new list of (user, minute, session) tuples, sorted by user and then by minute, where:

  1. each user's first event starts session 1;
  2. an event more than gap_minutes after the same user's previous event starts the next session (2, 3 and so on);
  3. an event exactly gap_minutes after the previous one stays in the same session.

For example, sessionise([("u1", 0), ("u1", 30), ("u1", 61)]) returns [("u1", 0, 1), ("u1", 30, 1), ("u1", 61, 2)]: 30 minutes is not more than 30, and 31 is. The function must not change the list it is given.

The sample tests check the boundary, several users in a shuffled list, another gap, an empty list and the input left as it was.

Starter code · sessions.py

def sessionise(events, gap_minutes=30):
    """Return (user, minute, session) tuples sorted by user and minute; a gap of more than gap_minutes starts a session."""
    # Replace these lines with your code.
    return [(user, minute, 1) for user, minute in events]
The sample tests · test_sessions.py
from sessions import sessionise


def test_boundary():
    """keeps an event exactly 30 minutes later in the session and starts a new one after 31"""
    assert sessionise([("u1", 0), ("u1", 30), ("u1", 61)]) == [("u1", 0, 1), ("u1", 30, 1), ("u1", 61, 2)]


def test_shuffled_users():
    """sorts by user and minute, and numbers each user's sessions from 1"""
    events = [("u2", 95), ("u1", 70), ("u2", 5), ("u1", 0), ("u1", 20), ("u2", 30), ("u1", 150)]
    assert sessionise(events) == [
        ("u1", 0, 1), ("u1", 20, 1), ("u1", 70, 2), ("u1", 150, 3),
        ("u2", 5, 1), ("u2", 30, 1), ("u2", 95, 2),
    ]


def test_other_gap():
    """uses the gap it is given"""
    assert sessionise([("a", 0), ("a", 10), ("a", 21), ("a", 31)], gap_minutes=10) == [
        ("a", 0, 1), ("a", 10, 1), ("a", 21, 2), ("a", 31, 2),
    ]


def test_empty():
    """returns an empty list for no events"""
    assert sessionise([]) == []


def test_input_unchanged():
    """leaves the list it is given as it was"""
    events = [("u1", 50), ("u1", 0)]
    sessionise(events)
    assert events == [("u1", 50), ("u1", 0)]
A hint

Sort a copy first: sorted(events) orders the pairs by user and then by minute. Walk through it once, remembering the previous user and minute. When the user changes, start again at session 1; when the user is the same and minute - previous_minute > gap_minutes, add 1 to the session number.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · Python

Count an ordered funnel from raw events

A timed drill: give yourself 15 minutes. Write funnel(events, steps=("visit", "cart", "pay")) in funnel.py, in plain Python, with the rule the lesson's funnel example uses.

events is a list of (user, event, minute) tuples in any order. Return a list with one number per step: how many users reached that step. A user reaches

  1. the first step at the earliest time they did it;
  2. each later step at the earliest time they did it at or after the time they reached the step before.

A user who has not reached a step cannot reach the steps after it, and events of a step done before the previous step do not count. Events whose name is not in steps are ignored. Each user counts at most once per step.

For example, a user with ("u1", "cart", 5), ("u1", "visit", 8) and ("u1", "pay", 12) reaches only the first step: the cart came before the visit. So funnel([("u1", "cart", 5), ("u1", "visit", 8), ("u1", "pay", 12)]) returns [1, 0, 0].

The sample tests check a complete path, a skipped step, events in the wrong order, repeated events and other steps.

Starter code · funnel.py

def funnel(events, steps=("visit", "cart", "pay")):
    """Return how many users reached each step, each step at or after the time the user reached the step before."""
    # Replace these lines with your code: this version counts every step separately.
    return [len({user for user, event, minute in events if event == step}) for step in steps]
The sample tests · test_funnel.py
from funnel import funnel


def test_complete_path():
    """counts a user at every step when the steps happen in order"""
    assert funnel([("u1", "visit", 0), ("u1", "cart", 4), ("u1", "pay", 9)]) == [1, 1, 1]


def test_skipped_step():
    """does not count a payment by a user who never reached the cart"""
    events = [("u1", "visit", 0), ("u1", "pay", 3), ("u2", "visit", 1), ("u2", "cart", 2)]
    assert funnel(events) == [2, 1, 0]


def test_wrong_order():
    """ignores a cart added before the visit, but counts a later one"""
    events = [("u1", "cart", 5), ("u1", "visit", 8), ("u1", "pay", 12),
              ("u2", "cart", 1), ("u2", "visit", 2), ("u2", "cart", 6)]
    assert funnel(events) == [2, 1, 0]


def test_repeated_events():
    """counts each user once per step, from the first time that qualifies"""
    events = [("u1", "visit", 0), ("u1", "visit", 7), ("u1", "cart", 3), ("u1", "cart", 4), ("u1", "pay", 3)]
    assert funnel(events) == [1, 1, 1]


def test_other_steps():
    """works for any list of steps and ignores other events"""
    events = [("a", "signup", 0), ("a", "search", 2), ("a", "book", 5), ("b", "search", 1), ("b", "signup", 3)]
    assert funnel(events, steps=("signup", "book")) == [2, 1]
A hint

First collect, for each user, the minutes of each step: a dictionary such as {"u1": {"visit": [0, 7], "cart": [3, 4]}}. Then walk the steps in order for each user, keeping the time of the step just reached (start with no limit for the first step). The next step counts when the user has a time at or after that one; take the smallest such time.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Mock Data Generator Make a larger practice table of orders or events to try the patterns on.

Check yourself

Six questions on the patterns

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 What does this program print?

    Read the code, then choose one answer.

    import pandas as pd
    
    s = pd.Series([3, 4, 5, 8, 9])
    print((s.diff() != 1).cumsum().tolist())
    Show the answer to question 1

    Answer: [1, 1, 1, 2, 2]

    diff() gives NaN, 1, 1, 3 and 1. NaN != 1 is True, so the first value starts run 1, and 4 and 5 continue it; 8 is 3 away from 5 and starts run 2, which 9 continues. The cumulative sum of the starts labels each island.

  2. Question 2 of 6 What does this program print?

    Read the code, then choose one answer.

    import pandas as pd
    
    revenue = pd.Series([100, 120, 150], index=["Jan", "Feb", "Apr"])
    print(round(revenue.pct_change().iloc[-1], 2))
    Show the answer to question 2

    Answer: 0.25

    pct_change compares each row with the row before it, whatever its label. With no March row, April (150) is compared with February (120): 150 / 120 - 1 = 0.25. Reindex to every month first; March then holds 0, and April's growth from zero is undefined.

  3. Question 3 of 6 A query computes SUM(amount) OVER (ORDER BY day) with no frame clause, and two orders share the same day. What do those two rows show?

    Choose one answer.

    Show the answer to question 3

    Answer: The same running total, which already includes both orders

    Without a frame clause, an ordered window uses RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and the current row brings its peers, every row with the same day. Write ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, with a tie-breaker in ORDER BY, for a total that grows order by order.

  4. Question 4 of 6 The data ends in April. What belongs in the March sign-up cohort's month-2 cell of a retention table?

    Choose one answer.

    Show the answer to question 4

    Answer: Nothing yet, because month 2 of the March cohort is May, which the data does not cover

    Month k of a cohort exists only once the cohort's month plus k has happened. A cell that the data covers and where nobody came back is 0 %; a cell after the end of the data stays empty, or the newest cohorts look lost.

  5. Question 5 of 6 Two of a customer's rows share the latest timestamp. Which of these return exactly one row for that customer?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • ROW_NUMBER() OVER (PARTITION BY customer ORDER BY changed_at DESC, change_id DESC) = 1
    • sort_values(["customer", "changed_at", "change_id"]), then drop_duplicates("customer", keep="last")

    ROW_NUMBER gives every row its own number and drop_duplicates keeps one row per key, so both return one row, and the change ID makes the choice repeatable. The max-and-join-back query and RANK() keep every row at the latest time, two rows here.

  6. Question 6 of 6 Counted separately, 6 users visited, 3 added to the cart and 4 paid. What is the right reading?

    Choose one answer.

    Show the answer to question 6

    Answer: Some payments did not follow a cart, so a funnel must count each step only after the step before

    A funnel cannot widen. Separate counts include users who paid without a cart event in the data or added to the cart before visiting. An ordered funnel takes each user's first visit, then the first cart at or after it, then the first payment at or after that cart.

Interview questions

Fresher · How do you split each user’s events into sessions with a 30-minute inactivity rule, in pandas and in SQL?

Sort the events by user and time, and compare each event with the same user’s previous one. An event starts a new session when it is the user’s first, or when more than 30 minutes have passed since the previous event; a running count of those starts numbers the sessions. In pandas, gap = df.groupby("user_id")["ts"].diff(), then starts = gap.isna() | (gap > pd.Timedelta(minutes=30)) and starts.astype(int).groupby(df["user_id"]).cumsum(). In SQL, LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) gives the previous time, a CASE turns each gap into 1 or 0, and SUM(...) OVER (PARTITION BY user_id ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) numbers the sessions. Say which side of the boundary exactly 30 minutes falls on, and how two events with the same timestamp are ordered.

Fresher · What belongs in the README of a take-home analysis?

Enough for a reader to find the answer in a minute and to trust it. Restate the question and the decision it serves. Give the answer first, in two to four sentences with the key numbers. List the data checks: the rows received, each cleaning rule with the rows it removed or changed, and the rows analysed. Define the method: what counts as late, active or a session, the grain of each table and the time window. Show one table or chart per question. State the limitations: what the data cannot show, how uncertain small groups are, and what you would check next. End with how to run it: one command that rebuilds every number from the raw data, with the versions you used.

Key takeaways

  • Before any code, say the grain, the keys, what a missing value means and how ties are broken.
  • Twelve recurring tasks rest on four moves: rank inside a group, compare with the row before, match across tables or time, and summarise or reshape.
  • Put a tie-breaker in every sort that feeds a top N or a latest-record answer, in pandas and in SQL alike.
  • A SQL running total without a ROWS frame gives rows with the same ORDER BY value the same total.
  • Complete the calendar before computing growth, and treat growth from zero as undefined.
  • In a retention table, a month the data has not reached is empty, not 0 %.
  • A funnel counts a step only when it happens after the step before.
  • A take-home README leads with the answer, logs every check with row counts, states its limits and rebuilds every number from the raw data with one command.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.