Skip to main content

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 def statement 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:

  1. Compiles the body return f"Hello, {name}" to a code object
  2. Creates a function object that wraps the code object
  3. Binds the name greet to 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:

  1. Assigned to a variable
  2. Stored in a data structure
  3. Passed to a function as an argument
  4. 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

ConceptSyntax / UsageNotes
Define a functiondef name(params): bodydef is a runtime statement
Call a functionname(args)Returns the function's return value
Reference a functionname (no parentheses)Returns the function object
Store in a variablealias = nameBoth names point to same object
Store in a dictd = {"op": func}Dispatch table pattern
Pass as argumentcall(func, data)Higher-order function
Return from functionreturn inner_funcFactory / closure pattern
Check if callablecallable(obj)Returns True/False
Inspect namefunc.__name__String name of the function
Inspect bytecodedis.dis(func)Requires import dis
Inspect parametersfunc.__code__.co_varnamesTuple of variable names
Inspect defaultsfunc.__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:

  1. A register(name) function that registers a function under a given name
  2. A run(name, *args, **kwargs) function that looks up and calls the registered function
  3. A list_plugins() function that returns all registered names
  4. 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

  • def is 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 with dis.dis()
  • A callable is any object with __call__ - functions are one type of callable
  • The dispatch table pattern (dict of functions) replaces long if/elif chains
  • First-class functions underpin decorators, hooks, callbacks, and ML pipeline design
  • func is a reference; func() is a call - confusing them causes TypeError
© 2026 EngineersOfAI. All rights reserved.