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.
Timezones can be a source of subtle bugs. Given user events with local timestamps and a user timezone column, design a pandas-based approach to normalize events into UTC, handle ambiguous times during DST transitions, and store both original local time and normalized UTC. Provide code examples and discuss edge cases.
Sample Answer
Direct answer
Localize each user's naive local timestamp to their own IANA time zone with Series.dt.tz_localize, using ambiguous='NaT' (not 'infer') and nonexistent='shift_forward', then convert to UTC (Coordinated Universal Time) for storage while keeping the original local timestamp as-is. ambiguous='infer' only works when a timezone has repeated (duplicate) wall-clock times to compare against each other in sequence; with one event per user per ambiguous instant, there is nothing to infer from and it raises.
Approach
- Parse the local timestamp as a naive datetime (no timezone attached yet).
- Group by each user's IANA time zone name (e.g.
America/New_York) sincetz_localizetakes one zone at a time. - Localize with an explicit policy for both DST (daylight saving time) edge cases:
ambiguous='NaT'marks the fall-back duplicate hour as missing rather than guessing, andnonexistent='shift_forward'rolls a spring-forward time that never existed to the next valid instant. - Convert the localized column to UTC with
.dt.tz_convert('UTC'). - Keep both columns: the tz-aware local time (for display) and the UTC time (for cross-user comparison and storage).
Worked example
import pandas as pd
pd.set_option("display.width", 200)
pd.set_option("display.max_columns", None)
df = pd.DataFrame({
"user_id": [1, 2, 3, 4],
"local_ts": ["2021-11-07 01:30:00", "2021-03-14 02:30:00", "2021-06-01 12:00:00", "2021-11-07 01:30:00"],
"user_tz": ["America/New_York", "America/New_York", "Europe/Berlin", "America/Chicago"],
})
def normalize(frame):
out = []
for tz, group in frame.groupby("user_tz"):
naive = pd.to_datetime(group["local_ts"])
# ambiguous='NaT': the duplicated fall-back hour becomes missing instead
# of a silent guess; nonexistent='shift_forward': spring-forward gaps
# roll to the next valid instant
aware = naive.dt.tz_localize(tz, ambiguous="NaT", nonexistent="shift_forward")
group = group.copy()
group["local_aware"] = aware
group["utc_ts"] = aware.dt.tz_convert("UTC")
group["ambiguous_dst"] = aware.isna() & naive.notna()
out.append(group)
return pd.concat(out).sort_index()
result = normalize(df)
print(result[["user_id", "local_ts", "user_tz", "local_aware", "utc_ts", "ambiguous_dst"]])
Output (verified against pandas 3.0.3):
user_id local_ts user_tz local_aware utc_ts ambiguous_dst
0 1 2021-11-07 01:30:00 America/New_York NaT NaT True
1 2 2021-03-14 02:30:00 America/New_York 2021-03-14 03:00:00-04:00 2021-03-14 07:00:00+00:00 False
2 3 2021-06-01 12:00:00 Europe/Berlin 2021-06-01 12:00:00+02:00 2021-06-01 10:00:00+00:00 False
3 4 2021-11-07 01:30:00 America/Chicago NaT NaT True
Row 0 and row 3 both fall inside the fall-back duplicate hour in their respective US time zones (clocks repeat 1:00-2:00 on 2021-11-07) and are correctly flagged rather than silently resolved. Row 1 falls inside the spring-forward gap (2:00-3:00 does not exist on 2021-03-14 in America/New_York) and is shifted forward to 3:00 local. Row 2 has no DST transition nearby and passes through unchanged.
Key points
ambiguous='infer'needs at least two occurrences of the ambiguous wall-clock time in the same call, in a sequence pandas can use to figure out which occurred first; with one row per user it has nothing to infer from and raisesValueError: Cannot infer dst time ... as there are no repeated times.'NaT'is the safe default when you cannot supply extra information to disambiguate.- Grouping by
user_tzbefore localizing is required becausetz_localizetakes a single timezone per call; it does not accept a per-row timezone. - Keep
local_aware(for showing the user their own event time) andutc_ts(for any cross-user math, like ordering events from different users) as two separate columns rather than collapsing to one.
Complexity
O(n) time overall: the per-timezone groups are each localized in one vectorized call, and the number of distinct timezones is typically small compared to n. Memory: tz_localize and tz_convert each return a new Series (pandas 3.0's Copy-on-Write, CoW, model means the original local_ts/naive column is never mutated in place by these calls), so expect roughly 2-3x the base column's memory for the local_aware, utc_ts and ambiguous_dst columns combined.
Edge cases
- Ambiguous times (fall-back): resolved to NaT here and surfaced via
ambiguous_dst; the calling code decides whether to fix these with more context (e.g. UTC millisecond offsets logged at collection time) or drop them. - Nonexistent times (spring-forward): resolved by shifting to the next valid instant;
nonexistent='shift_backward'ornonexistent='raise'are the other documented policies if shifting forward is not the right default for your data. - Invalid or unknown timezone strings:
tz_localizeraiseszoneinfo.ZoneInfoNotFoundErrorfor a bad IANA name; validate incoming timezone strings againstzoneinfo.available_timezones()before this step rather than letting the whole batch fail on one bad row. - Historical rule changes: IANA's tz database is updated periodically as governments change DST rules; keep the Python/OS tz database current, since a stale one silently mis-localizes historical timestamps near a rule-change boundary.
Trade-offs and pitfalls
ambiguous='infer'reads as the "smart" option but is the least robust in practice: it only works under a specific data shape (paired, ordered occurrences), and reaching for it as a default is a common mistake. Prefer an explicit, always-safe policy ('NaT') plus a follow-up decision, over a policy that sometimes throws depending on how the data happens to be grouped that day.- Storing only UTC and discarding the original local time is tempting for storage efficiency, but it throws away the user's actual wall-clock context (which matters for anything showing "your event was at 9am"); storing both is the safer default unless storage cost is a proven constraint.
You are asked to reduce memory usage of a DataFrame with numeric columns currently float64 and many repeated integer-like values. Provide a robust step-by-step pandas recipe (and code) to downcast numeric dtypes safely, detect when precision loss is acceptable, and validate results after downcasting.
Sample Answer
Direct answer
For each numeric column, decide whether it is genuinely integer-valued (either already an integer dtype, or a float column whose values are all whole numbers within tolerance) and downcast those to the smallest integer type that fits; for the remaining floats, downcast to float32 only if the resulting rounding error stays under an explicit absolute or relative tolerance you choose up front. Non-numeric columns must be left completely untouched, and every step should be validated by comparing before/after memory usage and by checking that the precision loss actually stayed within the declared tolerance rather than assuming it did.
Structured elaboration
- Step 1, classify each column: integer dtype already, integer-valued float (all values equal their own rounded value within a small tolerance), genuine float requiring real fractional precision, or non-numeric.
- Step 2, downcast integers:
pd.to_numeric(series, downcast='integer')picks the smallest integer type (int8/int16/int32) that still holds every value in the column. If the column has missing values, a plain integer dtype can't representNaN(not-a-number, pandas' missing-value marker) at all, so use pandas' nullableInt64(capital I, the nullable extension dtype, not the numpyint64dtype) instead, which isNaN-safe. - Step 3, downcast floats conditionally: cast to
float32and measure both the absolute and relative error against the originalfloat64values; only keep the downcast if the error is within the tolerance you set, otherwise keepfloat64. - Step 4, validate: report the byte usage per column before and after, and confirm the whole-DataFrame memory total actually decreased by the expected amount rather than assuming it did from the per-column deltas alone.
Worked example
import pandas as pd
import numpy as np
def is_integer_like(s, tol=0.0):
if not pd.api.types.is_float_dtype(s.dtype):
return False
s_nonnull = s.dropna()
if s_nonnull.empty:
return True
return np.all(np.isclose(s_nonnull, s_nonnull.round(), atol=tol))
def safe_downcast_df(df, float_tol=1e-6, float_rel_tol=1e-6):
before_mem = df.memory_usage(deep=True).sum()
result = df.copy()
numeric_cols = df.select_dtypes(include='number').columns
report = []
for col in df.columns:
s = df[col]
if col not in numeric_cols:
result[col] = s # non-numeric: leave untouched
elif pd.api.types.is_integer_dtype(s.dtype) or is_integer_like(s, tol=float_tol):
result[col] = s.round().astype('Int64') if s.isna().any() else pd.to_numeric(s, downcast='integer')
elif pd.api.types.is_float_dtype(s.dtype):
cast = s.astype('float32')
mask = s.notna()
abs_err = (s[mask] - cast[mask].astype(s.dtype)).abs()
rel_err = abs_err / s[mask].abs().replace(0, np.nan)
result[col] = cast if ((abs_err.fillna(0) <= float_tol).all() or (rel_err.fillna(0) <= float_rel_tol).all()) else s
else:
result[col] = s
report.append((col, s.dtype, result[col].dtype, s.memory_usage(deep=True), result[col].memory_usage(deep=True)))
after_mem = result.memory_usage(deep=True).sum()
return result, pd.DataFrame(report, columns=['col', 'orig_dtype', 'new_dtype', 'orig_bytes', 'new_bytes']), before_mem, after_mem
n = 1000
df = pd.DataFrame({
'int_like_float': np.repeat([1.0, 2.0, 3.0, 4.0, 5.0], n // 5), # float64 but whole numbers
'true_float': np.random.RandomState(0).uniform(0, 1, n), # needs real fractional precision
'small_int_float': np.repeat([10.0, 20.0], n // 2), # small-range integer-valued float
'category_like': np.repeat(['a', 'b', 'c', 'd'], n // 4), # non-numeric, must pass through
'with_nan_int_like': np.where(np.arange(n) % 7 == 0, np.nan,
np.repeat([1.0, 2.0], n // 2)), # integer-valued float with NaNs
'flag': np.repeat([True, False], n // 2), # boolean, must pass through
'ts': pd.date_range('2021-01-01', periods=n), # datetime, must pass through
})
result, report, before, after = safe_downcast_df(df)
print(report.to_string())
print(f"Memory: {before} -> {after} ({(before - after) / before:.1%} saved)")
Verified on the fixture above: the integer-like columns correctly downcast to int8, with_nan_int_like correctly becomes nullable Int64 with every original NaN (not-a-number, pandas' missing-value marker) preserved, and category_like/flag/ts pass through completely unchanged. true_float, despite holding genuinely random fractional values, also ends up downcast to float32 here (max relative error against the float64 original was about 5.7e-8), because the default float_rel_tol=1e-6 is looser than float32's own precision (roughly 1.2e-7 relative) for values in this range. That is worth noticing on its own: at these default tolerances almost any "normal-range" float column will pass, so a genuinely precision-sensitive column is only protected if you tighten the tolerance below what float32 can represent, not by the presence of fractional values alone. Total memory dropped from 50,132 to 33,132 bytes on this fixture, a 33.9% reduction.
Trade-offs and pitfalls
- Complexity:
O(n)per column for the scans (classification, error computation); memory isO(n)for the returned copy, since a new frame is built rather than mutated in place, unless memory pressure requires downcasting column-by-column in place instead. - Edge cases: values large enough to require
int64correctly stay atint64(downcasting must never silently overflow into a smaller type); a column that is float but has zero non-null values reportsis_integer_like == Truetrivially (nothing to contradict it) and downcasts to an empty-safe integer type; columns already at the smallest reasonable width are left alone rather than needlessly copied. - A pandas 3.0-specific bug worth naming explicitly: an earlier, unguarded version of
is_integer_likecallednp.issubdtype(s_nonnull.dtype, np.floating)unconditionally on every column, including non-numeric ones. This crashes under pandas 3.0 withTypeError: Cannot interpret '<StringDtype(na_value=nan)>' as a data typethe moment a string column is present, because pandas 3.0 changed the default dtype for string columns from genericobjectto a dedicatedStringDtype, whichnumpy.issubdtypecannot interpret at all. This was reproduced directly: constructing a plainpd.DataFrame({'col': ['a','b','c']})under pandas 3.0 and passing that column through the unguarded helper throws immediately, even though the exact same code ran fine on any pandas dtype scheme where a string column reported as plainobject. The fix used above checksis_float_dtypefirst and returnsFalseimmediately for anything that isn't a float column, so the numpy comparison is never reached for non-numeric dtypes. A recipe copied from older pandas material that skips this guard will now fail on the first DataFrame that has any text, boolean, or category column mixed in with the numeric ones, which is most real DataFrames. - Rounding an integer-like float before casting to
Int64(s.round().astype('Int64')) is deliberate: values like2.9999999999that are "integer-like" only within tolerance need to actually round to3before the integer cast, or the cast truncates toward2instead.
Explain what Copy-on-Write changed about when a pandas operation returns a view versus a copy, and why it is no longer something you can opt into or out of. Discuss what this means for code that used to rely on chained assignment sometimes working, and general strategies to avoid unnecessary copies and large temporary DataFrames when working with large datasets.
Sample Answer
Direct answer
Copy-on-Write (CoW) is now mandatory in pandas 3.0, it cannot be turned off. Under CoW, every pandas object behaves as if it holds independent data: reading through a result that used to be called a "view" is free, but the moment either the original or the derived object is written to, pandas makes a physical copy at that instant so the two can never silently diverge into each other. The old question "is this a view or a copy?" is no longer something you can predict from the syntax you used, and you no longer need to predict it, because a write can never leak across objects either way.
Structured elaboration
Before pandas 2.0, some operations (plain-column selection, some slicing) returned a "view" backed by the same underlying NumPy array as the source. Whether mutating that view also mutated the source depended on internal implementation details the documentation itself called unreliable, SettingWithCopyWarning existed precisely because pandas could not always tell you which case you were in. That warning was heuristic: it could fire on code that was actually fine, and stay silent on code that silently corrupted data.
Under mandatory CoW, SettingWithCopyWarning does not exist anymore. This was verified directly: pandas.errors.SettingWithCopyWarning raises AttributeError, it is not deprecated-but-present, it is gone, because the ambiguous partial-mutation scenario it warned about can no longer happen.
The two-statement form now silently succeeds on a copy
import pandas as pd
df = pd.DataFrame({'a': [1, 2, 3, 4, 5], 'b': [10, 20, 30, 40, 50]})
subset = df[df['a'] > 2]
subset['b'] = 999
Verified by running this exact code: no warning, no error, subset['b'] is 999 for the matching rows, and df is completely unchanged. This is the single biggest behavior change from the pre-3.0 mental model. What used to be "sometimes this mutates df, sometimes it doesn't, watch for the warning" is now deterministic: it never mutates df. subset is an independent object the instant it is assigned to a variable, so writing to it can only ever affect subset.
Only the single-expression chained form warns, and it is only a warning
import pandas as pd
df = pd.DataFrame({'a': [1, 2, 3, 4, 5], 'b': [10, 20, 30, 40, 50]})
df[df['a'] > 2]['b'] = 999
Verified by running this exact code: it raises no exception, df is unchanged, but a ChainedAssignmentError is emitted. Its class hierarchy (ChainedAssignmentError.__mro__) confirms it is a subclass of the built-in Warning, not a hard exception, so by default it prints to stderr and execution continues, exactly like any other uncaught Python warning. Write "warns via ChainedAssignmentError," not "raises ChainedAssignmentError," since the latter implies the program stops, and by default it does not.
Pandas can only catch this pattern in the single-expression form because that is the only shape where its internals can observe, within one call, that the object receiving the write was produced moments earlier by an indexing operation with no other references. Once the intermediate result is stored in a variable across two statements, as in the block above, subset is just an ordinary DataFrame by the time you write to it, so no warning path fires at all. That means the coverage of ChainedAssignmentError is narrower than most people assume: the two-statement form, which is the shape most engineers actually write, and the one most "avoid SettingWithCopyWarning" tutorials describe, is exactly the one pandas 3.0 cannot warn about.
Worked example
import pandas as pd
df = pd.DataFrame({'a': [1, 2, 3, 4, 5], 'b': [10, 20, 30, 40, 50]})
# two-statement chained assignment: silently succeeds on a copy
subset = df[df['a'] > 2]
subset['b'] = 999
print(df) # unchanged
print(subset) # 'b' is 999 for rows where a > 2
# single-expression chained assignment: warns via ChainedAssignmentError, does not halt
df[df['a'] > 2]['b'] = 999
print(df) # still unchanged
# the correct pattern: a single .loc assignment
df.loc[df['a'] > 2, 'b'] = 999
print(df) # 'b' is 999 for rows where a > 2, this is the only reliable pattern
Running this exact code against pandas 3.0.3: the two-statement block leaves df's b column as [10, 20, 30, 40, 50] (unchanged) and subset's b column as [999, 999, 999] for the three matching rows. The single-expression line prints a ChainedAssignmentError warning to stderr and also leaves df unchanged. Only the final .loc[...] = ... line mutates df, giving b = [10, 20, 999, 999, 999].
Trade-offs and pitfalls
- Do not treat "no warning" as a correctness signal anymore, since the two-statement chained form now produces neither a warning nor a mutation. Correctness depends entirely on habitually writing through a single
.loc/.ilocassignment,df.loc[mask, 'col'] = value, which is the only pattern guaranteed to mutatedfin both the pre-3.0 and 3.0 worlds. Treat it as the default even outside interview settings. - Study material that says "chained assignment sometimes works, watch for
SettingWithCopyWarning" is wrong on both halves for pandas 3.0: the warning does not exist, and "sometimes works" is not a real behavior either, the two-statement form is now unconditionally a no-op on the original. - Because CoW defers the physical copy until the first write, code that reads heavily but writes rarely is cheaper than eager-copy pandas used to be, you get view-like sharing on the read path for free. But once you do write, that write pays a full copy of the affected block at that moment, so a loop that repeatedly mutates the same derived frame can pay a copy cost on every iteration if pandas cannot prove there is only one reference to the underlying data. The fix is the same discipline as always: do the transformation as one vectorized
.locassignment, or build the result with.assign()orpd.concatrather than mutating iteratively. - To avoid unnecessary copies and large temporaries on genuinely large data: stop calling
.copy()defensively "just in case," CoW already protects you from cross-object mutation, so a defensive copy only doubles memory for no safety benefit; prefer a single.loc[...] = ...write over any chained form so you pay exactly one copy, not an unpredictable number; drop columns you do not need before doing a wide operation rather than after; and checkdf.memory_usage(deep=True)and downcast dtypes (categorical for low-cardinality strings, smaller integer or float widths), since dtype choice affects the size of any eventual copy far more than CoW's own bookkeeping does.
You currently compute a 'segment' column with df.apply(func, axis=1), where func is a small if/elif chain over a couple of numeric columns, and it is slow on a large DataFrame. Rewrite this so it no longer calls a Python function per row, and explain why your rewrite is faster.
Sample Answer
Direct answer
Replace the Python-level if/elif chain inside df.apply(func, axis=1) with a small number
of boolean masks fed to np.select, so each condition is evaluated once across the whole
column using compiled array operations instead of once per row through the Python interpreter.
Structured elaboration
- Build one boolean mask per branch of the original
if/elifchain, using vectorized
comparisons (df['col'] > x) instead of per-row Python comparisons. - Feed the masks to
np.select(conditions, choices, default=...)in the same priority order
as the originalif/elif/else:np.selectevaluates conditions in order and takes the
first one that is true per row, mirroringelifsemantics exactly. - The speedup comes from avoiding the per-row Python function call, not from doing less
work:df.apply(axis=1)still doesO(n)work, but with a large constant factor from
constructing aSeriesper row and dispatching into Python for every row.
Worked example
import numpy as np
import pandas as pd
df = pd.DataFrame({
'revenue': [150000, 60000, 5000, 100],
'region': ['EMEA', 'APAC', 'EMEA', 'EMEA'],
'active_users': [50, 5, 15, 2],
})
# original: slow, per-row Python function
def seg(row):
if row['revenue'] > 100000 and row['region'] == 'EMEA':
return 'Enterprise'
elif row['revenue'] > 50000:
return 'Mid-Market'
elif row['active_users'] >= 10:
return 'SMB'
else:
return 'Micro'
df['segment_apply'] = df.apply(seg, axis=1)
# vectorized replacement
cond1 = (df['revenue'] > 100000) & (df['region'] == 'EMEA')
cond2 = (df['revenue'] > 50000)
cond3 = (df['active_users'] >= 10)
df['segment'] = np.select([cond1, cond2, cond3], ['Enterprise', 'Mid-Market', 'SMB'], default='Micro')
print(df[['segment_apply', 'segment']])
assert (df['segment_apply'] == df['segment']).all()
# segment_apply segment
# 0 Enterprise Enterprise
# 1 Mid-Market Mid-Market
# 2 SMB SMB
# 3 Micro Micro
Both columns match row for row: the vectorized version reproduces the if/elif chain's
priority order exactly.
Key points
df.apply(axis=1)calls a Python function once per row, paying interpreter overhead on every
call.np.select(and boolean masks generally) run in compiled loops over whole arrays, cutting
Python-level dispatch to a handful of calls total, one per condition, not one per row.
Complexity
Both approaches are O(n) asymptotically, but the constant factor differs by roughly one to two
orders of magnitude: df.apply(axis=1) pays a Python function call and a Series construction
per row, while np.select pays a fixed number of vectorized array operations (one per
condition) regardless of row count. Typical speedups are 5 to 100 times, depending on row count
and how much work each row does.
Edge cases
Condition order matters: np.select takes the first matching condition per row, so conditions
must be written in the same priority order as the original elif chain, or made mutually
exclusive. Not-a-number (NaN) values in a comparison column make that comparison False rather
than raising, so a NaN row silently falls through to default unless handled explicitly with
fillna or an explicit isna() branch. For many conditions, building each boolean array is
itself O(n), so very wide condition sets should reuse intermediate boolean columns rather than
recomputing the same comparison multiple times.
Trade-offs and pitfalls
- When conditions are not mutually exclusive, get the order right deliberately:
np.select
silently takes the first match, which can hide a logic error thatif/elifwould have hit
in the same order, so the risk is identical, just less visually obvious in vectorized form. - For complex conditions spanning many columns, materializing intermediate boolean columns
(rather than inlining long boolean expressions) keeps the code readable and reusable. df.apply(axis=1)can still be the right tool when the per-row logic genuinely cannot be
expressed as array operations (arbitrary external calls, complex string parsing that has no
vectorized equivalent) or when the DataFrame is small enough that the readability win beats
the performance cost.
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.