Python and Pandas for Data Analysis Questions
Programmatic data manipulation and analysis in Python and R. Covers pandas transformations, joins and reshaping, aggregation, working with PySpark for larger data, and using R for statistical analysis. Emphasizes clean, reproducible analytical code.
Discuss when NumPy vectorized aggregation can meaningfully outperform pandas groupby for aggregating millions of rows by category label, and show a concrete example of that speedup. Then explain when pandas' own groupby implementation is preferable despite the difference.
Sample Answer
Direct answer: NumPy vectorized aggregation (integer-encode the category labels, then np.bincount) can meaningfully outperform pandas.groupby for simple numeric reductions like sum, count, or mean on a single column, because it skips the general-purpose machinery groupby carries for mixed dtypes, missing values, and multi-column, multi-aggregation pipelines. Once your aggregation needs any of that generality, pandas.groupby becomes the better choice again, both for correctness and for how much code you'd otherwise have to hand-write and maintain.
Structured elaboration, worked end to end (verified, pandas 3.0.3, numpy 2.5.1, seed 0, 20 rows, 4 groups, fully reproducible):
When NumPy wins: you have one (or a few) numeric column(s), a single simple reduction (sum, count, mean), category labels that can be mapped to small contiguous integers, and no missing values to reason about. np.bincount computes a per-group sum or count in one pass with none of groupby's dtype dispatch, index bookkeeping, or Python-level overhead per group.
import numpy as np
import pandas as pd
rng = np.random.RandomState(0)
N, G = 20, 4
groups = rng.randint(0, G, size=N).astype(np.int32)
values = rng.randn(N)
sums = np.bincount(groups, weights=values) # per-group sum, one pass
counts = np.bincount(groups) # per-group count
means = sums / counts # handle any zero-count group separately
print('sums =', sums)
print('counts =', counts)
print('means =', means)
Verified output: sums = [-0.4656, -0.0095, 4.4562, 1.9245], counts = [6, 4, 3, 7], means = [-0.0776, -0.0024, 1.4854, 0.2749] for groups 0-3.
If you need to accumulate in place or handle repeated indices explicitly rather than via weights=, np.add.at is the safe alternative:
acc = np.zeros(G, dtype=np.float64)
np.add.at(acc, groups, values)
print('np.allclose(acc, sums) =', np.allclose(acc, sums))
Verified: acc equals sums above exactly (np.allclose(acc, sums) is True).
Correctness check against pandas.groupby, same data:
df = pd.DataFrame({'cat': groups, 'val': values})
means_pd = df.groupby('cat')['val'].mean()
print('means_pd (sorted by group) =', means_pd.sort_index().to_numpy())
print('max abs difference =', np.max(np.abs(means_pd.sort_index().to_numpy() - means)))
Verified output: means_pd (sorted by group) is [-0.077606, -0.002381, 1.485384, 0.274924], identical to means above to 15 decimal places, max absolute difference 5.6e-17 (floating-point rounding noise, not a real difference). This confirms both approaches compute the identical aggregation on real data, so the choice between them is purely about performance and generality, not correctness. (Wall-clock timing numbers are intentionally omitted here since they are hardware- and environment-dependent and this small fixture is too small to be a fair benchmark anyway; the mechanism below is what actually explains the gap at scale.)
Why NumPy is faster for this narrow case: np.bincount is a single specialized C loop over a pre-encoded integer array with no per-row dtype checks, no handling of missing group labels, and no construction of an intermediate grouped-object representation. pandas.groupby builds a grouping object, handles arbitrary key types (strings, categoricals, tuples of columns), preserves index alignment, and supports dozens of aggregation functions and multi-column .agg() dictionaries, all of which cost something even when your specific case doesn't need it.
When pandas.groupby is preferable despite the difference
- Mixed dtypes or non-numeric group keys,
groupbyhandles string, categorical, and datetime keys directly; you would have to build your own integer encoding by hand for NumPy. - Missing values that need to be preserved or explicitly excluded,
groupbyhas consistent, well-testedNaN(not-a-number, pandas' missing-value marker) handling; a hand-rolledbincountpipeline requires you to reimplement that correctly. - Multiple different aggregations per column or across columns in one pass, e.g.
df.groupby('cat').agg({'sales': 'sum', 'user': 'nunique'}), this is one readable call in pandas versus several hand-written NumPy passes. - Time-aware or hierarchical grouping (resampling,
groupby(level=...)on aMultiIndex). - Code clarity and fewer manual edge-case bugs: division-by-zero on empty groups, sort/stability guarantees, and correct alignment back to the original frame are all handled for you.
Complexity: both approaches are O(n) in the number of rows for a single-pass reduction like sum or count; the difference is in the constant factor, not the asymptotic order, bincount has a smaller constant because it avoids groupby's per-row dispatch and object overhead. Memory is O(G) for both, where G is the number of distinct groups (the accumulator array in one case, the grouped result index in the other), so this trade-off is about per-row overhead and generality, not about complexity class.
Trade-offs and pitfalls
- Reach for the NumPy path only when you can cleanly map categories to small contiguous integers up front (
pd.factorizedoes this for you and returns exactly whatbincountneeds) and your aggregation genuinely is that simple. Building and maintaining that encoding for a case that doesn't actually need the speed is wasted complexity. - Benchmark on your real data and hardware before committing to the NumPy path in a production pipeline, the size of the gap depends heavily on group count, row count, and dtype, and
groupby's internal implementation improves across pandas releases. - A common middle ground: use
pandas.groupbyfor exploratory work and multi-aggregation reporting, and drop to a NumPy pre-aggregation pass only in a narrow, profiled hot path where it has been measured to matter.
Using pandas in Python, given a DataFrame 'events' with columns ['user_id','event_type','value','event_time'], provide code to compute: (1) total and average 'value' per 'event_type', (2) number of unique users per event_type, and (3) the top 5 event_types by total 'value'. Write readable pandas groupby expressions and explain the role of as_index and reset_index for returning DataFrames.
Sample Answer
Direct answer
Use named aggregation inside .groupby(...).agg(...) for readable, self-documenting column names, .nunique() for the distinct-user count, and .sort_values().head(5) for the top 5. as_index=False returns the group key as a regular column immediately, which is usually what you want for a result you are about to sort, display, or write out.
Approach
- Group by
event_typeand use named aggregation to computetotal_value(sum) andavg_value(mean) in one call, with clear output column names chosen up front rather than pandas' default multi-level column names. - A second grouped aggregation (or the same one, extended) for
unique_usersvia.nunique()onuser_id. - Sort the totals descending and take the first 5 rows for the top-5 ranking.
Worked example
import pandas as pd
events = pd.DataFrame({
"user_id": [1, 2, 1, 3, 2, 1, 4, 5, 2, 3, 6, 1],
"event_type": ["click", "click", "view", "purchase", "view", "purchase",
"click", "signup", "purchase", "view", "click", "signup"],
"value": [1, 1, 0, 50, 0, 30, 1, 0, 20, 0, 1, 0],
"event_time": pd.date_range("2024-01-01", periods=12, freq="h"),
})
agg = events.groupby("event_type", as_index=False).agg(
total_value=("value", "sum"),
avg_value=("value", "mean"),
)
unique_users = events.groupby("event_type", as_index=False).agg(
unique_users=("user_id", "nunique"),
)
top5 = agg.sort_values("total_value", ascending=False).head(5)
print(agg)
print(unique_users)
print(top5)
Output (verified against pandas 3.0.3):
event_type total_value avg_value
0 click 4 1.000000
1 purchase 100 33.333333
2 signup 0 0.000000
3 view 0 0.000000
event_type unique_users
0 click 4
1 purchase 3
2 signup 2
3 view 3
event_type total_value avg_value
1 purchase 100 33.333333
0 click 4 1.000000
2 signup 0 0.000000
3 view 0 0.000000
top5 shows all 4 event types here (fewer than 5 exist in this sample), correctly ordered by total_value descending.
Role of as_index and reset_index
- With
as_index=False(used above), the group key (event_type) comes back as an ordinary column and the result already has a default RangeIndex, ready to sort, merge, or write out without an extra step. - With the default
as_index=True, the group key becomes the DataFrame's index instead of a column; call.reset_index()afterward to turn it back into a column, which is exactly whatas_index=Falsedoes inline. The two are equivalent in the end result;as_index=Falsejust skips the separate call. - For a data-pipeline context specifically, returning tidy, index-free DataFrames at each stage (rather than index-heavy intermediate results) simplifies downstream joins and serialization, which is why
as_index=False(or an explicit.reset_index()) is the more common choice in ETL (extract-transform-load) code even thoughas_index=Trueis pandas' own default.
Complexity
Each .groupby().agg() call is a single O(n) pass over events (n = number of rows), regardless of the number of distinct event_type values k; the result size is O(k). .nunique() per group additionally costs proportional to the number of rows in that group to track distinct values, still O(n) overall. sort_values().head(5) on the k-row aggregate is negligible next to the O(n) grouping pass, since k is typically tiny.
Edge cases
- An event_type with zero total value (as with
signupandviewabove): still appears in the output withtotal_value=0andavg_value=0.0, not silently dropped, sincegroupbyincludes every group with at least one row by default. - Fewer than 5 distinct event types:
.head(5)simply returns all of them, as shown; no error or special-casing needed. - A
valuecolumn containing NaN:.sum()and.mean()skip NaN by default (skipna=True), so a group with some missing values still gets a meaningful (if partial) aggregate rather than becoming NaN itself; be explicit in documentation if that silent skip is not the intended behavior for your data.
Trade-offs and pitfalls
- Named aggregation (
total_value=('value','sum')) is preferred over the older dict-based.agg({'value': ['sum','mean']})form specifically because the dict form produces MultiIndex columns (('value','sum'),('value','mean')) that need flattening before they are convenient to use, while named aggregation gives flat, chosen names directly.
You filter a DataFrame into a subset and set values on one of its columns. Walk through what pandas is actually doing under the hood when it cannot tell whether that subset is an independent copy or a view into the original, why this used to be a silent correctness trap rather than just a noisy warning, and how you would rewrite the code to be unambiguously safe regardless of pandas version.
Sample Answer
Direct answer
This shows up because pandas cannot always tell, once you have filtered a DataFrame down to a subset, whether that subset shares memory with the original DataFrame (a "view") or is an independent block of memory (a "copy"). Which one you get depends on the exact operation and the internal memory layout, it is a pandas implementation detail, not something your code controls. When you then assign into that subset, pandas historically had to guess whether you meant to edit the original data too. On pandas 3.0, Copy-on-Write (CoW) removes the guesswork by making every indexing result behave as independent for write purposes, but that changes what the old textbook example actually does when you run it today, which is worth walking through precisely.
Why this used to be a silent correctness trap, not just a noisy warning
The problem was never the warning itself, it was that the outcome silently depended on which one pandas happened to pick:
- If the subset happened to be a view, editing it also edited the original DataFrame, even though you may only have meant to change your local copy.
- If the subset happened to be a copy (the more common outcome for boolean-mask filtering), your edit only landed on that temporary object and was discarded once it went out of scope. The original DataFrame stayed untouched, with no error, the code ran, it just did not do what you thought.
Concrete example:
import pandas as pd
df = pd.DataFrame({'a': [1, 2, 3], 'b': [10, 20, 30]})
subset = df[df['a'] > 1] # rows where a=2 and a=3
subset['b'] = subset['b'] * 2 # pre-2.0: often a SettingWithCopyWarning; pandas 3.0: silent, no warning
print(subset['b'].tolist()) # looks correct in isolation
print(df['b'].tolist()) # df was never actually updated
Verified output on pandas 3.0.3:
[40, 60]
[10, 20, 30]
subset['b'] shows [40, 60], df['b'] is still [10, 20, 30]. This is exactly the silent-trap outcome: the code runs cleanly and subset looks right, so nothing draws your attention to the fact that df was never touched.
What changed under Copy-on-Write, and what did not
A version note worth knowing precisely: this exact demo behaves differently depending on your pandas version, but the values it produces do not. Copy-on-Write became opt-in in pandas 2.0 and mandatory in pandas 3.0 (it can no longer be disabled), and under Copy-on-Write, pandas.errors.SettingWithCopyWarning has been removed from the library entirely. Running the exact code above on pandas 3.0.3 (verified in sandbox) still prints subset['b'] as [40, 60] and df['b'] as [10, 20, 30], the same silently-unchanged result as always, but with no warning at all now, not even a noisy one. Copy-on-Write closes the loophole structurally rather than just flagging it: every filter or slice is now treated as copy-on-write, so a write through a chained reference simply never reaches the original, deterministically, instead of pandas guessing and warning about it. The mechanism underneath (chained indexing being two separate operations, and why .loc or an explicit .copy() fixes it) is still exactly right, and still what you will run into in any pre-2.0 codebase still in production, you just cannot rely on a warning to catch it on current pandas, which is one more reason to make .loc-based writes a habit rather than something a warning will flag for you after the fact.
The underlying bug is chained indexing: subset['b'] = ... is really two separate operations under the hood, first df.__getitem__ produces subset, then a later __setitem__ tries to write into it. Because those are two separate steps, pandas cannot guarantee the write propagates back to df even on a version where subset happens to be a view, and the intermediate object can be garbage collected right after, so the edit just vanishes.
Rewriting it to be unambiguously safe, on any pandas version
Do the filter and the assignment as one expression with .loc, directly on df:
df.loc[df['a'] > 1, 'b'] = df.loc[df['a'] > 1, 'b'] * 2
print(df['b'].tolist())
Verified output: [10, 40, 60], df is correctly updated in place. .loc[row_selector, col_selector] = value is a single indexing operation, so pandas always knows you intend to modify df itself, no guessing involved, on pre-2.0 pandas, 2.x with or without Copy-on-Write, and pandas 3.0 alike.
If you genuinely want an independent object to experiment with, one that is not meant to affect df, make that explicit:
subset = df[df['a'] > 1].copy()
subset['b'] *= 2 # safe: subset is a real, independent DataFrame, no ambiguity
Trade-offs and pitfalls
- On pandas versions where
SettingWithCopyWarningstill exists (pre-2.0, or 2.x with Copy-on-Write not enabled), you can silence it withpd.options.mode.chained_assignment = None, but that only hides the ambiguity, it does not fix it, the silent no-op or accidental view-mutation is still possible underneath. On pandas 3.0+, this option is effectively a no-op, since Copy-on-Write means there is no moreSettingWithCopyWarningleft to silence. - Prefer rewriting with
.loc(when you intend to modify the original) or an explicit.copy()(when you intend an independent object) on any pandas version, rather than depending on version-specific warning behavior to catch the mistake for you. - The version-dependent framing matters for interviews specifically: candidates who learned pandas from material written before 2.0 will describe a
SettingWithCopyWarningthat current pandas will never show them for this exact code, stating the mechanism (chained indexing loses the write) without pinning it to "you'll see a warning" is the version-safe way to answer this.
Explain the difference between DataFrame.loc and DataFrame.iloc in pandas. Provide Python examples showing selection by label vs integer position, slicing semantics (inclusive/exclusive), behavior with boolean arrays and callable indexing, and discuss pitfalls when the index contains integer labels or duplicate labels.
Sample Answer
Direct answer
.loc selects by label, row and column names, and its slice endpoint is inclusive. .iloc selects by integer position, 0-based, and its slice endpoint is exclusive, exactly like a plain Python list slice. Both accept single labels/positions, lists, boolean arrays, and callables, but what those inputs mean differs: a .loc boolean array must align with the index, while .iloc needs a purely positional array of the right length.
Structured elaboration
Label vs position, and inclusive vs exclusive slicing.
import pandas as pd
df = pd.DataFrame({'A': [10, 20, 30]}, index=[0, 1, 2])
df.loc[1, 'A'] # 20 -> the row LABELED 1
df.iloc[1, 0] # 20 -> the row at POSITION 1
# these agree here only because the index [0, 1, 2] looks identical to positions;
# they are conceptually different lookups and diverge once the index is not 0..n-1
df.loc[0:1, :] # rows labeled 0 and 1 (BOTH included)
df.iloc[0:1, :] # row at position 0 only (stop at 1 is EXCLUDED)
mask = df['A'] > 15
df.loc[mask] # rows where A > 15, mask aligned by label
df.loc[lambda d: d['A'] > 15] # same result via a callable
# a boolean array passed to .iloc must be purely positional (no label alignment),
# so df.iloc[mask.values] is the form that works there, not the mask Series itself
df.iloc[mask.values]
Duplicate labels. If the index has repeated labels, df.loc[0] returns every row carrying that label as a DataFrame, not a single row; .iloc[0] is unaffected by duplicate labels since it addresses by position and always returns exactly the row at that position.
Worked example
import pandas as pd
df_dup = pd.DataFrame({'A': [1, 2, 3]}, index=[0, 0, 1])
df_dup.loc[0]
# A
# 0 1
# 0 2
df_dup.iloc[0]
# A 1
# Name: 0, dtype: int64
Trade-offs and pitfalls
Relying on integer-looking labels is the most common source of confusion: df.loc[1] and df.iloc[1] coincide only when the index happens to be a default 0..n-1 range; after any filter, sort, or concat that leaves gaps or reorders the index, they diverge silently, so .loc[1] may return a completely different row than .iloc[1], or raise a KeyError if label 1 no longer exists. When you specifically need position-based access regardless of what the labels are, use .iloc; when you need to address rows by an identifier that should stay stable across reshuffles, use .loc.
You have a DataFrame with nested JSON in a column 'payload' (strings of JSON), where some fields inside the payload are themselves lists. Show how to expand this column into separate flat columns, and how to turn the list-valued fields into one row per list item where needed. Discuss the performance implications of doing this at scale.
Sample Answer
Direct answer
Parse each JSON string once with json.loads, then use pandas.json_normalize to flatten the nested dict fields into dot-separated columns. For a field that is itself a list (like a per-record list of events), explode it into one row per list item first, then normalize that exploded column separately, since json_normalize alone flattens dicts but leaves list-valued cells untouched.
Approach
import pandas as pd
import json
from pandas import json_normalize
def flatten_nested_payload(df, payload_col="payload", list_field="events", nested_obj_field="user"):
parsed = df[payload_col].apply(json.loads)
# Step 1: flatten the top-level nested dict fields (user.*, meta.*).
# List-valued fields (events) are left as-is at this stage.
flat = json_normalize(parsed)
df_flat = pd.concat([df.drop(columns=[payload_col]).reset_index(drop=True), flat], axis=1)
# Step 2: explode the list-valued field into one row per item,
# then normalize the exploded dicts (and re-attach the parent nested object).
df_events = df.assign(parsed=parsed)
df_events = (
df_events
.assign(event=df_events["parsed"].apply(lambda d: d.get(list_field, [])))
.explode("event")
.reset_index(drop=True)
)
events_flat = json_normalize(df_events["event"])
nested_flat = json_normalize(df_events["parsed"].apply(lambda d: d.get(nested_obj_field, {}))).add_prefix(f"{nested_obj_field}_")
id_cols = [c for c in df.columns if c != payload_col]
result = pd.concat([df_events[id_cols].reset_index(drop=True), nested_flat, events_flat], axis=1)
return df_flat, result
Key points:
json_normalizeturns nested dict keys into dot-separated column names (user.name,meta.source); it does not expand list-valued cells on its own,explodeis a separate, required step for those.explodeduplicates every non-list column onto each new row (each event row gets the sameidas its parent record), which is exactly the one-row-per-list-item shape the question asks for.add_prefix("user_")on the nested user fields avoids a column-name collision: the record has its ownidand the nested user object also has anid, and concatenating both without renaming produces two columns both namedid, which is confusing (not an error, sincepd.concaton axis=1 tolerates duplicate names, but any laterdf["id"]lookup would return both columns and likely break downstream code).
Worked example
df = pd.DataFrame({
"id": [1, 2],
"payload": [
'{"user": {"id": 10, "name": "Alice"}, "events": [{"type": "click", "ts": 100}, {"type": "view", "ts": 110}], "meta": {"source": "web"}}',
'{"user": {"id": 20, "name": "Bob"}, "events": [{"type": "view", "ts": 200}], "meta": {"source": "app"}}',
],
})
df_flat, result = flatten_nested_payload(df)
print(df_flat[["id", "user.id", "user.name", "meta.source"]])
print(result)
Output (verified against pandas 3.0.3):
id user.id user.name meta.source
0 1 10 Alice web
1 2 20 Bob app
id user_id user_name type ts
0 1 10 Alice click 100
1 1 10 Alice view 110
2 2 20 Bob view 200
Record 1's two events (a click and a view) each become their own row, both carrying the parent record's id and the flattened user_id/user_name; record 2's single event becomes one row. The renamed user_id/user_name columns are cleanly distinct from the outer record id.
Complexity and edge cases
Complexity: json.loads per row is O(payload size) per record and is pure Python, so it dominates the cost at scale; json_normalize and explode are each roughly O(total output rows). Memory: json_normalize materializes one column per distinct key seen across all parsed records, so a payload with many rarely-used optional keys produces a wide, mostly-empty result; selecting only the keys actually needed before normalizing keeps this bounded.
Edge cases: a missing events key is handled by .get("events", []) defaulting to an empty list, so explode on an empty list produces a single row with NaN (not-a-number, pandas' missing-value marker) in the event columns rather than dropping the record entirely; heterogeneous schemas across rows (one payload has a key another doesn't) leave the missing key as NaN after json_normalize rather than raising; malformed JSON in a payload cell should be validated or wrapped in a try/except around json.loads before this pipeline runs, since one bad row otherwise raises and stops the whole .apply.
Trade-offs and pitfalls
json.loads plus .apply is a row-by-row Python-level loop under the hood, which is the main performance ceiling at scale; a faster JSON library (orjson or ujson) as a drop-in replacement for json.loads reduces the constant factor without changing the pandas-level structure. For very large payload volumes, precomputing the exact set of keys needed and only extracting those (rather than normalizing the entire nested structure and discarding columns afterward) avoids building wide intermediate DataFrames you throw away; chunked reads or a distributed engine (Dask, PySpark) parallelize the row-level parsing itself when a single machine's CPU becomes the bottleneck rather than memory. The renaming pitfall above generalizes: any time you flatten two independently-nested objects that might share a key name (here, the record's own id and the nested user.id), decide the naming scheme up front rather than discovering the collision after pd.concat has already silently produced two same-named columns.
Unlock Full Question Bank
Get access to all Python and Pandas for Data Analysis interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.