Every complex Python program I’ve ever written — no matter how big it eventually grew — started with the same basic building block: a simple function defined with def. It’s easy to skim past the fundamentals once you’re comfortable, but I’ve found that revisiting them occasionally still teaches me something about how Python actually works under the hood. This guide covers function definition and invocation from the ground up, including the mechanics most tutorials gloss over.
The Basic Syntax
A Python function is defined with the def keyword, a name, a parenthesized parameter list, and a colon, followed by an indented block of code.
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
Output:
Hello, Alice!
That’s the entire pattern. greet becomes a name in the current namespace bound to a function object. Calling greet("Alice") executes the function body with name bound to "Alice".
What Actually Happens When Python Reads a def Statement
This is something I didn’t fully appreciate for a while: def is not a declaration in the way it might be in a statically typed language — it’s an executable statement. When Python’s interpreter reaches a def line, it compiles the function body into a code object and creates a function object wrapping that code object. That function object is then bound to the given name in the current scope, exactly like an assignment.
I can prove this by defining a function conditionally:
if True:
def greet():
print("Hi from the True branch")
else:
def greet():
print("Hi from the False branch")
greet() # Output: Hi from the True branch
This wouldn’t be possible if def were a static compile-time declaration rather than a runtime statement.
Function Objects Are First-Class
Because def just creates an object and binds a name to it, functions in Python are first-class citizens — I can assign them to other variables, store them in lists or dictionaries, pass them as arguments, and return them from other functions.
def square(x):
return x * x
my_func = square
print(my_func(5)) # 25
operations = {"square": square}
print(operations["square"](4)) # 16
Parameters vs Arguments
I try to be precise about this distinction, even though people often use the terms interchangeably: parameters are the names listed in the function definition; arguments are the actual values passed in when the function is called.
def multiply(a, b): # a and b are parameters
return a * b
result = multiply(3, 4) # 3 and 4 are arguments
Positional and Keyword Arguments
Python lets me call the same function two different ways: by position, or by explicitly naming the parameter.
def describe_pet(name, animal_type="dog"):
print(f"{name} is a {animal_type}.")
describe_pet("Rex") # positional, uses default for animal_type
describe_pet("Whiskers", "cat") # both positional
describe_pet(name="Tweety", animal_type="bird") # both keyword
describe_pet(animal_type="fish", name="Nemo") # keyword args can be reordered
Keyword arguments can appear in any order relative to each other, but once I start using keyword arguments in a call, I can’t follow them with more positional arguments.
# This raises a SyntaxError:
# describe_pet(animal_type="cat", "Whiskers")
Default Parameter Values
Default values let me make certain arguments optional. I always keep in mind — and cover in more depth in my [mutable default arguments guide] — that default values are evaluated once, at function-definition time, which matters a lot for mutable defaults but is perfectly safe for immutable ones like strings, numbers, or None.
def power(base, exponent=2):
return base ** exponent
print(power(3)) # 9 (uses default exponent)
print(power(3, 3)) # 27
Keyword-Only and Positional-Only Parameters
Modern Python gives me fine-grained control over how parameters can be passed, using two special markers in the signature: * and /.
def connect(host, port, *, timeout=30):
print(host, port, timeout)
connect("localhost", 8080, timeout=5) # OK
# connect("localhost", 8080, 5) # TypeError: timeout must be passed by keyword
Everything after a bare * in the parameter list must be passed as a keyword argument. This is useful for arguments where the meaning isn’t obvious from position alone.
Since Python 3.8, I can also mark parameters as positional-only using /:
def divide(a, b, /):
return a / b
print(divide(10, 2)) # OK
# print(divide(a=10, b=2)) # TypeError: a and b are positional-only
I reach for positional-only parameters mainly when designing a public API where I want the freedom to rename internal parameter names later without breaking anyone’s code that calls the function with keyword arguments.
The Function Body, Scope, and the LEGB Rule
Every function creates its own local scope. Variables assigned inside a function are local to that function unless explicitly declared otherwise with global or nonlocal. Python resolves names using what’s often called the LEGB rule: Local, Enclosing, Global, Built-in — checked in that order.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # local
inner()
print(x) # enclosing
outer()
print(x) # global
If I want to modify a variable from an enclosing or global scope rather than shadow it with a new local variable, I have to say so explicitly:
counter = 0
def increment():
global counter
counter += 1
increment()
increment()
print(counter) # 2
Docstrings: Documenting Functions Properly
I make it a habit to add a docstring to any function whose purpose isn’t immediately obvious from its name and signature. It’s the first statement inside the function body, written as a string literal, and it becomes accessible via help() or the function’s __doc__ attribute.
def calculate_area(radius):
"""Return the area of a circle given its radius.
Args:
radius (float): The radius of the circle.
Returns:
float: The computed area.
"""
import math
return math.pi * radius ** 2
help(calculate_area)
Calling Functions: What Happens Internally
When I call a function, Python’s interpreter does roughly the following, in order:
- Looks up the name (
greet,square, etc.) and confirms it refers to a callable object. - Creates a new local frame for the function call, pushed onto the call stack.
- Binds arguments to parameters according to Python’s argument-binding rules (positional, then keyword, then defaults for anything unfilled).
- Executes the function’s bytecode within that new frame.
- Pops the frame off the stack when the function returns (explicitly via
return, or implicitly by reaching the end of the function body, which returnsNone).
I can watch this stack grow and shrink using the traceback module or simply by raising an exception deep inside nested calls and observing the printed call stack.
def a():
b()
def b():
c()
def c():
raise ValueError("boom")
a()
The traceback shows the full call chain — a calling b calling c — which is a direct visualization of the call stack Python maintains for me automatically.
Common Mistakes I’ve Made or Seen Beginners Make
- Forgetting the colon at the end of the
defline — a simple but frequentSyntaxError. - Inconsistent indentation inside the function body, which raises an
IndentationError. - Calling a function before it’s defined in the file, since Python executes top to bottom — a function must be defined (i.e., the
defstatement executed) before it can be called. - Shadowing built-in names, like naming a function
listorstr, which quietly overrides Python’s built-ins for the rest of that scope. - Confusing
returnwithprint, printing a result instead of returning it, which means the value can’t be reused elsewhere in the program.
Best Practices I Follow
- I use descriptive, verb-based function names (
calculate_total, notcalcordo_stuff). - I keep functions focused on a single responsibility — if a function’s name needs “and” in it, that’s often a sign it should be split into two.
- I add type hints for anything beyond a trivial script, since they make the function’s contract explicit:
def calculate_area(radius: float) -> float:
import math
return math.pi * radius ** 2
- I write a docstring for any function whose behavior isn’t obvious from the signature alone.
FAQs
Do Python functions need a return statement? No. A function without an explicit return automatically returns None when it reaches the end of its body.
Can I define a function inside another function? Yes — these are called nested functions, and they’re the foundation of closures. The inner function has access to variables in the enclosing function’s scope.
What’s the difference between a function and a method? A method is simply a function defined inside a class and accessed through an instance (or the class itself). Under the hood, methods are functions with an implicit first argument (self for instance methods).
Can a function call itself? Yes — this is called recursion, and it’s a completely normal, supported pattern in Python, subject to the recursion limit set by sys.getrecursionlimit().
Summary
Defining and calling functions is the most foundational skill in Python, but there’s real depth underneath the simple def name(): ... syntax — from how Python treats function definitions as executable statements, to scope resolution via the LEGB rule, to the different ways arguments can be bound to parameters. Getting comfortable with these fundamentals early made every advanced topic I learned afterward — decorators, closures, *args/**kwargs — click into place much faster.
