Python Core Interview Questions and Answers: Clear, Practical Explanations
Python interview questions have a strange way of sounding easy right up until you answer them out loud. You may know what a list is, what the GIL does, or why async exists, but an interview answer needs more than a keyword. It has to be clear, accurate, and practical.
This guide focuses on that middle ground: explanations that are correct enough to stand up to follow-up questions, but still natural enough that you could actually say them in a conversation.
If you want a simple answering pattern, use this:
- Start with the definition.
- Explain what it means in normal code.
- Give one practical example.
- Mention one tradeoff or common mistake.
Each question starts with a one-line short answer for quick review, then expands into examples, gotchas, and a version of the answer I would actually say in an interview.
Interview Map
Most of these questions fall into three buckets: Python objects, runtime behavior, and concurrency.
1. What Is the Difference Between a List, Tuple, Set, and Dictionary in Python?
Short answer: A list is an ordered mutable sequence, a tuple is an ordered immutable sequence, a set is an unordered collection of unique items, and a dict maps unique keys to values.
These four container types overlap just enough to confuse people, but they are built for different jobs:
listis the standard mutable sequence type.tupleis an immutable sequence.setis an unordered collection of distinct hashable objects.dictis Python’s standard mapping type: it maps hashable keys to values.
Here is the practical version:
| Type | Best mental model | Ordered | Mutable | Duplicate values | Main strength |
|---|---|---|---|---|---|
list |
an editable sequence | Yes | Yes | Yes | index-based access |
tuple |
a fixed sequence | Yes | No | Yes | safety, hashability in the right cases |
set |
unique items | No | Yes | No | fast membership tests |
dict |
a lookup table | Yes, by insertion order | Yes | keys must be unique | key-based lookup |
Which Are Mutable?
list,set, anddictare mutable.tupleis immutable.
That means you can add, remove, or update entries in a list, set, or dictionary after creation. A tuple is different: once the tuple exists, its slots are fixed.
nums = [1, 2, 3]
nums[0] = 99 # fine, lists are mutable
point = (1, 2, 3)
point[0] = 99 # TypeError: 'tuple' object does not support item assignment
Which Are Hashable?
An object is hashable when Python can give it a stable hash value for its lifetime and compare it reliably with other objects. That is what allows the object to live inside a set or be used as a dict key.
In this group:
listis not hashable.setis not hashable.dictis not hashable.tuplecan be hashable, but only if all of its elements are hashable too.
point = (10, 20)
cache = {point: "seen"}
That works because point is a tuple of hashable integers.
When Would You Use a Set Instead of a List?
Use a set when uniqueness matters or when you care about fast membership checks.
For example, set() is a simple way to remove duplicates from a sequence:
names = ["alice", "bob", "alice", "charlie"]
unique_names = set(names)
And if you need to ask “have I seen this before?” many times, a set is usually a better fit than scanning a list:
seen = {"alice", "bob", "charlie"}
if "alice" in seen:
print("duplicate")
How I Would Say It in an Interview
A list and tuple are both ordered sequences, but a list is mutable and a tuple is not. A set stores unique values and is especially useful for membership checks and deduplication. A dictionary maps keys to values and is the right choice when I want fast lookup by key.
2. What Is the Difference Between is and ==?
Short answer:
==compares values;iscompares identity, that is, whether two names point to the exact same object in memory.
This is one of those small Python questions that quickly reveals whether someone understands objects or has only memorized syntax.
==checks whether two objects have the same value.ischecks whether two references point to the same object.
Every Python object has an identity, a type, and a value. The is operator only cares about the identity part.
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True
print(a is b) # False
These two lists have the same value, but they are not the same list object. id() makes that concrete:
a = [1, 2, 3]
b = a
c = [1, 2, 3]
print(a is b) # True -> same object
print(id(a) == id(b)) # True
print(a == c) # True -> same value
print(a is c) # False -> different objects
print(id(a) == id(c)) # False
What Does Object Identity Mean?
Object identity answers “which object is this?”, not “what value does it hold?”
If two variables refer to the exact same underlying object, is returns True.
a = []
b = a
print(a is b) # True
Why Can a is b Be True for Small Integers or Strings?
Because CPython sometimes reuses immutable objects, especially small integers and interned strings.
For immutable types, Python implementations are free to return a reference to an existing object with the same type and value. That is why this can happen:
a = 10
b = 10
print(a is b) # Often True in CPython
But that is an implementation detail. It is useful to know, and dangerous to depend on.
Best Practice
Use:
==for value comparisonisfor identity checks, especially for singletons likeNone
if result is None:
...
How I Would Say It in an Interview
==is for value equality, whileisis for object identity. I use==for normal comparisons andismainly when I need to check whether something is literally the same object, especiallyis None.
3. Explain Python’s Memory Management
Short answer: CPython frees an object immediately when its reference count drops to zero, and a separate cyclic garbage collector reclaims reference cycles that counting alone can never free.
At a high level, Python handles memory for you. In a CPython interview answer, the two ideas that matter most are reference counting and cyclic garbage collection.
What Is Reference Counting?
A reference count is the number of active references to an object. When that count drops to zero, CPython can deallocate the object right away.
x = [1, 2, 3]
y = x
del x
del y
Once nothing refers to that list anymore, CPython can reclaim it. You can watch the count change with sys.getrefcount():
import sys
x = []
print(sys.getrefcount(x)) # 2 (x, plus the temporary argument to getrefcount)
y = x
print(sys.getrefcount(x)) # 3
del y
print(sys.getrefcount(x)) # 2
The count is one higher than most people expect because passing x into getrefcount() creates a temporary reference. Mentioning that detail is a nice signal that you have actually used the tool.
What Is Garbage Collection?
Garbage collection fills the gap that reference counting cannot cover. The main case is a reference cycle.
What Are Circular References?
A circular reference happens when objects keep references to each other, so their reference counts never reach zero even though the program no longer needs them.
a = {}
b = {}
a["other"] = b
b["other"] = a
Now a points to b, and b points back to a.
If the rest of the program stops referring to both objects, the pair can still keep each other alive. That is why CPython also has a cyclic garbage collector.
Why This Matters in Real Projects
Most of the time you do not manage memory manually in Python, but object lifetime still matters.
Common real-world mistakes include:
- large cached objects staying alive too long
- long-running services holding references by accident
- circular references in complex object graphs
- forgetting to close files, sockets, or database connections
How I Would Say It in an Interview
In CPython, memory management is based first on reference counting. When an object’s reference count drops to zero, it can be deallocated. On top of that, Python has a garbage collector for cyclic references, because reference counting alone cannot clean up objects that keep each other alive.
4. What Is the GIL?
Short answer: The GIL lets only one thread execute Python bytecode at a time per process, so threads do not speed up CPU-bound Python, but they still help I/O-bound work because the GIL is released during blocking I/O.
The GIL is the Global Interpreter Lock in CPython. In day-to-day terms, it means only one thread at a time can execute Python bytecode inside a single interpreter process.
The lock exists because CPython’s interpreter state and object model are not fully thread-safe without coordination. The GIL is the coordination mechanism.
Why Does Python Have the GIL?
Historically, it made CPython’s memory management and object model simpler and safer, especially around shared state and reference counting.
That does not mean it is good for every workload. It means it is a tradeoff.
How Does It Affect Multithreading?
If your code is CPU-bound and written mostly in pure Python, threads often do not speed it up the way people expect. The threads still compete for the GIL.
So if you start four Python threads for four heavy CPU tasks, that does not automatically mean four cores are running Python bytecode in parallel.
import threading, time
def cpu_task():
total = 0
for _ in range(20_000_000):
total += 1
start = time.perf_counter()
threads = [threading.Thread(target=cpu_task) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(f"two threads: {time.perf_counter() - start:.2f}s")
# Roughly the same as running cpu_task() twice in a row — the threads
# spend their time contending for the GIL, not running in parallel.
One modern nuance is worth knowing: CPython 3.13 introduced an experimental, opt-in free-threaded build (PEP 703) that can run without the GIL. It is not the default, and most libraries are not validated for it yet. For interviews, the GIL is still the right baseline assumption, but mentioning the free-threaded work shows you are current.
When Is Multithreading Still Useful?
Very often, for I/O-bound work.
CPython can release the GIL around potentially blocking I/O operations such as file reads and writes. That is why threads are still useful for:
- network requests
- database waits
- file I/O
- external API calls
How Would You Handle CPU-Bound Work?
For CPU-heavy work, the better answers are usually:
multiprocessing- native extensions that release the GIL
- vectorized libraries such as NumPy
- worker systems or external services
Important Nuance
Do not say “Python cannot do multithreading.” That is too blunt and not correct.
A better answer is:
CPython supports threads, but the GIL limits parallel execution of Python bytecode in a single process. Threads still help a lot for I/O-bound workloads.
5. What Is an Iterator in Python?
Short answer: An iterator is an object with
__next__()that yields items one at a time and raisesStopIterationwhen exhausted; an iterable is anything that can hand you a fresh iterator viaiter().
An iterator represents a stream of data. Repeated calls to __next__() return the next item, and when there is nothing left, the iterator raises StopIteration.
An iterator also implements __iter__(), which returns the iterator itself.
nums = iter([1, 2, 3])
print(next(nums)) # 1
print(next(nums)) # 2
print(next(nums)) # 3
print(next(nums)) # raises StopIteration — the stream is exhausted
Writing Your Own Iterator
Any object can become an iterator if it implements __iter__ and __next__.
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
for n in Countdown(3):
print(n) # 3, 2, 1
print(list(Countdown(3))) # [3, 2, 1]
A for loop calls iter() once, then calls next() repeatedly until StopIteration. That is why these two methods are enough to make a custom object work in loops, comprehensions, and list() calls.
Iterable vs Iterator
This distinction matters.
- An iterable is something you can loop over.
- An iterator is the object that actually produces the next value.
items = [1, 2, 3] # iterable
it = iter(items) # iterator
A list can produce a fresh iterator each time. An iterator itself is usually single-pass and gets exhausted.
items = [1, 2, 3]
print(list(items)) # [1, 2, 3]
print(list(items)) # [1, 2, 3] -> the iterable can be reused
it = iter(items)
print(list(it)) # [1, 2, 3]
print(list(it)) # [] -> the iterator is now exhausted
How I Would Say It in an Interview
An iterator is the object that yields values one by one through
__next__(). An iterable is any object that can give me an iterator. A list is iterable, but the object returned byiter(list_obj)is the iterator.
6. What Is a Generator?
Short answer: A generator is a lazy iterator built with
yield; it produces values one at a time and preserves its local state between calls, so it avoids materializing the whole result in memory.
A generator is one of Python’s nicest features because it lets you build an iterator without writing the iterator class by hand.
Technically, a generator function is a function that uses yield. Calling it returns a generator iterator.
def count_up_to(n):
current = 1
while current <= n:
yield current
current += 1
for value in count_up_to(3):
print(value) # 1, 2, 3
print(list(count_up_to(3))) # [1, 2, 3]
The whole Countdown iterator class from the previous question collapses into a few lines as a generator. Same behavior, much less boilerplate:
def countdown(start):
while start > 0:
yield start
start -= 1
print(list(countdown(3))) # [3, 2, 1]
How Does yield Work?
yield pauses the function and sends a value back to the caller. When iteration continues, the function resumes from where it left off, with its state preserved.
That is why generators are lazy: they compute values only when the caller asks for them.
Why Are Generators Memory-Efficient?
Because they do not build the whole result upfront.
If you are processing a large file, a large query result, or a stream of events, yielding one item at a time is often much cheaper than building a list of everything first.
import sys
nums_list = [x for x in range(1_000_000)] # builds the whole list
nums_gen = (x for x in range(1_000_000)) # builds nothing yet
print(sys.getsizeof(nums_list)) # ~8 MB
print(sys.getsizeof(nums_gen)) # ~200 bytes, no matter how large the range is
def read_ids(path):
with open(path) as f:
for line in f:
yield int(line.strip())
When Is a Generator Better Than a List?
When you only need one pass.
If you need to scan a huge dataset once, a generator is often the right tool. If you need indexing, slicing, or multiple passes over the same data, a list may be the better choice.
How I Would Say It in an Interview
A generator is a lazy iterator, usually created by a function that uses
yield. It gives values one at a time, preserves its state between yields, and is often much more memory-efficient than building a full list upfront.
7. What Is a Decorator?
Short answer: A decorator is a callable that takes a function and returns a replacement, letting you add behavior like logging, caching, or auth around it without editing its body.
A decorator is a callable that takes something, often a function, and returns a replacement for it. In practice, it lets you add behavior around a function without rewriting that function’s core logic.
Typical use cases:
- logging
- timing
- retries
- authentication
- caching
- instrumentation
Simple Logging Decorator
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
print(add.__name__) # 'add' — preserved because of @wraps
What Does functools.wraps Do?
functools.wraps() preserves metadata from the original function, such as:
__name____doc__- other wrapper metadata used by tooling and frameworks
Without it, the wrapped function often looks like it is just called wrapper. That is annoying in debugging and can break frameworks that rely on introspection.
def log_calls(func):
def wrapper(*args, **kwargs): # no @wraps this time
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
return a + b
print(add.__name__) # 'wrapper' — the original identity is lost
How I Would Say It in an Interview
A decorator wraps a function so I can add behavior around it without changing the original logic. I use them for cross-cutting concerns like logging or auth. I also use
functools.wrapsso the wrapped function keeps its original metadata.
8. What Is a Context Manager?
Short answer: A context manager defines setup and teardown through
__enter__and__exit__, so awithblock guarantees the teardown runs even if the body raises an exception.
The everyday use is the with statement. It acquires a resource, hands it to the block, and releases it on exit no matter how the block ends.
with open("data.txt") as f:
data = f.read()
# f is closed here automatically, even if read() raised
Creating a Class-Based Context Manager
An object is a context manager if it implements __enter__ and __exit__. __enter__ runs on the way in, and its return value is bound by as. __exit__ runs on the way out.
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc, tb):
self.elapsed = time.perf_counter() - self.start
print(f"took {self.elapsed:.3f}s")
return False # falsy -> any exception is re-raised, not swallowed
with Timer() as t:
sum(range(1_000_000))
# took 0.0xx s
__exit__ receives the exception type, value, and traceback when the block raised (all None otherwise). Returning a falsy value lets the exception propagate; returning True suppresses it.
Creating One with contextlib
For simple cases, contextlib.contextmanager turns a generator into a context manager. Everything before yield is setup, and everything after it is teardown. This is a useful example of generators doing real work beyond simple iteration.
import time
from contextlib import contextmanager
@contextmanager
def timer():
start = time.perf_counter()
try:
yield
finally:
print(f"took {time.perf_counter() - start:.3f}s")
with timer():
sum(range(1_000_000))
The try/finally is the important part. Putting teardown in finally is what guarantees cleanup even if the with body raises.
How I Would Say It in an Interview
A context manager defines
__enter__and__exit__so awithblock can guarantee cleanup — closing a file, releasing a lock, rolling back a transaction — even when the block raises. I can write one as a class, or more concisely as a generator decorated withcontextlib.contextmanager, where the code afteryieldin afinallyblock is the teardown.
9. Can a Context Manager Be Used as a Decorator?
Short answer: Yes —
contextlib’s@contextmanagerandContextDecoratormake a context manager usable as@cm(), so its setup/teardown brackets an entire function call instead of just awithblock.
This is a natural follow-up because it connects the decorator and context manager ideas. Sometimes you want the same enter/exit behavior to wrap a whole function, not just a block inside it.
The Generator Form Already Works
A function decorated with contextlib.contextmanager is also a ContextDecorator, so the same timer() from the previous question can be used as a decorator with no extra code:
from contextlib import contextmanager
import time
@contextmanager
def timer():
start = time.perf_counter()
try:
yield
finally:
print(f"took {time.perf_counter() - start:.3f}s")
@timer() # note the parentheses: decorate with an instance
def process():
sum(range(1_000_000))
process() # took 0.0xx s — enter/exit wraps the whole call
Each call enters a fresh context, so the same decorator is safe to reuse across many invocations.
The Class Form via ContextDecorator
Subclass ContextDecorator and the same object works both as a with block and as a decorator:
from contextlib import ContextDecorator
import time
class timed(ContextDecorator):
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, *exc):
print(f"took {time.perf_counter() - self.start:.3f}s")
return False
@timed()
def process():
sum(range(1_000_000))
with timed(): # still works as a normal context manager too
sum(range(1_000_000))
One caveat matters: the decorator form cannot bind the __enter__ return value because there is no as target. Use it when you care about the setup/teardown side effect, not when the function body needs a resource from the context manager.
How I Would Say It in an Interview
Yes. If I build a context manager with
contextlib.contextmanageror by subclassingContextDecorator, the same object works as both awithblock and a@decorator. I reach for the decorator form when the enter/exit logic should wrap an entire function — like timing or logging — and I don’t need the value the context manager would normally bind withas.
10. Explain async and await
Short answer:
async defdefines a coroutine andawaitsuspends it until the awaited operation is ready, letting the event loop run other coroutines meanwhile — great for I/O-bound concurrency, not a CPU-bound speedup.
Start with this: async is about concurrency, especially I/O-bound concurrency.
async defdefines a coroutine.awaitpauses that coroutine until the awaited operation is ready.- While it is waiting, the event loop can run other tasks.
import asyncio
async def fetch_data():
await asyncio.sleep(1)
return {"status": "ok"}
What Is the Event Loop?
The event loop is the scheduler. It runs asynchronous tasks and callbacks, performs network I/O, and decides when suspended coroutines should resume.
Here is the payoff: three one-second waits finish in about one second, not three, because the loop overlaps the waiting time:
import asyncio, time
async def task(name, delay):
await asyncio.sleep(delay)
return name
async def main():
start = time.perf_counter()
results = await asyncio.gather(task("a", 1), task("b", 1), task("c", 1))
print(results, f"{time.perf_counter() - start:.2f}s")
# ['a', 'b', 'c'] ~1.00s — concurrent, not 3s
asyncio.run(main())
Async vs Multithreading
Async and threads both help with concurrency, but they work differently.
Async:
- usually runs many tasks cooperatively
- is especially good for I/O-bound workloads
- relies on
awaitpoints where control can be handed back
Threads:
- involve OS threads
- are useful for blocking I/O and integration with blocking libraries
- are still constrained by the GIL for CPU-bound Python bytecode
When Should You Not Use Async?
Async is not automatically the best answer.
Avoid forcing async when:
- the work is CPU-bound
- the libraries are blocking anyway
- the added complexity does not improve throughput or latency
How I Would Say It in an Interview
asyncandawaitare for cooperative concurrency. A coroutine can pause while waiting on I/O, and the event loop can run other coroutines in the meantime. It is especially useful for I/O-bound services with lots of waiting, not as a magic speedup for CPU-heavy work.
11. What Happens If You Call a Blocking Function Inside an Async Endpoint?
Short answer: A blocking call inside
async deffreezes the whole event loop, so every other in-flight request stalls until it returns; fix it with an async library, a plaindefendpoint (FastAPI runs those in a threadpool), or a worker queue.
You block the event loop, which is exactly what async code is trying to avoid.
One slow blocking call can delay every other piece of work that the same event loop could otherwise be handling.
from fastapi import FastAPI
import time
app = FastAPI()
@app.get("/bad")
async def bad_endpoint():
time.sleep(5) # blocks the event loop
return {"ok": True}
Why Does This Hurt FastAPI?
FastAPI’s guidance is practical here:
- if a library supports
await, use it insideasync def - if a library is blocking and does not support
await, a normaldefendpoint can be the better choice - FastAPI can run regular
defpath functions in a threadpool instead of blocking the main event loop
The same rule shows up in file streaming too: blocking file operations can block the event loop, so a regular def path operation can be safer in those cases.
How Would You Fix It?
The fix depends on what is blocking:
- replace blocking I/O with async-compatible libraries
- use async DB drivers
- use async HTTP clients
- move blocking work to a thread pool when appropriate
- move long-running or CPU-bound work to a worker queue
For example:
from fastapi import FastAPI
import asyncio
app = FastAPI()
@app.get("/good")
async def good_endpoint():
await asyncio.sleep(5)
return {"ok": True}
And if the blocking library has no async equivalent, a plain def endpoint is often the safer choice. FastAPI runs it in a threadpool so it does not stall the event loop:
import time
@app.get("/ok-for-blocking-libs")
def blocking_but_safe():
time.sleep(5) # runs in FastAPI's threadpool, event loop stays free
return {"ok": True}
When Would You Use a Worker Queue?
When the work is:
- long-running
- CPU-heavy
- retryable
- not something the user should wait on during the HTTP request
Typical cases:
- video processing
- report generation
- large inference batches
- email delivery
- third-party API retry pipelines
How I Would Say It in an Interview
If I call blocking code inside an async endpoint, I lose the benefit of the event loop because that endpoint ties it up. In FastAPI I would prefer async-compatible libraries when possible, a normal
defendpoint for blocking libraries when appropriate, and a worker queue for long-running or CPU-heavy jobs.
What Interviewers Usually Want to Hear
Most Python core interviews are not really testing whether you memorized a glossary. They are testing whether you can move through three layers cleanly:
- the definition
- the practical implication
- the right tool or tradeoff
For example, this answer is weak:
A generator uses
yield.
This answer is much stronger:
A generator is a lazy iterator built with
yield. It pauses and resumes execution, so it can produce values one at a time instead of building the whole result in memory. I prefer it when processing large files or streaming records.
That is usually the difference between knowing a Python term and being able to explain the engineering tradeoff behind it.
References
The explanations above were checked against these references: