Python Data Structures and Coding Patterns
This chapter retains the complete legacy examples, outputs, caveats, comparisons, and interview tips for its owner domain.
Legacy source guide: Python Data Structures & Coding Patterns β Data Engineer Edition
π‘ Interview Tip
Interviewers don't expect LeetCode hard. They want to see you handle dicts, sets, sorting, and basic algorithms cleanly.
If you can do these patterns, you can handle any Python question they throw.
Data-structures source memory map
π§ PYTHON PATTERNS β SHBCQ
PYTHON PATTERNSSHBCQ
ββββββββββββββββββββββββββ
SSets (intersection, union, difference)
HHash maps (Counter, defaultdict, grouping)
BBuilt-ins (sorted, zip, enumerate, any/all)
CCollections (deque, namedtuple, OrderedDict)
QQueue/Stack patterns (LIFO, FIFO, bracket matching)
Answer First: Convert hashable collections to sets, then use intersection, difference, union, and symmetric difference to express membership comparisons directly.
Memory Map: Set operations -> hashable inputs -> intersection/difference/union -> membership result without duplicates.
Q01 β Set Operations for Data Comparison
Question: Given two sets of team members, find (a) common members, (b) members only in team A, (c) members in either but not both, and (d) all members combined.
Sample Input:
team_a = {"Alice", "Bob", "Charlie", "Diana"}
team_b = {"Bob", "Diana", "Eve", "Frank"}
Expected Output:
Common: ['Bob', 'Diana']
Only in A: ['Alice', 'Charlie']
Symmetric difference: ['Alice', 'Charlie', 'Eve', 'Frank']
All members: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']
Solution 1 β Manual loop approach β Set Operations for Data Comparison
team_a = {"Alice", "Bob", "Charlie", "Diana"}
team_b = {"Bob", "Diana", "Eve", "Frank"}
common = []
for member in team_a:
if member in team_b:
common.append(member)
print("Common:", sorted(common))
only_a = []
for member in team_a:
if member not in team_b:
only_a.append(member)
print("Only in A:", sorted(only_a))
sym_diff = []
for member in team_a:
if member not in team_b:
sym_diff.append(member)
for member in team_b:
if member not in team_a:
sym_diff.append(member)
print("Symmetric difference:", sorted(sym_diff))
all_members = set()
for member in team_a:
all_members.add(member)
for member in team_b:
all_members.add(member)
print("All members:", sorted(all_members))
Solution 2 β List comprehension approach β Set Operations for Data Comparison
team_a = {"Alice", "Bob", "Charlie", "Diana"}
team_b = {"Bob", "Diana", "Eve", "Frank"}
common = sorted([m for m in team_a if m in team_b])
print("Common:", common)
only_a = sorted([m for m in team_a if m not in team_b])
print("Only in A:", only_a)
sym_diff = sorted([m for m in team_a if m not in team_b] +
[m for m in team_b if m not in team_a])
print("Symmetric difference:", sym_diff)
all_members = sorted(set(list(team_a) + list(team_b)))
print("All members:", all_members)
Solution 3 β Optimal: Set operators (Pythonic) β Set Operations for Data Comparison
team_a = {"Alice", "Bob", "Charlie", "Diana"}
team_b = {"Bob", "Diana", "Eve", "Frank"}
print("Team A:", sorted(team_a))
print("Team B:", sorted(team_b))
common = team_a & team_b
print("Common (A & B):", sorted(common))
only_a = team_a - team_b
print("Only in A (A - B):", sorted(only_a))
sym_diff = team_a ^ team_b
print("In one but not both (A ^ B):", sorted(sym_diff))
all_members = team_a | team_b
print("All members (A | B):", sorted(all_members))
small = {"Bob", "Diana"}
print("Is small β team_a?", small <= team_a)
Interview Tip:
"Set operations are O(min(len(a), len(b))). Much faster than list comparison loops which are O(n*m). In data engineering, use sets to compare column lists, find missing columns, and deduplicate."
What NOT to Say:
"I would loop through both lists to find common elements." -- This shows you do not know set operations, which are fundamental in Python and data engineering.
Answer First: Use defaultdict when missing keys need a factory and Counter when the domain is frequency arithmetic.
Memory Map: defaultdict supplies missing-value factory -> Counter tallies frequencies -> choose by intent.
Q02 β defaultdict and Counter Patterns
Question: Given a list of words, count the frequency of each word. Then, given a list of transactions with categories and amounts, group the amounts by category.
Sample Input:
words = ["hello", "world", "hello", "foo", "world", "hello"]
transactions = [("food", 50), ("transport", 30), ("food", 25),
("entertainment", 100), ("transport", 45)]
Expected Output:
Word counts: {'foo': 1, 'hello': 3, 'world': 2}
Grouped: {'entertainment': [100], 'food': [50, 25], 'transport': [30, 45]}
Solution 1 β Manual dict approach β defaultdict and Counter Patterns
words = ["hello", "world", "hello", "foo", "world", "hello"]
word_count = {}
for word in words:
if word not in word_count:
word_count[word] = 0
word_count[word] += 1
print("Word counts:", dict(sorted(word_count.items())))
transactions = [("food", 50), ("transport", 30), ("food", 25),
("entertainment", 100), ("transport", 45)]
by_category = {}
for category, amount in transactions:
if category not in by_category:
by_category[category] = []
by_category[category].append(amount)
print("Grouped:", dict(sorted(by_category.items())))
Solution 2 β defaultdict approach β defaultdict and Counter Patterns
from collections import defaultdict
words = ["hello", "world", "hello", "foo", "world", "hello"]
word_count = defaultdict(int)
for word in words:
word_count[word] += 1
print("Word counts:", dict(sorted(word_count.items())))
transactions = [("food", 50), ("transport", 30), ("food", 25),
("entertainment", 100), ("transport", 45)]
by_category = defaultdict(list)
for category, amount in transactions:
by_category[category].append(amount)
print("Grouped:", dict(sorted(by_category.items())))
Solution 3 β Optimal: Counter (for counting) β defaultdict and Counter Patterns
from collections import Counter
words = ["hello", "world", "hello", "foo", "world", "hello"]
word_count = Counter(words)
print("Step 1 - Raw Counter:", word_count)
print("Step 2 - Sorted:", dict(sorted(word_count.items())))
top_2 = word_count.most_common(2)
print("Step 3 - Top 2:", top_2)
c1 = Counter("aabbc")
c2 = Counter("abbcc")
print("Step 4a - c1:", c1)
print("Step 4b - c2:", c2)
print("c1 + c2:", c1 + c2)
print("c1 - c2:", c1 - c2)
print("c1 & c2:", c1 & c2)
Interview Tip:
"Counter supports arithmetic: +, -, & (min), | (max). These are incredibly useful for frequency analysis in ETL. defaultdict eliminates boilerplate key-existence checks."
What NOT to Say:
"I would use .setdefault() for everything." -- setdefault works but is less readable than defaultdict. Know both, but prefer defaultdict in interviews.
Answer First: Pass a key that represents the complete sort order; tuple keys make multi-criteria sorting explicit and stable.
Memory Map: Custom sorting -> encode priority in key -> tuple for multiple criteria -> rely on stable ties.
Q03 β Sorting with Custom Keys
Question: Given a list of student tuples (name, grade), sort them by grade descending. If grades are equal, sort by name ascending.
Sample Input:
students = [("Alice", 85), ("Bob", 85), ("Charlie", 92), ("Diana", 78)]
Expected Output:
[('Charlie', 92), ('Alice', 85), ('Bob', 85), ('Diana', 78)]
Solution 1 β Manual: Bubble sort with custom comparison β Sorting with Custom Keys
students = [("Alice", 85), ("Bob", 85), ("Charlie", 92), ("Diana", 78)]
result = students[:]
for i in range(len(result)):
for j in range(i + 1, len(result)):
name_i, grade_i = result[i]
name_j, grade_j = result[j]
should_swap = False
if grade_j > grade_i:
should_swap = True
elif grade_j == grade_i and name_j < name_i:
should_swap = True
if should_swap:
result[i], result[j] = result[j], result[i]
print(result)
Solution 2 β sorted() with lambda key β Sorting with Custom Keys
students = [("Alice", 85), ("Bob", 85), ("Charlie", 92), ("Diana", 78)]
by_grade_desc = sorted(students, key=lambda x: x[1], reverse=True)
print(by_grade_desc)
Solution 3 β Optimal: Tuple key with negation for multi-criteria β Sorting with Custom Keys
students = [("Alice", 85), ("Bob", 85), ("Charlie", 92), ("Diana", 78)]
for s in students:
print(f" {s[0]:>8} β key = ({-s[1]}, '{s[0]}') = {(-s[1], s[0])}")
result = sorted(students, key=lambda x: (-x[1], x[0]))
print("Step 2 - Sorted:", result)
scores = {'alice': 85, 'bob': 92, 'charlie': 78}
print("Step 3 - Dict keys:", list(scores.keys()))
sorted_names = sorted(scores, key=scores.get, reverse=True)
print("Step 4 - Sorted by value desc:", sorted_names)
Interview Tip:
"For multi-criteria sorting, use a tuple in the key function. Negate numeric values for reverse order on specific fields. Python's sort is stable, so you can also do two passes β but the tuple key is cleaner."
What NOT to Say:
"I would sort twice β once by name, then by grade." -- While stable sort makes this technically correct, it shows you do not know the tuple-key trick, which is the standard approach.
Answer First: Use a list for LIFO stack operations and collections.deque for O(1) operations at both queue ends.
Memory Map: Stack uses list append/pop -> queue uses deque append/popleft -> both preserve end semantics.
Q04 β Stack and Queue Patterns
Question: Implement a function that checks whether a string of brackets is balanced. Valid pairs are (), [], {}.
Sample Input:
s1 = "({[]})"
s2 = "({[})"
s3 = "((()))"
Expected Output:
Solution 1 β Manual: Repeated string replacement β Stack and Queue Patterns
def is_balanced(s):
while "()" in s or "[]" in s or "{}" in s:
s = s.replace("()", "")
s = s.replace("[]", "")
s = s.replace("{}", "")
return len(s) == 0
print(is_balanced("({[]})"))
print(is_balanced("({[})"))
print(is_balanced("((()))"))
Solution 2 β Stack with list β Stack and Queue Patterns
def is_balanced(s):
stack = []
pairs = {')': '(', ']': '[', '}': '{'}
for char in s:
if char in '([{':
stack.append(char)
elif char in ')]}':
if not stack:
return False
if stack[-1] != pairs[char]:
return False
stack.pop()
return len(stack) == 0
print(is_balanced("({[]})"))
print(is_balanced("({[})"))
print(is_balanced("((()))"))
Solution 3 β Optimal: Stack with deque (O(1) operations) β Stack and Queue Patterns
from collections import deque
def is_balanced(s):
stack = deque()
pairs = {')': '(', ']': '[', '}': '{'}
openers = set('([{')
for char in s:
if char in openers:
stack.append(char)
elif char in pairs:
if not stack or stack[-1] != pairs[char]:
return False
stack.pop()
return len(stack) == 0
print(is_balanced("({[]})"))
print(is_balanced("({[})"))
print(is_balanced("((()))"))
stack = []
stack.append(1); stack.append(2); stack.append(3)
print(stack.pop())
queue = deque()
queue.append(1); queue.append(2); queue.append(3)
print(queue.popleft())
Interview Tip:
"Never use list.pop(0) for queues -- it is O(n) because every element shifts. Use deque.popleft() which is O(1). For stacks, list is fine since append/pop from the end are both O(1)."
What NOT to Say:
"I would use a list for the queue." -- This reveals you do not understand time complexity. list.pop(0) is O(n), while deque.popleft() is O(1).
Answer First: Keep lambdas to one small expression and prefer a comprehension when it makes the data flow easier to read.
Memory Map: Lambda one-liner -> map transforms -> filter selects -> switch to comprehension when clearer.
Q05 β Lambda, Map, Filter in One-Liners
Question: Given a list of integers, filter out the negative numbers and zero, then return the squares of the remaining positive numbers.
Sample Input:
numbers = [-3, -1, 0, 2, 4, 7]
Expected Output:
Solution 1 β Manual loop β Lambda, Map, Filter in One-Liners
numbers = [-3, -1, 0, 2, 4, 7]
result = []
for x in numbers:
if x > 0:
result.append(x ** 2)
print(result)
Solution 2 β map() and filter() with lambda β Lambda, Map, Filter in One-Liners
numbers = [-3, -1, 0, 2, 4, 7]
result = list(map(lambda x: x ** 2,
filter(lambda x: x > 0, numbers)))
print(result)
names = ["alice", "BOB", "Charlie"]
normalized = list(map(str.title, names))
print(normalized)
data = [{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25}]
sorted_data = sorted(data, key=lambda d: d["age"])
print(sorted_data)
Solution 3 β Optimal: List comprehension (Pythonic) β Lambda, Map, Filter in One-Liners
numbers = [-3, -1, 0, 2, 4, 7]
result = [x ** 2 for x in numbers if x > 0]
print(result)
classified = ["even" if x % 2 == 0 else "odd" for x in range(5)]
print(classified)
evens = [x for x in range(20) if x % 2 == 0]
print(evens)
Interview Tip:
"Prefer list comprehensions over map/filter with lambdas -- they are more readable and more Pythonic. But know both styles because you will encounter map/filter in legacy codebases."
What NOT to Say:
"I always use map and filter because they are more functional." -- In Python, list comprehensions are considered more idiomatic. Guido van Rossum himself prefers them.
Answer First: Use a dataclass for typed records with defaults and behavior, a named tuple for small immutable tuple-compatible records, and a dict for truly dynamic shape.
Memory Map: Namedtuple -> immutable tuple record; dataclass -> typed defaults/behavior; dict -> dynamic fields.
Q06 β namedtuple and dataclass
Question: Create a structured record for an Employee with name, department, and salary. Show how to create instances and access fields. Demonstrate both immutable and mutable approaches.
Sample Input:
name = "Alice", department = "Data Engineering", salary = 95000
Expected Output:
Immutable: Employee(name='Alice', department='Data Engineering', salary=95000)
Mutable after update: Employee(name='Alice', department='Data Engineering', salary=100000)
Solution 1 β Plain dictionary (simplest, no structure enforcement) β namedtuple and dataclass
emp = {
"name": "Alice",
"department": "Data Engineering",
"salary": 95000
}
print(f"Name: {emp['name']}, Dept: {emp['department']}, Salary: {emp['salary']}")
emp["salary"] = 100000
print(f"Updated salary: {emp['salary']}")
emp["salry"] = 99999
Solution 2 β namedtuple (immutable, tuple-like) β namedtuple and dataclass
from collections import namedtuple
Employee = namedtuple('Employee', ['name', 'department', 'salary'])
emp = Employee("Alice", "Data Engineering", 95000)
print(f"Immutable: {emp}")
print(emp.name)
print(emp[0])
updated = emp._replace(salary=100000)
print(f"Mutable after update: {updated}")
Solution 3 β Optimal: dataclass (Python 3.7+, mutable with defaults) β namedtuple and dataclass
from dataclasses import dataclass, asdict, astuple
@dataclass
class Employee:
name: str
department: str
salary: float = 50000.0
emp = Employee("Alice", "Data Engineering", 95000)
print("Step 1 - Created:", emp)
emp.salary = 100000
print("Step 2 - After mutation:", emp)
print("Step 3a - As dict:", asdict(emp))
print("Step 3b - As tuple:", astuple(emp))
emp2 = Employee("Alice", "Data Engineering", 100000)
print("Step 4 - emp == emp2:", emp == emp2)
@dataclass(frozen=True)
class ImmutableEmployee:
name: str
department: str
salary: float
frozen_emp = ImmutableEmployee("Bob", "Analytics", 80000)
print("Step 5 - Frozen:", frozen_emp)
Interview Tip:
"Use namedtuple for simple immutable records (like a row from a query). Use dataclass when you need mutability, defaults, or methods. dataclass also gives you __eq__, __repr__ for free."
What NOT to Say:
"I would just use a regular class with __init__." -- This shows you are not aware of modern Python. dataclass eliminates boilerplate and is the standard for structured data since Python 3.7.
Answer First: A decorator receives a callable and returns a wrapped callable; preserve metadata with functools.wraps and keep cross-cutting behavior outside business logic.
Memory Map: Decorators -> accept callable -> wrap cross-cutting behavior -> return result -> preserve with wraps.
Q07 β Decorators (Simplified)
Question: Write a decorator called timer that measures and prints how long a function takes to execute. Then show how @lru_cache can speed up a recursive Fibonacci function.
Sample Input:
Call slow_function() which sleeps for 1 second
Call fibonacci(10)
Expected Output:
slow_function took 1.001s
fibonacci(10) = 55 (instant with cache)
Solution 1 β Manual timing without a decorator β Decorators (Simplified)
import time
def slow_function():
time.sleep(1)
return "done"
start = time.time()
result = slow_function()
elapsed = time.time() - start
print(f"slow_function took {elapsed:.3f}s")
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
start = time.time()
print(f"fibonacci(10) = {fibonacci(10)}")
elapsed = time.time() - start
print(f"Took {elapsed:.6f}s")
Solution 2 β Custom decorator with @wraps β Decorators (Simplified)
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
return "done"
slow_function()
print(slow_function.__name__)
Solution 3 β Optimal: Built-in decorators (@lru_cache, @property, etc.) β Decorators (Simplified)
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(f"Step 1 - fibonacci(10) = {fibonacci(10)}")
print(f"Step 2 - Cache info: {fibonacci.cache_info()}")
print(f"Step 3 - fibonacci(100) = {fibonacci(100)}")
print(f"Step 4 - Cache after fib(100): {fibonacci.cache_info()}")
fibonacci.cache_clear()
print(f"Step 5 - After clear: {fibonacci.cache_info()}")
Interview Tip:
"Always use @wraps(func) in custom decorators -- it preserves the original function's name and docstring, which matters for debugging and logging."
What NOT to Say:
"A decorator modifies the function." -- It WRAPS, not modifies. The original function is unchanged. The decorator returns a new function that calls the original.
Answer First: A context manager pairs acquisition with guaranteed cleanup through __enter__/__exit__ or contextlib.contextmanager.
Memory Map: Context manager -> acquire in enter -> run with body -> release in exit even on error.
Q08 β Context Managers (with statement)
Question: Write a custom context manager called Timer that measures the elapsed time of a code block. Show both the class-based and the contextlib approach.
Sample Input:
with Timer():
time.sleep(1)
Expected Output:
Solution 1 β Manual try/finally (no context manager) β Context Managers (with statement)
import time
start = time.time()
try:
time.sleep(1)
finally:
elapsed = time.time() - start
print(f"Elapsed: {elapsed:.3f}s")
f = open("example.txt", "w")
try:
f.write("hello")
finally:
f.close()
Solution 2 β Class-based context manager (enter / exit) β Context Managers (with statement)
import time
class Timer:
def __enter__(self):
self.start = time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.time() - self.start
print(f"Elapsed: {self.elapsed:.3f}s")
return False
with Timer() as t:
time.sleep(1)
with open("example.txt", "w") as f:
f.write("hello")
Solution 3 β Optimal: contextlib.contextmanager (Pythonic) β Context Managers (with statement)
import time
from contextlib import contextmanager
@contextmanager
def timer(label="Block"):
start = time.time()
yield
elapsed = time.time() - start
print(f"{label} elapsed: {elapsed:.3f}s")
with timer("My task"):
time.sleep(1)
@contextmanager
def database_connection(db_url):
conn = connect(db_url)
try:
yield conn
finally:
conn.close()
Interview Tip:
"Context managers are critical in data engineering -- database connections, file handles, Spark sessions. Always use with to prevent resource leaks. The contextlib.contextmanager decorator is the simplest way to write one."
What NOT to Say:
"I close files manually with f.close()." -- This leaks resources if an exception occurs before close(). Always use with for automatic cleanup.
Answer First: Use lazy itertools primitives to compose streaming transformations without materializing intermediate collections.
Memory Map: Itertools -> lazy source -> chain/slice/group combinators -> consume without intermediate lists.
Question: Given multiple lists of data, (a) merge them into one, (b) group records by a key, and (c) generate all combinations of sizes and colors.
Sample Input:
list_a = [1, 2], list_b = [3, 4], list_c = [5, 6]
data = [("NY", 100), ("NY", 200), ("CA", 300), ("CA", 400)]
sizes = ['S', 'M', 'L'], colors = ['red', 'blue']
Expected Output:
Chained: [1, 2, 3, 4, 5, 6]
Grouped: NY -> [('NY', 100), ('NY', 200)], CA -> [('CA', 300), ('CA', 400)]
Product: [('S', 'red'), ('S', 'blue'), ('M', 'red'), ('M', 'blue'), ('L', 'red'), ('L', 'blue')]
list_a, list_b, list_c = [1, 2], [3, 4], [5, 6]
merged = []
for lst in [list_a, list_b, list_c]:
for item in lst:
merged.append(item)
print("Chained:", merged)
data = [("NY", 100), ("NY", 200), ("CA", 300), ("CA", 400)]
groups = {}
for state, value in data:
if state not in groups:
groups[state] = []
groups[state].append((state, value))
for state in sorted(groups):
print(f" {state} -> {groups[state]}")
sizes = ['S', 'M', 'L']
colors = ['red', 'blue']
combos = []
for s in sizes:
for c in colors:
combos.append((s, c))
print("Product:", combos)
list_a, list_b, list_c = [1, 2], [3, 4], [5, 6]
merged = [*list_a, *list_b, *list_c]
print("Chained:", merged)
sizes = ['S', 'M', 'L']
colors = ['red', 'blue']
combos = [(s, c) for s in sizes for c in colors]
print("Product:", combos)
items = [1, 2, 3]
pairs = [(items[i], items[j]) for i in range(len(items)) for j in range(i + 1, len(items))]
print("Combos:", pairs)
from itertools import chain, groupby, islice, product, combinations
merged = list(chain([1, 2], [3, 4], [5, 6]))
print("Chained:", merged)
data = [("CA", 300), ("CA", 400), ("NY", 100), ("NY", 200)]
for state, group in groupby(data, key=lambda x: x[0]):
print(f" {state} -> {list(group)}")
combos = list(product(['S', 'M', 'L'], ['red', 'blue']))
print("Product:", combos)
print("Combos:", list(combinations([1, 2, 3], 2)))
Interview Tip:
"groupby requires SORTED data -- it only groups CONSECUTIVE items. This is the number one mistake candidates make. Always sort before groupby."
What NOT to Say:
"groupby works like SQL GROUP BY." -- It does NOT. SQL GROUP BY processes all rows regardless of order. Python's groupby only groups consecutive identical keys.
Answer First: Catch the narrowest exception you can handle, add domain context, preserve the original cause with raise ... from ..., and never swallow failures silently.
Memory Map: Exception handling -> catch narrow type -> add domain context -> chain cause -> never swallow silently.
Q10 β Exception Handling Best Practices
Question: Write a function that safely converts a string to an integer. Handle invalid input with specific exceptions, and show how to create a custom exception.
Sample Input:
convert("42") -> 42
convert("abc") -> "Error: 'abc' is not a valid number"
convert(None) -> "Error: Input cannot be None"
Expected Output:
42
Error: 'abc' is not a valid number
Error: Input cannot be None
Solution 1 β Basic try/except with specific exceptions β Exception Handling Best Practices
def convert(value):
try:
return int(value)
except ValueError:
return f"Error: '{value}' is not a valid number"
except TypeError:
return f"Error: Input cannot be None"
print(convert("42"))
print(convert("abc"))
print(convert(None))
Solution 2 β Custom exception class β Exception Handling Best Practices
class DataValidationError(Exception):
"""Custom exception for data validation failures."""
def __init__(self, column, value, message):
self.column = column
self.value = value
super().__init__(f"Column '{column}': {message} (got: {value})")
def validate_age(data):
age = data.get("age")
if age is None:
raise DataValidationError("age", age, "must not be None")
if not isinstance(age, int):
raise DataValidationError("age", age, "must be an integer")
if age < 0:
raise DataValidationError("age", age, "must be positive")
return age
try:
result = validate_age({"age": 25})
print(f"Valid age: {result}")
except DataValidationError as e:
print(f"Validation failed: {e}")
try:
result = validate_age({"age": -5})
print(f"Valid age: {result}")
except DataValidationError as e:
print(f"Validation failed: {e}")
Solution 3 β Optimal: Exception chaining and best practices β Exception Handling Best Practices
class DataValidationError(Exception):
def __init__(self, column, value, message):
self.column = column
self.value = value
super().__init__(f"Column '{column}': {message} (got: {value})")
def safe_convert(value):
try:
return int(value)
except (ValueError, TypeError) as e:
raise DataValidationError("input", value, "must be a valid integer") from e
try:
result = safe_convert("abc")
except DataValidationError as e:
print(f"Error: {e}")
print(f" Column: {e.column}")
print(f" Value: {e.value}")
try:
result = int("abc")
except ValueError:
print("Caught specific ValueError")
Interview Tip:
"Always catch specific exceptions. except Exception hides bugs. Use raise ... from e to chain exceptions and preserve the traceback. In data pipelines, custom exceptions make debugging much easier."
What NOT to Say:
"I use try/except with bare except." -- This catches SystemExit and KeyboardInterrupt, making the program unkillable. Always catch specific exception types.
Answer First: Use f-strings for readable modern interpolation, including format specifications; recognize older styles when maintaining legacy code.
Memory Map: String formatting -> f-string expression -> format spec -> recognize .format and percent legacy.
Question: Given variables for name, age, and salary, format them in three different ways: (a) basic string with variables, (b) salary with commas and 2 decimal places, (c) aligned columns.
Sample Input:
name = "Alice", age = 30, salary = 95432.567
Expected Output:
Alice is 30 years old
Salary: $95,432.57
left | center | right
name = "Alice"
age = 30
salary = 95432.567
print("%s is %d years old" % (name, age))
print("Salary: $%.2f" % salary)
name = "Alice"
age = 30
salary = 95432.567
print("{} is {} years old".format(name, age))
print("{name} is {age} years old".format(name=name, age=age))
print("Salary: ${:,.2f}".format(salary))
print("{:<20}|{:^20}|{:>20}".format("left", "center", "right"))
name = "Alice"
age = 30
salary = 95432.567
print(f"{name} is {age} years old")
print(f"Salary: ${salary:,.2f}")
print(f"{'left':<20}|{'center':^20}|{'right':>20}")
print(f"{age:05d}")
x = 42
print(f"{x = }")
print(f"{x**2 = }")
Interview Tip:
"Always use f-strings -- they are the fastest and most readable. The f'{x = }' debug syntax (Python 3.8+) is a great trick for quick debugging, as it shows both the variable name and its value."
What NOT to Say:
"I use %-formatting because it is like C." -- This is outdated since Python 3.6. f-strings are faster, more readable, and the modern standard.
Answer First: Reach for built-ins and comprehensions when they state the operation directly, but expand dense expressions when readability or error handling suffers.
Memory Map: Data-engineering one-liners -> built-in or comprehension -> state operation directly -> expand dense logic.
Q12 β Common One-Liners Data Engineers Should Know
Question: Demonstrate essential Python one-liners: (a) flatten a nested list, (b) transpose a matrix, (c) get unique items preserving order, (d) merge two dicts, (e) create a dict from two lists.
Sample Input:
nested = [[1, 2], [3, 4], [5, 6]]
matrix = [[1, 2, 3], [4, 5, 6]]
items = [3, 1, 2, 1, 3, 4]
dict1 = {'a': 1, 'b': 2}; dict2 = {'b': 3, 'c': 4}
keys = ['a', 'b', 'c']; values = [1, 2, 3]
Expected Output:
Flat: [1, 2, 3, 4, 5, 6]
Transposed: [(1, 4), (2, 5), (3, 6)]
Unique: [3, 1, 2, 4]
Merged: {'a': 1, 'b': 3, 'c': 4}
Zipped: {'a': 1, 'b': 2, 'c': 3}
Solution 1 β Manual loops for each operation β Common One-Liners Data Engineers Should Know
nested = [[1, 2], [3, 4], [5, 6]]
flat = []
for sublist in nested:
for item in sublist:
flat.append(item)
print("Flat:", flat)
matrix = [[1, 2, 3], [4, 5, 6]]
transposed = []
for col in range(len(matrix[0])):
row = []
for r in range(len(matrix)):
row.append(matrix[r][col])
transposed.append(tuple(row))
print("Transposed:", transposed)
items = [3, 1, 2, 1, 3, 4]
seen = set()
unique = []
for item in items:
if item not in seen:
seen.add(item)
unique.append(item)
print("Unique:", unique)
dict1 = {'a': 1, 'b': 2}
dict2 = {'b': 3, 'c': 4}
merged = {}
for k, v in dict1.items():
merged[k] = v
for k, v in dict2.items():
merged[k] = v
print("Merged:", merged)
keys = ['a', 'b', 'c']
values = [1, 2, 3]
d = {}
for i in range(len(keys)):
d[keys[i]] = values[i]
print("Zipped:", d)
Solution 2 β Built-in functions β Common One-Liners Data Engineers Should Know
from itertools import chain
from collections import Counter
nested = [[1, 2], [3, 4], [5, 6]]
flat = list(chain.from_iterable(nested))
print("Flat:", flat)
matrix = [[1, 2, 3], [4, 5, 6]]
transposed = list(zip(*matrix))
print("Transposed:", transposed)
items = [3, 1, 2, 1, 3, 4]
unique = list(dict.fromkeys(items))
print("Unique:", unique)
dict1 = {'a': 1, 'b': 2}
dict2 = {'b': 3, 'c': 4}
merged = {**dict1, **dict2}
print("Merged:", merged)
keys = ['a', 'b', 'c']
values = [1, 2, 3]
d = dict(zip(keys, values))
print("Zipped:", d)
Solution 3 β Optimal: Pythonic one-liners β Common One-Liners Data Engineers Should Know
from collections import Counter
nested = [[1, 2], [3, 4], [5, 6]]
flat = [x for sub in nested for x in sub]
print("Flat:", flat)
matrix = [[1, 2, 3], [4, 5, 6]]
transposed = list(zip(*matrix))
print("Transposed:", transposed)
items = [3, 1, 2, 1, 3, 4]
unique = list(dict.fromkeys(items))
print("Unique:", unique)
dict1 = {'a': 1, 'b': 2}
dict2 = {'b': 3, 'c': 4}
merged = dict1 | dict2
print("Merged:", merged)
keys = ['a', 'b', 'c']
values = [1, 2, 3]
d = dict(zip(keys, values))
print("Zipped:", d)
a, b = 1, 2
a, b = b, a
print(a, b)
items = ["a", "b", "a", "c", "a", "b"]
most_common = Counter(items).most_common(1)[0][0]
print("Most common:", most_common)
numbers = [1, 2, 3, 4, 5]
all_positive = all(x > 0 for x in numbers)
print("All positive:", all_positive)
x = 42 if True else 0
print(x)
Interview Tip:
"These one-liners show Python fluency. Interviewers love seeing clean, idiomatic Python. Know dict.fromkeys for order-preserving dedup, zip(*matrix) for transpose, and dict1 | dict2 for merging."
What NOT to Say:
"I would use a for loop to flatten a list." -- While correct, it signals you are not comfortable with Pythonic patterns. Always show the comprehension version first, then mention the loop if asked.
Fundamental answer owners from the legacy question bank
Answer First: Lists are mutable; tuples are immutable, usually smaller, and can be dictionary keys only when every contained value is hashable.
Memory Map: List is mutable sequence -> tuple is immutable record -> hashability depends on every element.
Q01 β What is the difference between a list and a tuple?
Question: Explain the key differences between lists and tuples in Python. When would you use each?
Quick Answer: Lists are mutable (changeable), tuples are immutable (fixed). Tuples are faster, use less memory, and can be used as dictionary keys.
my_list = [1, 2, 3]
my_list[0] = 99
print(my_list)
my_tuple = (1, 2, 3)
print(my_tuple)
coords = {(10, 20): "New York", (40, 50): "London"}
print(coords[(10, 20)])
import sys
my_list = [1, 2, 3, 4, 5]
my_tuple = (1, 2, 3, 4, 5)
print(f"List size: {sys.getsizeof(my_list)} bytes")
print(f"Tuple size: {sys.getsizeof(my_tuple)} bytes")
π― Tip: "Tuples are hashable so they can be dict keys. Lists cannot because they're mutable. I use tuples for fixed data like DB row results or coordinates."
Answer First: A list comprehension evaluates eagerly into memory; a generator expression yields lazily and is normally consumed once.
Memory Map: Comprehension materializes list now -> generator expression yields later -> single-pass consumption.
Q03 β Explain list comprehension vs generator expression
Question: What is the difference between [x for x in range(n)] and (x for x in range(n))? When would you use each?
Quick Answer: List comprehension creates the full list in memory. Generator expression produces values lazily, one at a time. For large data, generators save memory.
squares_list = [x ** 2 for x in range(6)]
print(squares_list)
print(type(squares_list))
squares_gen = (x ** 2 for x in range(6))
print(type(squares_gen))
print(next(squares_gen))
print(next(squares_gen))
print(list(squares_gen))
import sys
list_comp = [x for x in range(10000)]
gen_expr = (x for x in range(10000))
print(f"List memory: {sys.getsizeof(list_comp)} bytes")
print(f"Generator memory: {sys.getsizeof(gen_expr)} bytes")
π― Tip: "In data pipelines, I use generators to avoid OOM on large datasets. If I need to iterate only once, a generator is always better than a list."
Answer First: A lambda is a small anonymous single-expression function, best used as a short key or callback rather than hidden complex logic.
Memory Map: Lambda function -> anonymous single expression -> short key/callback -> avoid concealed complex logic.
Q11 β What is a lambda function?
Question: What is a lambda function? How is it different from a regular function? When would you use it?
Quick Answer: A lambda is an anonymous, single-expression function. Used for short operations, especially with sorted(), map(), filter(). Cannot contain statements or multiple expressions.
square = lambda x: x ** 2
print(square(5))
def square_func(x):
return x ** 2
print(square_func(5))
employees = [
{"name": "Charlie", "salary": 75000},
{"name": "Alice", "salary": 90000},
{"name": "Bob", "salary": 60000},
]
by_salary = sorted(employees, key=lambda emp: emp["salary"])
for emp in by_salary:
print(f"{emp['name']}: {emp['salary']}")
by_name_desc = sorted(employees, key=lambda emp: emp["name"], reverse=True)
for emp in by_name_desc:
print(emp["name"])
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)
doubled = list(map(lambda x: x * 2, numbers))
print(doubled)
evens_lc = [x for x in numbers if x % 2 == 0]
doubled_lc = [x * 2 for x in numbers]
print(evens_lc)
print(doubled_lc)
π« What NOT to Say: "Lambda can have multiple statements." -- Lambda is ONE expression only. No assignments, no loops, no if/else blocks (only ternary x if cond else y).
Answer First: append adds one object as one element, whereas extend iterates its argument and adds each element.
Memory Map: append adds argument as one element -> extend iterates argument -> compare resulting list shape.
Q12 β Difference between append() and extend()?
Question: What is the difference between append() and extend() on a list? What about +=?
Quick Answer: append() adds one item (even if it's a list, it becomes a nested element). extend() adds each element individually from an iterable. += behaves like extend().
a = [1, 2]
a.append([3, 4])
print(a)
print(len(a))
b = [1, 2]
b.extend([3, 4])
print(b)
print(len(b))
c = [1, 2]
c.extend("abc")
print(c)
d = [1, 2]
d += [3, 4]
print(d)
append_result = [1, 2]
extend_result = [1, 2]
pluseq_result = [1, 2]
append_result.append([3, 4])
extend_result.extend([3, 4])
pluseq_result += [3, 4]
print(f"append: {append_result}")
print(f"extend: {extend_result}")
print(f"+=: {pluseq_result}")
π― Tip: "A common bug: using append when you meant extend, causing nested lists. In data pipelines, extend is usually what you want when combining batches of records."
Answer First: Generators suspend at yield, retain execution state, and produce a lazy single-pass stream.
Memory Map: Python generators -> yield value -> suspend frame state -> resume once -> exhaust stream.
Q13 β What are Python generators?
Question: What is a generator in Python? How does yield work? Why are generators useful in data engineering?
Quick Answer: Generators are functions that use yield instead of return. They produce values lazily -- one at a time -- and pause between yields. Memory efficient for large data.
def count_up_to(n):
"""Generator that yields numbers from 1 to n"""
i = 1
while i <= n:
yield i
i += 1
gen = count_up_to(5)
print(next(gen))
print(next(gen))
print(list(gen))
def chunked_range(start, end, chunk_size):
"""Yield data in chunks β simulates batch processing"""
for i in range(start, end, chunk_size):
chunk = list(range(i, min(i + chunk_size, end)))
yield chunk
for batch in chunked_range(0, 10, 3):
print(f"Processing batch: {batch}")
def generate_rows():
"""Extract: produce raw rows"""
data = [
{"name": "Alice", "score": 85},
{"name": "Bob", "score": 45},
{"name": "Charlie", "score": 92},
]
for row in data:
yield row
def filter_passing(rows, threshold=60):
"""Transform: keep only passing scores"""
for row in rows:
if row["score"] >= threshold:
yield row
def format_output(rows):
"""Transform: format for display"""
for row in rows:
yield f"{row['name']}: {row['score']}"
pipeline = format_output(filter_passing(generate_rows()))
for record in pipeline:
print(record)
π― Tip: "In ETL pipelines, generators let me process millions of rows without loading everything into memory. I chain them together like Unix pipes."
Answer First: A dictionary comprehension builds key-value pairs from an iterable with optional filtering, while retaining normal overwrite semantics for duplicate keys.
Memory Map: Dictionary comprehension -> derive key/value pairs -> optional filter -> later duplicate key overwrites.
Q16 β What is a dictionary comprehension?
Question: What is a dictionary comprehension? How does it compare to building a dict with a loop? Give practical examples.
Quick Answer: A dictionary comprehension creates a dict in a single expression: {key: value for item in iterable}. It's more concise and often faster than a loop.
squares = {x: x ** 2 for x in range(6)}
print(squares)
squares_loop = {}
for x in range(6):
squares_loop[x] = x ** 2
print(squares_loop)
scores = {"Alice": 85, "Bob": 55, "Charlie": 92, "Diana": 48}
passed = {k: v for k, v in sorted(scores.items()) if v >= 60}
print(passed)
inverted = {v: k for k, v in sorted(scores.items())}
print(inverted)
raw_data = [("host", "localhost"), ("port", "5432"), ("db", "analytics")]
config = {k: v for k, v in raw_data}
print(config)
employees = [
{"id": 101, "name": "Alice"},
{"id": 102, "name": "Bob"},
{"id": 103, "name": "Charlie"},
]
lookup = {emp["id"]: emp["name"] for emp in employees}
print(lookup)
print(lookup[102])
π― Tip: "Dict comprehensions are great for building lookup tables. In data engineering, I use them to create column mappings, config transforms, and ID-to-name lookups."
Answer First: enumerate yields (index, item) pairs directly, avoiding manual range(len(...)) indexing.
Memory Map: enumerate -> lazy (index, item) pairs -> optional start offset -> avoid range(len()).
Q17 β What is enumerate() and why use it?
Question: What does enumerate() do? Why is it better than using range(len(...))?
Quick Answer: enumerate() adds a counter to an iterable, returning (index, value) pairs. It's cleaner, more Pythonic, and less error-prone than manual indexing.
names = ["Alice", "Bob", "Charlie"]
for i in range(len(names)):
print(f"{i}: {names[i]}")
for i, name in enumerate(names):
print(f"{i}: {name}")
tasks = ["Extract", "Transform", "Load"]
for step, task in enumerate(tasks, start=1):
print(f"Step {step}: {task}")
scores = [72, 85, 91, 45, 88, 55, 93]
threshold = 80
high_scorers = [(i, score) for i, score in enumerate(scores) if score > threshold]
print(high_scorers)
index_map = dict(enumerate(["zero", "one", "two", "three"]))
print(index_map)
π« What NOT to Say: "I use for i in range(len(list)) to iterate with indices." -- That's unpythonic. Always use enumerate().
Answer First: zip aligns iterables into tuples and stops at the shortest; unpacking into zip(*pairs) transposes the pairs back into columns.
Memory Map: zip -> align columns to shortest input -> tuples out -> zip(*pairs) unzips/transposes.
Q20 β What is zip() and how do you unzip?
Question: What does zip() do? How do you unzip? What happens when iterables have different lengths?
Quick Answer: zip() pairs elements from multiple iterables into tuples. Unzip with zip(*zipped). In Python 3, zip() returns a lazy iterator and stops at the shortest iterable.
names = ["Alice", "Bob", "Charlie"]
scores = [85, 92, 78]
paired = list(zip(names, scores))
print(paired)
score_map = dict(zip(names, scores))
print(score_map)
pairs = [("Alice", 85), ("Bob", 92), ("Charlie", 78)]
names_back, scores_back = zip(*pairs)
print(names_back)
print(scores_back)
print(list(names_back))
from itertools import zip_longest
names = ["Alice", "Bob", "Charlie"]
scores = [85, 92]
print(list(zip(names, scores)))
print(list(zip_longest(names, scores, fillvalue=0)))
columns = ["id", "name", "score"]
values = [101, "Alice", 85]
for col, val in zip(columns, values):
print(f" {col} = {val}")
π« What NOT to Say: "zip returns a list." -- In Python 3, zip() returns a lazy iterator. Wrap in list() if you need a list. Also beware: regular zip silently drops extra elements from longer iterables.