Skip to content

My function printed the total, but gave me None

  • Python · Foundations
  • 15 min read, about 1 h with practice
  • Concept
  • Asked often: medium
  • Reviewed 2026-11

Your shop script adds up a basket and takes off a discount. You need it in three places, so you copy the five lines three times. Then the discount rule changes. You fix two copies and miss the third.

So you put the lines in a function, basket_total. It prints the total, and it looks right:

>>> basket_total([10.00, 10.00])
Basket: 20.00

Now you need the total with tax: basket_total([10.00, 10.00]) * 1.2. Python stops:

TypeError: unsupported operand type(s) for *: 'NoneType' and 'float'

The function showed 20.00. Why did it give back None?

  • Input: a list of prices, and a discount in percent. The discount is 0 when left out.
  • Output: the total after the discount, as a number, rounded to 2 places. The caller decides whether to print it, add tax, or compare it.
  • The hard part: showing a value on the screen isn’t the same as handing it back.

Example: basket_total([2.50, 1.50], discount=10) gives 3.6, and basket_total([10, 10]) * 1.2 gives 24.0.

Hints open one at a time:

Hint 1 of 3

A function hands back whatever follows return. With no return, it hands back None.

Hint 2 of 3

The screen is for people. return is for code.

Hint 3 of 3

Do the sum in the function and return it. Print it outside, where you call the function.

def makes a function. It names some steps, so you can run them again with one line.

def basket_total(prices, discount=0): # two parameters; discount has a default
...
return total # hands total back to the caller
  • Parameters are the names in brackets. Each call gives them values, called arguments.
  • A default like discount=0 is used when the caller leaves that argument out.
  • Call by position or by name: basket_total([10], 50) and basket_total([10], discount=50) do the same.
  • return ends the function and hands one value back. A function that ends without return hands back None.

Here are two functions side by side: one returns, one prints. Each call gets its own box. The player stops to ask what happens next. Then type your own lines under it.

return hands a value back; print only shows it

Built-inPython’s own names

  • print
  • len
  • max

Globalrunning now

no names yet

Step 1 of 11. Before the first line runs, the global box is empty. The built-in box holds names like print and len.

def total(a, b):
    return a + b
def show(a, b):
    print(a + b)
x = total(2, 3)
y = show(2, 3)
print(x, y)

Try your own input

Indent with spaces. It knows def, return, global, nonlocal, pass, =, +=, -=, calls, whole numbers, text, True, False, None, lists with [i], .append() and .clear(), and the built-ins print, len, max, min, abs, sum and str.

x gets 5 from return. y gets None, though show put 5 on the screen.

All three add the prices once, so all take O(n) time for n prices. What changes is how many places hold the rule, and whether the answer comes back.

Okay

Copy the lines

The idea: Paste the same loop and sum wherever you need a total.

On the two baskets: each copy adds its prices, takes off its discount, and prints. Both totals are right.

# The same lines, copied to each place that needs a basket total.
prices = [2.50, 1.50]
total = 0
for price in prices:
total += price
total = total * (1 - 10 / 100)
print(f"Basket: {total:.2f}")
prices = [10.00, 10.00]
total = 0
for price in prices:
total += price
total = total * (1 - 0 / 100)
print(f"Basket: {total:.2f}")
Time
O(n)
Space
O(1)
Why
One pass over the prices, in each copy.

Where it breaks: every copy holds the rule. Change the rule, and you must find and fix every copy. Miss one, and two parts of the shop give different totals.

Better

A function that prints

The idea: Put the lines in def basket_total(prices, discount=0) and print the total at the end.

The waste in Okay: the same five lines live in many places.

Now the rule lives in one place, and each call is one line.

Highlighted lines are new in this rung.

def basket_total(prices: list[float], discount: float = 0) -> None:
total = 0
for price in prices:
total += price
total = total * (1 - discount / 100)
print(f"Basket: {total:.2f}")
Time
O(n)
Space
O(1)
Why
One pass over the prices.
What changed from the previous rung
# The same lines, copied to each place that needs a basket total.
prices = [2.50, 1.50]
total = 0
for price in prices:
total += price
total = total * (1 - 10 / 100)
print(f"Basket: {total:.2f}")
prices = [10.00, 10.00]
total = 0
for price in prices:
total += price
total = total * (1 - 0 / 100)
print(f"Basket: {total:.2f}")
def basket_total(prices: list[float], discount: float = 0) -> None:
total = 0
for price in prices:
total += price
total = total * (1 - discount / 100)
print(f"Basket: {total:.2f}")

Where it breaks: the function ends without return, so it hands back None. You can see the total, but your code can’t use it: basket_total(...) * 1.2 is a TypeError.

Best

A function that returns

The idea: Return the rounded total, and let the caller print it.

The waste in Better: it decides for every caller that the total should be printed.

main() shows two callers: one prints the total, one adds tax first. round(…, 2) keeps money to two places.

Highlighted lines are new in this rung.

def basket_total(prices: list[float], discount: float = 0) -> float:
total = 0
for price in prices:
total += price
return round(total * (1 - discount / 100), 2)
def main() -> None:
print(f"Basket: {basket_total([2.50, 1.50], discount=10):.2f}")
with_tax = basket_total([10.00, 10.00]) * 1.2
print(f"With tax: {with_tax:.2f}")
Time
O(n)
Space
O(1)
Why
One pass over the prices.
What changed from the previous rung
def basket_total(prices: list[float], discount: float = 0) -> None:
def basket_total(prices: list[float], discount: float = 0) -> float:
total = 0
for price in prices:
total += price
total = total * (1 - discount / 100)
print(f"Basket: {total:.2f}")
return round(total * (1 - discount / 100), 2)
def main() -> None:
print(f"Basket: {basket_total([2.50, 1.50], discount=10):.2f}")
with_tax = basket_total([10.00, 10.00]) * 1.2
print(f"With tax: {with_tax:.2f}")

When Best isn’t best: Python already has sum(prices). In real code, write round(sum(prices) * (1 - discount / 100), 2). The loop here shows what sum() does.

Now write basket_total in the page. Press Run tests to check your code. Python runs in your browser, so nothing is sent anywhere.

Try Better from the ladder first. It prints every total, and still fails every test.

The basket total

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: basket_total([2.5, 1.5]) → 4
  2. Not run yet: basket_total([2.5, 1.5], discount=10) → 3.6
  3. Not run yet: basket_total([10, 10], 10) → 18
  4. Not run yet: Extra test
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayWhere the rule livesCan code use the total?Use it when
Okay: copiesIn every copyYes, in that copyNever more than once
Better: printsIn one functionNo: it gets NoneNever for a value someone needs
Best: returnsIn one functionYesAlmost always
  • Code after return never runs: return ends the function at once.
  • Calling without brackets: basket_total is the function itself; basket_total([1]) calls it.
  • Changing a name inside the function doesn’t change a name of the same name outside. The scope lesson explains why.
  • Too many arguments, or too few: Python raises TypeError: basket_total() missing 1 required positional argument: 'prices'.
  • Defaults must be last: def f(a=1, b) is a SyntaxError.

Questions to ask first

  • Should the function show the result, or return it for other code?
  • What should an empty input give?

A 60-second walkthrough

“I put the logic in one function so the rule lives in one place. It takes the prices and an optional discount, defaulting to 0. It returns the rounded total rather than printing it, so callers can print it, add tax or test it. A function without return gives back None, which is why the printing version failed when we multiplied its result.”

Likely follow-ups

  • What does a function return if there’s no return? None.
  • Can a function return more than one value? It returns one object, but that can be a tuple: return low, high, then low, high = f().
  • Parameter or argument? The parameter is the name in the def; the argument is the value passed in a call.

Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

Write average(nums). Return the average of a list of numbers. With no numbers there is no average, so return None.

Hint 1 of 3

The average is sum(nums) / len(nums).

Hint 2 of 3

What does dividing by len([]) do?

Hint 3 of 3

Is 0 a good answer for “no numbers”? What is the average of [-1, 1]?

The average

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: average([2, 4]) → 3
  2. Not run yet: average([5]) → 5
  3. Not run yet: average([]) → None
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Sum over length

The idea: Return sum(nums) / len(nums).

[2, 4] gives 3.0.

def average(nums: list[float]) -> float:
return sum(nums) / len(nums)
Time
O(n)
Space
O(1)
Why
sum() looks at each number once.

Where it breaks: an empty list divides by zero, and Python raises ZeroDivisionError.

Better

0 for an empty list

The idea: Return 0 when the list is empty.

The waste in Okay: it crashes on a list that is easy to get, like a day with no sales.

Highlighted lines are new in this rung.

def average(nums: list[float]) -> float:
if len(nums) == 0:
return 0
return sum(nums) / len(nums)
Time
O(n)
Space
O(1)
Why
One check, then sum().
What changed from the previous rung
def average(nums: list[float]) -> float:
if len(nums) == 0:
return 0
return sum(nums) / len(nums)

Where it breaks: 0 is a real average: [-1, 1] gives 0 too. The caller can’t tell “no numbers” from “numbers that average 0”.

Best

None for no average

The idea: Return None when there are no numbers.

The waste in Better: it uses a real number to mean “nothing”.

not nums is true for an empty list. The type hint float | None warns callers to check.

Highlighted lines are new in this rung.

def average(nums: list[float]) -> float | None:
if not nums:
return None # no numbers: there is no average
return sum(nums) / len(nums)
Time
O(n)
Space
O(1)
Why
One check, then sum().
What changed from the previous rung
def average(nums: list[float]) -> float:
if len(nums) == 0:
return 0
def average(nums: list[float]) -> float | None:
if not nums:
return None # no numbers: there is no average
return sum(nums) / len(nums)

When Best isn’t best: Python’s statistics.mean() raises StatisticsError on an empty list. Raising is better when an empty list means a bug, not a normal day.

Write count_vowels(text). Count the letters a, e, i, o and u, small or capital.

Example: count_vowels("Apple") gives 2.

Hint 1 of 3

Loop over the text: for ch in text gives one character at a time.

Hint 2 of 3

ch in "aeiou" is true when ch is one of those letters.

Hint 3 of 3

A small helper, is_vowel(ch), can lower the case first.

Count the vowels

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: count_vowels('banana') → 3
  2. Not run yet: count_vowels('Apple') → 2
  3. Not run yet: Extra test
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Five tests joined with or

The idea: Compare each character with each vowel.

"banana" gives 3.

def count_vowels(text: str) -> int:
count = 0
for ch in text:
if ch == "a" or ch == "e" or ch == "i" or ch == "o" or ch == "u":
count += 1
return count
Time
O(n)
Space
O(1)
Why
One look at each character.

Where it breaks: "A" matches none of the tests, so "Apple" gives 1. And the long line is easy to get wrong.

Better

ch in “aeiou”

The idea: One test: is the character in the text “aeiou”?

The waste in Okay: five comparisons where one in does.

Highlighted lines are new in this rung.

def count_vowels(text: str) -> int:
count = 0
for ch in text:
if ch in "aeiou":
count += 1
return count
Time
O(n)
Space
O(1)
Why
One look at each character; “aeiou” is always five letters.
What changed from the previous rung
def count_vowels(text: str) -> int:
count = 0
for ch in text:
if ch == "a" or ch == "e" or ch == "i" or ch == "o" or ch == "u":
if ch in "aeiou":
count += 1
return count

Where it breaks: capitals still aren’t counted.

Best

A helper that lowers the case

The idea: is_vowel(ch) checks ch.lower() in “aeiou”, and count_vowels calls it.

The waste in Better: it treats "A" and "a" as different letters.

The helper has a name that says what it checks, and can be tested on its own.

Highlighted lines are new in this rung.

def is_vowel(ch: str) -> bool:
return ch.lower() in "aeiou"
def count_vowels(text: str) -> int:
count = 0
for ch in text:
if is_vowel(ch):
count += 1
return count
Time
O(n)
Space
O(1)
Why
One look at each character.
What changed from the previous rung
def is_vowel(ch: str) -> bool:
return ch.lower() in "aeiou"
def count_vowels(text: str) -> int:
count = 0
for ch in text:
if ch in "aeiou":
if is_vowel(ch):
count += 1
return count

When Best isn’t best: for letters like é or ö, decide what counts as a vowel first. And sum(1 for ch in text if is_vowel(ch)) does the counting in one line, once you know the loop.

3. What’s wrong with the password (hard)

Section titled “3. What’s wrong with the password (hard)”

Write problems(password). Return a list of everything the password is missing, in this order: "at least 8 characters", "a digit", "a capital letter". A good password gives [].

Example: problems("abc") gives all three.

Hint 1 of 3

Returning at the first problem tells the user one thing at a time. Collect them all in a list.

Hint 2 of 3

ch.isdigit() and ch.isupper() test one character.

Hint 3 of 3

Small helpers like has_digit(password) can return True as soon as they find one.

What’s wrong with the password

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: problems('Abcdefg1') → []
  2. Not run yet: problems('abcdefgh') → ['a digit', 'a capital letter']
  3. Not run yet: problems('abc') → ['at least 8 characters', 'a digit', 'a capital letter']
  4. Not run yet: Extra test
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Return at the first problem

The idea: Check each rule in turn and return as soon as one fails.

A good password gives [], and each rule is checked.

def problems(password: str) -> list[str]:
if len(password) < 8:
return ["at least 8 characters"]
for ch in password:
if ch.isdigit():
break
else:
return ["a digit"]
for ch in password:
if ch.isupper():
break
else:
return ["a capital letter"]
return []
Time
O(n)
Space
O(1)
Why
At most two passes over the n characters.

Where it breaks: the user learns one problem, fixes it, and is told the next. "abc" gives only ["at least 8 characters"].

Better

Collect every problem

The idea: Append each failed rule to a list, using a flag for each character test.

The waste in Okay: it stops at the first problem when the user needs all of them.

This passes every test. But the two loops are almost the same, and each keeps going after it finds what it was looking for.

Highlighted lines are new in this rung.

def problems(password: str) -> list[str]:
found = []
if len(password) < 8:
found.append("at least 8 characters")
has_digit = False
for ch in password:
if ch.isdigit():
has_digit = True
if not has_digit:
found.append("a digit")
has_capital = False
for ch in password:
if ch.isupper():
has_capital = True
if not has_capital:
found.append("a capital letter")
return found
Time
O(n)
Space
O(1)
Why
Two full passes over the characters.
What changed from the previous rung
def problems(password: str) -> list[str]:
found = []
if len(password) < 8:
return ["at least 8 characters"]
found.append("at least 8 characters")
has_digit = False
for ch in password:
if ch.isdigit():
break
else:
return ["a digit"]
has_digit = True
if not has_digit:
found.append("a digit")
has_capital = False
for ch in password:
if ch.isupper():
break
else:
return ["a capital letter"]
return []
has_capital = True
if not has_capital:
found.append("a capital letter")
return found
Best

Small helpers

The idea: has_digit() and has_capital() each return True at the first match; problems() reads like the rules.

The waste in Better: repeated loops with flags, inside one long function.

Each helper does one job and has a name that says what it checks. problems() now reads like the list of rules.

Highlighted lines are new in this rung.

def has_digit(password: str) -> bool:
for ch in password: # noqa: SIM110 (the loop any() would run)
if ch.isdigit():
return True
return False
def has_capital(password: str) -> bool:
for ch in password: # noqa: SIM110 (the loop any() would run)
if ch.isupper():
return True
return False
def problems(password: str) -> list[str]:
found = []
if len(password) < 8:
found.append("at least 8 characters")
if not has_digit(password):
found.append("a digit")
if not has_capital(password):
found.append("a capital letter")
return found
Time
O(n)
Space
O(1)
Why
Each helper stops at its first match.
What changed from the previous rung
def has_digit(password: str) -> bool:
for ch in password: # noqa: SIM110 (the loop any() would run)
if ch.isdigit():
return True
return False
def has_capital(password: str) -> bool:
for ch in password: # noqa: SIM110 (the loop any() would run)
if ch.isupper():
return True
return False
def problems(password: str) -> list[str]:
found = []
if len(password) < 8:
found.append("at least 8 characters")
has_digit = False
for ch in password:
if ch.isdigit():
has_digit = True
if not has_digit:
if not has_digit(password):
found.append("a digit")
has_capital = False
for ch in password:
if ch.isupper():
has_capital = True
if not has_capital:
if not has_capital(password):
found.append("a capital letter")
return found

When Best isn’t best: Python’s any() writes each helper in one line: any(ch.isdigit() for ch in password). And real sites check length and known leaked passwords, not just character types.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Predict the outputWhat does this print?
    def double(n):
        print(n * 2)
    
    x = double(4)
    print(x)
  2. Predict the outputWhat does this print?
    def price(amount, tax=10):
        return amount + amount * tax / 100
    
    print(price(50, tax=20))
  3. Spot the bugsign(0) gives None. Why?
    def sign(n):
        if n > 0:
            return "plus"
        elif n < 0:
            return "minus"
  4. Fill in the answerA function that ends without a return statement hands back which value?

Add these to your review deck. Each card comes back a little later every time you get it right.

  • def name(param, other=default): makes a function; defaults come last.
  • return value ends the function and hands value back. No return means None.
  • Print for people, return for code. Do the work in the function and print outside it.
  • Call by position f(1, 2) or by name f(a=1, b=2).
  • Small functions with clear names are easier to read and to test.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started