Python Functions — From "What" to CPython Internals¶
Prerequisites
Variables, assignment, basic data types. See Part A — Fundamentals.
1. Big Picture¶
A function is the smallest unit of reusable, callable behavior in Python. Everything bigger — classes, modules, frameworks — is built on top of the same call mechanism you'll see in this chapter.
flowchart TD
A[Problem: repeated logic] --> B[Wrap logic in a function]
B --> C[Call it by name]
C --> D[Interpreter executes it]
D --> E[Result returned to caller]
2. Intuition¶
Before functions exist conceptually, imagine copy-pasting the same five lines of code every time you need them. A function is that block given a name, a place to plug in different inputs, and a promise to hand back an output.
3. Formal Definition¶
A function in Python is a first-class object that binds a name to a block of code, accepts zero or more arguments, executes a sequence of statements, and returns a value (None if no explicit return).
4. Visual Explanation¶
flowchart LR
subgraph Caller
X[result = add 10, 20]
end
subgraph Function Object
F[add.__code__]
end
X -- call --> F
F -- return value --> X
5. What Happens When You Write def add(a, b): ...¶
At module load time (before you ever call it), Python:
- Compiles the function body into a code object (bytecode).
- Creates a function object wrapping that code object, along with its defaults, closure, and
__name__. - Binds the name
addin the current namespace to that function object.
No addition happens yet — def only defines, it doesn't execute the body.
6. Execution — result = add(10, 20)¶
flowchart TD
A["Look up name 'add' in namespace"] --> B["Confirm it's callable"]
B --> C["Create a new stack frame"]
C --> D["Bind arguments: a=10, b=20"]
D --> E["Execute bytecode in the frame"]
E --> F["Evaluate a + b"]
F --> G["Return value pops the frame"]
G --> H["result is bound to 30"]
7. Runtime — Source to Execution¶
flowchart TD
A[Python source code] --> B[Tokenizer]
B --> C[Parser]
C --> D[AST]
D --> E[Compiler]
E --> F[Bytecode]
F --> G[Python Virtual Machine]
G --> H["Function call: new frame pushed"]
H --> I["Frame: locals a, b"]
I --> J[Argument binding]
J --> K[Expression evaluation]
K --> L[Return value]
8. Memory Model¶
10and20are small-int objects. CPython caches integers -5 to 256, so these don't get freshly allocated — the namesaandbinside the frame just hold references to the existing int objects.addis a name in the module namespace pointing to a function object on the heap.resultis a name in the module namespace pointing to whatever int object30resolves to (also cached, since it's in the -5..256 range).
flowchart LR
ns["Module namespace"] -- "add" --> func["Function object"]
ns -- "result" --> obj30["int object: 30"]
frame["Call frame (a, b)"] -- "a" --> obj10["int object: 10"]
frame -- "b" --> obj20["int object: 20"]
Nothing is "passed by value" or "passed by copy" here — names are bound to objects, and objects are passed by object reference. This is why mutable arguments (like lists) can be changed in place inside a function, while rebinding a name inside a function never affects the caller's name.
9. Internals — Bytecode¶
Conceptually, this disassembles to something like:
LOAD_FASTpushes a local variable onto the frame's value stack directly by slot index — this is faster thanLOAD_NAME/LOAD_GLOBALbecause locals are stored in a fixed-size array on the frame, not a dict lookup.BINARY_ADDpops two values, callsa.__add__(b)(falling back tob.__radd__(a)), and pushes the result.RETURN_VALUEpops the frame and hands the value back to the caller.
10. Edge Cases¶
add("10", "20") # "1020" — str defines __add__ as concatenation
add(10, "20") # TypeError: unsupported operand type(s)
+ isn't "addition" at the bytecode level — it's a dispatch to whatever __add__/__radd__ the operand types define. This is why the same one-line function behaves completely differently depending on what you hand it — a preview of Python's duck typing.
11. Common Errors & Debugging¶
| Symptom | Cause | Fix |
|---|---|---|
TypeError: add() missing 1 required positional argument |
Called with too few args | Check the call site against the signature |
UnboundLocalError |
Assigned to a name inside the function before reading it, without global/nonlocal |
Declare intent or pass the value in |
| Silent wrong result | Mutable default argument (def f(x=[])) shared across calls |
Use None as default, create the list inside the body |
12. Engineering Judgment¶
- Use a function when behavior is stateless or the state is fully captured by its parameters.
- Reach for a class when you need to bundle state and behavior that changes together over time.
- Over-abstracting (a function for every three-line block) hurts readability as much as under-abstracting.
13. Interview Questions¶
- What happens internally when a Python function is called?
- What is a stack frame, and what does it hold?
- Are Python arguments passed by value or by reference?
- What's the difference between an object and a variable/name?
- Why is
LOAD_FASTfaster thanLOAD_GLOBAL?
Mastery Ladder¶
- L0 — I've heard the term "function"
- L1 — I can define one
- L2 — I understand the intuition
- L3 — I can implement one with
*args/**kwargs, defaults, closures - L4 — N/A (no dedicated math layer for this topic)
- L5 — I can read its bytecode with
dis - L6 — I can debug
UnboundLocalError/ mutable-default bugs - L7 — I know when
LOAD_FASTvs dict-based lookup matters for performance - L8 — I use functions vs classes deliberately in real projects
- L9 — I can answer the interview bank above cleanly
- L10 — I can explain CPython's frame/call internals from memory