Pandas
DataFrame grouping, merging, filtering, and conditional aggregation tasks.
20 questions
JuniorTheoryVery commongroupby is lazy — how do agg, transform, and apply differ?
groupby is lazy — how do agg, transform, and apply differ?
groupby is lazy — it computes nothing until an op runs. agg reduces each group to one row per key; transform returns the same shape as the input, broadcasting each group's value onto every row; apply is the flexible, slower fallback. Only transform aligns a group aggregate back to the rows without a merge.
Common mistakes
- ✗Believing
transformreduces the frame likeaggdoes - ✗Reaching for
applywhen a vectorizedaggortransformwould be faster - ✗Merging a group aggregate back manually instead of using
transform
Follow-up questions
- →How would you add a column of each group's share of its total?
- →Why is
applyon a groupby often slower thanagg?
JuniorCodeVery commonTotal sales per employee by merging two DataFrames
Total sales per employee by merging two DataFrames
Aggregate sales per employee, then attach the name. Group sales by emp_id and sum num_sales, then merge with the names on emp_id: df_sales.groupby('emp_id')['num_sales'].sum().reset_index().merge(df_names, on='emp_id')[['emp_name','num_sales']].
Common mistakes
- ✗Using concat instead of a key-based merge
- ✗Returning per-date rows without summing num_sales
- ✗Counting name rows instead of summing sales
Follow-up questions
- →Does it matter whether you group before or after the merge?
- →How would an employee with no sales appear?
JuniorTheoryVery commonSeries vs DataFrame, and when do .loc and .iloc disagree?
Series vs DataFrame, and when do .loc and .iloc disagree?
A Series is a labelled 1-D column; a DataFrame is a 2-D table of aligned Series on one index. .loc selects by label, .iloc by integer position. They coincide on a default RangeIndex but split after filtering or a custom index — .loc[0] seeks label 0, maybe absent, while .iloc[0] takes the first row.
Common mistakes
- ✗Thinking
.locis positional and.ilocis label-based (they are reversed) - ✗Assuming both indexers always agree because the default index looks like positions
- ✗Forgetting
.locslices include the stop label while.ilocexcludes it
Follow-up questions
- →After
df = df[df.x > 0], why candf.loc[0]raise a KeyError? - →How does
.locslicing differ from.ilocat the stop bound?
JuniorCodeCommonDrop duplicate orders, keeping the latest row per order_id
Drop duplicate orders, keeping the latest row per order_id
Sort by the timestamp, then drop duplicates keeping the last row per key: df.sort_values('updated_at').drop_duplicates('order_id', keep='last'). The sort is what makes keep='last' mean newest; without it you keep whichever row happened to sit last in file order, not the latest update.
Common mistakes
- ✗Calling drop_duplicates without sorting first, so keep='last' is arbitrary
- ✗Sorting by order_id instead of by updated_at
- ✗Using keep='first' after a descending sort by mistake
Follow-up questions
- →How would you keep the latest row using groupby and idxmax instead?
- →What happens to ties where two rows share the same updated_at?
JuniorTheoryCommonfillna with 0, the mean, or forward-fill — when is each a lie?
fillna with 0, the mean, or forward-fill — when is each a lie?
fillna(0) suits truly-zero counts but biases a measurement by making unknown a real zero. Mean-fill keeps the mean yet shrinks variance and can leak across a train/test split. Forward-fill fits ordered series but carries a neighbour's value on unordered rows. Each lies when the missingness mechanism differs from its assumption.
Common mistakes
- ✗Filling a measurement with 0 and biasing its sum or mean downward
- ✗Mean-filling before a train/test split, leaking the target
- ✗Forward-filling rows that are not chronologically ordered
Follow-up questions
- →How would an indicator column for missingness help downstream?
- →When is dropping rows better than imputing them?
JuniorCodeCommonMonthly revenue per country, counting paid orders only
Monthly revenue per country, counting paid orders only
Mask the unpaid amounts to zero, then group once. Build paid = df['amount'].where(df['status'].eq('paid'), 0), derive the month from order_ts, and sum: df.assign(paid=paid, m=df['order_ts'].dt.to_period('M')).groupby(['country','m'])['paid'].sum(). The where keeps every row so one pass covers all months.
Common mistakes
- ✗Filtering paid rows so months with no paid orders vanish from the output
- ✗Forgetting to derive a month period from order_ts before grouping
- ✗Counting rows instead of summing the amount column
Follow-up questions
- →How would you keep months with zero paid revenue in the result?
- →Why prefer where-masking over filtering rows before the groupby?
MiddleCodeCommonMerge orders to customers without letting a duplicate key fan out and inflate the total — how?
Merge orders to customers without letting a duplicate key fan out and inflate the total — how?
Pass validate='many_to_one': pandas checks the right key is unique and raises MergeError on a fan-out instead of silently duplicating orders and inflating SUM(amount). Add indicator=True and filter _merge == 'left_only' for unmatched orders — both catch it at merge time.
Common mistakes
- ✗Assuming a left join alone prevents a fan-out from duplicate right keys
- ✗Getting the validate direction backwards (
one_to_manyhere) - ✗Reading unmatched orders from
right_onlyinstead ofleft_only
Follow-up questions
- →What exactly does
validate='one_to_one'assert versusmany_to_one? - →How would you dedupe
customersif a fan-out is expected but unwanted?
MiddleCodeCommonReshape a long event table into a wide cohort matrix — pivot or melt?
Reshape a long event table into a wide cohort matrix — pivot or melt?
pivot goes long to wide: df.pivot(index='cohort_month', columns='activity_month', values='users'). melt is the inverse, wide to long. Use pivot only when the index/column pair is unique; if a cell can repeat, pivot_table(..., aggfunc='sum') aggregates the collisions instead of raising.
Common mistakes
- ✗Swapping pivot and melt — reaching for melt to widen the frame
- ✗Calling pivot when index/column pairs repeat and hitting a ValueError
- ✗Assuming pivot aggregates duplicates like pivot_table does
Follow-up questions
- →When must you switch from pivot to pivot_table?
- →How does melt name its value and variable columns?
MiddleCodeCommonResample daily events to weekly totals and add a 4-week rolling average — how?
Resample daily events to weekly totals and add a 4-week rolling average — how?
resample needs a DatetimeIndex, so set_index('date') first, then resample('W')['events'].sum() gives weekly totals (week-ending Sunday). Chain .rolling(4, min_periods=1).mean() for the 4-week average. Unlike a groupby on week number, resample fills empty weeks.
Common mistakes
- ✗Calling
resamplewithout a DatetimeIndex and getting a TypeError - ✗Grouping by week number, which drops calendar weeks that had no events
- ✗Running
rollingon the daily rows instead of the weekly series
Follow-up questions
- →How would you switch the week to end on Monday instead of Sunday?
- →What does
min_periodschange at the start of the rolling window?
MiddleDebuggingCommonA fillna via chained indexing left the NaNs and printed SettingWithCopyWarning — what is the fix?
A fillna via chained indexing left the NaNs and printed SettingWithCopyWarning — what is the fix?
df[mask]['price'] = 0 is chained indexing: df[mask] builds a temporary copy, the write lands there, and df is untouched — so the NaNs stay and pandas warns. Fix it with one indexer: df.loc[df['price'].isna(), 'price'] = 0 (or .fillna). Rule: one .loc/.iloc per write.
Common mistakes
- ✗Silencing the warning instead of fixing the lost write
- ✗Blaming
isna()or a missing.copy()rather than chained indexing - ✗Not knowing
.loc[mask, col]takes a boolean mask on the row axis
Follow-up questions
- →Why can pandas not tell whether
df[mask]is a view or a copy? - →When would
df.fillnabe preferable to the.locassignment here?
MiddlePerformanceCommonA row-wise .apply() over 5M rows runs 40 minutes — how do you vectorize it, and where did the time go?
A row-wise .apply() over 5M rows runs 40 minutes — how do you vectorize it, and where did the time go?
.apply(axis=1) runs a Python loop with per-row object boxing and interpreter dispatch — that overhead dominates, not the arithmetic. Replace it with column-wise C ops over whole arrays: boolean masks, np.where/np.select, .str/.dt accessors. That turns minutes into seconds.
Common mistakes
- ✗Blaming the arithmetic instead of the Python-loop and boxing overhead
- ✗Believing
.iterrows()or.itertuples()is a vectorized speed-up - ✗Assuming every branch needs
applyrather thannp.where/np.select
Follow-up questions
- →When is
np.selecta better fit than a chain ofnp.wherecalls? - →How would you profile to confirm the loop, not I/O, is the cost?
JuniorCodeOccasionalEmployees who earn more than their manager
Employees who earn more than their manager
Self-merge the table on managerId == id to put each employee beside their manager, then filter where the employee's salary exceeds the manager's. The join key links each row's manager to that manager's own row so the two salaries sit side by side.
Common mistakes
- ✗Comparing to the team or global average instead of the manager
- ✗Forgetting to self-join to bring the manager's salary alongside
- ✗Assuming only one report per manager can out-earn them
Follow-up questions
- →Why does the join use left_on managerId and right_on id?
- →What happens to employees whose managerId is null?
MiddlePerformanceOccasionalA 6 GB CSV will not fit in RAM — how do you process it in pandas, and when do you stop?
A 6 GB CSV will not fit in RAM — how do you process it in pandas, and when do you stop?
Read in pieces with read_csv(chunksize=...) and aggregate each chunk (a chunked groupby), so only one chunk is resident. Cut the footprint with explicit dtype= — downcast numbers, category for low-cardinality strings — and usecols. When chunked pandas still thrashes, use DuckDB or Polars.
Common mistakes
- ✗Thinking
chunksizeloads the whole file before slicing it - ✗Leaving default
int64/objectdtypes that bloat the footprint - ✗Never combining chunk aggregates and so recomputing the whole file
Follow-up questions
- →How do you combine per-chunk
groupbyresults into one correct total? - →What signals that the job has outgrown pandas for DuckDB or Polars?
MiddleCodeOccasionalPercent of immediate orders among each customer's first order
Percent of immediate orders among each customer's first order
Pick each customer's first order, then take the share that are immediate. Get the first-order row indices with groupby('customer_id')['order_date'].idxmin(), select those rows, and compute the mean of the boolean order_date == customer_pref_delivery_date times 100, rounded to 2.
Common mistakes
- ✗Computing the percentage over all orders, not first orders
- ✗Selecting the last order instead of the earliest
- ✗Dividing by customers without isolating first orders
Follow-up questions
- →Why use idxmin rather than sorting and taking head(1)?
- →How would ties on order_date affect the first-order pick?
MiddleCodeOccasionalManagers with at least five direct reports
Managers with at least five direct reports
Count how many employees report to each manager, then keep managers meeting the threshold and look up their names. Group by managerId with size(), take the index where the count is >= 5, and return the names of employees whose id is in that set.
Common mistakes
- ✗Counting department size instead of reports per managerId
- ✗Counting employee id occurrences instead of grouping reports
- ✗Equating top-of-hierarchy with having five reports
Follow-up questions
- →Why group by managerId rather than by department?
- →How would you also report the report count per manager?
MiddleCodeOccasionalPer-buyer order count and days from signup to first order
Per-buyer order count and days from signup to first order
Aggregate orders per buyer, then bring in the join date. Group orders by buyer_id for the first order date (min(order_date)) and the order count (size), merge that with users on buyer_id == user_id, and compute delta_days = (first_order - join_date).dt.days.
Common mistakes
- ✗Using the latest order instead of the first for the delta
- ✗Counting user rows instead of order rows
- ✗Reporting per-order deltas instead of one per buyer
Follow-up questions
- →Why aggregate orders before merging with users?
- →How would a buyer with no orders appear in the output?
MiddleCodeOccasionalMonthly transaction stats per country with conditional aggregation
Monthly transaction stats per country with conditional aggregation
Build a month key and approved-only helper columns, then group so one pass yields all four aggregates. Set month = trans_date.dt.strftime('%Y-%m'), add is_approved = (state == 'approved') and approved_amount = amount.where(state == 'approved', 0).
Common mistakes
- ✗Dropping declined rows, which breaks the all-transactions totals
- ✗Assuming approved figures equal the totals
- ✗Grouping on the wrong dimensions
Follow-up questions
- →Why use amount.where(...) instead of filtering the frame?
- →How would you add a declined_count without a second pass?
MiddleTheoryOccasionalWhen does a MultiIndex earn its keep, and how do you flatten columns after a multi-key agg?
When does a MultiIndex earn its keep, and how do you flatten columns after a multi-key agg?
A MultiIndex pays off on hierarchical data — a multi-key groupby or stacked panel — giving label-aligned .xs/.loc slicing across levels without a surrogate key. A multi-column .agg returns MultiIndex columns; .reset_index() and joined tuples flatten them into a flat header.
Common mistakes
- ✗Treating a MultiIndex as noise and flattening before slicing by level
- ✗Forgetting that a multi-column agg returns MultiIndex columns, not flat ones
- ✗Reaching for a surrogate key instead of
.xs/.loccross-level slicing
Follow-up questions
- →How does
.xsdiffer from.locwhen selecting one level? - →What does
.groupby(..., as_index=False)change about the result?
MiddleCodeOccasionalProduct price on a given date, defaulting to 10 before any change
Product price on a given date, defaulting to 10 before any change
This is a point-in-time lookup. Keep changes on or before the target date, take each product's latest such change (max change_date, e.g. via idxmax after sorting or groupby), and read its new_price. Then reindex against all product ids and fill missing prices with 10 for products with no change by the date.
Common mistakes
- ✗Taking the latest change ignoring the target date
- ✗Defaulting only products with zero rows, not zero pre-date changes
- ✗Averaging prices instead of an as-of lookup
Follow-up questions
- →Why filter by date before taking the latest change?
- →How would you extend this to an arbitrary query date column?
SeniorCodeOccasionalDaily cancellation rate excluding banned clients and drivers
Daily cancellation rate excluding banned clients and drivers
Filter to trips where both parties are unbanned, then take the daily mean of a cancelled flag. Merge users twice — once on client_id, once on driver_id — to attach each party's ban status, keep rows where neither is banned, restrict to the date range, and group by day.
Common mistakes
- ✗Checking only the client's ban status, not the driver's
- ✗Skipping the ban filter entirely
- ✗Counting only one cancellation type as cancelled
Follow-up questions
- →Why are two separate merges with users needed here?
- →How does str.startswith handle both cancellation types at once?