Pandas Fundamentals
The pandas idioms LeetCode-style screens reach for — groupby, merge, time-series, vectorization, and the SQL↔pandas translation that wins the "could you also do this in SQL?" follow-up.
The interview approach
- Restate the problem. Ask about input shape (DataFrame vs list of dicts), expected output shape, edge cases (empty input, NaNs, ties).
- State your approach: "I'm going to groupby A, aggregate B, then merge back." Get a nod.
- Write the vectorized version first. Reach for
.apply()only with a comment about why it's the right call. - Mention the alternative: "Could also push to SQL or use DuckDB on this DataFrame at scale." This is the senior tell.
- Trace 2 example rows. Catches off-by-ones.
Groupby patterns
Basic aggregates
import pandas as pd
# Multiple aggregations per group, named outputs
result = df.groupby('customer_id').agg(
n_sessions=('session_id', 'count'),
total_revenue=('revenue', 'sum'),
first_session=('started_at', 'min'),
last_session=('started_at', 'max'),
)
# Aggregate different columns differently in one call
result = df.groupby('tier').agg({
'revenue': 'sum',
'session_id': 'nunique',
'started_at': ['min', 'max'],
})
Filter groups (HAVING-equivalent)
# Keep customers with at least 5 sessions
result = df.groupby('customer_id').filter(lambda g: len(g) >= 5)
# Equivalent, faster — avoids the Python lambda
counts = df.groupby('customer_id').size()
keep = counts[counts >= 5].index
result = df[df['customer_id'].isin(keep)]
Transform: add a group-level value back to every row
# Each row's revenue as a fraction of the customer's total
df['share_of_total'] = df['revenue'] / df.groupby('customer_id')['revenue'].transform('sum')
Pivot
wide = df.pivot_table(
index='customer_id',
columns='tier',
values='gpu_hours',
aggfunc='sum',
fill_value=0,
)
Merge gotchas
- Default
how='inner'— always specifyhowexplicitly.'left'is what you usually want for "enrich the left table." - Row duplication: if your join key isn't unique on the right side, you'll get more rows than you started with. Always check row count before and after.
- Column-name collisions: when both sides have a column not in the join key, pandas appends
_xand_y. Pre-rename or usesuffixes. validateargument:df.merge(other, on='key', how='left', validate='m:1')raises if the assumption is wrong. Use it.
enriched = sessions.merge(
customers[['customer_id', 'plan', 'country']],
on='customer_id',
how='left',
validate='m:1', # raises if customer_id isn't unique in customers
)
assert len(enriched) == len(sessions) # row count sanity
The asof merge
For "what was the value of X at the time of event Y" — point-in-time joins.
sessions = sessions.sort_values('started_at')
price_history = price_history.sort_values('changed_at')
result = pd.merge_asof(
sessions, price_history,
left_on='started_at', right_on='changed_at',
by='tier', # match within the same tier
direction='backward', # most-recent price at or before started_at
tolerance=pd.Timedelta('30 days'), # don't match older than 30 days
)
Window-function equivalents
| SQL | Pandas |
|---|---|
ROW_NUMBER() OVER (PARTITION BY a ORDER BY b) | df.sort_values('b').groupby('a').cumcount() + 1 |
RANK() OVER (...) | df.groupby('a')['b'].rank(method='min') |
DENSE_RANK() OVER (...) | df.groupby('a')['b'].rank(method='dense') |
LAG(b) OVER (PARTITION BY a ORDER BY t) | df.sort_values('t').groupby('a')['b'].shift(1) |
LEAD(b) OVER (...) | df.sort_values('t').groupby('a')['b'].shift(-1) |
SUM(b) OVER (PARTITION BY a) | df.groupby('a')['b'].transform('sum') |
SUM(b) OVER (PARTITION BY a ORDER BY t) (running) | df.sort_values('t').groupby('a')['b'].cumsum() |
AVG(b) OVER (ORDER BY t ROWS 6 PRECEDING) | df['b'].rolling(window=7, min_periods=1).mean() |
Time-series ops
Resample
For "aggregate by week" / "fill missing days" / "downsample to hourly":
df['started_at'] = pd.to_datetime(df['started_at'])
weekly = (df.set_index('started_at')
.groupby('customer_id')
.resample('W-MON') # ISO weeks
.agg({'revenue': 'sum', 'session_id': 'count'})
.reset_index())
Fill missing dates
daily = df.set_index('day').reindex(
pd.date_range(df['day'].min(), df['day'].max(), freq='D'),
fill_value=0
)
Date math
(df['ended_at'] - df['started_at']).dt.total_seconds() / 3600— gpu-hours.df['day'] = df['started_at'].dt.normalize()— strip time component to midnight.df['week'] = df['started_at'].dt.to_period('W').dt.start_time— week truncation.
Vectorization
Avoid .apply when possible
Pandas' .apply with a Python function is 10–100× slower than vectorized ops on big DataFrames. Reach for vectorization first.
# ✗ Slow
df['tier_label'] = df.apply(
lambda r: 'high' if r['gpu_hours'] > 100 else 'low', axis=1)
# ✓ Vectorized
df['tier_label'] = np.where(df['gpu_hours'] > 100, 'high', 'low')
# Multi-branch: np.select
import numpy as np
df['tier_label'] = np.select(
[df['gpu_hours'] > 1000, df['gpu_hours'] > 100, df['gpu_hours'] > 0],
['enterprise', 'pro', 'free'],
default='inactive'
)
When .apply is the right call
- The function is genuinely complex (multi-line logic, branching, lookups against external data).
- The DataFrame is small (<100k rows) and clarity matters more than speed.
- You're applying along axis=0 (column-wise) to a small number of columns.
Mention DuckDB on big data
For "at scale" follow-ups: DuckDB can query a pandas DataFrame directly with SQL, often faster than pure pandas for groupby/join-heavy work:
import duckdb
result = duckdb.sql("""
SELECT customer_id, COUNT(*) AS sessions, SUM(revenue) AS total
FROM df
WHERE started_at >= '2026-04-01'
GROUP BY customer_id
""").to_df()
Mentioning this unprompted reads senior — it shows you know when pandas isn't the right tool.
SQL ↔ pandas translation
| SQL | Pandas |
|---|---|
SELECT col FROM t | df['col'] or df[['col']] |
SELECT * FROM t WHERE x > 5 | df[df['x'] > 5] |
SELECT * FROM t WHERE x IN (1,2,3) | df[df['x'].isin([1,2,3])] |
SELECT * FROM t WHERE x IS NULL | df[df['x'].isna()] |
GROUP BY a HAVING COUNT(*) > 5 | df.groupby('a').filter(lambda g: len(g) > 5) |
ORDER BY a DESC | df.sort_values('a', ascending=False) |
LIMIT 10 | df.head(10) |
DISTINCT a, b | df[['a','b']].drop_duplicates() |
UNION ALL | pd.concat([df1, df2], ignore_index=True) |
JOIN ON | df1.merge(df2, on='id', how='inner') |
COALESCE(a, b) | df['a'].fillna(df['b']) |
CASE WHEN ... THEN ... END | np.where / np.select |
Edge cases & traps
The SettingWithCopyWarning
df[df['x'] > 0]['y'] = 99 may or may not modify the original DataFrame — pandas warns because the behavior depends on internal optimizations. The fix: df.loc[df['x'] > 0, 'y'] = 99.
NaN comparisons
NaN == NaN is False in pandas (and SQL). Use .isna() for tests; never == NaN.
Integer columns with NaN become floats
If you have a numeric column with any NaN, pandas casts the whole column to float. Use pd.Int64Dtype() (nullable integer) if you need to preserve integer semantics.
Object-dtype Series are slow
Strings stored as object are slow for many operations. pd.StringDtype() or pyarrow-backed strings are faster.
Sorting before groupby
Pandas' groupby sorts groups by default. If you've already sorted, pass sort=False to skip the redundant sort and save time.