Embercode
✕
ConditionalsLesson 11 of 14

35 min read

Decision tables in Python

Why this matters

A program often picks an answer from several facts. A cinema ticket price depends on the age of the buyer, whether the buyer has a student card, and whether the day is a weekend. Each fact is a condition, which is a yes or no question the program can ask. The answer the program gives is the outcome.

If the rules are written as nested if statements, they become hard to read. Some combinations get handled in one branch and others in a different branch, and it is easy to miss one combination. The mistake then shows up only for some customers, and nobody notices until a complaint arrives.

A decision table puts the rules in a grid. Each column is a condition, the last column is the outcome, and each row is one rule. The grid can be read in one pass, and each row maps to one branch of code. Checking the code against the table is then a matter of checking one row at a time.

This lesson uses the if statement, the bool type and return. Python writes the two truth values as True and False, and it uses the words and, or and not where other languages use symbols.

What a decision table looks like

Here is a small table for the price of a swimming pool entry. The first column asks whether the swimmer is under 12. The second column asks whether the swimmer has a student card. The third column is the price in dollars.

Under 12Student cardPrice (dollars)
yesany3
noyes5
nono8

Each row is read from left to right. The first row says that a swimmer under 12 pays 3, and it does not matter whether the swimmer has a card. The word any in that cell means the card column does not change the outcome for this row.

The second row says that a swimmer who is not under 12 and has a student card pays 5. The third row says that a swimmer who is not under 12 and has no card pays 8.

Two words will be used a lot. A row is one line of the table, and a cell is one box in it. A table with three conditions and two answers for each condition has 2 times 2 times 2, which is 8 combinations of answers. Each combination must land in exactly one row, or the program has a gap or a conflict.

Worked example 1: one branch for each row

Turn the pool table into code. A def line starts a function, which is a named block of code. The function takes the inputs and gives back a value with return. Each row of the table becomes one if with a return. The conditions come straight from the table. A cell that says yes becomes the condition itself, a cell that says no becomes the condition with not in front, and a cell that says any is left out.

Python · editable

The output is:

age 9, no card: 3
age 9, card: 3
age 30, card: 5
age 30, no card: 8

The first if is the first row. The second if is the second row, and the age < 12 part of that row is not written again. It can be left out because the first if has already returned for every swimmer under 12. The last return covers the third row, for the same reason.

This short form works, but it depends on the order of the branches. A reader has to remember that the first if was checked before the second one. The next example writes every condition in full, which makes each row readable on its own.

Worked example 2: three conditions and eight combinations

Now a table with three conditions. The price of a cinema ticket depends on whether the buyer is a child under 12, whether the buyer has a student card, and whether the day is a weekend.

ChildStudent cardWeekendPrice (dollars)
yesanyany5
noyesno6
noyesyes8
nonono9
nonoyes12

The first row covers every child, because both the card and the weekend are any. The other four rows cover the adults. Together the rows cover every combination of the three conditions, as long as the child row is checked first. If the adult row for no | no | no came first and did not test the child column, a child with no card on a weekday would match it and get 9 instead of 5. The mistakes section shows that case in running code.

Each row gets its own if. The code is in the same order as the table, and a comment marks each row number. Every condition is written out in full, so each if can be read alone.

The loop at the bottom uses product from the itertools module. itertools is one of the modules that ship with Python, and product([True, False], repeat=3) gives every combination of three values, starting with all True and ending with all False. Each combination is a tuple, which is a fixed group of values written in parentheses. The for line unpacks each tuple into three names.

Python · editable

The output is:

child=True student=True weekend=True -> 5
child=True student=True weekend=False -> 5
child=True student=False weekend=True -> 5
child=True student=False weekend=False -> 5
child=False student=True weekend=True -> 8
child=False student=True weekend=False -> 6
child=False student=False weekend=True -> 12
child=False student=False weekend=False -> 9

Python prints the booleans as True and False, with capital letters.

The program runs all 8 combinations of the three conditions. Each line shows the answer the code gives. A reader can check any line against the table by finding the row whose cells match. For example, child=False student=True weekend=True matches row 3, and the code returns 8.

The final raise line is never reached when the table is complete. It is there as a safety net. raise stops the function with an error, which is what Python does instead of returning a value. If a future edit removes a row, the function stops with a clear message instead of returning a value by accident.

The rule for turning a table cell into a condition is short. A yes in a column becomes the name of the condition, such as student. A no becomes the name with not in front, such as not student. An any leaves that condition out of the if. Following this rule keeps the code close to the table, so a change to a row can be found quickly.

Worked example 3: the table as data

The table in the last example is written as if statements. A table with many rows is easier to keep up to date when the rows are data that the program reads. A list holds values in order, and each row can be one item in that list.

Each row is stored as a small object with four fields. The @dataclass line above the class is a decorator, which is a line that starts with @ and changes the class below it. Here it writes the setup code for the class, so Rule(True, None, None, 5) makes one row with those four values. frozen=True means a row cannot be changed after it is made.

In this version, None in a cell means any. None is Python's way of saying that a value is missing. The matches method checks is None for that case. The check runs the rows from top to bottom and takes the first row that matches. This is the same rule as the if chain above.

Python · editable

The output is:

child=True student=True weekend=True -> 5
child=True student=True weekend=False -> 5
child=True student=False weekend=True -> 5
child=True student=False weekend=False -> 5
child=False student=True weekend=True -> 8
child=False student=True weekend=False -> 6
child=False student=False weekend=True -> 12
child=False student=False weekend=False -> 9
combinations checked: 8
uncovered: 0

In first_match, the part in parentheses is a generator expression. It goes through the rows in order and keeps only the rows whose matches check is true. The next function takes the first one. If no row matches, next returns the second value it was given, which is None. The caller then checks rule is None. Python has no separate value for "no answer", so None plays that role.

The uncovered counter is the coverage check. It counts the combinations that no row handles. A value of 0 means the table has no gaps for these three conditions.

None now has two meanings in this program. In a cell, it means any. As a result of first_match, it means no row matched. The two uses are kept apart by the code, but a reader can mix them up. Write a comment when a value has more than one meaning.

This version prints the same answers as the if chain, which is a useful check. When two versions of the same rule give the same output for every combination, the table and the code agree.

From sentences to a table

Rules often arrive as sentences, not as a grid. The first job is to turn each sentence into one row, and to ask about any case the sentences leave out. Take three rules for a car park:

  1. Badge holders park free.
  2. Staff park free on weekdays.
  3. Everyone else pays 4 dollars.

Three conditions appear in these sentences: whether the driver has a badge, whether the driver is staff, and whether the day is a weekday. Rule 1 has no words about staff or weekdays, so its row uses any in those two columns. Rule 2 says nothing about badges, so its row says no for the badge column, which means the rule applies only to people without a badge. Rule 3 covers everyone who is left.

BadgeStaffWeekdayFee (dollars)
yesanyany0
noyesyes0
noyesno4
nonoany4

The table follows the sentences in order, with rule 2 split into a weekday row and a weekend row. Writing the rows exposes a case that rule 2 leaves open: a staff member on a weekend. Rule 3 seems to cover that case with 4, which is the fee the third row gives. Check that reading with the person who wrote the rules before writing the code. Two rows can share the same fee. Keep them as separate rows when they come from separate sentences, so each sentence can be found in the table.

Here is the same table as code, with a comment on each branch and a loop that checks every combination of the three conditions.

Python · editable

The output is:

badge=True staff=True weekday=True -> 0
badge=True staff=True weekday=False -> 0
badge=True staff=False weekday=True -> 0
badge=True staff=False weekday=False -> 0
badge=False staff=True weekday=True -> 0
badge=False staff=True weekday=False -> 4
badge=False staff=False weekday=True -> 4
badge=False staff=False weekday=False -> 4
combinations checked: 8

Each output line matches one row. The line badge=False staff=True weekday=True matches row 2, and the fee is 0. The count of 8 combinations is the same idea as the coverage count in the list version: every combination is visited, so a gap in the table would stop the program with the "no row matched" error.

Common mistakes

The examples below come from running real programs. Each mistake shows its real output. The file paths in the error messages depend on where you save the file, so yours will differ.

Rows in the wrong order

The first row of the cinema table covers every child. If a row that covers adults with no card and no weekend is placed first, that row also matches children, and it gives the wrong price. Python does not know that the table was meant to be read in another order.

Python · editable

The output is:

wrong order, child on a weekday, no card: 9
correct price should be 5

This mistake gives no error. The program runs to the end and prints a wrong price. The fix is to check the child row first, as the table does. Writing every condition in full in the adult rows, with not child, also works, because then no adult row can match a child.

A row is missing

If the table has no row for adults with no card on a weekend, that combination has no answer. In the if version, the function then reaches the final raise and stops.

Python · editable

The output is:

correct price for an adult with no card on a weekend: 12
Traceback (most recent call last):
  File "/path/to/missing_row.py", line 14, in <module>
    print(missing_row(False, False, True))
          ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "/path/to/missing_row.py", line 10, in missing_row
    raise ValueError(f"no row for child={child} student={student} weekend={weekend}")
ValueError: no row for child=False student=False weekend=True

Python prints the traceback from the top down, and the last line is the error. The message names the combination that has no row, which makes the gap easy to find. The program stops here instead of printing a made-up price. In the list version, the same gap shows up as a count in the uncovered line.

A missing return at the end

If the last row has no return and no raise after it, Python does not refuse to run the code. A function that reaches the end without a return gives back None. The error then shows up later, at the line that uses the value.

Python · editable

The output is:

fare: None
Traceback (most recent call last):
  File "/path/to/missing_return.py", line 12, in <module>
    print("fare plus 1:", fare + 1)
                          ~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'

The first print shows None for the fare, which is the sign of the gap. The TypeError appears on the next line, where the program tries to add 1 to None. The cause is the missing row, but the error is reported far from it. Adding a final return for the last case, or a raise as in the earlier example, makes the gap show up where it happens.

A misspelled condition

A condition name that does not match a variable is a plain typing error. Python reports the unknown word when the line that uses it runs.

Python · editable

The output is:

5
Traceback (most recent call last):
  File "/path/to/misspelled.py", line 10, in <module>
    print(price_for(False, False, False))
          ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
  File "/path/to/misspelled.py", line 4, in price_for
    if not child and studnet and not weekend:
                     ^^^^^^^
NameError: name 'studnet' is not defined. Did you mean: 'student'?

The first call returns 5 before the code reaches the misspelled name, so it runs without error. The second call reaches the line with studnet, and Python stops there. This is a difference from Java, which checks the names before the program runs. In Python, a typo can sit in a branch that no test has reached yet. Compare each name in the code with the column headings in the table, and run every row at least once.

Practice

Each exercise has its answer below it. Try to work it out before you read the answer, and run the code if you can.

Exercise 1. Turn this table into a function

Turn this table into a function fare_for(age) with one if for each row, and return the fare in dollars.

Under 5Senior (65 or older)Fare (dollars)
yesany0
noyes1
nono2

Show the answer

def fare_for(age):
    if age < 5:
        return 0
    if age >= 65:
        return 1
    return 2

The first row is the under-5 check, which is age < 5. The second row is the senior check, which is age >= 65. The last row is the case where neither applies, so it needs no condition. An age of 3 returns 0, an age of 70 returns 1, and an age of 30 returns 2.

Exercise 2. Find the row in the wrong place

The function below should charge a child 5 whether or not the child has a student card. It charges 9 to an adult with no card. For a child with no card, it returns 9. Find the mistake and fix it with the smallest change.

def price(child, student):
    if not student:
        return 9
    if child:
        return 5
    return 6

Show the answer

The first row is in the wrong place. It matches every person with no card, including children, so the child row is never reached for them. Moving the child row above it fixes the code:

def price(child, student):
    if child:
        return 5
    if not student:
        return 9
    return 6

A child with no card now returns 5. An adult with no card returns 9. An adult with a card returns 6.

Exercise 3. Predict the output after a row is removed

Take the list version of the cinema table from the worked example and delete the row Rule(False, False, True, 12). Without running it, predict the line that the program prints for the combination child=False student=False weekend=True, and the final uncovered count. Then run the program to check.

Show the answer

The program prints no rule for child=False student=False weekend=True for that combination, and the last line is uncovered: 1. No row is left that says adults with no card on a weekend, so first_match returns None. The count shows the gap without a reader having to scan every line.

Exercise 4. Shipping table

A shop charges shipping in dollars. Members pay 2 unless the order is over 50, and then they pay 0. Non-members pay 5 unless the order is over 50, and then they pay 0. Write a table with three columns, and then write the function.

Show the answer

Over 50MemberShipping (dollars)
yesany0
noyes2
nono5
def shipping_for(over_50, member):
    if over_50:
        return 0
    if member:
        return 2
    return 5

The "over 50" row comes first because it covers both members and non-members. Putting it second would make members with large orders pay 2 instead of 0.

Exercise 5. Why does a complete table still need a last line?

In plain words, explain why the code for a complete table still needs a final return or raise, even though the table covers every combination.

Show the answer

Python does not require a final line. A function that reaches the end without a return gives back None, and the program carries on with that value. A final return for the last row, or a raise for a gap, makes the outcome explicit. The raise also protects the program against a later edit. If someone removes a row by mistake, the program stops with a clear message instead of returning a value that was never chosen.

Summary

  • A decision table has one column for each condition, one column for the outcome, and one row for each rule.
  • A yes cell becomes a condition, a no cell becomes a not condition, and an any cell is left out of the if.
  • Rows are checked from top to bottom and the first row that matches wins, so the order of rows decides which row applies when two rows match.
  • Each row becomes one if with a return, and a final return or raise handles any combination that no row covers.
  • Running every combination of the conditions shows the answer for each one and finds gaps in the table.
  • Keep the table and the code in the same order, so a reader can check any row against its branch.
Check yourself: Decision tables (6 questions) →Practice this topic: coding problems for Conditionals →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Shared cases in match with no fall-through