Return Semantics - What Python Functions Actually Return
Reading time: ~17 minutes | Level: Foundation → Engineering
Here is something almost every Python developer gets subtly wrong:
def get_bounds(data):
return min(data), max(data) # looks like "returning two values"
result = get_bounds([3, 1, 4, 1, 5, 9, 2, 6])
print(result) # (1, 9)
print(type(result)) # <class 'tuple'>
print(result[0]) # 1
print(result[1]) # 9
# The "two return values" are one tuple - always
lo, hi = get_bounds([3, 1, 4, 1, 5, 9])
print(lo, hi) # 1 9
Python does not have "multiple return values." It has one return value - and when you write return a, b, you are returning a tuple. The comma builds the tuple; the return sends it.
This is not pedantry. It matters when you check if result:, when you annotate the return type, when you store it in a variable and later try to unpack it, and when you design libraries that other people will consume.
What You Will Learn
- What
returndoes at the bytecode level - theRETURN_VALUEopcode - Why "multiple return values" is actually a tuple
- How to unpack return values safely
- Returning early: guard clauses and defensive programming
- When to return
Noneexplicitly vs implicitly - Why returning different types from the same function is a design error
- How to use functions as expressions in chained calls
- Factory functions: returning callables from functions
- Return type annotations:
-> int,-> tuple[int, str],-> None - Structured return types:
NamedTupleandTypedDict - The Result/Either pattern for error handling without exceptions
Prerequisites
- Python 3.8+ installed
- Comfortable writing and calling functions
- Basic understanding of tuples and type hints
Part 1 - What return Does at the Bytecode Level
When you write return expr, Python compiles it to two bytecode instructions:
import dis
def add(a, b):
return a + b
dis.dis(add)
# 2 0 LOAD_FAST 0 (a)
# 2 LOAD_FAST 1 (b)
# 4 BINARY_OP 0 (+)
# 6 RETURN_VALUE
RETURN_VALUE is the final instruction. It pops the top value from the evaluation stack and hands it to the caller's stack frame. Execution of the function then terminates - no further instructions in that frame run.
Every Python function ends with RETURN_VALUE. If your function has no explicit return, the compiler inserts LOAD_CONST None followed by RETURN_VALUE at the end - this is the implicit return.
Part 2 - "Multiple Return Values" Are Tuples
The comma operator constructs a tuple. When you write return a, b, Python sees return (a, b).
def divmod_manual(a, b):
return a // b, a % b # returns one tuple
result = divmod_manual(17, 5)
print(result) # (3, 2)
print(type(result)) # <class 'tuple'>
# Unpacking is separate from returning
quotient, remainder = divmod_manual(17, 5)
print(quotient) # 3
print(remainder) # 2
You can verify this with the bytecode:
import dis
def pair():
return 1, 2
dis.dis(pair)
# 2 0 LOAD_CONST 1 (1)
# 2 LOAD_CONST 2 (2)
# 4 BUILD_TUPLE 2 <-- one tuple
# 6 RETURN_VALUE
BUILD_TUPLE 2 assembles the two values into one tuple object. RETURN_VALUE sends that one object. There is no concept of "multiple return" in CPython's execution model.
Why This Matters
# This is fine - tuple is truthy when non-empty
def get_range(data):
return min(data), max(data)
result = get_range([0, 0, 0])
if result: # (0, 0) - tuple is truthy even though both values are 0
print("got range") # this always prints, even if both values are 0
If you expected if result: to check whether the values are meaningful, you need to unpack first:
lo, hi = get_range([0, 0, 0])
if lo != hi:
print("non-trivial range")
Part 3 - Unpacking Return Values
Basic Unpacking
def http_response(code):
messages = {200: "OK", 404: "Not Found", 500: "Internal Server Error"}
return code, messages.get(code, "Unknown")
status, message = http_response(200)
print(status) # 200
print(message) # OK
Extended Unpacking (Python 3+)
def get_stats(data):
sorted_data = sorted(data)
return sorted_data[0], sorted_data[-1], sum(data) / len(data)
lo, hi, mean = get_stats([5, 3, 8, 1, 9, 2, 7])
print(f"min={lo}, max={hi}, mean={mean:.2f}")
# min=1, max=9, mean=5.00
# Use * to capture middle elements
first, *middle, last = (1, 2, 3, 4, 5)
print(first) # 1
print(middle) # [2, 3, 4]
print(last) # 5
Ignoring Return Values with _
def get_user_info(user_id):
# Only need name and role; ignore id and email
_, name, _, role = get_user_info(42)
print(f"{name} is {role}") # Alice is admin
:::note _ is a Real Variable
The underscore _ is a legitimate Python variable name. By convention it signals "I am intentionally ignoring this value." In the REPL, _ also holds the result of the last expression. Do not use _ for something you actually need.
:::
Part 4 - Returning Early: Guard Clauses
Guard clauses return early at the top of a function when preconditions are not met. This eliminates deep nesting and makes the happy path obvious.
Before Guard Clauses
def process_payment(amount, account, discount_code=None):
if amount > 0:
if account is not None:
if account.get("active"):
discount = 0
if discount_code:
if is_valid_code(discount_code):
discount = get_discount(discount_code)
final_amount = amount * (1 - discount)
return charge(account, final_amount)
else:
return {"error": "account inactive"}
else:
return {"error": "no account"}
else:
return {"error": "invalid amount"}
After Guard Clauses
def process_payment(amount, account, discount_code=None):
if amount <= 0:
return {"error": "invalid amount"}
if account is None:
return {"error": "no account"}
if not account.get("active"):
return {"error": "account inactive"}
# Happy path - unindented, easy to read
discount = 0
if discount_code and is_valid_code(discount_code):
discount = get_discount(discount_code)
final_amount = amount * (1 - discount)
return charge(account, final_amount)
Guard clauses are not just style - they reduce cyclomatic complexity, which correlates directly with bug density and test difficulty. Every nested if doubles the number of test cases needed to achieve branch coverage.
Part 5 - None Return: Implicit vs Explicit
Implicit Return (No Return Statement)
def greet(name):
print(f"Hello, {name}!")
# No return statement - returns None implicitly
result = greet("Alice")
# Hello, Alice!
print(result) # None
print(type(result)) # <class 'NoneType'>
Explicit return None
def validate(value):
if value < 0:
return None # explicit
return value * 2
Explicit return (No Value)
def early_exit(data):
if not data:
return # equivalent to return None
process(data)
All three produce the same result: None is returned. The choice is stylistic:
| Style | Use Case |
|---|---|
| No return | Functions with side effects only (print, write, mutate); acts like a procedure |
return | Guard clause exits - signals "done early" without a value |
return None | When None is a deliberate return value, e.g., failed lookup |
:::warning The Most Common Bug With None
# list.sort() returns None - it sorts in place
names = ["Charlie", "Alice", "Bob"]
sorted_names = names.sort() # BUG: sorted_names is None
# sorted() returns a new list - it does not mutate
sorted_names = sorted(names) # CORRECT
print(sorted_names) # ['Alice', 'Bob', 'Charlie']
Methods that mutate in place (.sort(), .append(), .extend(), .reverse()) return None. Functions that create new objects (sorted(), reversed(), list comprehensions) return the result. Never assign the result of a mutating method unless you need the return value (.pop(), .setdefault()).
:::
Part 6 - Functions as Expressions and Chained Calls
Because return produces a value, function calls are expressions. You can chain them:
def normalize(text):
return text.strip().lower()
def tokenize(text):
return normalize(text).split()
def count_words(text):
return len(tokenize(text))
# Chain calls inline
print(count_words(" Hello World ")) # 2
# Or compose them
pipeline = [normalize, tokenize, count_words]
def run_pipeline(value, steps):
for step in steps:
value = step(value)
return value
# But be careful: tokenize returns a list, count_words expects a string
# Design your pipeline to have consistent types between steps
Builder Pattern via Return Self
A common pattern in APIs: return self from methods to enable chaining:
class QueryBuilder:
def __init__(self, table):
self._table = table
self._conditions = []
self._limit = None
def where(self, condition):
self._conditions.append(condition)
return self # return self for chaining
def limit(self, n):
self._limit = n
return self
def build(self):
sql = f"SELECT * FROM {self._table}"
if self._conditions:
sql += " WHERE " + " AND ".join(self._conditions)
if self._limit:
sql += f" LIMIT {self._limit}"
return sql
query = (
QueryBuilder("users")
.where("active = true")
.where("role = 'admin'")
.limit(10)
.build()
)
print(query)
# SELECT * FROM users WHERE active = true AND role = 'admin' LIMIT 10
Part 7 - Factory Functions: Returning Callables
A function that returns another function is called a factory function. This is the foundation of closures and decorators.
def make_validator(min_val, max_val):
"""Return a validator function for the given range."""
def validate(value):
if not (min_val <= value <= max_val):
raise ValueError(f"{value} is not in [{min_val}, {max_val}]")
return value
return validate
# Each call creates a new validator with different bounds
validate_age = make_validator(0, 150)
validate_score = make_validator(0, 100)
validate_rating = make_validator(1, 5)
print(validate_age(25)) # 25
print(validate_score(87)) # 87
print(validate_rating(4)) # 4
try:
validate_rating(6)
except ValueError as e:
print(e) # 6 is not in [1, 5]
The returned function validate closes over min_val and max_val from the enclosing scope. Each factory call produces a function with different captured values.
def make_multiplier(factor):
return lambda x: x * factor
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(7)) # 14
print(triple(7)) # 21
# Works with map
print(list(map(double, [1, 2, 3, 4]))) # [2, 4, 6, 8]
Part 8 - Type Consistency: Never Return Different Types
This is one of the most common design errors in Python code:
# BAD: returns str on success, None on failure, False on error
def find_user(user_id):
if user_id <= 0:
return False # type 1: bool
user = db.get(user_id)
if user is None:
return None # type 2: NoneType
return user["name"] # type 3: str
The caller now needs a three-way type check:
result = find_user(some_id)
if result is False: # invalid id
...
elif result is None: # not found
...
else: # str - the actual name
...
This is fragile and untyped. The correct approach:
# GOOD: raise on invalid input; return None when not found; return str when found
def find_user(user_id: int) -> str | None:
if user_id <= 0:
raise ValueError(f"user_id must be positive, got {user_id}")
user = db.get(user_id)
if user is None:
return None
return user["name"] # always str here
# Clean caller:
name = find_user(42)
if name is None:
print("User not found")
else:
print(f"Hello, {name}")
:::danger Never Return Different Types for the Same Semantic Case
If your function can return str or None, that is a union type - document it as -> str | None. If it can return str or int or list depending on some flag, redesign the function - split it into multiple functions or use a structured return type.
:::
Part 9 - Return Type Annotations
Type annotations make your return contract explicit and enable static analysis tools like mypy and pyright.
def add(a: int, b: int) -> int:
return a + b
def get_name(user_id: int) -> str | None:
# returns str if found, None if not
...
def process(data: list[float]) -> tuple[float, float, float]:
# returns (min, max, mean)
return min(data), max(data), sum(data) / len(data)
def setup() -> None:
# procedure - no meaningful return value
initialize_db()
seed_data()
Complex Return Types
from typing import Union
# Python 3.10+ union syntax (preferred)
def parse_number(text: str) -> int | float | None:
try:
return int(text)
except ValueError:
try:
return float(text)
except ValueError:
return None
# Annotating callables as return types
def make_adder(n: int) -> callable: # basic - use Callable for precision
return lambda x: x + n
from typing import Callable
def make_adder(n: int) -> Callable[[int], int]:
return lambda x: x + n
Part 10 - Structured Return Types
When a function returns several related values, use NamedTuple or TypedDict instead of a plain tuple or dict.
NamedTuple
from typing import NamedTuple
class Stats(NamedTuple):
minimum: float
maximum: float
mean: float
std_dev: float
def compute_stats(data: list[float]) -> Stats:
n = len(data)
mean = sum(data) / n
variance = sum((x - mean) ** 2 for x in data) / n
return Stats(
minimum = min(data),
maximum = max(data),
mean = mean,
std_dev = variance ** 0.5,
)
stats = compute_stats([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0])
print(stats.mean) # 5.0
print(stats.std_dev) # 2.0
# Still supports tuple unpacking
lo, hi, avg, sd = stats
# Still supports indexing
print(stats[0]) # 2.0 (minimum)
# And repr is meaningful
print(stats)
# Stats(minimum=2.0, maximum=9.0, mean=5.0, std_dev=2.0)
TypedDict
from typing import TypedDict
class UserRecord(TypedDict):
id: int
username: str
email: str
role: str
active: bool
def fetch_user(user_id: int) -> UserRecord | None:
# Type checker knows exactly what keys and types this dict has
row = db.fetchone("SELECT * FROM users WHERE id = ?", user_id)
if row is None:
return None
return UserRecord(
id = row[0],
username = row[1],
email = row[2],
role = row[3],
active = bool(row[4]),
)
Part 11 - The Result Pattern
Exceptions are powerful but have costs: they unwind the call stack, they are not visible in the type signature, and they can be accidentally swallowed. The Result pattern (also called Either or Try in functional languages) represents success or failure as a typed return value.
from dataclasses import dataclass
from typing import Generic, TypeVar, Callable
T = TypeVar("T")
E = TypeVar("E")
@dataclass
class Ok(Generic[T]):
value: T
is_ok: bool = True
def map(self, func: Callable[[T], "Ok[T]"]) -> "Ok":
try:
return Ok(func(self.value))
except Exception as e:
return Err(str(e))
@dataclass
class Err(Generic[E]):
error: E
is_ok: bool = False
def map(self, func) -> "Err":
return self # errors propagate unchanged
Result = Ok | Err
def parse_int(text: str) -> Result:
try:
return Ok(int(text))
except ValueError:
return Err(f"Cannot parse {text!r} as int")
def divide(a: int, b: int) -> Result:
if b == 0:
return Err("Division by zero")
return Ok(a // b)
# Usage
r = parse_int("42")
print(r) # Ok(value=42, is_ok=True)
print(r.is_ok) # True
r = parse_int("abc")
print(r) # Err(error="Cannot parse 'abc' as int", is_ok=False)
# Chaining - map short-circuits on Err
result = parse_int("100").map(lambda x: x * 2)
print(result) # Ok(value=200, is_ok=True)
result = parse_int("bad").map(lambda x: x * 2)
print(result) # Err(error="Cannot parse 'bad' as int", is_ok=False)
# Practical: reading config with fallible parsing
def load_config(raw: dict) -> Result:
port_result = parse_int(raw.get("port", ""))
if not port_result.is_ok:
return Err(f"Invalid port: {port_result.error}")
port = port_result.value
if not (1 <= port <= 65535):
return Err(f"Port {port} out of range [1, 65535]")
return Ok({"host": raw.get("host", "localhost"), "port": port})
print(load_config({"host": "0.0.0.0", "port": "8080"}))
# Ok(value={'host': '0.0.0.0', 'port': 8080}, is_ok=True)
print(load_config({"port": "abc"}))
# Err(error="Invalid port: Cannot parse 'abc' as int", is_ok=False)
The Result pattern is most valuable at system boundaries - parsing user input, reading files, making network calls - where failures are common and expected, not exceptional.
Interview Questions
Q1: What does return a, b actually return in Python?
Answer: A single tuple (a, b). The comma constructs the tuple; return sends one object. Python has no native "multiple return values" - it has one return value that can be a tuple. You can verify this with type(func()) or by disassembling with dis.dis(), which shows a BUILD_TUPLE instruction before RETURN_VALUE.
Q2: What is the RETURN_VALUE bytecode instruction?
Answer: RETURN_VALUE is the CPython bytecode instruction that terminates a function call. It pops the top value from the current frame's evaluation stack and hands it to the calling frame. Every Python function ends with RETURN_VALUE. If there is no explicit return statement, the compiler automatically inserts LOAD_CONST None followed by RETURN_VALUE.
Q3: Why should you avoid returning different types from the same function?
Answer: When a function can return str, None, False, or int depending on conditions, the caller must perform multi-way type checks that are fragile, verbose, and cannot be verified by static analysis tools. Instead: raise exceptions for invalid inputs (the function was misused), return None or a Result type for expected failures (the lookup genuinely found nothing), and always return the same type for the success case. Annotate the return type so mypy/pyright can enforce it.
Q4: What is the difference between return, return None, and having no return statement?
Answer: All three produce the same runtime behavior - None is returned to the caller. The stylistic distinction: no return statement is used for pure side-effect functions (procedures); bare return is used in guard clauses to exit early; return None is used when None is a deliberate, documented return value (e.g., a database lookup that found nothing). Type annotate procedures as -> None.
Q5: What does list.sort() return and why does that matter?
Answer: list.sort() returns None. It sorts the list in place and returns nothing useful. The common bug is sorted_list = my_list.sort() - sorted_list is None. Use sorted(my_list) when you need the sorted list as a new object. This is Python's "Command-Query Separation" convention: functions that mutate return None; functions that compute return a value. Methods like .append(), .extend(), .reverse(), .clear() all return None.
Q6: When would you use NamedTuple vs a plain tuple for return values?
Answer: Use a NamedTuple when:
- The function returns 3 or more values - positional unpacking becomes error-prone
- The fields have semantic meaning (
.minimum,.maximum) that position does not convey - You want IDE autocompletion and static type checking on field access
- The return type appears in a public API that other code will consume
Use a plain tuple when:
- The function returns exactly 2 values with obvious meaning (like
divmod) - The return type is truly positional by convention (coordinates
(x, y)) - The tuple is transient and immediately unpacked by the caller
Practice Challenges
Beginner: Parse and Return Stats
Write a function parse_stats(text) that takes a comma-separated string of numbers, parses them, and returns a tuple of (count, total, minimum, maximum). Handle empty input by returning (0, 0, None, None).
parse_stats("3, 1, 4, 1, 5, 9") # (6, 23, 1, 9)
parse_stats("") # (0, 0, None, None)
Solution
def parse_stats(text: str) -> tuple:
"""Parse comma-separated numbers and return (count, total, min, max)."""
if not text.strip():
return (0, 0, None, None)
numbers = [float(x.strip()) for x in text.split(",")]
return (
len(numbers),
sum(numbers),
min(numbers),
max(numbers),
)
# Tests
count, total, lo, hi = parse_stats("3, 1, 4, 1, 5, 9")
print(count, total, lo, hi) # 6 23.0 1.0 9.0
result = parse_stats("")
print(result) # (0, 0, None, None)
# Demonstrate that this is one tuple
raw = parse_stats("10, 20, 30")
print(type(raw)) # <class 'tuple'>
print(raw) # (3, 60.0, 10.0, 30.0)
Intermediate: Guard-Clause Refactor
The following function has deeply nested conditionals. Rewrite it using guard clauses.
# Before:
def register_user(username, email, password):
if username:
if len(username) >= 3:
if email:
if "@" in email:
if password:
if len(password) >= 8:
return {"ok": True, "username": username, "email": email}
else:
return {"ok": False, "error": "password too short"}
else:
return {"ok": False, "error": "password required"}
else:
return {"ok": False, "error": "invalid email"}
else:
return {"ok": False, "error": "email required"}
else:
return {"ok": False, "error": "username too short"}
else:
return {"ok": False, "error": "username required"}
Solution
def register_user(username: str, email: str, password: str) -> dict:
"""Register a user. Returns {'ok': True, ...} or {'ok': False, 'error': ...}."""
# Guard clauses: fail fast, fail clearly
if not username:
return {"ok": False, "error": "username required"}
if len(username) < 3:
return {"ok": False, "error": "username too short"}
if not email:
return {"ok": False, "error": "email required"}
if "@" not in email:
return {"ok": False, "error": "invalid email"}
if not password:
return {"ok": False, "error": "password required"}
if len(password) < 8:
return {"ok": False, "error": "password too short"}
# Happy path - one clean return at the end
return {"ok": True, "username": username, "email": email}
# Tests
# {'ok': False, 'error': 'username required'}
# {'ok': False, 'error': 'username too short'}
print(register_user("alice", "not-an-email", "secret123"))
# {'ok': False, 'error': 'invalid email'}
# {'ok': False, 'error': 'password too short'}
# {'ok': True, 'username': 'alice', 'email': '[email protected]'}
Benefits: 6 levels of nesting reduced to 0. Cyclomatic complexity reduced from 7 to 1 (for the happy path). Each validation rule is independently readable and testable. Adding a new rule means adding one if at the top, not nesting deeper.
Advanced: Implement a Result Type
Implement a Result dataclass with Ok and Err variants. Add a map method that applies a function to the value if Ok, or propagates the error if Err. Add a map_err method that transforms the error message. Demonstrate it on a realistic parsing pipeline.
Solution
from __future__ import annotations
from dataclasses import dataclass
from typing import TypeVar, Generic, Callable
T = TypeVar("T")
U = TypeVar("U")
E = TypeVar("E")
@dataclass
class Ok(Generic[T]):
value: T
@property
def is_ok(self) -> bool:
return True
def map(self, func: Callable[[T], U]) -> Ok[U] | Err:
"""Apply func to the value; wrap any exception as Err."""
try:
return Ok(func(self.value))
except Exception as e:
return Err(str(e))
def map_err(self, func) -> Ok[T]:
"""Ok - no error to transform."""
return self
def unwrap(self, default=None) -> T:
return self.value
def __repr__(self) -> str:
return f"Ok({self.value!r})"
@dataclass
class Err(Generic[E]):
error: E
@property
def is_ok(self) -> bool:
return False
def map(self, func) -> Err[E]:
"""Err - propagate unchanged."""
return self
def map_err(self, func: Callable[[E], U]) -> Err[U]:
"""Transform the error value."""
return Err(func(self.error))
def unwrap(self, default=None):
return default
def __repr__(self) -> str:
return f"Err({self.error!r})"
Result = Ok | Err
# --- Helper constructors ---
def parse_int(text: str) -> Result:
try:
return Ok(int(text.strip()))
except (ValueError, AttributeError):
return Err(f"Cannot parse {text!r} as integer")
def validate_positive(n: int) -> Result:
if n <= 0:
return Err(f"Value must be positive, got {n}")
return Ok(n)
def validate_max(limit: int):
def validator(n: int) -> Result:
if n > limit:
return Err(f"Value {n} exceeds maximum {limit}")
return Ok(n)
return validator
# --- Pipeline demo ---
def parse_port(text: str) -> Result:
"""Parse and validate a TCP port from text."""
return (
parse_int(text)
.map(validate_positive) # Ok(Ok(n)) -- needs flat_map ideally
.map(lambda r: r.unwrap(0)) # unwrap the inner Ok
.map(validate_max(65535))
.map(lambda r: r.unwrap(0))
)
# Simpler version without the nesting issue:
def parse_port_clean(text: str) -> Result:
"""Parse and validate a TCP port from text - clean pipeline."""
result = parse_int(text)
if not result.is_ok:
return result
port = result.value
if port <= 0:
return Err(f"Port must be positive, got {port}")
if port > 65535:
return Err(f"Port {port} exceeds maximum 65535")
return Ok(port)
# Tests
print(parse_port_clean("8080")) # Ok(8080)
print(parse_port_clean("0")) # Err('Port must be positive, got 0')
print(parse_port_clean("99999")) # Err('Port 99999 exceeds maximum 65535')
print(parse_port_clean("abc")) # Err("Cannot parse 'abc' as integer")
# Using map for transformations
r = parse_port_clean("443")
doubled = r.map(lambda p: p * 2)
print(doubled) # Ok(886)
err = parse_port_clean("bad")
doubled = err.map(lambda p: p * 2)
print(doubled) # Err("Cannot parse 'bad' as integer") -- error propagated
# map_err to add context
result = parse_port_clean("bad").map_err(
lambda e: f"Invalid configuration: {e}"
)
print(result) # Err('Invalid configuration: Cannot parse \'bad\' as integer')
# unwrap with default
port = parse_port_clean("8080").unwrap(default=80)
print(port) # 8080
port = parse_port_clean("bad").unwrap(default=80)
print(port) # 80 (default)
Why Result instead of exceptions?
- The return type
Resultis visible in the function signature - callers know this can fail without reading the implementation - Errors cannot be accidentally swallowed (you must check
is_okor callunwrap) - You can chain transformations with
mapand only check at the end - Useful at system boundaries: config parsing, user input, file reading, API calls
Quick Reference
| Pattern | Syntax | Notes |
|---|---|---|
| Return a value | return expr | Sends expr to caller; terminates function |
| Return nothing | return or no return | Returns None implicitly |
| Return multiple (tuple) | return a, b, c | One tuple - BUILD_TUPLE + RETURN_VALUE |
| Unpack return | a, b = func() | Unpacking is separate from returning |
| Guard clause | if bad: return early_val | Return early for invalid inputs |
| Ignore a value | _ = func() | Convention: underscore means "discarded" |
| Annotate return | def f() -> int: | Checked by mypy/pyright |
| Optional return | def f() -> str | None: | Explicit about nullable |
| No meaningful return | def f() -> None: | Procedure - side effects only |
| Named return type | class X(NamedTuple): ... | Named fields, still a tuple |
| Typed dict return | class X(TypedDict): ... | Named fields, dict subtype |
| Factory function | def make(): return inner | Return callable - closure pattern |
The in-place mutation trap:
list.sort() → None (in-place)
sorted(list) → new list
list.append(x) → None (in-place)
list + [x] → new list
list.reverse() → None (in-place)
reversed(list) → iterator
Key Takeaways
RETURN_VALUEis the bytecode instruction that terminates a function and delivers one value to the callerreturn a, breturns one tuple - Python has no native multiple-return-value mechanism- Guard clauses (early returns) reduce nesting, lower cyclomatic complexity, and make the happy path readable
- Mutating methods like
list.sort()returnNone- assigning their result is the most commonNone-related bug - Never return different types for the same semantic case - use exceptions for misuse,
Nonefor expected absence, and a consistent type for success - Annotate return types with
-> T,-> T | None,-> None- it is documentation that tools can verify NamedTupleis the right choice when a function returns 3+ related values with semantic meaning- The Result/Either pattern makes failure handling explicit in the type signature and eliminates accidental exception swallowing at system boundaries
