Return Statements in Python Functions

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)   # 8
Why 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)   # 38
return is only valid inside a function

Trying to use return outside a function's body raises an error immediately:

return 5
# SyntaxError: 'return' outside function

This 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)   # None
def log_message(message):
    print(message)
    return   # bare return — also produces None

result = log_message("Test")
print(result)   # None

Both 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 too
When 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 outcome

Returning 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 9

This 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.8

A 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))    # positive

Only 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 guard

Each 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 None or 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 None

A 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.