Function Return Values

Every Python function returns something — explicitly with return, or implicitly None — and understanding both cases is essential to reasoning about function results.

The return statement ends a function's execution and hands a value back to the caller. Python functions are expression-oriented in how they produce results: you can return a single value, multiple values packed as a tuple, or nothing at all — in which case Python silently returns None. Knowing exactly what a function returns, and when, is fundamental to writing and reading correct code.

The return Statement

return expression immediately ends the function call and sends the evaluated expression back to wherever the function was called from.

def square(x):
    return x * x

result = square(7)
print(result)   # 49

Implicit None

A function with no return statement, or a bare return with no expression, returns None. This is not an error or a special case — it's the same as if every function body ended with an invisible return None.

This is why chaining a method that mutates in place (like list.sort() or list.append()) and expecting a usable return value is a classic bug — those return None by design.

def log(message):
    print(message)   # no return statement

result = log("hi")
print(result)      # None

def early_exit():
    return          # bare return -> also None

print(early_exit())  # None

Returning Multiple Values (Tuple Packing)

Python has no dedicated 'multiple return values' syntax; instead, listing several comma-separated expressions after return implicitly packs them into a tuple, which the caller can then unpack.

def min_max(numbers):
    return min(numbers), max(numbers)   # packed into a 2-tuple

result = min_max([3, 1, 4, 1, 5, 9])
print(result)             # (1, 9)
print(type(result))       # <class 'tuple'>

low, high = min_max([3, 1, 4, 1, 5, 9])   # unpacked immediately
print(low, high)          # 1 9

Unpacking Return Values

Because the 'multiple return' is really a tuple, all normal tuple-unpacking rules apply, including partial unpacking with *.

def stats(numbers):
    return len(numbers), sum(numbers), sum(numbers) / len(numbers)

count, total, avg = stats([10, 20, 30])
print(count, total, avg)   # 3 60 20.0

def split_first(items):
    return items[0], items[1:]

first, rest = split_first([1, 2, 3, 4])
print(first, rest)         # 1 [2, 3, 4]

Early Return

return can appear anywhere in a function body, not just at the end. Using it to exit as soon as a result or a disqualifying condition is known — the 'guard clause' style — often reads more clearly than deeply nested if/else chains.

Any code after a return statement in the same block is unreachable and never executes — it's dead code.

def classify(n):
    if n < 0:
        return "negative"
    if n == 0:
        return "zero"
    return "positive"

print(classify(-5), classify(0), classify(5))  # negative zero positive

Returning Objects

A function can return any object: a number, a string, a collection, another function, a class instance, or even a coroutine/generator object. Since names bind to objects rather than copy them, returning a mutable object returns a reference — the caller and any other holder of that object see the same underlying data.

def make_point(x, y):
    return {"x": x, "y": y}   # returns a fresh dict object

p = make_point(1, 2)
p["x"] = 99
print(p)   # {'x': 99, 'y': 2} — mutating the returned object is fine, it's p's own dict

Practical Usage

A common, readable pattern is returning a small dataclass or NamedTuple instead of a bare tuple when a function returns several related values, so callers get named fields instead of having to remember positional order.

from typing import NamedTuple

class Stats(NamedTuple):
    count: int
    total: float
    average: float

def stats(numbers):
    return Stats(len(numbers), sum(numbers), sum(numbers) / len(numbers))

result = stats([10, 20, 30])
print(result.average)      # 20.0 — self-documenting, unlike result[2]
print(result)               # Stats(count=3, total=60, average=20.0)

Edge Cases

A return inside a try block still runs any matching finally block before actually returning — and if finally itself contains a return, that value silently overrides the one from try, which is almost always a bug.

A generator function's return value inside its body does not make the generator produce value on iteration — instead it sets value as the StopIteration.value, which is a distinct, more advanced mechanic.

def risky():
    try:
        return "from try"
    finally:
        return "from finally"   # overrides! avoid this pattern

print(risky())   # 'from finally' — surprising and generally considered bad practice

Mental Model

Every function call is an expression that evaluates to exactly one object. 'Returning multiple values' is really returning one tuple object holding several values — Python's assignment unpacking is what makes it feel like multiple return values at the call site.

Quick Reference

return expr        -> hands `expr`'s value back to the caller, ends the call
return a, b, c      -> packs (a, b, c) into a tuple, returns the tuple
return               -> returns None
(no return at all)  -> function ends normally, returns None

x, y = f()          -> unpacks a 2-tuple return value into x and y

Key Takeaways

return ends a function immediately and sends its expression's value back to the caller.

A function with no explicit return, or a bare return, returns None — this is normal, not an error.

'Multiple return values' are really a single tuple, created by comma-separated expressions after return and split apart by unpacking at the call site.

Early returns (guard clauses) can make functions easier to read than deeply nested conditionals.

A return inside finally silently overrides a return from the matching try, which is a well-known gotcha to avoid.

def sort_and_get(items): return items.sort() # list.sort() returns None!

def sort_and_get(items): items.sort() return items list.sort() sorts in place and always returns None; returning its result directly returns None instead of the sorted list.

def process(data): if not data: return result = compute(data) return result print("done") # unreachable, never runs def process(data): if not data: return None result = compute(data) print("done") return result

Code placed after a return statement in the same block is dead code and never executes; the print should be moved before the return.

def risky(): try: return compute() finally: return cleanup_value # silently discards compute()'s result def risky(): try: return compute() finally: cleanup() # do cleanup work, but don't return from finally

A return statement inside finally always wins over a return from the try block, silently discarding the intended result — finally should perform cleanup, not return a value.

What does a function return if it has no return statement at all?

None — Python implicitly ends the function and returns None when execution falls off the end of the function body.

How does Python support 'returning multiple values' if return only takes one expression?

Comma-separated expressions after return are automatically packed into a single tuple, which is one value; the caller then unpacks that tuple into multiple names.

Why is code placed immediately after a return statement (in the same block) never executed?

Because return immediately exits the function, transferring control back to the caller before any subsequent statement in that block can run — it becomes unreachable dead code.

What's the danger of putting a return statement inside a finally block?

It unconditionally overrides any return value (or even an in-flight exception) from the try block, silently discarding the original result or error, which is almost always unintended.

Why would you prefer returning a NamedTuple or dataclass over a plain tuple for a function with several related results?

Because a plain tuple only supports positional access (result[0], result[1]), which is easy to misread or reorder incorrectly, while a NamedTuple/dataclass gives named, self-documenting fields.

If a function returns a list, and the caller mutates that list, does the function's internal state change too?

It depends: if the returned list is a genuinely new object built inside the function, mutating it externally has no effect on the function; but if the function returned a reference to some object it still holds elsewhere (like a shared cache), the mutation is visible everywhere that object is referenced.

What is a 'guard clause' and why is it a good use of early return?

A guard clause is an early return at the top of a function that handles an edge case or invalid input immediately, letting the rest of the function assume the common case — this avoids deep nesting and improves readability.

Write a function divide_safe(a, b) that returns the quotient and a boolean success flag as a tuple, without raising on division by zero.

def divide_safe(a, b): if b == 0: return None, False return a / b, True print(divide_safe(10, 2)) print(divide_safe(10, 0)) (5.0, True) then (None, False) — the function always returns a 2-tuple so the caller can unpack (value, ok) consistently.

What does this print?

1 — the loop's return statement fires as soon as i == 1, immediately exiting the function before the loop finishes or the final return -1 is reached.

Write a function parse_name(full_name) that splits 'First Last' into first and last name and returns both, then show it being unpacked at the call site.

def parse_name(full_name): first, last = full_name.split(" ", 1) return first, last first, last = parse_name("Ada Lovelace") print(first, last)

Ada Lovelace — split(' ', 1) divides on the first space only, and the tuple (first, last) is unpacked directly into two names.

What does this print, and why?

2 — the return inside finally always overrides the return from try, silently discarding the value 1.

def f():
    for i in range(3):
        if i == 1:
            return i
    return -1

print(f())
def f():
    try:
        return 1
    finally:
        return 2

print(f())