When I first started writing Python, I treated the return statement like a formality — something I tacked onto the end of a function because “that’s what functions do.” It took me a while to actually understand what’s happening under the hood when Python returns a value, and even longer to appreciate how flexible the return statement really is. In this guide, I’m going to walk you through everything I’ve learned about returning values in Python, from the absolute basics to the internal mechanics, so you can write functions that are cleaner, faster, and easier to reason about.
What Does return Actually Do?
At its core, return does two things: it stops the execution of a function immediately, and it sends a value back to whoever called that function. That’s it. No magic, no special syntax rules beyond that.
def add(a, b):
return a + b
result = add(3, 5)
print(result) # Output: 8
Here, add(3, 5) doesn’t just run and disappear — it hands the value 8 back to result. If I hadn’t written return, the function would have executed the addition and thrown the result away, because a function without an explicit return statement returns None by default.
def add_no_return(a, b):
a + b # computed but discarded
result = add_no_return(3, 5)
print(result) # Output: None
I’ve been bitten by this more than once — writing a helper function, forgetting the return, and then wondering why my variable is mysteriously None three lines later.
The Function Call Stack: What Happens Internally
To really understand return, it helps to know what’s going on with Python’s call stack. Every time I call a function, Python pushes a new frame onto the call stack. This frame holds the function’s local variables, its bytecode instruction pointer, and other bookkeeping information. When Python hits a return statement, it does the following internally:
- Evaluates the expression after
return(if any) into a value. - Pops the current frame off the call stack.
- Passes the evaluated value back to the calling frame, where it becomes the result of the function call expression.
You can actually see this at the bytecode level using the dis module, which I find genuinely useful when I want to understand what Python is doing behind the scenes:
import dis
def add(a, b):
return a + b
dis.dis(add)
Output:
4 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_ADD
6 RETURN_VALUE
That RETURN_VALUE opcode is the literal instruction that pops the top of the stack and hands control back to the caller. It’s a nice reminder that return isn’t some abstract keyword — it compiles down to a very concrete operation.
Returning Multiple Values
One of the things I love about Python compared to languages like Java or C is how naturally it lets me return multiple values from a single function. Under the hood, Python isn’t actually returning “multiple” values — it’s packing them into a single tuple and returning that tuple.
def get_min_max(numbers):
return min(numbers), max(numbers)
low, high = get_min_max([4, 2, 9, 1, 7])
print(low, high) # Output: 1 9
That min(numbers), max(numbers) expression creates a tuple (1, 9), and the assignment low, high = ... unpacks it. I can prove this to myself easily:
result = get_min_max([4, 2, 9, 1, 7])
print(type(result)) # Output: <class 'tuple'>
This tuple-packing-and-unpacking pattern is everywhere in idiomatic Python — from enumerate() to dict.items() to functions like divmod().
Returning Named Values with namedtuple or Dataclasses
When a function returns several related values, plain tuples can get confusing — did I put the width first or the height? I’ve started using collections.namedtuple or dataclasses.dataclass for functions with more meaningful multi-value returns:
from collections import namedtuple
Dimensions = namedtuple("Dimensions", ["width", "height"])
def get_dimensions(image):
return Dimensions(width=image.shape[1], height=image.shape[0])
dims = get_dimensions(some_image)
print(dims.width, dims.height)
This costs almost nothing in performance but makes the calling code dramatically more readable.
Early Returns and Guard Clauses
I use return constantly as a way to exit a function early, especially for validation logic. This pattern — often called a “guard clause” — keeps my functions flat instead of nesting if blocks five levels deep.
def process_order(order):
if order is None:
return None
if not order.items:
return None
if order.total <= 0:
return None
# main processing logic
return finalize(order)
Compare that to the nested alternative, which I find much harder to scan:
def process_order(order):
if order is not None:
if order.items:
if order.total > 0:
return finalize(order)
return None
Guard clauses aren’t just a style preference for me — they genuinely reduce cognitive load when I’m reading code six months later.
Returning Nothing: The Role of None
Every Python function returns something, even if you never write return. Functions like list.append(), list.sort(), and dict.update() return None on purpose because they modify objects in place rather than producing a new value. I make this same choice deliberately when I write functions meant purely for side effects, like logging or writing to a file:
def log_event(message):
print(f"[LOG] {message}")
# implicit return None
I don’t add an explicit return None here unless it improves clarity — Python does it for me automatically, and PEP 8 doesn’t require it.
Returning Generators and Lazy Values
Not every “return” needs to hand back a fully computed value immediately. If a function uses yield instead of return, it becomes a generator function, and calling it returns a generator object rather than a value:
def count_up_to(n):
i = 1
while i <= n:
yield i
i += 1
gen = count_up_to(3)
print(next(gen)) # 1
print(next(gen)) # 2
I cover generators in more depth in my [Iterator vs Iterable vs Generator guide], but it’s worth knowing here that return inside a generator function has a special meaning too — it stops iteration and, since Python 3.3, its value becomes the StopIteration exception’s value attribute.
def gen_with_return():
yield 1
yield 2
return "done"
g = gen_with_return()
next(g)
next(g)
try:
next(g)
except StopIteration as e:
print(e.value) # Output: done
Type Hints for Return Values
As my codebases have grown, I’ve leaned more heavily on type hints for return values. They don’t change runtime behavior at all — Python doesn’t enforce them — but they make my intent explicit and let tools like mypy catch mistakes before I ship them.
def get_min_max(numbers: list[float]) -> tuple[float, float]:
return min(numbers), max(numbers)
For functions that might return nothing under some conditions, I use Optional:
from typing import Optional
def find_user(user_id: int) -> Optional[dict]:
if user_id in database:
return database[user_id]
return None
Performance Considerations
Returning values in Python is cheap — it’s just a reference being passed, not a deep copy. Whether I return an integer, a list with a million elements, or a custom object, the cost is the same: Python passes a pointer to the object, not the object itself. This is worth knowing because it means I never need to worry about “expensive” return statements the way I might in a language that copies structs by value.
That said, if I’m building up a large return value (like a list) inside a loop, I’ve learned it’s usually more efficient to use a generator or a list comprehension than to repeatedly call .append() in a verbose loop, purely because comprehensions are optimized at the bytecode level.
def squares(n):
return [x ** 2 for x in range(n)]
Common Mistakes I’ve Made (and Seen Others Make)
- Forgetting
returnentirely and being confused when a function “does nothing.” - Returning inside a loop incorrectly, cutting the loop short after just one iteration:
def find_even(numbers):
for n in numbers:
if n % 2 == 0:
return n
return None # BUG: this returns None after checking just the first number
The fix is to move return None outside the loop entirely, so all numbers get checked first.
- Mixing return types inconsistently — sometimes returning a list, sometimes
None, sometimes a string. This makes calling code fragile because it constantly has to guard against different types. - Using
returnandprintinterchangeably. Early on, I confused printing a value with returning one.print()displays output to the console;returnhands a value back into the program so it can be used elsewhere. A function that only prints its result can’t have that result stored, passed around, or tested.
FAQs
Can a Python function have multiple return statements? Yes, and it’s common. Only one return statement executes per function call — whichever one is reached first — but a function can contain several, typically for different branches of logic.
What’s the difference between return and yield? return ends the function and sends back a single value. yield pauses the function, preserving its state, and produces a value each time it’s iterated, turning the function into a generator.
Does return copy the value it sends back? No. Python returns a reference to the object, not a copy. If you return a mutable object like a list, the caller gets the same object in memory, not a duplicate.
Can I return a function from another function? Absolutely — functions are first-class objects in Python, so returning one from another is the foundation of closures and decorators.
def make_multiplier(factor):
def multiplier(x):
return x * factor
return multiplier
double = make_multiplier(2)
print(double(5)) # Output: 10
Summary
The return statement is deceptively simple on the surface but touches nearly every part of how Python functions work — from the call stack, to tuple packing for multiple values, to how generators use return differently than regular functions. Once I started paying attention to what my functions returned and why, my code became noticeably easier to test and reuse. My advice: be intentional about return values, use guard clauses to keep logic flat, and reach for type hints once your functions start returning anything more complex than a single primitive.
