Defining Functions in Python - What def Actually Does
Reading time: ~16 minutes | Level: Foundation → Engineering
Here is a question most Python developers get wrong:
def greet(name):
return f"Hello, {name}"
print(type(greet))
print(greet.___name___)
print(id(greet))
What is type(greet)?
Most people say "a function." That is correct but incomplete.
The real answer is <class 'function'> - meaning greet is an instance of the function class, stored on the heap like any other object, with attributes you can inspect and modify.
If you have never thought of a function as just another object, this page will change how you see Python entirely.
What You Will Learn
- What the
defstatement actually does at runtime (it is not just syntax) - What a function object contains -
__name__,__code__,__defaults__,__globals__ - Why Python functions are first-class objects and what that enables
- How to store functions in lists and dictionaries
- How to pass functions as arguments to other functions
- How to return functions from functions
- The difference between a function and a callable
- How this underpins decorators, callbacks, and ML pipelines
Prerequisites
- Running Python 3.8+ in a terminal or REPL
- Understanding of Python data types (int, str, list, dict)
- Basic Python syntax (expressions, statements, indentation)
Mental Model: def Is a Runtime Statement
Most languages compile functions before execution. Python is different.
The key insight: def is a runtime expression, not a compile-time directive.
This means you can define functions inside if blocks, inside loops, inside other functions - and each execution of def creates a fresh function object.
Watch: Python Functions - Complete Tutorial
:::info Video Corey Schafer's Python Functions tutorial is one of the most-watched Python videos online. It covers the fundamentals clearly before this page takes you deeper. :::
Part 1 - The def Statement in Detail
The Simplest Function
def greet(name):
"""Return a greeting string."""
return f"Hello, {name}"
result = greet("Alice")
print(result) # Hello, Alice
When Python executes def greet(name):, it:
- Compiles the body
return f"Hello, {name}"to a code object - Creates a
functionobject that wraps the code object - Binds the name
greetto that function object in the current namespace
Inspecting the Function Object
def greet(name):
"""Return a greeting string."""
return f"Hello, {name}"
print(type(greet)) # <class 'function'>
print(greet.__name__) # greet
print(greet.__doc__) # Return a greeting string.
print(greet.__code__) # <code object greet at 0x...>
print(greet.__code__.co_varnames) # ('name',)
print(greet.__code__.co_filename) # path to source file
The function object is a real Python object. It has attributes. It has an identity (id(greet)). It can be stored, passed, and inspected.
The Code Object
The __code__ attribute holds the compiled bytecode:
import dis
def add(a, b):
return a + b
dis.dis(add)
# Output:
# 2 0 LOAD_FAST 0 (a)
# 2 LOAD_FAST 1 (b)
# 4 BINARY_ADD
# 6 RETURN_VALUE
dis.dis() disassembles the bytecode into human-readable instructions. This is what CPython actually executes - not the source code.
Understanding bytecode is not required to use Python, but it explains why Python behaves the way it does.
Part 2 - Functions Are First-Class Objects
In Python, "first-class" means a value can be:
- Assigned to a variable
- Stored in a data structure
- Passed to a function as an argument
- Returned from a function
Python functions satisfy all four. This makes Python a language with first-class functions - the foundation of functional programming style.
1. Assigning a Function to a Variable
def square(x):
return x * x
# Assign the function object to another name
sq = square
print(sq(5)) # 25
print(sq is square) # True - same object, different name
Notice: sq = square does not call the function. It binds a new name to the same function object.
sq(5) calls the function. square without () is just a reference.
2. Storing Functions in a Data Structure
def add(a, b): return a + b
def subtract(a, b): return a - b
def multiply(a, b): return a * b
def divide(a, b): return a / b
# A dispatch table - a dictionary of functions
operations = {
"+": add,
"-": subtract,
"*": multiply,
"/": divide,
}
# Use it like a calculator
op = "+"
result = operations[op](10, 3)
print(result) # 13
op = "*"
result = operations[op](10, 3)
print(result) # 30
This pattern - the dispatch table - replaces long chains of if/elif statements. It is faster (dict lookup is O(1)) and more extensible (add new operations without changing the lookup logic).
3. Passing a Function as an Argument
def apply(func, value):
"""Apply func to value and return the result."""
return func(value)
def double(x):
return x * 2
def negate(x):
return -x
print(apply(double, 5)) # 10
print(apply(negate, 5)) # -5
print(apply(abs, -42)) # 42 - built-in functions work too
apply is a higher-order function - it accepts a function as an argument. This is how Python's built-in map, filter, and sorted work.
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
# sorted accepts a function as the `key` argument
sorted_by_reverse = sorted(numbers, key=lambda x: -x)
print(sorted_by_reverse) # [9, 6, 5, 4, 3, 2, 1, 1]
4. Returning a Function from a Function
def make_multiplier(factor):
"""Return a function that multiplies its argument by factor."""
def multiplier(x):
return x * factor
return multiplier # Return the function object, not the result
triple = make_multiplier(3)
print(triple(10)) # 30
print(triple(7)) # 21
double = make_multiplier(2)
print(double(10)) # 20
make_multiplier returns a new function each time it is called. The returned function multiplier "remembers" the value of factor - this is a closure (covered fully in topic 09).
Part 3 - Anatomy of a Function Object
Let us inspect everything a function object contains:
def process(data, threshold=0.5, *, verbose=False):
"""Process data above threshold."""
results = [x for x in data if x > threshold]
if verbose:
print(f"Processed {len(results)} items")
return results
# Introspect the function object
print(process.__name__) # process
print(process.__qualname__) # process
print(process.__doc__) # Process data above threshold.
print(process.__defaults__) # (0.5,) - positional defaults
print(process.__kwdefaults__) # {'verbose': False} - keyword-only defaults
print(process.__code__.co_argcount) # 2 (data, threshold)
print(process.__code__.co_kwonlyargcount) # 1 (verbose)
print(process.__code__.co_varnames) # ('data', 'threshold', 'verbose', 'results')
Function Attributes You Can Set
Because functions are objects, you can attach arbitrary attributes to them:
def fetch_user(user_id):
"""Fetch user from database."""
return {"id": user_id, "name": "Alice"}
# Attach metadata
fetch_user.cache = {}
fetch_user.call_count = 0
def cached_fetch(user_id):
if user_id not in fetch_user.cache:
fetch_user.cache[user_id] = fetch_user(user_id)
fetch_user.call_count += 1
return fetch_user.cache[user_id]
print(cached_fetch(1)) # {'id': 1, 'name': 'Alice'}
print(cached_fetch(1)) # From cache
print(fetch_user.call_count) # 1
This pattern is used in Python's functools.lru_cache implementation.
Part 4 - Functions Defined Conditionally and Dynamically
Since def executes at runtime, you can define functions dynamically:
import sys
# Define different implementations based on platform
if sys.platform == "win32":
def get_path_separator():
return "\\"
else:
def get_path_separator():
return "/"
print(get_path_separator()) # / on Unix, \\ on Windows
# Define functions in a loop - each is a separate object
math_ops = []
for power in [1, 2, 3]:
def raise_to(x, p=power): # p=power captures current value
return x ** p
math_ops.append(raise_to)
print(math_ops[0](5)) # 5
print(math_ops[1](5)) # 25
print(math_ops[2](5)) # 125
:::warning Loop Variable Capture
Without p=power in the default argument, all three functions would share the same power variable and all would compute x ** 3. Default argument capture is the correct workaround here. Closures and the loop variable problem are covered fully in topic 09.
:::
Part 5 - The Difference Between a Function and a Callable
A callable is any object that can be called with ().
Functions are callables. But not all callables are functions:
# Functions are callable
def double(x): return x * 2
print(callable(double)) # True
# Classes are callable (calling them creates an instance)
class Counter:
def __init__(self):
self.count = 0
def __call__(self, increment=1):
self.count += increment
return self.count
c = Counter()
print(callable(c)) # True - instances with __call__ are callable
print(c()) # 1
print(c(5)) # 6
# Lambda expressions are callable functions
square = lambda x: x * x
print(callable(square)) # True
# Built-in functions are callable
print(callable(len)) # True
print(callable(print)) # True
The callable() built-in checks whether an object implements __call__. This is the protocol Python uses to allow any object to act like a function.
# Check what makes an object callable
class NotCallable:
pass
obj = NotCallable()
print(callable(obj)) # False
class IsCallable:
def __call__(self):
return "called!"
obj2 = IsCallable()
print(callable(obj2)) # True
print(obj2()) # called!
Part 6 - Practical Patterns Using First-Class Functions
Pattern 1: Strategy Pattern
def sort_by_name(item):
return item["name"]
def sort_by_price(item):
return item["price"]
def sort_by_rating(item):
return item["rating"]
products = [
{"name": "Laptop", "price": 999, "rating": 4.5},
{"name": "Monitor", "price": 299, "rating": 4.8},
{"name": "Keyboard","price": 79, "rating": 4.2},
]
def display_sorted(products, sort_key):
"""Display products sorted by the given key function."""
for product in sorted(products, key=sort_key):
print(f"{product['name']:10} ${product['price']:6} ★{product['rating']}")
display_sorted(products, sort_by_price)
# Keyboard $ 79 ★4.2
# Monitor $ 299 ★4.8
# Laptop $ 999 ★4.5
display_sorted(products, sort_by_rating)
# Keyboard $ 79 ★4.2
# Laptop $ 999 ★4.5
# Monitor $ 299 ★4.8
Pattern 2: Command Registry
# A simple command dispatcher
commands = {}
def register(name):
"""Decorator that registers a function as a command."""
def decorator(func):
commands[name] = func
return func
return decorator
@register("hello")
def cmd_hello(args):
print(f"Hello, {args[0]}!")
@register("add")
def cmd_add(args):
print(sum(int(a) for a in args))
# Dispatch commands
def run_command(name, args):
if name in commands:
commands[name](args)
else:
print(f"Unknown command: {name}")
run_command("hello", ["World"]) # Hello, World!
run_command("add", ["1", "2", "3"]) # 6
This is the pattern behind Flask's @app.route(), Pytest's test discovery, and Click CLI commands.
Common Mistakes
Mistake 1: Calling a Function When You Mean to Reference It
def process(data):
return [x * 2 for x in data]
# Bug: calling the function immediately, storing the result
handler = process([1, 2, 3]) # handler is now [2, 4, 6], not a function!
handler([4, 5]) # TypeError: 'list' object is not callable
# Correct: store the function reference
handler = process
result = handler([1, 2, 3]) # [2, 4, 6]
Mistake 2: Redefining a Built-in Name
# Dangerous: shadowing the built-in `list`
def list(data): # This shadows the built-in!
return sorted(data)
x = list([3, 1, 2]) # Works
y = list("hello") # Works, returns sorted chars
z = list(range(5)) # Breaks if you later need the real list()
# The built-in is now inaccessible by name in this scope
Mistake 3: Forgetting That Functions Are Bound at Definition Time
x = 10
def show_x():
print(x) # Reads x from global scope at CALL TIME, not definition time
show_x() # 10
x = 20
show_x() # 20 - reads the current value of x, not the value at definition
This is actually correct Python behavior - functions look up global names at call time - but it surprises developers from other language backgrounds.
AI/ML Real-World Connection
First-class functions are at the core of every modern ML framework.
PyTorch: Hooks as Callable Arguments
import torch
import torch.nn as nn
model = nn.Linear(10, 5)
# Register a hook - pass a function as an argument
def inspect_gradient(module, grad_input, grad_output):
print(f"Gradient shape: {grad_output[0].shape}")
print(f"Gradient norm: {grad_output[0].norm():.4f}")
# PyTorch stores and calls this function during backward pass
handle = model.register_backward_hook(inspect_gradient)
scikit-learn: Functions as Transformers
from sklearn.preprocessing import FunctionTransformer
import numpy as np
# A regular function used as a transformer
def log_transform(X):
return np.log1p(X)
transformer = FunctionTransformer(log_transform)
# scikit-learn calls your function on each batch of data
Keras: Loss Functions as First-Class Arguments
import tensorflow as tf
def custom_loss(y_true, y_pred):
"""Mean absolute error with a penalty term."""
mae = tf.reduce_mean(tf.abs(y_true - y_pred))
penalty = tf.reduce_mean(tf.square(y_pred))
return mae + 0.01 * penalty
# Pass a function object to compile - Keras calls it during training
model.compile(optimizer="adam", loss=custom_loss)
In every case, functions are treated as values - stored, passed, and called by the framework.
Interview Questions
Q1: What does the def statement actually do at runtime?
Answer: It compiles the function body to a bytecode code object, creates a function object on the heap that wraps the code object along with default values and a reference to the global namespace, then binds the function's name to that object in the current namespace. It does not execute the function body.
Q2: What does it mean for Python to have "first-class functions"?
Answer: It means functions can be assigned to variables, stored in data structures (lists, dicts), passed as arguments to other functions, and returned as values from functions - the same operations you can perform on any other Python object.
Q3: What is the difference between func and func()?
Answer: func is a reference to the function object itself. func() calls the function and produces its return value. Confusing the two is a common bug - for example, passing func() as a callback when you meant to pass func.
Q4: How do you inspect the bytecode of a Python function?
Answer: Use import dis; dis.dis(func). This disassembles the function's code object into human-readable bytecode instructions. The code object is also accessible via func.__code__.
Q5: What is a callable? How is it different from a function?
Answer: A callable is any object that implements the __call__ method and can therefore be invoked with (). All functions are callables, but not all callables are functions - class instances with __call__ defined, classes themselves (calling them creates an instance), and built-in operations are also callables. Use callable(obj) to test.
Q6: What happens when you define a function inside an if block?
Answer: Since def executes at runtime, the function is only created if the branch executes. The name is bound (or not bound) depending on which branch runs. This is legal Python and is useful for platform-specific or configuration-specific implementations.
Q7: What does function.__code__ contain?
Answer: The code object, which contains the compiled bytecode (co_code), variable names (co_varnames), constant values (co_consts), the filename and line number (co_filename, co_firstlineno), argument counts, and other metadata needed to execute the function.
Quick Reference Cheatsheet
| Concept | Syntax / Usage | Notes |
|---|---|---|
| Define a function | def name(params): body | def is a runtime statement |
| Call a function | name(args) | Returns the function's return value |
| Reference a function | name (no parentheses) | Returns the function object |
| Store in a variable | alias = name | Both names point to same object |
| Store in a dict | d = {"op": func} | Dispatch table pattern |
| Pass as argument | call(func, data) | Higher-order function |
| Return from function | return inner_func | Factory / closure pattern |
| Check if callable | callable(obj) | Returns True/False |
| Inspect name | func.__name__ | String name of the function |
| Inspect bytecode | dis.dis(func) | Requires import dis |
| Inspect parameters | func.__code__.co_varnames | Tuple of variable names |
| Inspect defaults | func.__defaults__ | Tuple of positional defaults |
Graded Practice Challenges
Level 1 - Predict the Output
def greet(name):
return f"Hi {name}"
say_hello = greet
print(say_hello("Alice"))
print(greet is say_hello)
Show Answer
Output:
Hi Alice
True
say_hello = greet binds a second name to the same function object. Calling say_hello("Alice") is identical to calling greet("Alice"). greet is say_hello confirms both names reference the exact same object in memory.
def make_greeting(prefix):
def greet(name):
return f"{prefix}, {name}!"
return greet
hi = make_greeting("Hi")
hello = make_greeting("Hello")
print(hi("Alice"))
print(hello("Bob"))
print(hi is hello)
Show Answer
Output:
Hi, Alice!
Hello, Bob!
False
Each call to make_greeting executes the def greet statement and creates a new, independent function object. hi and hello are different objects (different closures capturing different prefix values), so hi is hello is False.
Level 2 - Debug the Code
Find and fix the bug:
def build_pipeline(steps):
"""Build a processing pipeline from a list of functions."""
def run(data):
result = data
for step in steps:
result = step(result)
return result
return run
def double(x): return x * 2
def add_ten(x): return x + 10
pipeline = build_pipeline([double, add_ten])
# Bug: the user accidentally called the functions instead of passing them
broken_pipeline = build_pipeline([double(5), add_ten(5)])
print(broken_pipeline(3))
Show Answer
Bug: double(5) evaluates to 10 and add_ten(5) evaluates to 15. The steps list contains integers, not functions. When run tries to call 10(result), it raises TypeError: 'int' object is not callable.
Fix:
# Pass function references, not function calls
broken_pipeline = build_pipeline([double, add_ten])
print(broken_pipeline(3)) # double(3) = 6, add_ten(6) = 16
# Output: 16
Always remember: function name without () is a reference. Function name with () is a call that produces the return value.
Level 3 - Design Challenge
Build a simple plugin registry system using first-class functions.
Requirements:
- A
register(name)function that registers a function under a given name - A
run(name, *args, **kwargs)function that looks up and calls the registered function - A
list_plugins()function that returns all registered names - Functions should be registerable with a decorator syntax:
@register("my_plugin")
Demonstrate it with at least 3 registered plugins.
Show Reference Solution
# Plugin registry using first-class functions
_registry = {}
def register(name):
"""Register a function as a plugin under the given name."""
def decorator(func):
_registry[name] = func
return func # Return the original function unchanged
return decorator
def run(name, *args, **kwargs):
"""Look up and call the plugin with the given name."""
if name not in _registry:
raise KeyError(f"No plugin registered under '{name}'")
return _registry[name](*args, **kwargs)
def list_plugins():
"""Return a list of all registered plugin names."""
return list(_registry.keys())
# Register plugins using decorator syntax
@register("greet")
def plugin_greet(name):
return f"Hello, {name}!"
@register("shout")
def plugin_shout(message):
return message.upper() + "!!!"
@register("add")
def plugin_add(*numbers):
return sum(numbers)
# Use the registry
print(list_plugins()) # ['greet', 'shout', 'add']
print(run("greet", "Alice")) # Hello, Alice!
print(run("shout", "hello")) # HELLO!!!
print(run("add", 1, 2, 3, 4)) # 10
# Design note: the registry is a dict of first-class functions.
# `register` is a factory (higher-order function) that returns a decorator.
# This is the same pattern used by Flask, Pytest, and Click.
Key Takeaways
defis a runtime statement that creates a function object and binds it to a name- A function object lives on the heap like any other Python object
- Functions have attributes:
__name__,__doc__,__code__,__defaults__,__globals__ - Python functions are first-class - they can be assigned, stored, passed, and returned
- The
__code__attribute holds compiled bytecode inspectable withdis.dis() - A callable is any object with
__call__- functions are one type of callable - The dispatch table pattern (dict of functions) replaces long
if/elifchains - First-class functions underpin decorators, hooks, callbacks, and ML pipeline design
funcis a reference;func()is a call - confusing them causesTypeError
