Skip to main content

Python Return Semantics Practice Problems & Exercises

Practice: Return Semantics

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Single Value ReturnEasy
returnbasic

Write a function that returns the absolute value of an integer without using the built-in abs(). This tests the most basic return pattern: computing a value and sending it back to the caller.

Test with inputs -5, 0, and 7.

Python
def absolute_value(n):
    if n < 0:
        return -n
    return n

print(absolute_value(-5))
print(absolute_value(0))
print(absolute_value(7))
Solution
def absolute_value(n):
if n < 0:
return -n
return n

Every path through the function must reach a return statement. Here the negative case returns early, and the non-negative case falls through to the final return. The RETURN_VALUE bytecode instruction pops the result from the evaluation stack and hands it to the caller. If you forget the second return, Python implicitly returns None for non-negative inputs.

def absolute_value(n):
    """Return the absolute value of n without using abs().
    If n is negative, return -n. Otherwise return n.
    """
    # TODO: implement
    pass
Expected Output
5
0
7
Hints

Hint 1: Check whether n is less than zero.

Hint 2: If negative, return -n (negation flips the sign). Otherwise return n as-is.

#2Return vs PrintEasy
returnprintNone

Two functions are provided: add_print prints the sum, add_return returns it. Write demonstrate_difference that calls both with (3, 4), captures each return value, and returns them as a tuple.

This reveals the fundamental difference: print sends text to the console and the function returns None; return sends a value to the caller.

Python
def add_print(a, b):
    print(a + b)

def add_return(a, b):
    return a + b

def demonstrate_difference():
    result_print = add_print(3, 4)
    result_return = add_return(3, 4)
    return (result_print, result_return)

print(demonstrate_difference())
Solution
def demonstrate_difference():
result_print = add_print(3, 4)
result_return = add_return(3, 4)
return (result_print, result_return)

add_print outputs 7 to the console but returns None. add_return returns the integer 7 without printing anything. The tuple (None, 7) proves that print is a side effect, not a return value. This is the most common beginner mistake: using print inside a function when you need return, then wondering why the caller gets None.

def add_print(a, b):
    """This function PRINTS the sum. Do not change it."""
    print(a + b)

def add_return(a, b):
    """This function RETURNS the sum. Do not change it."""
    return a + b

def demonstrate_difference():
    """Call both functions with (3, 4).
    Capture the return value of each into variables
    result_print and result_return.
    Return a tuple: (result_print, result_return)
    """
    # TODO: implement
    pass
Expected Output
7
(None, 7)
Hints

Hint 1: Assign the result of add_print(3, 4) to a variable. What value does it hold?

Hint 2: Functions that print but do not return produce None. Functions that return give you a usable value.

#3Multiple Return Values as TupleEasy
tuplemultiple-return

Write a function that takes a list of numbers and returns the minimum, maximum, and arithmetic mean as a "multiple return value" (which is actually a tuple).

Test by unpacking into three variables and also by checking type() on the raw return value.

Python
def min_max_mean(numbers):
    return min(numbers), max(numbers), sum(numbers) / len(numbers)

lo, hi, avg = min_max_mean([1, 5, 3, 9, 2])
print(lo)
print(hi)
print(avg)
print(type(min_max_mean([1, 5, 3, 9, 2])))
Solution
def min_max_mean(numbers):
return min(numbers), max(numbers), sum(numbers) / len(numbers)

return a, b, c constructs one tuple (a, b, c) and returns it. The disassembly would show BUILD_TUPLE 3 followed by RETURN_VALUE. When you write lo, hi, avg = min_max_mean(...), that is tuple unpacking on the caller side, completely separate from the return mechanism. The raw return value is always a single tuple object.

def min_max_mean(numbers):
    """Return the minimum, maximum, and mean of a list of numbers.
    Return them as a single expression: min, max, mean.
    The caller should be able to unpack into three variables.
    """
    # TODO: implement
    pass
Expected Output
1
9
5.0
<class 'tuple'>
Hints

Hint 1: Use return min_val, max_val, mean_val — the comma builds a tuple automatically.

Hint 2: mean = sum(numbers) / len(numbers)

#4Safe Tuple UnpackingEasy
unpackingextended-unpacking

Write a function that splits a list into its first element and the remaining elements. Handle the empty-list edge case by returning (None, []).

Test with [1, 2, 3, 4, 5], [], and [42].

Python
def head_tail(lst):
    if not lst:
        return (None, [])
    first, *rest = lst
    return first, rest

print(head_tail([1, 2, 3, 4, 5]))
print(head_tail([]))
print(head_tail([42]))
Solution
def head_tail(lst):
if not lst:
return (None, [])
first, *rest = lst
return first, rest

Extended unpacking with *rest captures all remaining elements as a list. For [42], first is 42 and *rest captures an empty list []. The guard clause at the top prevents a ValueError from unpacking an empty sequence. Note that return first, rest returns a tuple (first, rest) where the second element is always a list, keeping the return type consistent.

def head_tail(lst):
    """Return the first element and the rest of the list.
    If the list is empty, return (None, []).
    Use extended unpacking (star expression) for non-empty lists.
    """
    # TODO: implement
    pass
Expected Output
(1, [2, 3, 4, 5])
(None, [])
(42, [])
Hints

Hint 1: For an empty list, return (None, []) as a guard clause.

Hint 2: For a non-empty list, use first, *rest = lst and then return first, rest.


Medium

#5Guard Clause RefactorMedium
guard-clauseearly-return

The function below has deeply nested conditionals. Rewrite it using guard clauses so the happy path has zero nesting. Every invalid input should return -1 via an early return.

Test with: (100, "regular", None), (100, "premium", None), (100, "vip", "SAVE5"), (-10, "regular", None), (100, "unknown", None).

Python
def calculate_discount(price, customer_type, coupon_code):
    if price <= 0:
        return -1
    if customer_type not in ('regular', 'premium', 'vip'):
        return -1

    discounts = {'regular': 0, 'premium': 0.10, 'vip': 0.20}
    final = price * (1 - discounts[customer_type])

    if coupon_code == 'SAVE5':
        final = final * 0.95

    return round(final, 2)

print(calculate_discount(100, "regular", None))
print(calculate_discount(100, "premium", None))
print(calculate_discount(100, "vip", "SAVE5"))
print(calculate_discount(-10, "regular", None))
print(calculate_discount(100, "unknown", None))
Solution
def calculate_discount(price, customer_type, coupon_code):
if price <= 0:
return -1
if customer_type not in ('regular', 'premium', 'vip'):
return -1

discounts = {'regular': 0, 'premium': 0.10, 'vip': 0.20}
final = price * (1 - discounts[customer_type])

if coupon_code == 'SAVE5':
final = final * 0.95

return round(final, 2)

Guard clauses validate inputs at the top and return early on failure. The happy path runs at the base indentation level with zero nesting. This reduces cyclomatic complexity: each guard is an independent, testable check. Compare this to nesting if price > 0: if customer_type in ...: if ... which creates a deeply indented pyramid that is harder to read and modify.

def calculate_discount(price, customer_type, coupon_code):
    """Refactor using guard clauses. Return the final price.
    Rules:
    - price must be positive (return -1 for invalid)
    - customer_type must be 'regular', 'premium', or 'vip'
      (return -1 for invalid)
    - discount: regular=0%, premium=10%, vip=20%
    - coupon_code 'SAVE5' gives extra 5% off (applied after tier discount)
    - Return the final discounted price rounded to 2 decimals
    """
    # TODO: rewrite with guard clauses (no nesting)
    pass
Expected Output
100.0
90.0
76.0
-1
-1
Hints

Hint 1: Put all validation checks at the top, each returning -1 immediately if the condition fails.

Hint 2: After the guards, the happy path is flat: look up discount, check coupon, compute price.

#6Implicit None DetectionMedium
Noneimplicit-returnin-place

Write a function that tests common list methods and determines which ones return None (in-place mutators) vs a useful value. For each operation in the input list, run it on a fresh copy of [3, 1, 4, 1, 5] and record whether the return value is None.

Python
def find_none_traps(operations):
    results = {}
    args_map = {
        'sort': (), 'append': (99,), 'reverse': (),
        'pop': (), 'extend': ([6, 7],), 'remove': (1,),
    }
    for op in operations:
        sample = [3, 1, 4, 1, 5]
        method = getattr(sample, op)
        result = method(*args_map[op])
        results[op] = result is None
    return results

ops = ['sort', 'append', 'reverse', 'pop', 'extend', 'remove']
print(find_none_traps(ops))
Solution
def find_none_traps(operations):
results = {}
args_map = {
'sort': (), 'append': (99,), 'reverse': (),
'pop': (), 'extend': ([6, 7],), 'remove': (1,),
}
for op in operations:
sample = [3, 1, 4, 1, 5]
method = getattr(sample, op)
result = method(*args_map[op])
results[op] = result is None
return results

Five of the six operations return None because they mutate the list in place. Only pop returns a useful value (the removed element). This is Python's Command-Query Separation convention: methods that change state return nothing; methods that compute return a value. The classic bug is sorted_list = my_list.sort() which gives you None instead of a sorted list.

def find_none_traps(operations):
    """Given a list of strings representing list operations,
    return which ones produce None when their return value
    is captured.
    
    Test these operations on a sample list [3, 1, 4, 1, 5]:
    'sort', 'append', 'reverse', 'pop', 'extend', 'remove'
    
    Return a dict mapping operation name to True if it
    returns None, False otherwise.
    """
    # TODO: implement
    pass
Expected Output
{'sort': True, 'append': True, 'reverse': True, 'pop': False, 'extend': True, 'remove': True}
Hints

Hint 1: Create a fresh copy of the sample list for each operation so mutations do not interfere.

Hint 2: Call getattr(lst, op)(...) with appropriate arguments: append needs a value, extend needs a list, remove needs an element, pop takes no args.

#7Consistent Return TypesMedium
type-consistencyrefactoring

The provided lookup_user_broken returns three different types: bool, NoneType, and dict. Write lookup_user_fixed that raises ValueError for invalid input, returns None for not-found, and returns the user dict when found. This gives a clean dict | None return type.

Test with a sample user list.

Python
def lookup_user_fixed(users, user_id):
    if user_id < 0:
        raise ValueError(
            "user_id must be non-negative, got " + str(user_id)
        )
    for u in users:
        if u['id'] == user_id:
            return u
    return None

users = [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]

result = lookup_user_fixed(users, 1)
if result is not None:
    print("Found:", result['name'])

result = lookup_user_fixed(users, 99)
if result is None:
    print("Not found")

try:
    lookup_user_fixed(users, -1)
except ValueError as e:
    print("Caught:", e)
Solution
def lookup_user_fixed(users, user_id):
if user_id < 0:
raise ValueError(
"user_id must be non-negative, got " + str(user_id)
)
for u in users:
if u['id'] == user_id:
return u
return None

Never return different types for the same semantic case. The broken version forces callers to check is False, then is None, then assume dict -- a three-way branch that static analysis tools cannot verify. The fixed version uses exceptions for misuse (invalid ID) and None for expected absence (user not in list). The caller only needs one check: if result is None. This is the standard T | None pattern that type checkers understand.

def lookup_user_broken(users, user_id):
    """BROKEN: returns different types.
    - Returns False if user_id is negative
    - Returns None if user not found
    - Returns the user dict if found
    DO NOT MODIFY THIS FUNCTION.
    """
    if user_id < 0:
        return False
    for u in users:
        if u['id'] == user_id:
            return u
    return None

def lookup_user_fixed(users, user_id):
    """FIXED version: raise ValueError for invalid input,
    return None if not found, return the user dict if found.
    Return type is consistently dict or None.
    """
    # TODO: implement
    pass
Expected Output
Found: Alice
Not found
Caught: user_id must be non-negative, got -1
Hints

Hint 1: Replace the False return with a raised ValueError for invalid input.

Hint 2: The function should only ever return dict or None, never bool.

#8Return with NamedTupleMedium
NamedTuplestructured-return

Define a TextStats NamedTuple with fields word_count, char_count, avg_word_length, and longest_word. Then write analyze_text that returns a TextStats instance.

Show that the result supports both attribute access and tuple unpacking.

Python
from typing import NamedTuple

class TextStats(NamedTuple):
    word_count: int
    char_count: int
    avg_word_length: float
    longest_word: str

def analyze_text(text):
    if not text.strip():
        return TextStats(0, 0, 0.0, '')
    words = text.split()
    wc = len(words)
    cc = sum(len(w) for w in words)
    avg = round(cc / wc, 2)
    longest = max(words, key=len)
    return TextStats(wc, cc, avg, longest)

stats = analyze_text("the quick brown fox jumped high")
print(stats)
print(stats.word_count)
print(stats.longest_word)
print(isinstance(stats, tuple))
Solution
from typing import NamedTuple

class TextStats(NamedTuple):
word_count: int
char_count: int
avg_word_length: float
longest_word: str

def analyze_text(text):
if not text.strip():
return TextStats(0, 0, 0.0, '')
words = text.split()
wc = len(words)
cc = sum(len(w) for w in words)
avg = round(cc / wc, 2)
longest = max(words, key=len)
return TextStats(wc, cc, avg, longest)

NamedTuple is the right choice when returning 3+ related values with semantic meaning. Unlike a plain tuple, stats.longest_word is self-documenting. Unlike a dict, the fields are typed and order-guaranteed. NamedTuples are still tuples under the hood, so isinstance(stats, tuple) returns True and you can unpack them: wc, cc, avg, lw = stats. Use NamedTuple for public APIs and plain tuples for transient, two-element returns like coordinates.

from typing import NamedTuple

# TODO: define a NamedTuple class called TextStats
# Fields: word_count (int), char_count (int),
#         avg_word_length (float), longest_word (str)

def analyze_text(text):
    """Analyze the given text and return a TextStats NamedTuple.
    Words are split on whitespace.
    avg_word_length is rounded to 2 decimal places.
    If text is empty, return TextStats(0, 0, 0.0, '').
    """
    # TODO: implement
    pass
Expected Output
TextStats(word_count=6, char_count=28, avg_word_length=4.67, longest_word='jumped')
6
jumped
True
Hints

Hint 1: Define TextStats as a class inheriting from NamedTuple with four typed fields.

Hint 2: Split on whitespace, compute each stat, and return a TextStats instance.


Hard

#9Factory Function: Validator BuilderHard
factory-functionclosurereturning-callables

Build a validation system using factory functions. make_range_validator(name, lo, hi) returns a validator function. make_pipeline(validators_dict) returns a function that validates an entire dictionary of values against their respective validators.

This tests returning callables, closures, and composing functions.

Python
def make_range_validator(name, lo, hi):
    def validate(value):
        if not (lo <= value <= hi):
            raise ValueError(
                name + ": " + str(value) +
                " is not in range [" + str(lo) + ", " + str(hi) + "]"
            )
        return value
    return validate

def make_pipeline(validators):
    def run(data):
        result = {}
        for field, validator in validators.items():
            result[field] = validator(data[field])
        return result
    return run

validate_age = make_range_validator("age", 0, 150)
print(validate_age(25))

try:
    validate_age(200)
except ValueError as e:
    print("Caught:", e)

pipeline = make_pipeline({
    'age': make_range_validator("age", 0, 150),
    'score': make_range_validator("score", 0, 100),
})

print(pipeline({'age': 25, 'score': 87}))

try:
    pipeline({'age': 25, 'score': 150})
except ValueError as e:
    print("Caught:", e)
Solution
def make_range_validator(name, lo, hi):
def validate(value):
if not (lo <= value <= hi):
raise ValueError(
name + ": " + str(value) +
" is not in range [" + str(lo) + ", " + str(hi) + "]"
)
return value
return validate

def make_pipeline(validators):
def run(data):
result = {}
for field, validator in validators.items():
result[field] = validator(data[field])
return result
return run

Factory functions return new functions that close over their creation parameters. Each call to make_range_validator produces a distinct closure with its own captured name, lo, and hi. make_pipeline composes these into a higher-order function. This is the foundation of decorators and dependency injection in Python. The key insight is that return validate returns the function object itself, not the result of calling it.

def make_range_validator(name, lo, hi):
    """Return a function that validates whether a value
    is within [lo, hi] inclusive.
    
    The returned function should:
    - Return the value if valid
    - Raise ValueError with a message including the field
      name, the value, and the valid range
    """
    # TODO: implement
    pass

def make_pipeline(*validators):
    """Return a function that runs a dict of values
    through the corresponding validators.
    
    validators is a dict mapping field names to validator functions.
    The returned function takes a dict, validates each field,
    and returns a dict of validated values.
    If any validator raises, let the exception propagate.
    """
    # TODO: implement
    pass
Expected Output
25
Caught: age: 200 is not in range [0, 150]
{'age': 25, 'score': 87}
Caught: score: 150 is not in range [0, 100]
Hints

Hint 1: make_range_validator returns an inner function that closes over name, lo, and hi.

Hint 2: make_pipeline takes a dict of validators and returns a function that applies each to the corresponding field.

#10Generator Yield vs ReturnHard
generatoryieldreturnlazy-evaluation

Implement Fibonacci two ways: one using return (builds full list), one using yield (lazy generator). Then write compare_types that demonstrates the behavioral difference.

Show that both produce the same values for n=8, but have different types and the generator is single-use.

Python
def fibonacci_return(n):
    fibs = []
    a, b = 0, 1
    for _ in range(n):
        fibs.append(a)
        a, b = b, a + b
    return fibs

def fibonacci_yield(n):
    a, b = 0, 1
    for _ in range(n):
        yield a
        a, b = b, a + b

def compare_types():
    ret = fibonacci_return(5)
    gen = fibonacci_yield(5)
    type_ret = str(type(ret))
    type_gen = str(type(gen))
    consumed = list(gen)
    exhausted = list(gen) == []
    return (type_ret, type_gen, exhausted)

print(fibonacci_return(8))
print(list(fibonacci_yield(8)))
print(compare_types())
Solution
def fibonacci_return(n):
fibs = []
a, b = 0, 1
for _ in range(n):
fibs.append(a)
a, b = b, a + b
return fibs

def fibonacci_yield(n):
a, b = 0, 1
for _ in range(n):
yield a
a, b = b, a + b

def compare_types():
ret = fibonacci_return(5)
gen = fibonacci_yield(5)
type_ret = str(type(ret))
type_gen = str(type(gen))
consumed = list(gen)
exhausted = list(gen) == []
return (type_ret, type_gen, exhausted)

return sends one value and terminates the function. yield suspends the function and produces a value, resuming on the next iteration. A function with yield becomes a generator function -- calling it returns a generator object, not the values themselves. The generator is lazy (computes values on demand) and single-use (once exhausted, iterating again produces nothing). Use return when you need all values at once; use yield when the sequence is large or infinite, or when the caller may not need all values.

def fibonacci_return(n):
    """Return a LIST of the first n Fibonacci numbers.
    Uses return -- builds the entire list in memory.
    """
    # TODO: implement
    pass

def fibonacci_yield(n):
    """YIELD the first n Fibonacci numbers one at a time.
    Uses yield -- lazy, generates on demand.
    """
    # TODO: implement
    pass

def compare_types():
    """Return a tuple of:
    - type of fibonacci_return(5) as a string
    - type of fibonacci_yield(5) as a string
    - whether fibonacci_yield(5) is exhausted after
      converting to list (bool)
    """
    # TODO: implement
    pass
Expected Output
[0, 1, 1, 2, 3, 5, 8, 13]
[0, 1, 1, 2, 3, 5, 8, 13]
("<class 'list'>", "<class 'generator'>", True)
Hints

Hint 1: fibonacci_return builds a list with append and returns it at the end.

Hint 2: fibonacci_yield uses yield inside a loop to produce values lazily. A generator is exhausted after one full iteration.

#11Result Pattern PipelineHard
Result-patternerror-handlingdataclass

Implement the Result pattern with Ok and Err dataclasses. Add a map method that chains operations: Ok.map(f) applies f and wraps the result; Err.map(f) propagates the error unchanged.

Build a pipeline: parse_int -> validate_positive -> double that gracefully handles errors at any stage.

Python
from dataclasses import dataclass

@dataclass
class Ok:
    value: object
    is_ok: bool = True

    def map(self, func):
        try:
            return Ok(func(self.value))
        except Exception as e:
            return Err(str(e))

@dataclass
class Err:
    error: str
    is_ok: bool = False

    def map(self, func):
        return self

def parse_int(text):
    try:
        return Ok(int(text))
    except ValueError:
        return Err("Cannot parse '" + text + "' as int")

def validate_positive(n):
    if n <= 0:
        raise ValueError("Value must be positive, got " + str(n))
    return n

def double(n):
    return n * 2

print(parse_int("42").map(validate_positive).map(double))
print(parse_int("abc").map(validate_positive).map(double))
print(parse_int("-5").map(validate_positive).map(double))
print(parse_int("10").map(validate_positive).map(double))
Solution
from dataclasses import dataclass

@dataclass
class Ok:
value: object
is_ok: bool = True

def map(self, func):
try:
return Ok(func(self.value))
except Exception as e:
return Err(str(e))

@dataclass
class Err:
error: str
is_ok: bool = False

def map(self, func):
return self

def parse_int(text):
try:
return Ok(int(text))
except ValueError:
return Err("Cannot parse '" + text + "' as int")

def validate_positive(n):
if n <= 0:
raise ValueError("Value must be positive, got " + str(n))
return n

def double(n):
return n * 2

The Result pattern makes failure explicit in the return type. Ok.map(f) applies the function and wraps the result; if f raises, it catches the exception and converts it to Err. Err.map(f) ignores f entirely and propagates the error. This lets you chain operations with .map().map().map() and only check for errors at the end. The pattern is borrowed from Rust's Result and Haskell's Either. It is most valuable at system boundaries -- parsing config, handling user input, making API calls -- where failures are expected and should not unwind the call stack.

from dataclasses import dataclass

@dataclass
class Ok:
    value: object
    is_ok: bool = True

    def map(self, func):
        # TODO: apply func to self.value
        # If func raises, return Err with the error message
        pass

@dataclass
class Err:
    error: str
    is_ok: bool = False

    def map(self, func):
        # TODO: propagate error unchanged
        pass

def parse_int(text):
    """Parse text to int. Return Ok or Err."""
    # TODO: implement
    pass

def validate_positive(n):
    """Return n if positive, raise ValueError otherwise."""
    # TODO: implement
    pass

def double(n):
    """Return n * 2."""
    # TODO: implement
    pass
Expected Output
Ok(value=84, is_ok=True)
Err(error="Cannot parse 'abc' as int", is_ok=True)
Err(error='Value must be positive, got -5', is_ok=True)
Ok(value=20, is_ok=True)
Hints

Hint 1: Ok.map should try/except: on success return Ok(func(value)), on exception return Err(str(e)).

Hint 2: Err.map should return self unchanged -- errors short-circuit the chain.

Hint 3: Chain with: parse_int(text).map(validate_positive).map(double)

© 2026 EngineersOfAI. All rights reserved.