Python Basics¶
Python fluency is the baseline expectation for every data science role. Interviewers use these questions to separate candidates who know Python deeply from those who have only used it as a scripting tool.
Q1: What is the difference between mutable and immutable types? Why does it matter in practice?¶
Show answer
Immutable types cannot be changed after creation. Mutating them creates a new object in memory instead. Examples: int, float, str, tuple, frozenset.
Mutable types can be modified in place. Examples: list, dict, set.
This matters because Python uses reference semantics — variables hold references to objects, not copies of them. When you pass a mutable object to a function, the function can modify the original.
def append_value(lst, val):
lst.append(val)
data = [1, 2, 3]
append_value(data, 99)
print(data) # [1, 2, 3, 99] — original is mutated
With an immutable type, mutation is impossible:
def try_change(t):
t += (99,) # creates a new tuple, does not mutate original
return t
original = (1, 2, 3)
result = try_change(original)
print(original) # (1, 2, 3) — unchanged
print(result) # (1, 2, 3, 99)
Interviewers probe whether you understand that mutability affects function side effects, default argument traps, and thread safety.
Q2: What is the default mutable argument trap?¶
Show answer
Default argument values in Python are evaluated once, at function definition time — not on each call. This is a common silent bug when using mutable defaults like lists or dicts.
def add_item(item, container=[]):
container.append(item)
return container
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] — not ['b']!
print(add_item("c")) # ['a', 'b', 'c']
The same list object is reused across all calls because it was created once when the function was defined.
Fix: use None as the default and initialise inside the function body.
def add_item(item, container=None):
if container is None:
container = []
container.append(item)
return container
print(add_item("a")) # ['a']
print(add_item("b")) # ['b']
This is one of the most frequently asked Python gotcha questions. Candidates who have not hit this bug in real code often get it wrong.
Q3: What is the difference between is and ==?¶
Show answer
== checks value equality — whether two objects have the same content.
is checks identity — whether two variables point to the exact same object in memory.
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True — same values
print(a is b) # False — different objects in memory
c = a
print(a is c) # True — same object
CPython caches small integers (typically -5 to 256) and interned strings, which can make is return True unexpectedly for those values:
x = 256
y = 256
print(x is y) # True — cached
x = 257
y = 257
print(x is y) # False — not cached (CPython implementation detail)
Rule of thumb: use == for value comparisons. Reserve is for identity checks, most commonly if value is None.
Q4: How do you correctly check for None?¶
Show answer
Always use is or is not when checking for None. Never use ==.
value = None
# Correct
if value is None:
print("no value")
if value is not None:
print("has value")
# Avoid — technically works but misleading and can be overridden
if value == None:
print("no value")
The reason: None is a singleton in Python — there is exactly one None object in any Python process. Identity check is both semantically correct and marginally faster.
A custom class can override __eq__ to make obj == None return True even when obj is not None. Using is None is immune to this.
Q5: What is the difference between a shallow copy and a deep copy?¶
Show answer
A shallow copy creates a new container object but does not copy the nested objects inside it — it copies references to them.
A deep copy recursively copies all nested objects, producing a fully independent clone.
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
original[0].append(99)
print(original) # [[1, 2, 99], [3, 4]]
print(shallow) # [[1, 2, 99], [3, 4]] — inner list is shared
print(deep) # [[1, 2], [3, 4]] — fully independent
For flat lists (no nested objects), list.copy(), slice [:], or list() all produce shallow copies and are sufficient.
In data science, deep copy matters when you are working with nested structures like lists of arrays or custom objects. For NumPy arrays, use array.copy() instead.
Q6: What is the difference between list comprehensions and generator expressions?¶
Show answer
Both produce sequences of values. The key difference is when values are computed and where they are stored.
A list comprehension evaluates all values immediately and stores the entire result in memory.
squares = [x**2 for x in range(10)] # all 10 values in memory
print(squares) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
A generator expression uses lazy evaluation — it produces values one at a time, on demand. Only one value exists in memory at a time.
squares_gen = (x**2 for x in range(10)) # no values computed yet
print(next(squares_gen)) # 0
print(next(squares_gen)) # 1
Use a generator when:
- The dataset is large and you cannot afford to hold it all in memory
- You only need to iterate once
- You are feeding into another iterator (e.g., sum(), max(), for loop)
Use a list when you need random access, multiple passes, or to check the length.
Q7: How do *args and **kwargs work?¶
Show answer
*args collects extra positional arguments into a tuple.
**kwargs collects extra keyword arguments into a dict.
def describe(*args, **kwargs):
print("Positional:", args)
print("Keyword:", kwargs)
describe(1, 2, 3, name="Alice", role="analyst")
# Positional: (1, 2, 3)
# Keyword: {'name': 'Alice', 'role': 'analyst'}
They are also used to unpack when calling functions:
def add(a, b, c):
return a + b + c
nums = [1, 2, 3]
params = {"a": 1, "b": 2, "c": 3}
print(add(*nums)) # 6
print(add(**params)) # 6
Common data science use case: writing wrapper functions that pass arguments through to scikit-learn estimators or Pandas methods without hardcoding every parameter.
Q8: How does Python handle variable scope? What is a closure?¶
Show answer
Python resolves names using the LEGB rule (Local → Enclosing → Global → Built-in), searched in that order.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # "local"
inner()
print(x) # "enclosing"
outer()
print(x) # "global"
A closure is a function that captures variables from its enclosing scope even after the outer function has returned.
def make_multiplier(factor):
def multiply(x):
return x * factor # 'factor' is captured from outer scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
Closures are common in decorators and callback-based APIs. The global and nonlocal keywords let inner scopes modify outer variables, but relying on them too heavily signals poor design.
Q9: What are f-strings and why should you prefer them?¶
Show answer
F-strings (formatted string literals, introduced in Python 3.6) embed expressions directly in string literals using {} syntax.
name = "Alice"
score = 97.456
# f-string — most readable, fastest at runtime
print(f"Name: {name}, Score: {score:.2f}")
# Name: Alice, Score: 97.46
# .format() — verbose but compatible with older Python
print("Name: {}, Score: {:.2f}".format(name, score))
# % formatting — legacy, avoid in new code
print("Name: %s, Score: %.2f" % (name, score))
F-strings support arbitrary expressions:
items = [1, 2, 3, 4, 5]
print(f"Count: {len(items)}, Total: {sum(items)}, Mean: {sum(items)/len(items):.2f}")
# Count: 5, Total: 15, Mean: 3.00
Python 3.12 added = for self-documenting expressions:
Prefer f-strings in all new code. They are faster than .format() and far more readable than % formatting.
Q10: How does exception handling work in Python? What makes a good exception handling pattern?¶
Show answer
Python uses try / except / else / finally blocks.
try:
result = int("abc")
except ValueError as e:
print(f"Conversion failed: {e}")
else:
print(f"Success: {result}") # runs only if no exception was raised
finally:
print("Always runs") # cleanup code goes here
Catch specific exceptions, not bare except: or except Exception: unless you intend to handle everything.
# Bad — hides bugs
try:
data = load_data(path)
except:
pass
# Good — handle what you expect, let the rest propagate
try:
data = load_data(path)
except FileNotFoundError:
print(f"File not found: {path}")
raise
Use raise without arguments inside an except block to re-raise the original exception with its traceback intact.
Custom exceptions improve code clarity:
Q11: What is a context manager and how does with work?¶
Show answer
A context manager handles setup and teardown automatically — even if an exception occurs. The with statement calls __enter__ on entry and __exit__ on exit.
with open("data.csv", "r") as f:
content = f.read()
# file is closed here, regardless of whether an exception occurred
You can write custom context managers using contextlib.contextmanager:
from contextlib import contextmanager
import time
@contextmanager
def timer(label):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.4f}s")
with timer("data processing"):
result = [x**2 for x in range(1_000_000)]
# data processing: 0.0821s
Context managers are the right tool for: file handles, database connections, locks, timing blocks, temporary directory creation, and suppressing specific exceptions.
Q12: What is Python's GIL and what does it mean for data science workloads?¶
Show answer
The Global Interpreter Lock (GIL) is a mutex in CPython that allows only one thread to execute Python bytecode at a time, even on multi-core machines.
Implication: Python threads do not achieve true CPU parallelism for CPU-bound tasks.
# Threading is fine for I/O-bound work (network calls, file reads)
import threading
def fetch(url):
# HTTP request releases the GIL while waiting
pass
# But threading does NOT speed up CPU-bound computation
import numpy as np
# NumPy operations release the GIL internally — they run in C
# So numpy + threading CAN achieve parallelism at the C level
arr = np.random.rand(1_000_000)
result = np.sum(arr) # GIL released during this C-level operation
Workarounds for CPU-bound parallelism:
- multiprocessing — spawns separate processes, each with its own GIL
- NumPy/SciPy/PyTorch — heavy computation runs in C/C++/CUDA, releasing the GIL
- concurrent.futures.ProcessPoolExecutor for embarrassingly parallel tasks
For most data science work, the GIL is not a bottleneck because the heavy lifting happens inside NumPy, Pandas, and scikit-learn's C extensions, which release the GIL during computation.
Q13: What is the difference between __repr__ and __str__?¶
Show answer
__str__ returns a human-readable string, intended for end users.
__repr__ returns an unambiguous representation, intended for developers and debugging. It should ideally be valid Python that could recreate the object.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
def __str__(self):
return f"({self.x}, {self.y})"
p = Point(3, 4)
print(repr(p)) # Point(x=3, y=4) — calls __repr__
print(str(p)) # (3, 4) — calls __str__
print(p) # (3, 4) — print() calls __str__
If only __repr__ is defined, Python falls back to it for __str__ as well. If only __str__ is defined, repr() falls back to the default <__main__.Point object at 0x...>.
Rule: always implement __repr__. Add __str__ when you need a different user-facing format.
Q14: How does Python's memory management work at a high level?¶
Show answer
Python manages memory through reference counting combined with a cyclic garbage collector.
Every object in CPython maintains a count of how many references point to it. When the count drops to zero, the object's memory is freed immediately.
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 — one for 'a', one for the getrefcount argument
b = a
print(sys.getrefcount(a)) # 3
del b
print(sys.getrefcount(a)) # 2
Cyclic garbage collector handles reference cycles — cases where two objects reference each other and neither reaches a count of zero through normal means.
# Reference cycle
a = []
b = [a]
a.append(b)
# a and b reference each other — cycle collector handles cleanup
Practical implications for data science:
- Holding references to large DataFrames or arrays prevents memory from being freed. Explicitly del df and call gc.collect() in memory-constrained pipelines.
- NumPy arrays are managed by NumPy's own allocator but still use Python reference counting for the array object wrapper.