All 57 lessons

Lesson 57 of 57 · Writing Professional Python

20 Solved Python Interview Questions for Data Roles

By Skillancy Editorial Team

Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).

Interviews for data analyst, analytics engineer and data scientist roles tend to follow a pattern. There is a handful of core Python questions (strings, lists, dictionaries, a gotcha or two), a block of pandas problems on a business dataset, and a couple of reasoning questions about statistics or performance. The interviewer is rarely hunting for exotic tricks. They are watching how you think: whether you clarify the question, pick a sensible data structure, handle edge cases and explain your result in plain words.

This final lesson collects twenty such questions with worked solutions. Every code block here runs and every number in the commentary comes from the real output, so you can copy the answers into your own practice notebook. For each question you will see what the interviewer is testing, a solution that you could write on a whiteboard, and a short note on complexity, alternatives or follow-up questions. The pandas questions use the FreshBasket data from the rest of this course.

A suggested way to use this page: read the question, close the solution, write your own answer, then compare. Say your reasoning out loud as you go. That is what the interview will ask you to do.

How to answer in an interview

1. Restate the problem and ask about edge cases (empty input? duplicates? ties? missing values?).

2. Start simple. Say the brute-force idea first, then improve it. A correct simple answer beats an unfinished clever one.

3. Choose the data structure on purpose: a dict or set for fast lookup, a list for order, a heap or sort for ranking.

4. Test with a tiny example, including an edge case, before you say you are done.

5. State the complexity (time and memory) in one sentence.

Part 1: Core Python

Question 1: Two Sum

Given a list of numbers and a target, return the indices of two numbers that add up to the target.

What they are testing: whether you reach for a dictionary instead of a double loop.

def two_sum(nums, target):
    seen = {}                                # value -> index
    for i, n in enumerate(nums):
        need = target - n
        if need in seen:
            return seen[need], i
        seen[n] = i
    return None

print(two_sum([2, 7, 11, 15], 9))
print(two_sum([3, 3], 6))
print(two_sum([1, 2, 3], 99))

Output

(0, 1)
(0, 1)
None

Explanation: The brute-force approach checks every pair, which takes O(n squared) time. Instead, walk through the list once and keep a dictionary of the values already seen. For each number, the partner we need is target - n; looking it up in the dictionary takes constant time, so the whole algorithm is O(n) time and O(n) memory. Edge cases: duplicates ([3, 3] works because we check before storing the current value) and no solution (None).

Reading the result: The answer for the classic example is (0, 1), the duplicate case gives (0, 1), and an impossible target returns None.

Question 2: Most common words

Find the three most common words in the customer feedback file, ignoring case and punctuation.

What they are testing: collections.Counter, regex tokenising and string cleanup.

import re
from collections import Counter
from pathlib import Path

text = " | ".join(line.split(" | ", 3)[3] for line in Path("feedback.txt").read_text(encoding="utf-8").splitlines())
words = re.findall(r"[a-z']+", text.lower())
stop = {"the", "a", "was", "and", "of", "in", "my", "me", "at", "is", "to", "on", "or", "for", "it", "i"}
counts = Counter(w for w in words if w not in stop and len(w) > 2)
print(counts.most_common(5))
print(len(words), len(counts))

Output

[('example', 20), ('com', 20), ('not', 14), ('item', 11), ('rider', 10)]
467 101

Explanation: re.findall(r"[a-z']+", ...) splits the text into lowercase words, Counter counts them in one pass (O(n)), and most_common(k) returns the top k. A real answer mentions stop words (very common words such as "the" that carry no meaning) and asks whether punctuation, numbers and emails should be removed. Here we only keep alphabetic tokens, so the digits in phone numbers and order references disappear.

Reading the result: The five most frequent content words are [('example', 20), ('com', 20), ('not', 14), ('item', 11), ('rider', 10)], out of 467 tokens and 101 distinct words after filtering.

Question 3: Group anagrams

Group a list of words so that anagrams end up together.

What they are testing: choosing a good dictionary key (a canonical form).

from collections import defaultdict

def group_anagrams(words):
    groups = defaultdict(list)
    for w in words:
        groups["".join(sorted(w.lower()))].append(w)
    return list(groups.values())

result = group_anagrams(["listen", "silent", "enlist", "rice", "ceri", "milk", "klim", "eggs"])
print(result)
print(sorted(len(g) for g in result))

Output

[['listen', 'silent', 'enlist'], ['rice', 'ceri'], ['milk', 'klim'], ['eggs']]
[1, 2, 2, 3]

Explanation: Anagrams become identical when their letters are sorted, so the sorted string is a canonical key: listen, silent and enlist all map to eilnst. A defaultdict(list) groups the words by key in a single pass. Time is O(n k log k) for n words of length k. A faster key for long words is a tuple of letter counts, giving O(n k).

Reading the result: The function formed 4 groups with sizes [1, 2, 2, 3], and the first group is ['listen', 'silent', 'enlist'].

Question 4: Balanced brackets

Check whether a string of brackets such as "{[()]}" is balanced.

What they are testing: the stack data structure.

def is_balanced(s):
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for ch in s:
        if ch in "([{":
            stack.append(ch)
        elif ch in pairs:
            if not stack or stack.pop() != pairs[ch]:
                return False
    return not stack

tests = ["{[()]}", "([)]", "((", "", "a(b)c"]
print([is_balanced(t) for t in tests])

Output

[True, False, False, True, True]

Explanation: Push every opening bracket onto a stack. Every closing bracket must match the most recent unmatched opener, which is exactly the top of the stack (last in, first out). At the end the stack must be empty, otherwise some opener was never closed. The two classic failures are a closer with an empty stack and a mismatched pair like ([)]. Time O(n), memory O(n). A Python list works as a stack with append and pop.

Reading the result: The results for ['{[()]}', '([)]', '((', '', 'a(b)c'] are [True, False, False, True, True]. The empty string counts as balanced, and non-bracket characters are ignored.

Question 5: Remove duplicates but keep the order

Remove duplicates from a list while preserving the first occurrence order.

What they are testing: knowing that sets are unordered, and that dicts preserve insertion order.

cities = ["Delhi", "Pune", "Delhi", "Mumbai", "Pune", "Chennai", "Delhi"]

print(list(set(cities)) == cities)           # a set loses the order guarantee
print(list(dict.fromkeys(cities)))           # preserves first-seen order

seen = set()
ordered = [c for c in cities if not (c in seen or seen.add(c))]
print(ordered)

Output

False
['Delhi', 'Pune', 'Mumbai', 'Chennai']
['Delhi', 'Pune', 'Mumbai', 'Chennai']

Explanation: set(cities) removes duplicates but gives no ordering promise. Since Python 3.7 dictionaries preserve insertion order, so dict.fromkeys(items) is the shortest correct answer: duplicate keys collapse to the first. The explicit version keeps a seen set for O(1) membership checks; it relies on set.add returning None, a trick worth explaining rather than hiding. Both run in O(n).

Reading the result: The unique cities in order are ['Delhi', 'Pune', 'Mumbai', 'Chennai']: 7 items reduced to 4.

Question 6: Flatten a nested list

Flatten a list that may contain lists nested to any depth: [1, [2, [3, 4]], 5].

What they are testing: recursion and generators.

def flatten(items):
    for item in items:
        if isinstance(item, (list, tuple)):
            yield from flatten(item)
        else:
            yield item

nested = [1, [2, [3, 4]], 5, [[6], []], ("a", ["b"])]
print(list(flatten(nested)))
print(list(flatten([])))

Output

[1, 2, 3, 4, 5, 6, 'a', 'b']
[]

Explanation: A function that calls itself on sub-lists handles any depth. Using yield from makes it a generator, so it flattens lazily without building intermediate lists. Mention the base case (non-list items are yielded) and that very deep nesting can hit Python's recursion limit (about 1,000 levels), in which case you would use an explicit stack. Strings are deliberately not treated as sequences, otherwise they would be split into characters forever.

Reading the result: The flattened result has 8 items: [1, 2, 3, 4, 5, 6, 'a', 'b'].

Question 7: The mutable default argument

What is wrong with this function, and how do you fix it?

def add_item(item, basket=[]):
    basket.append(item)
    return basket

What they are testing: a classic Python gotcha and whether you understand when defaults are evaluated.

def add_item(item, basket=[]):
    basket.append(item)
    return basket

print(add_item("milk"))
print(add_item("eggs"))          # surprise: the same list is reused

def add_item_fixed(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

print(add_item_fixed("milk"))
print(add_item_fixed("eggs"))
print(add_item.__defaults__)

Output

['milk']
['milk', 'eggs']
['milk']
['eggs']
(['milk', 'eggs'],)

Explanation: Default values are evaluated once, when the function is defined, not on every call. The empty list is created a single time and shared by every call that omits basket, so items pile up across calls. The fix is the None sentinel: create a fresh list inside the function. The same trap applies to dictionaries, sets and any other mutable default (and is why dataclasses require default_factory).

Reading the result: The second call returned ['milk', 'eggs', 'beans'] because the default list now holds all earlier items ((['milk', 'eggs', 'beans'],)), whereas the fixed version returns a fresh list each time: ['eggs'].

Question 8: Copies, is and ==

What is the difference between is and ==, and between a shallow and a deep copy?

What they are testing: understanding of references and mutability.

import copy

a = [1, 2, [3, 4]]
b = a                      # same object
c = a.copy()               # shallow copy: new outer list, same inner list
d = copy.deepcopy(a)       # fully independent

print(a == c, a is c, a[2] is c[2])
a[2].append(99)
print(c[2], d[2])
print(b is a)

x, y = [1, 2], [1, 2]
print(x == y, x is y)

Output

True False True
[3, 4, 99] [3, 4]
True
True False

Explanation: == asks "do these have equal values?", while is asks "are these the very same object?". Assignment (b = a) copies the reference, not the data. A shallow copy creates a new outer container but still shares the inner objects, so changing the inner list through a changes c as well; deepcopy copies everything recursively. For None checks always use is None, since is cannot be fooled by a custom __eq__.

Reading the result: After appending 99 to the inner list of a, the shallow copy shows [3, 4, 99] while the deep copy still has [3, 4]. Two separately built equal lists are == (True) but not is (False).

Question 9: Merge overlapping intervals

Merge overlapping delivery windows: [(1,3), (2,6), (8,10), (15,18)] becomes [(1,6), (8,10), (15,18)].

What they are testing: sorting as a first step and a clean single pass.

def merge_intervals(intervals):
    merged = []
    for start, end in sorted(intervals):
        if merged and start <= merged[-1][1]:
            merged[-1] = (merged[-1][0], max(merged[-1][1], end))
        else:
            merged.append((start, end))
    return merged

print(merge_intervals([(1, 3), (2, 6), (8, 10), (15, 18)]))
print(merge_intervals([(1, 4), (4, 5)]))
print(merge_intervals([(5, 7), (1, 10)]))
print(merge_intervals([]))

Output

[(1, 6), (8, 10), (15, 18)]
[(1, 5)]
[(1, 10)]
[]

Explanation: Sort by start time. Then, for each interval, either it overlaps the last merged interval (its start is at or before the previous end), in which case you extend the previous end to the larger of the two ends, or it begins a new interval. The sort dominates: O(n log n) time. Edge cases to mention: touching intervals ((1,4) and (4,5) merge here; ask whether that is desired), an interval completely inside another, and an empty list.

Reading the result: The main example merges to [(1, 6), (8, 10), (15, 18)], touching intervals merge into [(1, 5)], and a contained interval is absorbed: [(1, 10)].

Question 10: Two Python surprises

What does each snippet print, and why?

What they are testing: late-binding closures and shared references.

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])

funcs_fixed = [lambda i=i: i for i in range(3)]
print([f() for f in funcs_fixed])

grid = [[0] * 3] * 2
grid[0][0] = 9
print(grid)

grid_ok = [[0] * 3 for _ in range(2)]
grid_ok[0][0] = 9
print(grid_ok)

Output

[2, 2, 2]
[0, 1, 2]
[[9, 0, 0], [9, 0, 0]]
[[9, 0, 0], [0, 0, 0]]

Explanation: A closure looks up the variable i when the function is called, not when it was created. After the loop, i is 2, so all three lambdas return 2; binding the current value through a default argument (i=i) freezes it. In the second puzzle, [[0] * 3] * 2 repeats a reference to the same inner list, so changing one row changes both; build rows with a comprehension so that each is a separate list. Both are about references and when expressions are evaluated.

Reading the result: The late-binding lambdas return [2, 2, 2], the fixed ones [0, 1, 2]; the aliased grid becomes [[9, 0, 0], [9, 0, 0]] while the correct grid is [[9, 0, 0], [0, 0, 0]].

Part 2: pandas and data analysis

The next questions use FreshBasket data. Interviewers love to give a table and ask for a ranking, a running total or a comparison between two periods.

import pandas as pd
import numpy as np

orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items = pd.read_csv("order_items.csv")
products = pd.read_csv("products.csv")
stores = pd.read_csv("stores.csv")
customers = pd.read_csv("customers.csv", parse_dates=["signup_date"])

value = (items["quantity"] * items["unit_price"]).groupby(items["order_id"]).sum().rename("value")
df = orders.merge(value, on="order_id").merge(stores[["store_id", "city"]], on="store_id")
delivered = df[df["order_status"] == "Delivered"].copy()
print(df.shape, delivered.shape)

Output

(6000, 11) (5391, 11)

Question 11: Top N per group

For each product category, return the two products with the highest revenue.

What they are testing: grouped ranking, one of the most common pandas questions.

lines = items.merge(products[["product_id", "product_name", "category"]], on="product_id")
lines["revenue"] = lines["quantity"] * lines["unit_price"]
rev = lines.groupby(["category", "product_name"], as_index=False)["revenue"].sum()

top2 = rev.sort_values(["category", "revenue"], ascending=[True, False]).groupby("category").head(2)
print(top2.head(6).to_string(index=False))

# alternative with rank
rev["rank"] = rev.groupby("category")["revenue"].rank(method="first", ascending=False)
alt = rev[rev["rank"] <= 2]
print(len(top2), len(alt), set(top2["product_name"]) == set(alt["product_name"]))

Output

    category       product_name  revenue
   Beverages   Masala Chai 500g 117600.0
   Beverages  Green Tea 25 bags  67200.0
       Dairy        Paneer 200g 241300.0
       Dairy Cheese Slices 200g 109480.0
Fruits & Veg     Mango Alphonso 249900.0
Fruits & Veg      Spinach Bunch  58825.0
14 14 True

Explanation: Aggregate to one row per product, sort by category and revenue (descending), and keep the first two rows of each group with groupby().head(2). The alternative uses rank within each group and filters, which is more flexible (you can keep ties with method="min"). In SQL this is ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC). Mention which you would use when ties matter.

Reading the result: There are 7 categories, so the answer has 14 rows; both methods agree (True). The overall best product is Detergent 2kg.

Question 12: Nth highest

Find the second-highest order value in each city.

What they are testing: handling ties and the nth pattern.

def nth_highest(s, n=2):
    unique = s.drop_duplicates().nlargest(n)
    return unique.iloc[-1] if len(unique) == n else np.nan

second = delivered.groupby("city")["value"].apply(nth_highest)
print(second)

delivered["rank_in_city"] = delivered.groupby("city")["value"].rank(method="dense", ascending=False)
check = delivered[delivered["rank_in_city"] == 2].groupby("city")["value"].first()
print(second.equals(check.astype(float)) or (second - check).abs().max() < 1e-9)

Output

city
Ahmedabad    2684.0
Bengaluru    2869.0
Chennai      2579.0
Delhi        2928.0
Hyderabad    3599.0
Kolkata      2419.0
Mumbai       3090.0
Pune         2948.0
Name: value, dtype: float64
True

Explanation: The trap is ties: if two orders share the top value, is the "second highest" the same number or the next distinct one? Clarify first. Here we mean the next distinct value, so we drop duplicates before taking the n largest. The second method uses a dense rank (ties share a rank and the next rank is not skipped) and picks rank 2, and the two agree. The SQL equivalent is DENSE_RANK().

Reading the result: The second-highest order value ranges from ₹2,419 to ₹3,599 across 8 cities, highest in Hyderabad.

Question 13: Running total and rolling average

Show monthly revenue with a running total and a 3-month moving average.

What they are testing: cumsum, rolling and time grouping.

monthly = delivered.groupby(delivered["order_date"].dt.to_period("M"))["value"].sum().to_frame("revenue")
monthly["running_total"] = monthly["revenue"].cumsum()
monthly["moving_avg_3"] = monthly["revenue"].rolling(3).mean()
monthly["share_of_total"] = monthly["running_total"] / monthly["revenue"].sum()

print(monthly.round(2).tail(4))
print(monthly["moving_avg_3"].isna().sum())
print(round(monthly["running_total"].iloc[-1] - monthly["revenue"].sum(), 6))

Output

             revenue  running_total  moving_avg_3  share_of_total
order_date                                                       
2026-03     319037.0      2406099.0     296281.33            0.68
2026-04     376434.0      2782533.0     333862.33            0.79
2026-05     362430.0      3144963.0     352633.67            0.89
2026-06     388871.0      3533834.0     375911.67            1.00
2
0.0

Explanation: cumsum gives the running total, and rolling(3).mean() the average of the current and previous two months. The first two moving-average values are NaN because a full window does not exist yet (min_periods can change that). A good check is that the final running total equals the grand total. Window functions in SQL do the same with SUM(...) OVER (ORDER BY month).

Reading the result: The final running total is ₹3,533,834, the same as total revenue, and the first 2 months have no moving average.

Question 14: Find the duplicates

Which customers placed more than one delivered order on the same day?

What they are testing: duplicated, groupby().size() and boolean filtering.

per_day = delivered.groupby(["customer_id", "order_date"]).size().rename("n").reset_index()
repeat = per_day[per_day["n"] > 1]
print(len(repeat), repeat["customer_id"].nunique())
print(repeat.sort_values("n", ascending=False).head(3).to_string(index=False))

dupes = delivered[delivered.duplicated(["customer_id", "order_date"], keep=False)]
print(len(dupes), dupes["order_id"].is_unique)

Output

203 67
customer_id order_date  n
      C0406 2025-10-17  5
      C0254 2026-06-09  3
      C0294 2025-03-19  3
423 True

Explanation: Group by the pair (customer, date) and count; any count above 1 is a repeat. duplicated(subset, keep=False) marks all rows involved in a duplicate (the default keep="first" would leave one row of each pair unmarked), which is the right choice when you want to inspect every offending row. Ask the interviewer what counts as a duplicate: the same order ID twice, the same customer and date, or the same basket?

Reading the result: 203 customer-days have more than one delivered order, involving 67 different customers and 423 order rows in total.

Question 15: Month-over-month growth by city

Compute each city's month-over-month revenue growth and report the city with the highest average growth.

What they are testing: groupby combined with pct_change.

city_month = (
    delivered.assign(month=delivered["order_date"].dt.to_period("M"))
    .groupby(["city", "month"])["value"].sum()
    .unstack("city")
)
growth = city_month.pct_change()
print(growth.tail(2).round(3).T)

avg_growth = growth.mean().sort_values(ascending=False)
print(avg_growth.round(3).head(3))
print(city_month.isna().sum().sum())

Output

month      2026-05  2026-06
city                       
Ahmedabad    0.189    0.280
Bengaluru    0.319   -0.100
Chennai     -0.118   -0.047
Delhi        0.028   -0.054
Hyderabad   -0.200    0.206
Kolkata     -0.039   -0.053
Mumbai      -0.096    0.151
Pune        -0.324    0.581
city
Chennai    2.453
Pune       0.724
Kolkata    0.615
dtype: float64
1

Explanation: Pivot to one column per city (unstack), then pct_change() computes each column's change from the previous month. Because the data starts small, early growth percentages are huge; mention that an average of percentages can be dominated by tiny bases, and that a compound growth rate or a median is more robust. Also check for gaps: a missing month would make the next pct_change span two months.

Reading the result: The highest average monthly growth is Chennai at 245.3%, and there are 1 missing city-months in the table.

Question 16: Who ordered in one month but not the next?

List customers who ordered in January 2026 but did not order in February 2026.

What they are testing: anti-joins and set logic.

jan = set(delivered.loc[delivered["order_date"].between("2026-01-01", "2026-01-31"), "customer_id"])
feb = set(delivered.loc[delivered["order_date"].between("2026-02-01", "2026-02-28"), "customer_id"])

lost = jan - feb
print(len(jan), len(feb), len(lost), len(jan & feb))

# the same with a merge indicator
a = pd.DataFrame({"customer_id": sorted(jan)})
b = pd.DataFrame({"customer_id": sorted(feb), "in_feb": True})
anti = a.merge(b, on="customer_id", how="left")
anti = anti[anti["in_feb"].isna()]
print(len(anti) == len(lost))

Output

232 255 112 120
True

Explanation: Python sets make this a one-liner: jan - feb is the difference. In pandas, an anti-join is a left join followed by keeping the rows with no match. In SQL, it is LEFT JOIN ... WHERE right.key IS NULL or NOT EXISTS. Be ready to discuss what "churned" means: a customer missing from one month may simply be a monthly shopper, so a better definition looks at a longer window.

Reading the result: 232 customers ordered in January and 255 in February; 112 January customers did not order in February, a month-to-month retention of 51.7%.

Question 17: The row count after a join

How many rows will customers.merge(orders, how="left") return, and why might it surprise you?

What they are testing: understanding of join cardinality.

left = customers.merge(orders[["order_id", "customer_id"]], on="customer_id", how="left")
inner = customers.merge(orders[["order_id", "customer_id"]], on="customer_id", how="inner")

print(len(customers), len(orders))
print("left :", len(left), " inner:", len(inner))
print("customers with no order:", int(left["order_id"].isna().sum()))
print("distinct customers in left join:", left["customer_id"].nunique())

Output

600 6000
left : 6012  inner: 6000
customers with no order: 12
distinct customers in left join: 600

Explanation: A join returns one row per matching pair. Each customer with several orders is repeated once per order, so the left join has more rows than there are customers. Customers without orders still appear (once, with NaN for the order columns) in a left join but vanish from an inner join. The common bug is then summing a customer-level column (such as a credit limit) over the joined table, which counts it several times. Validate joins with validate="one_to_many" or by comparing row counts before and after.

Reading the result: 600 customers joined to 6000 orders gives 6012 rows in the left join and 6000 in the inner join. The difference of 12 rows is the customers with no orders at all.

Question 18: Missing values

Delivery time is blank for cancelled orders. How would you handle it before computing the average delivery time?

What they are testing: judgment about missing data, not just syntax.

raw = df["delivery_minutes"]
print("missing:", int(raw.isna().sum()), "| mean ignoring NaN:", round(raw.mean(), 2))
print("mean if blanks filled with 0:", round(raw.fillna(0).mean(), 2))
print("mean over delivered only:", round(df.loc[df["order_status"] == "Delivered", "delivery_minutes"].mean(), 2))
print(df.loc[raw.isna(), "order_status"].value_counts().to_dict())

Output

missing: 370 | mean ignoring NaN: 23.67
mean if blanks filled with 0: 22.21
mean over delivered only: 23.68
{'Cancelled': 370}

Explanation: The best answer starts with why the values are missing. Here, cancelled orders never reached the customer, so a delivery time does not exist; it is "not applicable", not "zero". Filling with 0 would drag the average down and is wrong. Dropping them (what mean() does by default) is correct for this metric. Also check that only cancelled orders are missing; any other missing rows would be a data problem to investigate. Mention imputation only when the model needs a complete column and the missingness is informative.

Reading the result: 370 orders have no delivery time, all of them ['Cancelled']. The correct mean is 23.67 minutes, but filling blanks with zero would wrongly report 22.21.

Part 3: Reasoning questions

Question 19: Is the improvement real?

A new checkout design converted 520 of 4,000 visitors; the old one converted 480 of 4,000. Is the new design better?

What they are testing: whether you can reason about noise instead of just comparing two numbers.

from scipy import stats

conv_a, n_a = 480, 4000
conv_b, n_b = 520, 4000
p_a, p_b = conv_a / n_a, conv_b / n_b

pooled = (conv_a + conv_b) / (n_a + n_b)
se = np.sqrt(pooled * (1 - pooled) * (1 / n_a + 1 / n_b))
z = (p_b - p_a) / se
p_value = 2 * (1 - stats.norm.cdf(abs(z)))

se_diff = np.sqrt(p_a * (1 - p_a) / n_a + p_b * (1 - p_b) / n_b)
ci = ((p_b - p_a) - 1.96 * se_diff, (p_b - p_a) + 1.96 * se_diff)

print(f"A {p_a:.1%}  B {p_b:.1%}  lift {(p_b - p_a) / p_a:.1%}")
print(f"z = {z:.2f}, p = {p_value:.3f}")
print(f"95% CI for the difference: {ci[0]:.2%} to {ci[1]:.2%}")

Output

A 12.0%  B 13.0%  lift 8.3%
z = 1.35, p = 0.176
95% CI for the difference: -0.45% to 2.45%

Explanation: B converts better in the sample (13.0% against 12.0%), but the question is whether that gap could be luck. A two-proportion z-test gives a p-value; if it is above 0.05 we cannot rule out chance. The confidence interval for the difference is even more informative: if it spans zero, the data are consistent with no effect. A strong answer also asks about the experiment: was assignment random, was the sample size fixed in advance, and what is the smallest lift that matters to the business?

Reading the result: The lift is 8.3% relative with z = 1.35 and p = 0.176, so the result is not significant at 5%. The 95% interval for the difference runs from -0.45% to 2.45%.

Question 20: Why is this code slow?

Checking x in my_list inside a loop over 100,000 items is slow. Why, and how do you fix it?

What they are testing: awareness of data-structure complexity.

import time

big_list = list(range(100_000))
big_set = set(big_list)
targets = [99_999] * 200

t0 = time.perf_counter()
hits_list = sum(t in big_list for t in targets)
list_time = time.perf_counter() - t0

t0 = time.perf_counter()
hits_set = sum(t in big_set for t in targets)
set_time = time.perf_counter() - t0

print(hits_list == hits_set == 200)
print("set is faster by more than 100x:", list_time / max(set_time, 1e-9) > 100)

Output

True
set is faster by more than 100x: True

Explanation: Membership in a list is a linear scan, O(n): in the worst case Python compares the target with every element. A set (or dict) uses hashing, so membership is O(1) on average. Converting a list to a set costs O(n) once, and then every lookup is nearly free, so a loop of m lookups drops from O(n x m) to O(n + m). The general lesson: when you search repeatedly, build an index first. The pandas version of the same advice: use isin or a merge, never a Python loop of df[df.id == x] lookups.

Reading the result: Both versions found all 200 targets, and the set lookups were about 6,835 times faster in this run (exact timings vary by machine, but the gap is always huge).

Key takeaways

  • Clarify, start simple, pick the data structure on purpose, test an edge case, state the complexity.
  • Dictionaries and sets turn O(n squared) puzzles into O(n); sorting often unlocks the rest.
  • Know the gotchas: mutable defaults, late-binding closures, shared references, shallow versus deep copies.
  • For pandas questions think in patterns: top-N per group, nth highest with ties, running totals, anti-joins and join cardinality.
  • Treat missing values by asking why they are missing before choosing how to fill them.
  • Back every claim about an improvement with a significance test or interval, and always say what sample and assumptions it rests on.

Frequently asked questions

What if I get stuck?

Say what you are thinking, restate the problem, try a tiny example by hand and propose the brute-force approach. Interviewers help candidates who communicate, and they remember how you recover.

Should I use pandas for everything?

No. Use pure Python for small puzzles and pandas for tabular analysis. For huge data, say that you would push the work to SQL or a distributed engine.

How do I practise?

Re-solve these twenty without looking, then vary them: change the data, add a constraint, or write the SQL version. Time yourself, and explain aloud.

Do I need to memorise syntax?

You need the common idioms (groupby, merge, value_counts, comprehensions, Counter, sorted(key=...)). Looking up an obscure argument is fine; not knowing how to approach the problem is what hurts.

What do interviewers look for beyond correct code?

Clear naming, edge cases, a quick test, a complexity statement and an honest "I would check X before trusting this result". The same habits as good professional work.

Newsletter

Keep learning Python

One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.

Ready to practise? Try the Python interview questions