# Python Interview Questions: Complete Guide (2025)

Source: https://www.techinterview.org/post/3233460381/python-interview-questions/
Updated: 2026-07-05 · techinterview.org

Python is the dominant language in machine learning, data engineering, backend APIs, and scripting. This guide covers the most frequently asked Python interview questions across all seniority levels — from L3 to L7.

## Core Language Concepts

### 1. How does Python's GIL affect multithreading?

The Global Interpreter Lock (GIL) allows only one thread to execute Python bytecode at a time. This means CPU-bound tasks do NOT benefit from threads — use `multiprocessing` instead. I/O-bound tasks (network, file, sleep) DO benefit because the GIL is released during I/O waits.


```
import threading
import multiprocessing
import time

def cpu_task(n):
    # Simulate CPU-bound work
    total = 0
    for i in range(n):
        total += i * i
    return total

# Threading (GIL-limited — does NOT parallelize CPU work)
start = time.time()
threads = [threading.Thread(target=cpu_task, args=(5_000_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"Threads: {time.time() - start:.2f}s")

# Multiprocessing (true parallelism)
start = time.time()
with multiprocessing.Pool(4) as pool:
    pool.map(cpu_task, [5_000_000] * 4)
print(f"Processes: {time.time() - start:.2f}s")
```


### 2. Explain Python generators and lazy evaluation


```
from typing import Iterator, Generator
import sys

def fibonacci() -> Generator[int, None, None]:
    """Infinite Fibonacci sequence — memory-efficient via lazy eval."""
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

def chunked(iterable, size: int) -> Iterator[list]:
    """Yield successive chunks — processes large datasets without loading all."""
    chunk = []
    for item in iterable:
        chunk.append(item)
        if len(chunk) == size:
            yield chunk
            chunk = []
    if chunk:
        yield chunk

# Memory comparison: list vs generator for 1M integers
list_size   = sys.getsizeof(list(range(1_000_000)))
gen_size    = sys.getsizeof(x for x in range(1_000_000))
print(f"List: {list_size:,} bytes | Generator: {gen_size} bytes")
# List: 8,697,464 bytes | Generator: 208 bytes

# Practical use: pipeline without intermediate lists
def read_log_lines(path: str) -> Iterator[str]:
    with open(path) as f:
        for line in f:
            yield line.strip()

def filter_errors(lines: Iterator[str]) -> Iterator[str]:
    return (line for line in lines if "ERROR" in line)

def parse_timestamps(lines: Iterator[str]) -> Iterator[str]:
    return (line.split()[0] for line in lines)
```


### 3. Decorators — implementation and use cases


```
import functools
import time
import logging
from typing import Callable, TypeVar, Any

F = TypeVar("F", bound=Callable[..., Any])

def retry(max_attempts: int = 3, delay: float = 1.0, exceptions=(Exception,)):
    """Retry decorator with exponential backoff."""
    def decorator(func: F) -> F:
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exc = e
                    wait = delay * (2 ** attempt)
                    logging.warning(f"Attempt {attempt+1} failed: {e}. Retrying in {wait}s")
                    time.sleep(wait)
            raise last_exc
        return wrapper  # type: ignore
    return decorator

def memoize(func: F) -> F:
    """Simple memoization decorator."""
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper  # type: ignore

def rate_limit(calls_per_second: float):
    """Throttle function calls."""
    min_interval = 1.0 / calls_per_second
    last_called = [0.0]
    def decorator(func: F) -> F:
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            elapsed = time.time() - last_called[0]
            if elapsed < min_interval:
                time.sleep(min_interval - elapsed)
            last_called[0] = time.time()
            return func(*args, **kwargs)
        return wrapper  # type: ignore
    return decorator


@retry(max_attempts=5, delay=0.5, exceptions=(ConnectionError, TimeoutError))
@rate_limit(calls_per_second=10)
def fetch_data(url: str) -> dict:
    # ... actual HTTP call
    pass
```


### 4. Context managers and the `with` statement


```
from contextlib import contextmanager, asynccontextmanager
import threading

class DatabaseTransaction:
    """Context manager using __enter__/__exit__ protocol."""
    def __init__(self, connection):
        self.conn = connection
        self.savepoint = None

    def __enter__(self):
        self.savepoint = self.conn.execute("SAVEPOINT sp1")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            self.conn.execute("RELEASE SAVEPOINT sp1")
        else:
            self.conn.execute("ROLLBACK TO SAVEPOINT sp1")
        return False  # Do not suppress exceptions

@contextmanager
def timer(label: str = ""):
    """Generator-based context manager for timing blocks."""
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: {elapsed:.4f}s")

@contextmanager
def acquire_locks(*locks):
    """Deadlock-free multi-lock acquisition (sorted by id)."""
    sorted_locks = sorted(locks, key=id)
    acquired = []
    try:
        for lock in sorted_locks:
            lock.acquire()
            acquired.append(lock)
        yield
    finally:
        for lock in reversed(acquired):
            lock.release()

# Usage
with timer("sort 1M items"):
    sorted(range(1_000_000), reverse=True)
```


### 5. Async Python — event loop and concurrency patterns


```
import asyncio
import aiohttp
from typing import List

async def fetch_url(session: aiohttp.ClientSession, url: str) -> dict:
    async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
        resp.raise_for_status()
        return {"url": url, "status": resp.status, "data": await resp.json()}

async def fetch_all(urls: List[str]) -> List[dict]:
    """Fetch URLs concurrently — O(max_latency) not O(sum_latency)."""
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)
    return [r for r in results if not isinstance(r, Exception)]

class AsyncQueue:
    """Bounded async queue for producer-consumer pipelines."""
    def __init__(self, maxsize: int = 100):
        self._q: asyncio.Queue = asyncio.Queue(maxsize=maxsize)

    async def produce(self, items):
        for item in items:
            await self._q.put(item)
        await self._q.put(None)  # sentinel

    async def consume(self, process_fn):
        while True:
            item = await self._q.get()
            if item is None:
                break
            await process_fn(item)
            self._q.task_done()

# asyncio.run(fetch_all(["https://api.example.com/data"]))
```


### 6. Python data structures — collections module


```
from collections import defaultdict, Counter, deque, OrderedDict
import heapq
from typing import List, Tuple

# defaultdict — avoids KeyError for grouping
def group_by_key(items: List[Tuple]) -> dict:
    groups = defaultdict(list)
    for key, val in items:
        groups[key].append(val)
    return dict(groups)

# Counter — frequency analysis
def top_k_elements(items: List, k: int) -> List[Tuple]:
    return Counter(items).most_common(k)

# deque — O(1) append/pop from both ends
class SlidingWindow:
    def __init__(self, size: int):
        self.window = deque(maxlen=size)
        self.total = 0

    def add(self, val: float) -> float:
        if len(self.window) == self.window.maxlen:
            self.total -= self.window[0]
        self.window.append(val)
        self.total += val
        return self.total / len(self.window)

# heapq — priority queue / top-k
def merge_k_sorted(lists: List[List[int]]) -> List[int]:
    heap = []
    for i, lst in enumerate(lists):
        if lst:
            heapq.heappush(heap, (lst[0], i, 0))
    result = []
    while heap:
        val, list_idx, elem_idx = heapq.heappop(heap)
        result.append(val)
        next_idx = elem_idx + 1
        if next_idx < len(lists[list_idx]):
            heapq.heappush(heap, (lists[list_idx][next_idx], list_idx, next_idx))
    return result
```


## Python Performance and Memory

| Technique | When to use | Speedup |
| --- | --- | --- |
| List comprehension vs loop | Simple transforms | 2-5x faster |
| Generator expressions | Large datasets, streaming | Saves memory |
| __slots__ on classes | Many instances of same class | 40% less RAM |
| numpy vectorization | Numerical arrays | 10-100x faster |
| multiprocessing.Pool | CPU-bound parallel work | Near-linear scale |
| functools.lru_cache | Pure functions with repeated args | Cache hits O(1) |
| struct / memoryview | Binary protocol parsing | Zero-copy reads |

## Common Interview Pitfalls


```
# WRONG: mutable default argument
def append_to(element, lst=[]):
    lst.append(element)
    return lst
# append_to(1) → [1], append_to(2) → [1, 2]  ← shared list!

# RIGHT: use None sentinel
def append_to(element, lst=None):
    if lst is None:
        lst = []
    lst.append(element)
    return lst

# WRONG: late binding in closures
fns = [lambda x: x + i for i in range(5)]
# fns[0](0) → 4, not 0 — all closures capture same i

# RIGHT: default argument captures value
fns = [lambda x, i=i: x + i for i in range(5)]

# WRONG: chained comparisons — actually fine in Python!
# 1 < x < 10 works correctly (unlike C/Java)
```


## System Design Considerations for Python Services

- **Async frameworks**: FastAPI + uvicorn for high-concurrency APIs (handles 50k+ req/s on modern hardware)

- **Worker pools**: Celery + Redis/RabbitMQ for task queues; use `multiprocessing` for CPU-bound workers

- **Memory profiling**: `tracemalloc` for leak detection, `memory_profiler` for line-by-line analysis

- **Type safety**: mypy strict mode + Pydantic for runtime validation at API boundaries

- **Packaging**: Poetry or uv for dependency management; pyproject.toml over setup.py

## Frequently Asked Questions

### What is the Python GIL and how does it affect performance?

The GIL (Global Interpreter Lock) allows only one thread to execute Python bytecode at a time. For CPU-bound tasks, threading does not achieve parallelism — use multiprocessing instead. For I/O-bound tasks (network, file I/O), the GIL is released during the wait, so threading is effective. Python 3.13+ has an experimental no-GIL mode.

### What is the difference between a generator and a list comprehension?

A list comprehension creates the entire list in memory at once. A generator expression creates a lazy iterator that produces values one at a time. For large datasets, generators use O(1) memory vs O(n) for lists. Use generators when you process items sequentially and do not need random access.

### How do Python decorators work?

A decorator is a function that takes a function and returns a new function, wrapping the original with additional behavior. They use functools.wraps to preserve the original function metadata. Common uses: logging, authentication, retry logic, caching, rate limiting. Stacked decorators are applied bottom-up.
