DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
Getting Started
01
What is Python and Why Learn It in 2026
02
Installing Python on Windows, Mac, and Linux
03
Setting up VS Code / PyCharm for Python
04
Understanding the Python Interpreter and REPL
05
Writing and Running Your First Python Program
06
Python Syntax and Indentation Rules
Variables and Data Types
01
Variables and Naming Conventions in Python
02
Numbers in Python (int, float, complex)
03
Strings and String Methods in Python
04
Booleans in Python
05
Type Conversion / Casting in Python
06
Understanding the None Type in Python
Operators
01
Arithmetic Operators in Python
02
Comparison Operators in Python
03
Logical Operators in Python
04
Assignment Operators in Python
05
Bitwise Operators in Python
06
Identity (is) and Membership (in) Operators
07
Walrus Operator (:=) in Python
Input, Output & Strings
01
input() and print() Functions in Python
02
f-strings and String Formatting in Python
03
String Slicing and Indexing in Python
04
Common String Methods (split, join, strip, replace)
05
Escape Characters in Python Strings
Control Flow
01
if, elif, else Statements in Python
02
Nested Conditionals in Python
03
for Loops in Python
04
while Loops in Python
05
break, continue, and pass Statements
06
Loop with else Clause in Python
07
match-case (Structural Pattern Matching)
Data Structures
01
Python Lists: Creation, Indexing, Slicing, Methods
02
Python Tuples and Immutability
03
Python Sets and Set Operations
04
Python Dictionaries: Keys, Values, and Methods
05
List Comprehensions in Python
06
Dictionary & Set Comprehensions in Python
07
Working with Nested Lists and Dictionaries
Functions
01
Defining and Calling Functions in Python
02
Function Arguments: Positional, Keyword, Default
03
*args and **kwargs in Python
04
Return Statements in Python Functions
05
Lambda Functions in Python
06
Recursion in Python
07
Variable Scope: Local, Global, Nonlocal
08
Docstrings and Function Annotations
Functional Programming Basics
01
map(), filter(), and reduce() in Python
02
Closures in Python Explained
03
Python Decorators: Basic to Advanced
04
Generators and yield in Python
Object-Oriented Programming (OOP)
01
Classes and Objects in Python
02
The init Constructor in Python
03
Instance vs Class Variables in Python
04
Inheritance in Python (Single, Multiple, Multilevel)
05
Polymorphism in Python
06
Encapsulation in Python
07
Abstraction in Python with the abc Module
08
Magic/Dunder Methods in Python
09
Static and Class Methods in Python
10
The @property Decorator in Python
11
Python Dataclasses Explained
Error Handling
01
try, except, finally in Python
02
Handling Multiple Exceptions in Python
03
Raising Custom Exceptions in Python
04
Understanding Python's Exception Hierarchy
05
Assertions in Python
File Handling
01
Reading and Writing Text Files in Python
02
Working with the with Statement in Python
03
Working with CSV Files in Python
04
Working with JSON Files in Python
05
Working with Directories (os, pathlib) in Python
Modules and Packages
01
Importing Built-in Modules in Python
02
Creating Your Own Modules in Python
03
Understanding Packages and init.py
04
Python Standard Library Overview
05
Installing Packages with pip
Advanced Core Concepts
01
Iterators and Iterables in Python
02
Context Managers in Python (with, contextlib)
03
Multithreading Basics in Python
04
Multiprocessing Basics in Python
05
Asyncio Basics: async/await in Python
06
Memory Management and Garbage Collection in Python
07
Type Hints in Python (typing module)

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.

PREVIOUSNEXT LESSON