The return statement was introduced briefly in the earlier functions article — this one is dedicated entirely to python return statement behavior in depth: what happens when there isn't one, how to return multiple values cleanly, and the early-return pattern that makes validation and guard-clause logic genuinely cleaner.
Introduction: What the return Statement Does
return immediately ends a function's execution and sends a value back to whatever called it.
def add(a, b):
return a + b
result = add(3, 5)
print(result) # 8Why it matters
return is what makes a function's output actually usable elsewhere in your program. Without it, whatever a function computes stays trapped inside that function — you can't assign it to a variable, pass it into another function, or use it in a larger expression. return is the bridge that connects a function's internal work to the rest of your program.
total = add(3, 5) + add(10, 20) # using two returned values directly in an expression
print(total) # 38return is only valid inside a function
Trying to use return outside a function's body raises an error immediately:
return 5
# SyntaxError: 'return' outside functionThis makes sense once you remember what return actually does — it ends a function call and hands a value back to whoever made that call. Outside a function, there's no "caller" for it to return to.
Functions Without a Value: The None Default
The implicit None
As covered in the earlier functions article, if a function never executes a return statement — or executes a bare return with nothing after it — Python implicitly returns None.
def greet(name):
print(f"Hello, {name}!")
# no return statement at all
result = greet("Alex")
print(result) # Nonedef log_message(message):
print(message)
return # bare return — also produces None
result = log_message("Test")
print(result) # NoneBoth versions behave identically as far as the caller is concerned — no explicit return, or a bare return, both hand back None.
The common beginner trap: print() vs. return
This is worth restating clearly, because it's genuinely one of the most common sources of confusion for beginners: print() displays output to the console. return produces a usable value the caller can actually work with. A function that only prints has nothing to hand back — its return value is always None, regardless of how much it printed.
def calculate_total(price, tax_rate):
print(price * (1 + tax_rate)) # prints, but doesn't return
total = calculate_total(50, 0.08)
print(total) # None — not 54.0!
# Trying to use the "result" further breaks immediately
new_total = total + 10
# TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'The fix is straightforward — use return instead of print() whenever a function's job is to produce a value for further use, and let the caller decide whether (and how) to display it:
def calculate_total(price, tax_rate):
return price * (1 + tax_rate) # returns, doesn't print
total = calculate_total(50, 0.08)
print(total) # 54.0 — now this actually works
new_total = total + 10 # and this works tooWhen to explicitly write return None
Since return None and simply omitting return entirely behave identically, writing it out explicitly is a style choice — but it's a genuinely useful one in specific situations. If a function sometimes returns a real value and sometimes returns nothing meaningful (say, depending on a condition), explicitly writing return None in the "nothing to return" branch makes that intent visible to anyone reading the code, rather than leaving them to infer it from the absence of any return statement at all:
def find_user(user_id, users):
for user in users:
if user["id"] == user_id:
return user
return None # explicit — makes clear that "not found" is a real, expected outcomeReturning Multiple Values
Comma-separated values pack into a tuple
As covered briefly in the earlier functions article, separating multiple values with commas after return automatically packages them into a tuple:
def get_min_max(numbers):
return min(numbers), max(numbers)
result = get_min_max([4, 7, 1, 9, 3])
print(result) # (1, 9) — a tuple
print(type(result)) # <class 'tuple'>Unpacking at the call site
The more common, more readable way to use a function like this is to unpack the returned tuple directly into separate variables:
lowest, highest = get_min_max([4, 7, 1, 9, 3])
print(lowest, highest) # 1 9This relies on the same multiple-assignment unpacking covered in the earlier assignment operators article — the tuple (1, 9) gets unpacked directly into lowest and highest in one line.
Alternative: returning a collection directly
For situations with more than two or three related values, or where the values genuinely belong together as a named group rather than loose individual items, returning a list, dictionary, or other collection can be clearer than an unnamed tuple:
def get_stats(numbers):
return {
"min": min(numbers),
"max": max(numbers),
"average": sum(numbers) / len(numbers),
}
stats = get_stats([4, 7, 1, 9, 3])
print(stats["average"]) # 4.8A dictionary result like this is often more self-documenting than a plain tuple, especially once you're returning more than two values — stats["average"] is immediately clear about what it represents, where a bare tuple's third position wouldn't be, without checking the function's implementation.
Multiple Return Statements and Early Exits
A function can have several return statements
There's no rule limiting a function to a single return — you can have as many as your logic needs. Whichever one actually executes first ends the function immediately; nothing after it, within that call, ever runs.
def categorize(number):
if number < 0:
return "negative"
if number == 0:
return "zero"
return "positive"
print(categorize(-5)) # negative
print(categorize(0)) # zero
print(categorize(5)) # positiveOnly one of those three return statements ever executes per call — the moment one of them runs, the function exits immediately, and the rest of the function body is simply never reached for that particular call.
Early returns as guard clauses
This connects directly to the guard-clause pattern covered in the earlier nested conditionals article: using an early return to exit a function as soon as some condition fails, rather than nesting the "real" logic inside a deeply indented if block.
def process_order(order):
if not order.get("items"):
return "Error: order has no items"
if order.get("total", 0) <= 0:
return "Error: invalid order total"
# by this point, both checks have passed — the rest of the function
# can safely assume the order is valid, without further nested checks
return f"Processing order with {len(order['items'])} items"Example: validating input before doing the real work
def calculate_discount(price, discount_percent):
if price < 0:
return None # invalid input — exit early, don't attempt the calculation
if not (0 <= discount_percent <= 100):
return None # also invalid — exit early
# the actual calculation only runs once both inputs are confirmed valid
return price * (1 - discount_percent / 100)
print(calculate_discount(100, 20)) # 80.0
print(calculate_discount(-50, 20)) # None — caught by the first guardEach guard clause handles exactly one way the input could be invalid, and exits immediately — by the time execution reaches the final calculation, every precondition has already been confirmed, without needing to nest that calculation inside multiple layers of if blocks.
5. Best Practices and Advanced Notes
Keep return types consistent
Where reasonably possible, try to keep a function's return type consistent across all its branches. A function that sometimes returns a number and sometimes returns a string (or None, without a clear, well-documented reason) is harder to use safely — the caller can't confidently predict what type they're going to get back, which tends to produce defensive, type-checking code wherever that function is used.
# Inconsistent — sometimes a number, sometimes a string
def get_price(item):
if item not in catalog:
return "Item not found" # a string
return catalog[item]["price"] # a number
# More consistent — always returns a number, or explicitly None for "not found"
def get_price(item):
if item not in catalog:
return None
return catalog[item]["price"]The second version is easier to reason about — a caller only needs to check if price is not None: rather than also handling the possibility of an unexpected string type showing up.
A preview: returning a function from another function
Worth a brief mention here, even though it's a more advanced topic covered later in this series: return isn't limited to plain values — a function can also return another function. This is the foundation of closures and higher-order functions, and it's exactly what makes the decorator pattern (briefly shown in the earlier *args/**kwargs article) possible. For now, just recognize that return handing back a function, rather than a plain value, is a legitimate and genuinely useful pattern you'll encounter as you go further.
A quick checklist
Document what a function returns, ideally with a docstring — especially when the return type might not be obvious from the function's name alone.
Test edge cases where different branches return different things — particularly any branch that returns
Noneor an empty collection, since those are the cases most likely to cause a confusing error somewhere downstream if the caller isn't expecting them.
def find_user(user_id, users):
"""Return the matching user dict, or None if no user has this ID."""
for user in users:
if user["id"] == user_id:
return user
return NoneA one-line docstring like this costs almost nothing to write and saves real time for anyone (including future you) who needs to use the function without re-reading its full implementation first.