Skip to content

Function contracts

  • Lesson 7
  • 40 min
  • Module 3: Functions and program structure
Lesson 7 of 18 · Python for Practical AI EngineeringFunction contracts

What you will be able to do

Split count_tickets.py into small functions, each with one job and a clear contract: what goes in, what comes out, and what it does not change. Then you can check each part alone, and use it again in other programs.

NotebookDo this lesson in a notebookColab · Kaggle · GitHub

The notebook has every example, task and check of this lesson, with the instructions, and the same count_tickets.py code. It needs no install and no local project: only Python. Read the explanations here, and run the code there.

Colab and Kaggle open your own copy. They need a free Google or Kaggle account. Stuck? See the solution notebook.

Before you startWhat to know and to have readyAbout 40 minutes

What you need to know

What you need on your computer (only for the local route; the notebook and the boxes on this page need none of it)

Tested with Python 3.14 (terminal) and Pyodide 314.0 (browser). The notebook was tested in Jupyter.

At the end of Module 2, count_tickets.py does everything from top to bottom: filter, count, average and print. It works, but three things are hard:

  • To check one part, you must run all of it, and read the printed output.
  • To use one part again, for example the count in a different report, you must copy the lines.
  • To read it, you must follow every variable from the top, because any line can change any variable.

A function is a named block of code that you can run many times. It takes input values, does one job, and gives back a result. You already use functions that Python provides: len(), print() and round(). Now you write your own.

def count_by_category(tickets):
"""Return the number of tickets in each category."""
counts = {}
for ticket in tickets:
category = ticket["category"]
counts[category] = counts.get(category, 0) + 1
return counts
Part Meaning
def Starts a function definition. Python remembers the function. It does not run the body yet.
count_by_category The name. Use a verb that says what the function does.
(tickets) The parameter: a name for the value that the caller gives.
"""Return ...""" The docstring: one sentence that says what the function gives back.
The indented body The work. It runs each time that someone calls the function.
return counts Ends the function, and gives the value back to the caller.

To call the function, write its name with a value in brackets. The value is the argument. Python gives the parameter tickets the argument’s value, runs the body, and replaces the call with the returned value:

category_counts = count_by_category(tickets)

A function’s contract is the promise that it makes to the code that calls it. Write it in plain words before you write the body:

Question count_by_category
What goes in? A list of ticket dictionaries. Each one has a "category" key.
What comes out? A dictionary: category → number of tickets. Empty input gives {}.
What does it not do? It does not change the list, and it does not print.

The last row matters most. A function that only reads its input and returns a result is a pure function. For the same input, it always gives the same output, and it has no other effect. Pure functions are the easiest to check: give an input, compare the output. Keep the parts that have effects, such as print or writing a file, in a few places at the edge of the program.

Beginners often write a function that prints its result. That function can show a result, but no other code can use it:

def show_count(tickets):
print(len(tickets))
result = show_count(tickets) # prints 6
print(result) # prints None

A function without a return statement gives back None. So result is None, and you cannot calculate with it. Return the value, and let the caller decide what to do with it: print it, save it, or check it.

The variables in a function body, such as counts and category, are local: they exist only while the function runs. After the call, they are gone. Outside the function, print(counts) stops with NameError: name 'counts' is not defined.

This is a protection, not a limitation. A function cannot change a variable of its caller by accident, and two functions can both use the name counts without affecting each other. The only way to get a value out is return.

Run Python in your browser

Call a function, and use the result

The same function runs three times with different arguments. Add a line at the end: print(counts). Predict what happens, then run it.

A parameter can have a default value. The caller can leave out that argument, and the function uses the default:

def filter_by_status(tickets, status="open"):
"""Return the tickets that have the given status."""
selected = []
for ticket in tickets:
if ticket["status"] == status:
selected.append(ticket)
return selected
filter_by_status(tickets) # the open tickets
filter_by_status(tickets, "closed") # the closed tickets
filter_by_status(tickets, status="closed")

The last call names the argument: status="closed". Named arguments make a call easier to read, especially when a function has more than two parameters. Parameters with a default come after the parameters without one.

Worked example: count_tickets.py as functions

Section titled “Worked example: count_tickets.py as functions”

Here is the Module 2 script, rewritten as functions. Each function has one job. main() calls them in order, and it is the only function that prints.

count_tickets.py
TICKETS = [
{"id": "T-1001", "status": "open", "category": "billing", "priority": 2},
{"id": "T-1002", "status": "closed", "category": "login", "priority": 3},
{"id": "T-1003", "status": "open", "category": "login", "priority": 1},
{"id": "T-1004", "status": "pending", "category": "billing", "priority": 2},
{"id": "T-1005", "status": "open", "category": "billing", "priority": 3},
{"id": "T-1006", "status": "closed", "category": "shipping", "priority": 1},
]
def filter_by_status(tickets, status="open"):
"""Return the tickets that have the given status."""
selected = []
for ticket in tickets:
if ticket["status"] == status:
selected.append(ticket)
return selected
def count_by_category(tickets):
"""Return the number of tickets in each category, sorted by category."""
counts = {}
for ticket in tickets:
category = ticket["category"]
counts[category] = counts.get(category, 0) + 1
return dict(sorted(counts.items()))
def average_priority(tickets):
"""Return the average priority, or None when there are no tickets."""
if not tickets:
return None
total = 0
for ticket in tickets:
total = total + ticket["priority"]
return round(total / len(tickets), 1)
def main():
status = "open"
selected = filter_by_status(TICKETS, status)
print(f"Tickets with status '{status}': {len(selected)}")
if not selected:
print("No tickets to count.")
return
for category, count in count_by_category(selected).items():
print(f" {category}: {count}")
print(f"Average priority: {average_priority(selected)}")
main()

Three details:

  • TICKETS is in capital letters. That is the Python way to say “a constant: this value does not change while the program runs”.
  • average_priority returns None for empty input, instead of stopping with ZeroDivisionError. The docstring says so, so the caller knows to check.
  • return with no value in main() ends the function early. Here it is an early exit for empty input, like break in a loop.

The last line, main(), calls main. Without it, the script defines four functions and then ends without doing anything.

Refactor means “change the structure of code without changing what it does”. The output must be the same before and after.

  1. Run your current count_tickets.py. Copy its output into a comment at the end of the file, or into a text file. This is the output to compare with.

  2. Replace the file’s contents with the worked example. Keep your own ticket data if you changed it.

  3. Run it: python count_tickets.py.

    Output
    Tickets with status 'open': 3
    billing: 2
    login: 1
    Average priority: 2.0
  4. Compare it with the output from step 1. It must be the same. If it is different, find which function gives a different value. Print its result in main().

  5. Change status in main() to "resolved", and run it again. The output is the count 0 and No tickets to count.

Each function is pure, so a few lines can check it. Call it with a small input that you can calculate in your head, and compare the result. assert stops the program with an error if a condition is False, and does nothing if it is True.

Run Python in your browser

Check the functions

Each assert is one small fact about the contract. Add the missing check at the end. Then break the function on purpose: change return None to return 0, and run it again. Which line fails?

In Module 5, you move checks like these into test files, and a tool called pytest runs them for you.

Add a function urgent_ids(tickets) to count_tickets.py. Its contract:

Question Answer
What goes in? A list of ticket dictionaries.
What comes out? A list of the IDs of the tickets with priority 1, in the same order. Empty input gives [].
What does it not do? It does not change the list, and it does not print.

Call it in main() for the selected tickets, and print the result. With the six tickets and status "open", the new line is Urgent: ['T-1003'].

Show one answer
def urgent_ids(tickets):
"""Return the IDs of the tickets with priority 1, in order."""
return [ticket["id"] for ticket in tickets if ticket["priority"] == 1]

In main(), after the average: print(f"Urgent: {urgent_ids(selected)}").

Common mistake

A default list that remembers

Symptom: a function gives a different result each time that you call it with the same arguments:

def add_problem(problem, problems=[]):
problems.append(problem)
return problems
print(add_problem("missing id")) # ['missing id']
print(add_problem("unknown status")) # ['missing id', 'unknown status']

The second call should return only ['unknown status'].

Cause: Python makes the default value once, when it runs the def line, not on each call. A list can change, so every call without the argument gets the same list, and each append adds to it. This is the shared-reference problem from Collections, in a less visible place.

Fix: use None as the default, and make a new list in the body:

def add_problem(problem, problems=None):
if problems is None:
problems = []
problems.append(problem)
return problems

Use only values that cannot change as defaults: numbers, strings, True, False and None. Never a list, a dictionary or a set.

Check your understanding

A function ends with print(total) and has no return. What does result = the_function(...) give, and why is that a problem?

Show the answer

result is None, because a function without return returns None. The total is shown on the screen, but the program cannot use it: it cannot compare it, save it, or check it with assert. Return the value, and print it where you call the function.

Check your understanding

Module check, part 1: explain the contract of filter_by_status in plain words, then prove two parts of it with assert.

Show the answer

In: a list of ticket dictionaries, and a status (default "open"). Out: a new list with only the tickets that have that status, in the same order. Empty input gives an empty list. It does not change the input list and does not print. Two checks: assert filter_by_status([]) == [] and, for a list with one open and one closed ticket, assert len(filter_by_status(two, "closed")) == 1.

Recap

  • A function has parameters, a body and a return. Calling it gives the parameters the argument values, and the call becomes the returned value.
  • Write the contract first: what goes in, what comes out, and what it does not change.
  • A pure function only reads its input and returns a result. It is the easiest kind to check with assert.
  • Variables in a function are local. Values leave a function only through return.
  • Never use a list, dictionary or set as a default value. Use None, then make a new one in the body.

Next, you move the functions out of the script into a module, so other programs can import them.

Next: Modules and imports