Python Advanced Usage
Python Advanced Usage Interview with follow-up questions
1. Can you explain what decorators are in Python?
A decorator is a callable that takes a function (or class) and returns a replacement, letting you add behavior without editing the original source. The @decorator syntax above a definition is just sugar for func = decorator(func).
import functools, time
def timed(func):
@functools.wraps(func) # preserves __name__, __doc__, signature
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")
return wrapper
@timed
def work(n: int) -> int:
return sum(range(n))
Interviewers push on the deeper mechanics:
functools.wrapsis mandatory — without it the wrapper hides the original's metadata, breaking introspection and other decorators.- Decorators with arguments need a third layer (a factory that returns the decorator):
def retry(times: int):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception:
if attempt == times - 1:
raise
return wrapper
return decorator
@retry(times=3)
def fetch(url: str) -> bytes: ...
- Stacking applies bottom-up: with
@aover@b, the call isa(b(func)). - Real-world uses:
functools.cache/lru_cache(memoization),@property,@dataclass,@staticmethod/@classmethod, plus auth, logging, and rate-limiting. Class decorators and__call__-based class decorators carry state across calls.
Follow-up 1
How would you use a decorator in a real-world scenario?
Decorators can be used in various real-world scenarios. One common use case is to add logging or timing functionality to functions. For example, a decorator can be used to log the start and end time of a function, or to log the arguments and return value of a function. Another use case is to enforce authentication or authorization checks before executing a function. Decorators can also be used to implement caching, memoization, or error handling logic for functions.
Follow-up 2
What are the advantages of using decorators?
Using decorators in Python has several advantages. Firstly, decorators allow you to separate cross-cutting concerns from the core logic of a function or class. This improves code modularity and makes it easier to maintain and test the code. Secondly, decorators enable you to add functionality to existing functions or classes without modifying their source code, which can be useful when working with third-party libraries or code that you don't have control over. Lastly, decorators promote code reusability, as you can apply the same decorator to multiple functions or classes.
Follow-up 3
Can you write a simple code snippet demonstrating the use of decorators?
Sure! Here's an example of a decorator that logs the start and end time of a function:
import time
def log_time(func):
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f'{func.__name__} took {end_time - start_time} seconds to execute')
return result
return wrapper
@log_time
def my_function():
# Function logic goes here
pass
my_function()
When my_function() is called, the log_time decorator is applied to it, and the start and end time of the function execution are logged. This can be useful for performance profiling or debugging purposes.
2. What are generators in Python and how are they different from normal functions?
A generator is a function that produces values lazily, one at a time, using yield instead of return. Calling it doesn't run the body — it returns a generator object; each next() resumes execution until the next yield, then pauses, preserving local state.
def countdown(n: int):
while n > 0:
yield n # pause here, hand back n, resume on next()
n -= 1
How they differ from normal functions — and what interviewers probe:
- State is suspended, not lost. A regular function returns once and its frame is destroyed; a generator's frame is frozen at each
yieldand reused. It raisesStopIterationwhen the body ends. - Memory efficiency. Generators never materialize the whole sequence, so they handle huge or infinite streams (
itertools.count()) and pipelines that a list couldn't hold:
total = sum(x * x for x in range(10_000_000)) # generator expression, O(1) memory
yield fromdelegates to a sub-generator, flattening nested producers cleanly.- Two-way communication via
.send(),.throw(), and.close()lets a generator receive values, not just emit them (the historical basis for coroutines beforeasync/await). - They're single-pass — once exhausted, a generator can't be re-iterated; you must call the function again. That's the trade-off versus a reusable list.
Follow-up 1
Can you provide an example of a generator function?
Sure! Here's an example of a generator function that generates the Fibonacci sequence:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Usage:
for num in fibonacci():
print(num)
In this example, the 'fibonacci()' function is a generator function that uses the 'yield' keyword to yield the next number in the Fibonacci sequence. The function can be called in a for loop to generate an infinite sequence of Fibonacci numbers.
Follow-up 2
What are the benefits of using generators?
There are several benefits of using generators in Python:
Memory efficiency: Generators generate values on-the-fly, which means they don't store all the values in memory at once. This makes them more memory-efficient compared to normal functions that return a list or tuple of values.
Lazy evaluation: Generators use lazy evaluation, which means they only generate values as they are needed. This can be useful when working with large datasets or infinite sequences, as it allows you to generate and process values one at a time, without having to generate all the values upfront.
Improved performance: Due to their memory efficiency and lazy evaluation, generators can often be faster and more efficient than using normal functions and storing all the values in memory.
Simplified code: Generators can simplify code by allowing you to express complex logic using a simple and concise syntax. They can also make code more readable by separating the generation of values from the processing of values.
Follow-up 3
In what scenarios would you prefer generators over regular functions?
Generators are particularly useful in the following scenarios:
Processing large datasets: When working with large datasets, generators can be used to process the data one chunk at a time, without having to load the entire dataset into memory. This can significantly reduce memory usage and improve performance.
Generating infinite sequences: Generators are ideal for generating infinite sequences, such as an infinite stream of random numbers or an infinite sequence of prime numbers. Since generators generate values on-the-fly, they can generate and process values indefinitely without running out of memory.
Iterating over a sequence only once: If you only need to iterate over a sequence once and don't need to store all the values in memory, generators can be a more memory-efficient and faster alternative to using a list or tuple.
Simplifying complex logic: Generators can simplify code by allowing you to express complex logic using a simple and concise syntax. They can make code more readable by separating the generation of values from the processing of values.
3. Can you explain the concept of list comprehensions in Python?
A list comprehension builds a list in a single, readable expression by iterating over an iterable and optionally filtering or transforming each element. The shape is [expression for item in iterable if condition].
squares = [x * x for x in range(10)]
evens = [x for x in range(20) if x % 2 == 0]
pairs = [(x, y) for x in range(3) for y in range(3)] # nested loops
What interviewers expect alongside the basics:
- Comprehensions exist for all four built-ins: list
[], set{x for ...}, dict{k: v for ...}, and generator(x for ...)— the last is lazy and memory-cheap. - Readability is the point. If it has multiple
forclauses plus conditionals and stops being scannable, use a regular loop. They're slightly faster than an equivalentfor+appendbecause the append is optimized away. - Conditional placement matters: a filter goes at the end (
... if cond); a value choice uses a ternary up front ([a if cond else b for x in xs]). - Walrus
:=can capture and reuse a computed value:[y for x in data if (y := f(x)) > 0]. - Loop variables don't leak. Unlike Python 2, the comprehension's variable is scoped to the comprehension and won't clobber an outer name.
Follow-up 1
Can you write a code snippet using list comprehension?
Sure! Here's an example of a list comprehension that generates a list of squares of numbers from 1 to 10:
squares = [x**2 for x in range(1, 11)]
print(squares) # Output: [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
Follow-up 2
What are the advantages of using list comprehensions?
There are several advantages of using list comprehensions:
- Concise syntax: List comprehensions allow you to express complex operations on lists in a single line of code, making your code more readable and maintainable.
- Efficiency: List comprehensions are generally faster than traditional for loops because they are implemented in C under the hood.
- Avoiding temporary variables: List comprehensions eliminate the need for temporary variables, reducing the amount of code you need to write.
- Expressiveness: List comprehensions make your code more expressive by allowing you to express operations on lists in a declarative manner.
Follow-up 3
How does list comprehension improve code readability?
List comprehensions improve code readability by providing a concise and expressive way to perform operations on lists. They eliminate the need for explicit for loops and temporary variables, making your code more compact and easier to understand. List comprehensions also allow you to express operations on lists in a declarative manner, which can make your code more self-explanatory and easier to maintain.
4. What is the purpose of the 'yield' keyword in Python?
yield turns a function into a generator: instead of returning once, it produces a value and pauses, freezing all local state until the next value is requested. On resume, execution continues right after the yield.
def read_lines(path: str):
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n") # one line at a time, file stays open
Why it matters and the follow-ups interviewers ask:
- Lazy evaluation = low memory. Values are computed on demand, so you can stream gigabytes or model infinite sequences without holding them all in RAM.
- The function doesn't run on call — it returns a generator object; the body executes only as you iterate (
for,next(),sum(), etc.). It raisesStopIterationwhen it falls off the end. yield from iterabledelegates to another generator/iterable, forwarding values (and.send()/.throw()) — cleaner than a manual re-yield loop.yieldcan receive values.x = yieldpairs withgen.send(value), the generator-as-coroutine pattern that predatedasync/await.- A function with even one
yieldis entirely a generator — there's no "sometimes return, sometimes yield." Mixing a barereturn valueinside setsStopIteration.valuerather than yielding.
Follow-up 1
Can you provide an example where 'yield' is used?
Sure! Here's an example of a generator function that generates the Fibonacci sequence using the 'yield' keyword:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Usage:
for num in fibonacci():
print(num)
In this example, the 'fibonacci' function is a generator function that yields the next number in the Fibonacci sequence each time it is called. The generator can be used in a 'for' loop to iterate over the Fibonacci numbers indefinitely.
Follow-up 2
How does 'yield' work in the context of a generator?
When a generator function is called, it returns an iterator object. The generator function is not executed immediately, but instead, it is executed when the iterator's 'next' method is called. When the 'next' method is called, the generator function starts executing until it encounters a 'yield' statement. The value after the 'yield' keyword is returned as the next value of the iterator, and the function is paused. The next time the 'next' method is called on the iterator, the function resumes execution from where it left off, continuing until it encounters the next 'yield' statement or reaches the end of the function.
Follow-up 3
What is the difference between 'return' and 'yield'?
The 'return' statement is used to return a value from a function and terminate its execution. When a 'return' statement is encountered, the function immediately exits and returns the specified value. On the other hand, the 'yield' statement is used to yield a value from a generator function and pause its execution. When a 'yield' statement is encountered, the function is paused, and the yielded value is returned. The function can then be resumed from where it left off the next time the 'next' method is called on the generator's iterator. In summary, 'return' terminates the function, while 'yield' pauses the function and allows it to be resumed later.
5. Can you explain the concept of context managers in Python?
A context manager is an object that defines setup and teardown for a block of code via __enter__ and __exit__, used with the with statement. It guarantees cleanup runs even if the block raises — no manual try/finally needed.
with open("data.txt", encoding="utf-8") as f: # __enter__ returns f
process(f)
# __exit__ closes the file here, exception or not
__enter__ runs on entry and returns the value bound by as; __exit__(exc_type, exc, tb) runs on exit, receiving any exception so it can clean up (and, by returning truthy, optionally suppress it).
What interviewers like to explore:
contextlib.contextmanagerlets you write one with a generator instead of a class — the code beforeyieldis setup, after is teardown:
from contextlib import contextmanager
@contextmanager
def timer():
start = time.perf_counter()
try:
yield
finally:
print(f"{time.perf_counter() - start:.3f}s")
- Multiple managers in one
with(comma-separated, or parenthesized in 3.10+) enter left-to-right and exit right-to-left. - Beyond files: locks (
with lock:), DB transactions,contextlib.suppress(...),tempfile, and mocking in tests. __exit__returningTrueswallows the exception — usually a bug unless intentional.
Follow-up 1
Can you provide an example of a context manager?
Sure! Here's an example of a context manager that opens and closes a file:
class FileManager:
def __init__(self, filename):
self.filename = filename
def __enter__(self):
self.file = open(self.filename, 'r')
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
with FileManager('example.txt') as file:
contents = file.read()
print(contents)
In this example, the FileManager class is a context manager that opens the file specified by the filename parameter in its __init__() method. The __enter__() method opens the file and returns it, allowing it to be used within the with statement. The __exit__() method closes the file. The with statement ensures that the file is properly closed, even if an exception is raised.
Follow-up 2
What are the benefits of using context managers?
Using context managers has several benefits:
Automatic resource management: Context managers ensure that resources are properly initialized and cleaned up, even if an exception is raised. This helps prevent resource leaks and makes code more robust.
Simpler code: Context managers encapsulate the setup and teardown logic for resources, making code more readable and easier to maintain.
Consistent usage: The
withstatement provides a consistent and idiomatic way to use context managers, making code more understandable and reducing the chance of errors.Support for multiple resources: Context managers can be nested, allowing for the management of multiple resources in a single
withstatement.
Follow-up 3
How does the 'with' keyword work in Python?
The with keyword in Python is used to create a context manager. It ensures that the __enter__() method of the context manager is called before the block of code inside the with statement is executed, and that the __exit__() method is called afterwards, even if an exception is raised. The with statement can be used with any object that implements the context manager protocol, which means it defines the __enter__() and __exit__() methods. When the with statement is executed, the resource returned by the __enter__() method is assigned to a variable, which can be used within the block of code. After the block of code is executed, the __exit__() method is called to clean up the resource. The with statement can also handle exceptions raised within the block of code, allowing for proper cleanup even in the presence of errors.
6. How do type hints work in Python, and are they enforced at runtime?
Type hints (PEP 484 and successors) annotate variables, parameters, and return types. The crucial point interviewers probe: CPython does NOT enforce them at runtime — they're ignored by the interpreter and exist for tooling, readability, and IDEs.
def greet(name: str, times: int = 1) -> str:
return (f"Hi {name}! " * times).strip()
Enforcement comes from static type checkers — mypy or pyright (and the newer Rust-based ty) — run in CI/pre-commit, not at execution time. Modern syntax to know: built-in generics list[int]/dict[str, int] (no more typing.List), union types int | None (PEP 604), Optional/Any, Protocol for structural typing, TypedDict, and PEP 695 (3.12+) type aliases and class Stack[T] generic syntax.
Follow-ups: hints power @dataclass field generation and pydantic/FastAPI validation (which do enforce at runtime by reading the annotations); from __future__ import annotations / PEP 649 deferred evaluation avoids forward-reference issues and import costs; and typing.get_type_hints() reads them reflectively.
Live mock interview
Mock interview: Python Advanced Usage
- Read your scene and goals
- Talk it out; goals tick off live
- Get a score and stronger lines
Your voice and your AI key never touch our servers; the key stays in this browser and is sent only to Google. Only your round scores are saved to track progress.