RizTech Academy logo
RizTech Academy
FunctionsLesson 3 of 715 min

Return values, and functions that return nothing

return looks like the simplest part of a function. It has three behaviours worth knowing properly, and one of them explains a bug you met back in module 2.

Every function returns something

Even when you do not say so:

def greet(name):
    print(f"Hello, {name}!")

result = greet("Priya")
print(result)
Hello, Priya!
None

A function with no return returns None. So does a bare return with nothing after it. There is no such thing as a function that returns nothing at all — only one that returns None.

This is why numbers.sort() gave you None in module 2: it sorts in place and has nothing to hand back. The rule from the list-methods lesson — if a method changes the thing, it gives you nothing back — is this behaviour, followed consistently.

When you see AttributeError: 'NoneType' object has no attribute ..., the cause is almost always that you used the result of a function that returned None.

return ends the function

Immediately, wherever it is:

def check(age):
    if age < 0:
        return "Invalid"
    print("This only runs for non-negative ages")
    return "Valid"

Nothing after a return on the same path ever runs. Useful deliberately:

def find_user(users, email):
    for user in users:
        if user["email"] == email:
            return user
    return None

The return inside the loop exits the whole function the moment it finds a match — no break, no flag, no second variable. The final return None handles "not found", and is worth writing explicitly even though it is what would happen anyway. A reader should not have to know the rule to understand the function.

Early returns keep code flat

Compare:

def process(order):
    if order is not None:
        if order["items"]:
            if order["paid"]:
                return "ready to ship"
            else:
                return "awaiting payment"
        else:
            return "empty order"
    else:
        return "no order"

with:

def process(order):
    if order is None:
        return "no order"
    if not order["items"]:
        return "empty order"
    if not order["paid"]:
        return "awaiting payment"
    return "ready to ship"

Identical behaviour. The second is flat, each case is one line, and the happy path sits at the bottom with nothing left to check. Adding a fifth condition is one more line rather than one more level of indentation.

This is the guard clause pattern, and it is one of the highest-value habits in this course.

Returning several values

Separate them with commas:

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

lowest, highest, average = get_stats([3, 7, 2, 9])
print(lowest, highest, average)
2 9 5.25

What actually happens: the values are packed into a tuple and unpacked on the other side. You can see it:

print(get_stats([3, 7, 2, 9]))
(2, 9, 5.25)

This is the tuple lesson made useful, and it is why divmod(17, 5) gave you (3, 2).

Ignore parts you do not need with _:

_, highest, _ = get_stats(numbers)

Past three values, return a dictionary instead. Unpacking four or five things in the right order is exactly the positional-argument problem again:

def get_stats(numbers):
    return {
        "min": min(numbers),
        "max": max(numbers),
        "average": sum(numbers) / len(numbers),
        "count": len(numbers),
    }

stats = get_stats([3, 7, 2, 9])
print(stats["average"])

Callers name what they want and cannot get the order wrong.

Returning different types

Legal, and usually a mistake:

def divide(a, b):
    if b == 0:
        return "Cannot divide by zero"
    return a / b

Sometimes a number, sometimes a string. Every caller must now check which it got, and one that forgets will try arithmetic on an error message.

Better options, in increasing order of how much you will like them later:

def divide(a, b):
    if b == 0:
        return None          # caller checks for None
    return a / b
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Raising is usually right, and it is module 6. For now: keep a function's return type consistent. Returning None for "no result" is fine, because is None is an easy and obvious check. Returning an error message pretending to be data is not.

Returning nothing on purpose

Plenty of good functions return None, because their job is an effect rather than an answer:

def save_to_file(data, filename):
    ...

That is fine. What is not fine is a function that does a calculation and only prints it — you have thrown the answer away and nobody else can use it.

A useful split: functions that work things out should return; functions that do things may not. Keeping those separate makes the calculating ones testable, which module 10 depends on.

Practice

  1. Write a function with no return, assign its result, and print it.
  2. Write check(age) with a return followed by a print on the same path. Confirm the print never runs.
  3. Write find_user(users, email) over a list of dictionaries, returning the user or None. Test both outcomes.
  4. Rewrite the deeply nested process(order) above using guard clauses without looking at the version given.
  5. Write get_stats(numbers) returning min, max and average as a tuple. Unpack it. Then print the raw return value and confirm it is a tuple.
  6. Rewrite get_stats to return a dictionary with four values. Which version would you rather call?
  7. Write divide(a, b) returning a string on error, then write a caller that breaks because of it. Fix it by returning None instead.
  8. Find a place in your guessing game from module 3 where a calculation is only printed. Change it to return, and print at the call site.

Next: scope — why a variable you changed inside a function is unchanged outside it.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship