Skip to content

The CPython Execution Model Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on the CPython execution model: source to bytecode to .pyc, the interpreter loop, the dis module, frames and the call stack, and whether Python is compiled or interpreted.

Read the in-depth guideThe CPython Execution Model Explained — Bytecode, the Interpreter Loop, and the GIL(opens in new tab)
15 of 15

CPython first compiles your .py source into bytecode — a compact, platform-independent instruction set for the Python virtual machine. That bytecode is then executed by the interpreter loop (a big evaluation loop, historically a giant switch), which runs one bytecode instruction at a time. Compiled bytecode is cached as .pyc files in __pycache__.

# mymod.py  ->  compiled to  __pycache__/mymod.cpython-3xx.pyc
# The .pyc is reused if the source is unchanged (matched by hash/timestamp),
# so imports skip recompiling. It is NOT machine code — still bytecode.
def add(a, b):
    return a + b

The key points: bytecode is an intermediate representation (not native machine code), .pyc is just a cache to skip recompilation on import, and the VM interprets it at runtime. Rule of thumb: source -> bytecode -> interpreter loop, with .pyc caching the middle step.

CPython is the reference implementation written in C — it's what you get from python.org and what most people mean by "Python." The language is a spec; an implementation runs it. Alternatives include PyPy (with a JIT that often runs much faster), Jython (runs on the JVM), and IronPython (on .NET).

import platform
print(platform.python_implementation())   # 'CPython', 'PyPy', 'Jython', ...

They differ in performance and integration: PyPy speeds up long-running pure Python via JIT, Jython/IronPython interoperate with Java/.NET libraries, and CPython has the widest C-extension ecosystem (NumPy, etc.) plus the GIL. Rule of thumb: "Python" is the language; CPython is the dominant implementation, and others trade ecosystem reach for speed or platform integration.

The dis ("disassemble") module shows the bytecode instructions a function compiles to — useful for understanding what Python actually does under the hood and for comparing the cost of two approaches.

import dis

def add(a, b):
    return a + b

dis.dis(add)
# Example output (abbreviated):
#   LOAD_FAST   a
#   LOAD_FAST   b
#   BINARY_OP   +        # add the two
#   RETURN_VALUE

Each line is one VM instruction the interpreter loop executes. dis is great for answering "is this comprehension really faster?" or seeing how the compiler desugars a construct. Rule of thumb: when you want to know what the interpreter literally runs, disassemble it with dis.

Every time a function is called, CPython creates a frame object — a record holding that call's local variables, the current instruction pointer, and a reference to the caller. Frames are pushed onto the call stack as functions call each other and popped as they return. This is the structure a traceback walks when printing an error.

import inspect

def inner():
    frame = inspect.currentframe()
    print(frame.f_code.co_name)        # 'inner'
    print(frame.f_back.f_code.co_name) # 'outer' — the caller's frame

def outer():
    inner()

outer()

Frames are why each call has isolated locals and why exceptions can report the full chain of calls. Deep/infinite recursion piles up frames until RecursionError (the stack limit). Rule of thumb: one frame per active call, all chained together as the call stack.

Both. Python source is first compiled to bytecode (a real compilation step), and that bytecode is then interpreted by the CPython virtual machine at runtime. So the common "Python is interpreted" is only half the story — there's a compile phase, just to bytecode rather than to native machine code.

import py_compile
py_compile.compile("mymod.py")   # explicitly produces the .pyc bytecode

# At runtime, the VM's interpreter loop executes that bytecode.

The distinction that matters: Python compiles to portable bytecode, not to CPU-specific machine code (the way C does ahead-of-time). PyPy adds a JIT that does compile hot bytecode to machine code at runtime. Rule of thumb: Python is compiled to bytecode and then interpreted.

The GIL (Global Interpreter Lock) is a single mutex that lets only one thread execute Python bytecode at a time in a CPython process. The interpreter loop holds it while running instructions and periodically releases it (and during blocking I/O), so threads take turns rather than running Python code in parallel.

# Two threads doing CPU-bound Python work do NOT run in parallel:
# the GIL serializes their bytecode execution on one core.
# threads -> good for I/O-bound (GIL released during I/O)
# processes -> needed for CPU-bound parallelism (each has its own GIL)

It exists largely to make CPython's memory management (reference counting) simpler and C extensions safer. The consequence is that threads don't give CPU parallelism — that's what multiprocessing is for (see the Concurrency & Parallelism topic). Rule of thumb: the GIL means one-bytecode-at-a-time per process, so use processes for CPU-bound parallelism.

A code object holds the compiled bytecode and metadata (argument names, constants, variable names) — the immutable "what to run." A function object wraps a code object plus runtime context: defaults, closure cells, and globals. One code object can back many function objects.

def f(x):
    return x + 1

f.__code__                    # the code object
f.__code__.co_varnames        # ('x',)
f.__code__.co_consts          # (None, 1)
f.__code__.co_argcount        # 1

Rule of thumb: code object = the compiled instructions/metadata; function object = code + defaults/closure/globals that make it callable.

By default CPython stores the source's mtime and size in the .pyc header and recompiles if they changed. Python 3.7+ also supports hash-based .pyc files (checked against the source hash) for reproducible builds.

# timestamp-based (default): compares source mtime/size
# hash-based: compile with SOURCE_DATE_EPOCH or:
#   python -m py_compile --invalidation-mode checked-hash mymod.py

Rule of thumb: .pyc reuse is keyed on source mtime/size (or a hash) — edit the source and the cache is invalidated and rebuilt automatically.

CPython's compiler does constant folding and (in 3.11+) an enhanced peephole/ optimizer pass: it pre-computes constant expressions and simplifies bytecode. It does not do deep optimizations like inlining or type specialization at compile time (3.11+ adds a runtime specializing adaptive interpreter).

import dis
def f():
    return 60 * 60 * 24       # folded to the constant 86400 at compile time
dis.dis(f)                    # LOAD_CONST 86400 ; RETURN_VALUE

Rule of thumb: constant expressions are folded at compile time, but Python relies on the runtime (and 3.11+ adaptive specialization) for most speedups — don't expect C-compiler-level optimization.

Python 3.11 introduced an adaptive interpreter (PEP 659): hot bytecode instructions are specialized at runtime based on observed types (e.g. a generic BINARY_OP becomes an int-specific fast path). It's not a JIT to machine code, but it meaningfully speeds up CPython.

# a loop doing integer math gets BINARY_OP specialized to BINARY_OP_ADD_INT
# after a few iterations, then falls back if it sees other types
total = 0
for i in range(1_000_000):
    total += i            # specialized hot path

Rule of thumb: 3.11+ specializes hot, type-stable bytecode for speed; consistent types in hot loops let the adaptive interpreter optimize better.

Its bytecode operates on an evaluation stack: instructions push operands and pop them to compute results, rather than using named registers. LOAD_FAST pushes a value; BINARY_OP pops two and pushes the result.

import dis
dis.dis(compile("a + b", "<s>", "eval"))
#   LOAD_NAME a      <- push a
#   LOAD_NAME b      <- push b
#   BINARY_OP +      <- pop b, pop a, push a+b
#   RETURN_VALUE     <- pop result

Rule of thumb: CPython evaluates expressions by pushing/popping on a value stack — that's the model dis output reflects.

The compiler picks the opcode by scope: LOAD_FAST for locals (array slot, very fast), LOAD_GLOBAL for module globals/builtins (dict lookups), LOAD_DEREF for closure variables. This is why local access is faster than global.

import dis
g = 10
def f(x):
    return x + g
dis.dis(f)
#   LOAD_FAST   x     <- local: fast array access
#   LOAD_GLOBAL g     <- global: dict lookup (slower)

Rule of thumb: locals (LOAD_FAST) beat globals (LOAD_GLOBAL); in hot loops, binding a global to a local can shave lookup time.

Each recursive call pushes a frame onto the call stack (which sits on the C stack). Python caps recursion (default ~1000) via sys.setrecursionlimit to raise a clean RecursionError instead of crashing with a C-level stack overflow.

import sys
sys.getrecursionlimit()        # 1000 (default)
sys.setrecursionlimit(5000)    # raise it — but risks a real segfault if too high

def deep(n):
    return 1 if n == 0 else deep(n - 1)  # RecursionError past the limit

Rule of thumb: the limit guards the C stack — raise it cautiously, or rewrite deep recursion iteratively (Python has no tail-call optimization).

No — CPython deliberately has no tail-call optimization. Each recursive call adds a frame, so deep tail recursion still hits RecursionError. Guido has argued keeping frames aids debugging (full tracebacks).

def factorial(n, acc=1):
    if n == 0:
        return acc
    return factorial(n - 1, acc * n)   # NOT optimized — frame per call

# iterative version avoids the stack growth:
def factorial_iter(n):
    acc = 1
    for i in range(2, n + 1):
        acc *= i
    return acc

Rule of thumb: don't rely on tail recursion in Python; convert deep recursion to a loop or use an explicit stack.

The compiler stores literals in the code object's co_consts and reuses identical immutable constants within the same code object. Identical string/number literals in one function often share one object — a compile-time optimization distinct from runtime interning.

def f():
    a = "hello"
    b = "hello"
    return a is b        # True — same constant in co_consts

f.__code__.co_consts     # (None, 'hello') — stored once

Rule of thumb: literals are deduped per code object at compile time; don't rely on this across functions or for runtime-built values — use == for value checks.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel