This lesson continues building your Python interview foundation by exploring several important intermediate concepts. These topics appear frequently in backend, full-stack, and systems-level discussions. The goal here is to learn not just the definitions, but also how these ideas connect to real-world problem solving and performance considerations.
A decorator in Python is a way to modify or extend the behavior of a function or method without changing its actual code. Think of a decorator as “wrapping” one function inside another. It receives a function as input, adds some behavior, and returns a new function.
Decorators are especially useful when you want to apply the same kind of additional behavior to many different functions, such as logging, authentication checks, caching, or execution timing. Instead of rewriting the same code multiple times, you wrap the function using a decorator.
def greet():
return "Hello"
# Basic decorator structure
def simple_decorator(func):
def wrapper():
print("Function is about to run...")
result = func()
print("Function finished running.")
return result
return wrapper
# Applying decorator
greet = simple_decorator(greet)
print(greet())Python also has syntactic sugar for decorators using the @ symbol:
@simple_decorator
def greet():
return "Hello"Context managers allow you to allocate and release resources safely and automatically using the with statement. They are most commonly used when working with files, network connections, database sessions, and locks where certain cleanup steps must happen no matter what.
For example, when working with files, you should always close the file after finishing. A context manager ensures this automatically.
# Using context manager
with open("data.txt", "r") as file:
content = file.read()
# File automatically closes hereYou can also create your own context manager using __enter__ and __exit__:
class CustomManager:
def __enter__(self):
print("Entering context")
return "Resource"
def __exit__(self, exc_type, exc_val, exc_tb):
print("Exiting context")
with CustomManager() as resource:
print(resource)Monkey patching means changing or extending the behavior of a module or class at runtime without modifying the original source code. It is like temporarily giving a new behavior to an existing function.
This is often used in testing when you want to replace code that interacts with external systems (for example, APIs or databases) with controlled behavior.
class Person:
def speak(self):
return "Hello"
def new_speak():
return "Hi, I have been patched!"
p = Person()
# Monkey patching
Person.speak = new_speak
print(p.speak()) # Output: Hi, I have been patched!The Global Interpreter Lock is a mechanism in the standard Python implementation (CPython) that ensures only one thread executes Python bytecode at a time. In a normal build, even on a machine with many cores, Python threads cannot run Python code in parallel.
The GIL exists mainly to protect CPython’s reference-counting memory management, which is not thread-safe without it. It makes the interpreter simpler and single-threaded code faster, at the cost of true CPU-bound parallelism in threads.
This is changing - and it is the follow-up question to expect
Saying “the GIL means Python can’t do parallelism” is no longer a complete answer. PEP 703 added a free-threaded build of CPython that removes the GIL entirely. It shipped as experimental in Python 3.13 (October 2024) and became an officially supported build in Python 3.14 (October 2025), where it is no longer classed as experimental.
What a strong answer covers:
python3.14t (the t suffix). The default download is still the GIL build, so most production code in 2026 still runs with a GIL.Separately, CPython 3.13 also added an experimental JIT compiler (a copy-and-patch tier-2 compiler). It is off by default and the gains are still modest, but it is worth knowing it exists when someone asks “is Python getting faster?”.
The honest summary for an interview: on a default build the GIL still applies, so use processes for CPU-bound work and threads or asyncio for I/O-bound work. But you should know that free-threaded CPython is now a supported option, what it costs, and why it does not remove the need for locks.
Multithreading and multiprocessing both allow tasks to run concurrently, but they work in different ways and are suitable for different types of workloads.
Threads share the same memory space. They are lightweight and switch quickly. Because of the GIL, Python threads do not run CPU computations truly in parallel, but they work well for tasks that spend time waiting, such as network requests or disk operations.
Each process has its own memory and Python interpreter. This bypasses the GIL, allowing true parallel execution on multiple CPU cores. Multiprocessing is ideal for CPU-heavy computations like data processing or machine learning tasks.
# Example of multithreading
import threading
def task():
print("Running task")
thread = threading.Thread(target=task)
thread.start()
thread.join()# Example of multiprocessing
from multiprocessing import Process
def task():
print("Running task")
process = Process(target=task)
process.start()
process.join()Together, these concepts help you build efficient, maintainable, and scalable Python applications.
4 questions · no sign-up, nothing stored
Progress is stored in this browser only - no sign-up, nothing sent anywhere.
Step through the algorithms behind these answers one operation at a time, forwards or backwards, with the code highlighted as it runs.