This page is for analysts, data scientists and engineers facing a pandas round, whether it's a live notebook exercise or a spoken technical interview. Most rounds start with Series, DataFrames and loc versus iloc, then move to cleaning messy columns, groupby, merges and reshaping. Stronger rounds test dates and time series, why apply is slow, how to handle files bigger than memory and the SettingWithCopyWarning trap. Every question has short code you should be able to type from memory, plus a spoken answer. Run the snippets on a small table of your own, then practise saying the answers out loud.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Series: one-dimensional, one dtype, values plus an index of labels.
DataFrame: two-dimensional; each column is a Series and all columns share the same row index.
In practice: one bracket returns a Series, a list of columns returns a DataFrame.
"A Series is a one-dimensional labelled array. It has values of a single dtype and an index that gives each value a label. A DataFrame is a table, and the easiest way to think of it is a collection of Series that all share the same row index, one Series per column. Each column can have its own dtype, so I can have text, integers and dates side by side. That's also why selection behaves the way it does: df['amount'] gives me a Series, while df[['amount']] with a list gives me a DataFrame with one column. It matters because some methods only exist on one of them, like the .str and .dt accessors, which live on Series."
import pandas as pd
df = pd.DataFrame({'name': ['Ana', 'Ben'], 'amount': [120, 80]})
type(df['amount']) # Series
type(df[['amount']]) # DataFrame with one column
df['amount'].index.equals(df.index) # True: columns share the row index
Describing a DataFrame as just a list of lists, with no mention of the shared index or per-column dtypes.
Alignment: arithmetic lines values up by index label, not by position.
Result: the output index is the union of both; a label found in only one side gives NaN.
Fix: use add with fill_value, or reset the index if position is what you meant.
"Pandas aligns on the index before it does any arithmetic. So when I add two Series, it matches values by label, not by position, and the result has the union of both indexes. Any label that exists in only one of them has nothing to add to, so it comes out as NaN. This often happens after filtering or sorting one of the Series, because the labels no longer line up the way the rows look on screen. If missing labels should count as zero, I use s1.add(s2, fill_value=0). If I really wanted positional addition, I reset both indexes first. The same rule applies when you assign a Series into a DataFrame column: it lines up by index, which surprises people."
s1 = pd.Series([10, 20, 30], index=['a', 'b', 'c'])
s2 = pd.Series([1, 2, 3], index=['b', 'c', 'd'])
s1 + s2 # a NaN, b 21, c 32, d NaN
s1.add(s2, fill_value=0) # a 10, b 21, c 32, d 3
Saying pandas adds row by row like a list, or blaming the NaN on bad input data without mentioning the index.
Chain: each method returns a new DataFrame, so steps read top to bottom inside parentheses.
assign: adds columns; a lambda receives the frame at that point in the chain.
pipe: drops your own function into the chain.
Debug: comment out lines from the bottom, or pipe in a step that prints the shape.
"Method chaining means writing a transformation as one expression, where each step returns a new DataFrame and the next method runs on it. I wrap it in parentheses and put one step per line, so it reads like a recipe: load, filter, add columns, group, sort. assign is what makes it work for new columns. If I pass a lambda, it receives the DataFrame as it is at that point in the chain, so I can use columns created earlier in the chain. pipe lets me slot in my own function, like a cleaning step I reuse. The benefit is no half-finished intermediate variables and no inplace calls. The cost is debugging, so I comment out lines from the bottom, or pipe in a small function that prints the shape."
monthly = (
pd.read_csv('orders.csv', parse_dates=['order_date'])
.query('status == "complete"')
.assign(month=lambda d: d['order_date'].dt.to_period('M'),
net=lambda d: d['amount'] - d['discount'])
.groupby('month', as_index=False)['net'].sum()
.sort_values('month')
)
Writing a fifty-step chain with no way to inspect the middle, or not knowing why assign needs a lambda there.
loc: selects by label, accepts boolean masks, and label slices include the end.
iloc: selects by integer position, and slices exclude the end like normal Python.
Trap: after sorting or filtering, label 0 is no longer the first row.
"loc works with labels, so row index labels and column names, and it also takes a boolean mask. iloc works with integer positions, like a list. Slicing is different too: df.loc[2:4] includes label 4, while df.iloc[2:4] stops before position 4. The case where people get burned is after a sort or a filter. The default index keeps its original labels, so after sorting by amount, df.loc[0] is still the row that was originally first, while df.iloc[0] is the row that's now on top. My habit is loc whenever I'm selecting rows by a condition or columns by name, and iloc only when I truly mean position, like taking the first row of each sorted group."
df = pd.DataFrame({'amount': [50, 10, 30]})
sorted_df = df.sort_values('amount')
sorted_df.loc[0, 'amount'] # 50: the row labelled 0
sorted_df.iloc[0, 0] # 10: the row now in first position
df.loc[df['amount'] > 20, 'amount'] # mask plus column name
Saying they are interchangeable on a default index, without noticing that sorting or filtering breaks that.
Mask: each condition returns a boolean Series; combine them with & and |, negate with ~.
Parentheses: & binds tighter than comparisons, so each condition needs its own brackets.
Why not and: and asks for one True or False for the whole Series, which pandas refuses as ambiguous.
"I build one boolean Series per condition and combine them. For the region I use isin with a list, which is cleaner than chaining equals checks with or. Then I combine that with the amount condition using the ampersand, and each condition goes in its own parentheses, because the ampersand binds more tightly than the greater-than sign. Python's and doesn't work because it tries to turn the whole Series into a single True or False, and pandas raises a ValueError saying the truth value of a Series is ambiguous. For readability with many conditions, query is a nice alternative, and for a not condition I use the tilde."
mask = df['region'].isin(['north', 'west']) & (df['amount'] > 100)
big_orders = df[mask]
# same thing with query
big_orders = df.query("region in ['north', 'west'] and amount > 100")
# everything except those rows
rest = df[~mask]
Writing df[df.a > 1 and df.b < 5] and not recognising the error it throws.
Cause: chained indexing like df[mask]['col'] = x; the first step may give a copy, so the write may not reach df.
Fix in place: one loc call with the row mask and the column together.
Separate subset: if you want a new table, take .copy() explicitly.
Copy-on-Write: the default from pandas 3.0; every selection acts as a copy, so chained assignment never edits the original, and a ChainedAssignmentError warning replaces the old one.
"The warning comes from chained assignment, like df[df.amount > 100]['flag'] = True. That's two steps: the first selection builds a new object that may or may not share data with df, and the second sets a value on it. If it's a copy, the original never changes, and the warning is the only hint. The fix depends on what I meant. To change the original, I use one loc call with the mask and the column together. If I want a separate subset to work on, I call .copy() when I create it, so it's clearly independent. From pandas 3.0, Copy-on-Write is the default: every selection behaves like a copy, so chained assignment never updates the original, and the old warning is replaced by a ChainedAssignmentError warning. One loc call is correct in every version."
# chained: sets the value on a temporary copy, df is unchanged
df[df['amount'] > 100]['flag'] = True
# clear: one step, updates df
df.loc[df['amount'] > 100, 'flag'] = True
# clear: an independent subset on purpose
big = df[df['amount'] > 100].copy()
big['flag'] = True
Silencing the warning globally, or saying it's harmless noise, without knowing whether the original data was updated.
Find: isna().sum() per column; NaN, None and NaT all count; never compare with == NaN.
Drop: dropna with subset or thresh, so you only drop what the analysis can't use.
Fill: fillna with a value or a dict per column, or ffill for ordered data.
Side effects: an int column with gaps turns float unless you use the nullable Int64 dtype.
"First I measure: df.isna().sum() gives the count per column, and dividing by the length gives the share. isna catches NaN, None and NaT for dates, and I never test with double equals, because NaN isn't equal to itself. Then I decide per column. If a row is useless without a key field, I drop with dropna and a subset, rather than dropping any row with any gap. If a sensible default exists, I fillna with a dict so each column gets its own value. For time-ordered data, forward fill can make sense. Two things I keep in mind: an integer column with a missing value becomes float unless I use the nullable Int64 dtype, and sum and mean skip missing values by default, so a mean may cover fewer rows than I think."
df.isna().sum() # count per column
df.isna().mean().sort_values() # share per column
clean = df.dropna(subset=['customer_id'])
clean = clean.fillna({'discount': 0, 'channel': 'unknown'})
clean['visits'] = clean['visits'].astype('Int64') # keeps gaps as <NA>
Reaching for df.dropna() on the whole table as the default, without checking how many rows it throws away.
Inspect: duplicated with keep=False shows every row involved, so you see what differs.
Rule: sort by the timestamp, then drop_duplicates on the key keeping the last.
Check: the result has one row per key, and exact duplicates are a separate question.
"First I look before deleting anything. duplicated on customer_id with keep=False marks every row that shares a key, so I can see whether they differ in real fields or are exact copies. Then I need a rule for which row wins, and here it's the newest. I sort by updated_at and call drop_duplicates on customer_id with keep='last', so the latest row survives. An alternative is idxmax on the timestamp per group, which gives the index of the newest row. Afterwards I check that customer_id is unique. Without the subset argument, drop_duplicates only removes rows that match on every column, which usually isn't what the business means by a duplicate customer."
dups = df[df.duplicated('customer_id', keep=False)]
print(dups.sort_values(['customer_id', 'updated_at']).head())
latest = (df.sort_values('updated_at')
.drop_duplicates('customer_id', keep='last'))
assert latest['customer_id'].is_unique
# alternative
latest2 = df.loc[df.groupby('customer_id')['updated_at'].idxmax()]
Calling drop_duplicates() with no subset and no sort, then trusting that the right row was kept.
Clean text: .str.strip and .str.replace to remove spaces and thousands separators.
Convert: pd.to_numeric with errors='coerce' turns anything unparseable into NaN.
Audit: list the raw values that became NaN but weren't blank, before trusting the column.
"I'd clean the text first with the .str methods, which work on the whole column at once: strip the spaces and remove the commas. Then pd.to_numeric with errors set to coerce converts everything it can and turns the rest into NaN. The step people skip is the audit. I compare the converted column with the raw one and pull out values that became NaN but weren't empty to start with. That tells me whether it's just 'N/A' placeholders, or something like a currency code or a range I should handle properly. Only then do I replace the column. I don't call astype(int) directly, because it fails on the first bad value and can't hold NaN anyway."
raw = df['price']
clean = pd.to_numeric(
raw.astype(str).str.strip().str.replace(',', '', regex=False),
errors='coerce',
)
bad = raw[clean.isna() & raw.notna() & (raw.astype(str).str.strip() != '')]
print(bad.value_counts().head(10)) # what failed, and how often
df['price'] = clean
Using errors='coerce' and moving on, so real data problems quietly turn into missing values nobody reviews.
Peek: nrows to look at a sample, and check the separator and encoding.
Types: dtype for IDs and codes as strings, so leading zeros survive; parse_dates for dates.
Values: na_values for placeholders like 'N/A', thousands for separators, usecols to skip what you don't need.
"I start by reading a few rows with nrows to see the real shape, then set the options on purpose. Anything that looks like a number but is really a code, such as an ID or a postal code, I load as a string with dtype, otherwise leading zeros disappear and long IDs can get mangled. Date columns go in parse_dates. If the export uses placeholders like 'N/A' or a dash for missing, I list them in na_values, and if numbers have thousands separators I set thousands to a comma. If the file is wide, usecols loads only what I need, which saves memory and time. And if I get a decode error, I check the encoding and the separator before anything else."
df = pd.read_csv(
'customers.csv',
usecols=['id', 'postcode', 'signup_date', 'spend'],
dtype={'id': 'string', 'postcode': 'string'},
parse_dates=['signup_date'],
na_values=['N/A', '-'],
thousands=',',
)
df.dtypes
Loading with defaults and then being surprised that postcodes lost their leading zeros or dates stayed as text.
Split: groupby on the key column.
Apply: named aggregation, each output as name=(column, function).
Derive: ratios such as average order value come from the aggregated columns, not a mean of lines.
Combine: reset_index or as_index=False to get a flat table back.
"groupby splits the rows by region, applies a function to each group and combines the results into one table. For several metrics with readable names, I use named aggregation, where each output is written as a name equal to a pair of source column and function. Orders is nunique of order_id, because one order can have several lines, and revenue is the sum of amount. Average order value is then revenue divided by orders, which I add with assign. Taking the mean of amount would give the average line, not the average order, which is an easy slip. Named aggregation also avoids the multi-level column names you get from a dict of lists, and reset_index makes region a normal column again."
summary = (
df.groupby('region')
.agg(orders=('order_id', 'nunique'),
revenue=('amount', 'sum'))
.assign(avg_order=lambda d: d['revenue'] / d['orders'])
.reset_index()
.sort_values('revenue', ascending=False)
)
Looping over df['region'].unique() and filtering the table once per region instead of using groupby.
agg: one row per group, such as a total per category.
transform: same length and index as the input, so it can be assigned back as a column.
filter: keeps or drops whole groups based on a condition about the group.
apply: most flexible, slowest; use it only when the others can't express it.
"The difference is the shape of what comes back. agg reduces each group to one row, so I get one line per category. transform returns something the same length as the original, aligned to its index, so I can assign it straight back as a new column. That's how I fill missing prices with the median price of their own category, or compute each order's share of its region's total. filter works on whole groups: it keeps every row of groups that pass a test, like customers with at least three orders, and drops the rest. apply can return anything, but it calls Python once per group, so I only use it when agg, transform or filter can't do the job."
per_cat = df.groupby('category')['price'].agg('median')
cat_median = df.groupby('category')['price'].transform('median')
df['price'] = df['price'].fillna(cat_median)
df['share'] = df['amount'] / df.groupby('region')['amount'].transform('sum')
regulars = df.groupby('customer_id').filter(lambda g: len(g) >= 3)
Computing a group total with agg and then trying to assign it back to the original rows, getting misaligned NaNs.
Aggregate: revenue per region and product first.
Rank: sort by region and revenue descending, then head(3) per group.
Ties: use rank with method='dense' if equal revenues should share a place.
"I first aggregate to one row per region and product with the summed revenue, because the raw data has many lines per product. Then I sort by region, and by revenue from highest to lowest, and take groupby region head three. head keeps the first rows of each group in their current order, so the sort does the ranking. If ties matter, like two products with the same revenue in third place, I'd use rank with method dense, descending, within each region, and keep ranks up to three. That can return more than three rows for a region, which is the honest answer, and I'd say so to whoever asked."
rev = df.groupby(['region', 'product'], as_index=False)['amount'].sum()
top3 = (rev.sort_values(['region', 'amount'], ascending=[True, False])
.groupby('region')
.head(3))
# tie-aware version
rev['rk'] = rev.groupby('region')['amount'].rank(method='dense', ascending=False)
top3_ties = rev[rev['rk'] <= 3]
Taking the overall top three products and then splitting them by region, which answers a different question.
merge: SQL-style join on one or more columns, with how set to inner, left, right or outer; inner by default.
join: a DataFrame method that joins on the index by default, left join by default.
concat: stacks tables along rows or columns, lining up by column names or index, no key matching.
"merge is the general SQL-style join. I give it the key columns with on, or left_on and right_on if the names differ, and choose how: inner is the default, and left, right and outer are the others. join is a convenience method on a DataFrame that matches on the index by default and does a left join, so it's handy when both tables are already indexed by the key. concat doesn't match keys at all. It stacks tables, by default one under the other, lining up columns by name, so it's what I use for monthly files with the same layout. If the columns don't match, concat fills the gaps with NaN, which is a quick way to spot a renamed column."
orders_full = orders.merge(customers, on='customer_id', how='left')
by_id = orders.set_index('customer_id').join(customers.set_index('customer_id'))
all_months = pd.concat([jan, feb, mar], ignore_index=True)
Using concat with axis=1 to combine two tables that should have been merged on a key, silently pairing unrelated rows.
Cause: the key repeats in the right table, so each left row matches several rows.
Guard: validate='many_to_one' raises if the right key isn't unique; assert the row count.
Diagnose: indicator=True shows what matched; also check key dtypes, spaces and case.
"A left merge keeps every left row, but if the key appears more than once in the right table, each left row is repeated once per match. So the row count grows and any sum over the merged table is inflated. I find it by checking which keys are duplicated in the right table. To guard against it, I pass validate, for example many_to_one when I expect each order to match one customer. pandas then raises an error instead of quietly duplicating. I also assert the row count didn't change, and use indicator=True to count matched and unmatched rows. The opposite surprise, rows not matching, usually comes from keys that differ in dtype, stray spaces or case, so I normalise keys before the merge."
print(customers['customer_id'].duplicated().sum()) # repeated keys?
before = len(orders)
out = orders.merge(customers, on='customer_id', how='left',
validate='many_to_one', indicator=True)
assert len(out) == before
print(out['_merge'].value_counts()) # both / left_only
Fixing the inflated count by calling drop_duplicates after the merge, without understanding which rows were duplicated or why.
Simple: isin on the key, negated with ~.
Explicit: left merge with indicator=True and keep left_only.
Care: deduplicate the right keys first so the merge stays one row per customer.
"pandas doesn't have an anti-join method, so there are two common ways. The quickest is isin: take the customers whose ID is not in the orders table's customer_id column, using the tilde to negate. The more explicit way is a left merge with indicator set to True, then keeping rows where the merge column says left_only. I like the merge version when I also want to report how many customers matched, and I deduplicate the order keys first so the merge doesn't repeat customers. Either way, I'd check the key dtypes match, because an integer ID in one table and a text ID in the other would make every customer look like they never ordered."
no_orders = customers[~customers['customer_id'].isin(orders['customer_id'])]
keys = orders[['customer_id']].drop_duplicates()
m = customers.merge(keys, on='customer_id', how='left', indicator=True)
no_orders2 = m[m['_merge'] == 'left_only'].drop(columns='_merge')
Looping over customers and checking each ID against the orders table one by one.
pivot: pure reshape; each index and column pair must appear once.
pivot_table: aggregates duplicate pairs with aggfunc, which defaults to the mean.
Extras: fill_value for empty cells, margins for totals.
"pivot only reshapes. It takes one column for the new rows, one for the new columns and one for the values, and every row and column pair must appear exactly once. If the data has two rows for the same region and month, pivot doesn't know which value to put in the cell, so it raises an error about duplicate entries. pivot_table handles that by aggregating: I choose aggfunc, and if I don't, it takes the mean, which is a classic surprise when someone expected totals. It also has fill_value for combinations with no data, and margins to add totals. So for a sales-by-region-and-month report I use pivot_table with aggfunc set to sum."
report = df.pivot_table(
index='region',
columns='month',
values='amount',
aggfunc='sum',
fill_value=0,
)
Using pivot_table without setting aggfunc and reporting averages as if they were totals.
Wide to long: melt with id_vars for the columns to keep, var_name and value_name for the new columns.
Why long: one row per observation makes groupby, filtering and plotting simple.
Back to wide: pivot with the same three columns.
"I'd use melt. The store column stays as it is, so it goes in id_vars, and every month column gets folded into two new columns: month, holding the old column name, and sales, holding the value. Now each row is one store in one month. That long layout is what most pandas work wants, because I can group by month, filter to a quarter, or feed it to a plotting library without listing column names. To go back to the wide layout for a report, I pivot with store as the index, month as the columns and sales as the values. After melting, I'd usually convert the month text into a real date so it sorts in calendar order, not alphabetically."
long = wide.melt(id_vars=['store'], var_name='month', value_name='sales')
long['month'] = pd.to_datetime(long['month'], format='%Y-%m')
back = long.pivot(index='store', columns='month', values='sales')
Building the long table with nested loops over rows and columns instead of melt.
Why slow: apply with axis=1 calls a Python function once per row and builds a Series each time.
Vectorise: column arithmetic, np.where for two outcomes, np.select for several.
Other tools: .str and .dt accessors, map with a dict for lookups, merge for table lookups.
"apply with axis=1 looks vectorised but it isn't. It runs a Python function once per row, and builds a small Series for each row to pass in, so on millions of rows most of the time is overhead. Vectorised operations run on whole columns in compiled code. So if I'm labelling orders as high or low, I use np.where with the condition on the whole column. For several bands, np.select takes a list of conditions and a matching list of labels, checked in order, with a default. For text I use the .str methods, for dates the .dt accessor, and for looking values up I use map with a dict or a merge. I keep apply for logic that really can't be expressed on columns, and I measure before and after."
import numpy as np
# slow
df['band'] = df.apply(lambda r: 'high' if r['amount'] > 1000 else 'low', axis=1)
# vectorised
df['band'] = np.where(df['amount'] > 1000, 'high', 'low')
conds = [df['amount'] > 1000, df['amount'] > 100]
df['tier'] = np.select(conds, ['high', 'mid'], default='low')
Saying apply is vectorised, or switching from iterrows to apply and calling the problem solved.
Measure: df.info(memory_usage='deep') or memory_usage(deep=True) per column.
Text: low-cardinality text such as status or country code becomes category.
Numbers: downcast with pd.to_numeric; float32 only if the precision is acceptable.
At load: usecols and dtype so the big version never exists.
"I start with memory_usage with deep set to True, because without it pandas can undercount text columns, and text is usually where the memory goes. Then I go column by column. A text column with a few distinct values repeated millions of times, like status or country code, becomes the category dtype, which stores each value once plus small integer codes. Numeric columns default to 64-bit, so I downcast with pd.to_numeric, and use float32 only where the lost precision doesn't matter. The biggest win is often not loading data at all: usecols to skip columns I don't need and dtype at read time. I measure again after each change to see what actually helped."
print(df.memory_usage(deep=True).sort_values(ascending=False).head())
df['status'] = df['status'].astype('category')
df['qty'] = pd.to_numeric(df['qty'], downcast='integer')
print(df.memory_usage(deep=True).sum() / 1e6, 'MB')
Converting every text column to category, including unique IDs, without measuring first.
Load less: usecols and dtype first; often that alone makes it fit.
Chunks: read_csv with chunksize, aggregate each chunk, then combine the partial results.
Combine correctly: sums and counts add up; a mean needs sum and count kept separately.
Next step: convert once to Parquet, or move to an engine built for out-of-memory work.
"First I load less. With usecols I read only the user ID and amount, with compact dtypes, and sometimes that alone fits. If not, I read in chunks with chunksize, which gives an iterator of DataFrames. For each chunk I group by user and sum, then add that into a running total with fill_value zero, so users seen in only some chunks still count. The thing to get right is which aggregates combine: sums and counts add up across chunks, but a mean doesn't, so for an average I keep sum and count and divide at the end. If this job runs often, I'd convert the file to Parquet once so later reads can pick columns, or move it to an engine that works out of memory."
totals = None
reader = pd.read_csv('events.csv', usecols=['user_id', 'amount'],
dtype={'user_id': 'int64', 'amount': 'float64'},
chunksize=1_000_000)
for chunk in reader:
part = chunk.groupby('user_id')['amount'].sum()
totals = part if totals is None else totals.add(part, fill_value=0)
totals = totals.sort_values(ascending=False)
Averaging the per-chunk averages, or suggesting buying more memory as the only answer.
Parse: pd.to_datetime with an explicit format, so 03/04 isn't read the wrong way round.
Failures: errors='coerce' turns bad values into NaT; count and inspect them.
Use: the .dt accessor for year, day name, or month as a period.
"I use pd.to_datetime and give it the format explicitly. Without a format, a value like 03/04 is ambiguous, and I don't want pandas guessing whether that's March or April. With errors set to coerce, anything that doesn't match becomes NaT, the missing value for dates, instead of stopping the whole conversion. Then I check how many NaTs appeared where the text wasn't empty, and look at a few, because a cluster of failures usually means a second format hiding in the data. Once the column is a real datetime, the .dt accessor gives me year, month, day name or a month period for grouping, and date comparisons and sorting work properly."
df['order_date'] = pd.to_datetime(df['order_date_raw'],
format='%d/%m/%Y', errors='coerce')
failed = df['order_date'].isna() & df['order_date_raw'].notna()
print(failed.sum(), df.loc[failed, 'order_date_raw'].head())
df['weekday'] = df['order_date'].dt.day_name()
df['month'] = df['order_date'].dt.to_period('M')
Parsing ambiguous dates without a format and never checking whether days and months were swapped.
Overall: set the timestamp as the index, or pass on=, then resample('W').sum().
Per store: groupby with the store and pd.Grouper(key=..., freq='W').
Gaps: resample creates empty weeks too, a sum shows zero and a mean NaN; the Grouper version leaves a store's empty weeks out.
"resample is groupby for time. It needs a datetime index, or a datetime column passed with on. Resampling to 'W' and summing gives one row per week, and by default those weekly bins end on Sunday and are labelled with that Sunday, which I'd confirm with whoever reads the report. A useful difference from a plain groupby is that resample creates every week in the range, even ones with no sales, so gaps show up as zero for a sum or NaN for a mean instead of just being missing. For weekly totals per store, I use groupby with two keys: the store column and a pd.Grouper on the date with weekly frequency. That gives one row per store per week, though a week with no sales for a store is simply absent, and I reset the index to get a flat table."
weekly = (df.set_index('sold_at')
.sort_index()['amount']
.resample('W')
.sum())
per_store = (df.groupby(['store', pd.Grouper(key='sold_at', freq='W')])['amount']
.sum()
.reset_index())
Grouping by the week number alone, which merges the same week from different years.
Order: sort by store and date first; shift works on row order.
Group: groupby('store') then shift, so one store's value never leaks into the next.
Gaps: a missing day makes the previous row an older date; reindex to a full daily range if that matters.
"shift moves values down by one row, so the change is the value minus the shifted value. A plain df['sales'].shift() has two problems. It works on row order, so if the data isn't sorted by date I'm comparing random days. And across stores, the first row of store B would pick up the last value of store A. So I sort by store and date, then do groupby store and shift, which restarts the shift for each store and leaves the first day as NaN. The subtler issue is gaps: if a store was closed on a Tuesday, Wednesday gets compared with Monday. If the question really means yesterday, I reindex each store to a full daily range first, or I compare on dates rather than rows."
df = df.sort_values(['store', 'date'])
prev = df.groupby('store')['sales'].shift(1)
df['change'] = df['sales'] - prev
df['growth'] = df['sales'] / prev - 1
# same as the row before, not necessarily yesterday:
df['gap_days'] = df.groupby('store')['date'].diff().dt.days
Using df['sales'].shift() on the whole unsorted table and never checking the first row of each store.
The number: what was reported and how the error came to light.
The trace: how you narrowed it to one step, such as checking totals before and after each merge.
The guard: the fix plus the checks you added so it can't recur silently.
"At my last company I built a monthly revenue-by-segment report, and the finance lead said one segment looked too high against their system. I reconciled step by step: the raw orders total matched finance, but after I merged in the customer table, the total went up. The customer table had duplicate IDs for a few hundred customers after a system migration, so those customers' orders were counted twice. I fixed the source by keeping the newest customer record, and in the code I added validate set to many_to_one on the merge plus an assertion that the row count and revenue total don't change after joining. Since then I reconcile the headline total against a trusted source before I share any new report."
A story where the error was found by someone else and fixed by patching the final number, with no root cause or safeguard.
Context: what the script did and why the time mattered.
Measure: timing steps on a sample to find the slowest part.
Fix and result: the specific change, like replacing apply with vectorised code, and the before and after.
"At my last company we had a daily script that calculated a fee for each transaction and it took about forty minutes. Instead of guessing, I timed each step on a sample of a hundred thousand rows. Almost all the time was in one apply with axis=1 that looked up a rate from a dictionary and then used if and else on three columns. I replaced the lookup with a merge against a small rates table, and the if and else chain with np.select. The script went from forty minutes to under two, and I compared the old and new outputs row for row before switching it over."
Claiming a big speed-up without having measured where the time went, or without checking the results still matched.
The gap: what was missing, and how much of the data it touched.
The choice: the options, what each would do to the result, and which you picked.
The explanation: how you put it in plain words, and what the person decided.
"In one analysis of customer survey data, about a fifth of the responses had no age. The marketing manager wanted results by age group. If I dropped those rows, the overall satisfaction score would shift, because the people skipping the age question were mostly newer customers. If I filled ages with an average, I'd be inventing data. I showed the manager a small table: the score with all responses, and the score with only responses that had an age. Then I explained in one sentence that dropping changes who we're describing. We agreed to keep everyone in the overall score, show the age breakdown with an 'age not given' group, and add a note on the chart."
Saying you always drop missing rows, or that the stakeholder didn't need to know how the data was cleaned.
Size it: count the failures and look at examples; often it's one second format.
Quick fix: parse the second format and fill the gaps, then re-check.
If not fixable: ship with the affected rows counted and flagged, and say what it changes.
"First I'd spend five minutes sizing it. I count the rows that became NaT but had text, and look at the most common raw values. Very often it's a second date format from one source, like year first instead of day first. If so, I parse those with their own format, fill the gaps, and check the failure count drops to near zero. That's usually quicker than it sounds. If it's something I can't fix in time, I don't drop those rows quietly, because they'd vanish from the totals. I'd send the report on time with a clear note: how many rows had unreadable dates, which figures they affect, and when I'll send the corrected version."
failed = df['sold_at'].isna() & df['sold_at_raw'].notna()
print(failed.sum(), df.loc[failed, 'sold_at_raw'].value_counts().head())
alt = pd.to_datetime(df['sold_at_raw'], format='%Y-%m-%d', errors='coerce')
df['sold_at'] = df['sold_at'].fillna(alt)
Dropping the NaT rows without telling anyone, or missing the deadline to chase a perfect fix.
Baseline: restart and run all, and save the current output before touching anything.
Isolate: find where the rule lives and move it into a small function you can check alone.
Compare: diff new against old; only rows touched by the rule should change, and you can explain each.
"Before changing anything, I'd restart the kernel and run it top to bottom, because notebooks often only work thanks to cells run out of order. If it reproduces last month's figure, I save that output as my baseline. Then I find where the rule is applied and pull that logic into a small function, so I can test it on a few hand-made rows. After the change, I rerun and compare against the baseline. I expect differences only in the rows the rule touches, so I check which rows changed and that I can explain each one. pd.testing.assert_frame_equal is handy to prove everything else is identical. Finally, I'd write down the rule change and the difference in the headline number for whoever signs it off."
Editing the rule, rerunning once and sending the new number because it looks reasonable.
Measure: where memory peaks and how fast the input is growing.
Cheap fixes: load fewer columns, tighter dtypes, filter early, Parquet input, drop intermediates.
Decide: if the job will still outgrow one machine soon, plan the move with tests; if not, keep it simple.
"I'd hold the rewrite until we've measured. First I'd find where memory peaks. It's often one step, like a merge that multiplies rows or text columns loaded as full strings. Then I'd try the cheap fixes: read only the needed columns with tight dtypes, filter as early as possible, switch the input from CSV to Parquet, and delete big intermediate frames once used. Those often cut memory by a large factor in a day's work. Then I'd look at the growth trend. If the data will outgrow a single machine within months even after that, a distributed rewrite is the right call, and I'd plan it with the current output as the test baseline. If not, a rewrite adds cost and complexity for nothing."
Either rewriting straight away without measuring, or refusing to consider it without looking at how fast the data is growing.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.