Python · Course navigation

Contracts, invariants, and exceptions

State what a function requires and guarantees, and fail fast with clear errors.

On this page
Before you start

Classes, exceptions, and assertions.

  • State what a function requires and guarantees, and fail fast with clear errors.
  • Predict the sample output and explain the state changes.
  • Complete the practice task and check the boundary cases.

The concept

Design by contract describes a function by three things: preconditions that callers must satisfy, postconditions that the function guarantees, and class invariants that stay true whenever no method is running. When a precondition is violated, the caller has a bug. When a postcondition or invariant fails, the function itself has a bug.

In Python, validate inputs that come from outside the function with explicit checks that raise exceptions: a ValueError for a bad value, and a custom exception for a meaningful domain failure such as insufficient funds. Callers can then handle each case differently. Use assert only for internal assumptions that should never be false: asserts are removed with python -O, so they cannot be the only validation of user input.

Fail fast means detecting a problem as close to its cause as possible and reporting it clearly. The Account class rejects a negative opening balance, a non-positive withdrawal, and an overdraft before changing any state, so a failed call leaves the object exactly as it was. The error messages say what was wrong and with which values, which makes problems far quicker to diagnose.

Code example

Python
class InsufficientFunds(Exception):
    pass


class Account:
    def __init__(self, balance):
        if balance < 0:
            raise ValueError("opening balance cannot be negative")
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        if amount > self._balance:
            raise InsufficientFunds(f"balance {self._balance} is less than {amount}")
        self._balance -= amount
        assert self._balance >= 0
        return self._balance


account = Account(100)
print(account.withdraw(30))
for amount in (0, 500):
    try:
        account.withdraw(amount)
    except InsufficientFunds as error:
        print("rejected:", error)
    except ValueError as error:
        print("invalid:", error)
print(account.balance)
Output
70
invalid: amount must be positive
rejected: balance 70 is less than 500
70

Follow the code

Open the walkthrough to inspect the example one step at a time.

Show guided walkthrough
Guided walkthroughReady
1class InsufficientFunds(Exception):
2 pass
3
4
5class Account:
6 def __init__(self, balance):
7 if balance < 0:
8 raise ValueError("opening balance cannot be negative")
9 self._balance = balance
10
11 @property
12 def balance(self):
13 return self._balance
14
15 def withdraw(self, amount):
16 if amount <= 0:
17 raise ValueError("amount must be positive")
18 if amount > self._balance:
19 raise InsufficientFunds(f"balance {self._balance} is less than {amount}")
20 self._balance -= amount
21 assert self._balance >= 0
22 return self._balance
23
24
25account = Account(100)
26print(account.withdraw(30))
27for amount in (0, 500):
28 try:
29 account.withdraw(amount)
30 except InsufficientFunds as error:
31 print("rejected:", error)
32 except ValueError as error:
33 print("invalid:", error)
34print(account.balance)

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
Precondition check: a withdrawal must be a positive amount, otherwise the caller has made a mistake.—
—
A domain-specific exception with a message that includes the actual values.—
—
Class invariant: the balance is never negative. It documents an assumption that must always hold.—
—
A valid withdrawal succeeds and returns the new balance.balance = 70
70
Both failed calls left the balance unchanged, because they checked before modifying anything.balance = 70
70
invalid: amount must be positive
rejected: balance 70 is less than 500
70

Behind the scenes

How Python processes this code

Python runs this source through its interpreter. It creates objects for values and resolves names as each statement runs.

Cost and common mistakes

Each withdrawal performs O(1) checks and one update.

  • Do not use assert to validate user input. python -O removes assert statements, and the check would silently disappear.
  • Catching a broad Exception and ignoring it hides real bugs. Catch the specific errors you can handle.
  • Change state only after every check has passed, so a rejected call has no side effects.

Try it yourself

Which exception does Account(-5) raise, and why is it raised in __init__?

Reveal answer and reasoning

ValueError. The constructor must establish the invariant that the balance is never negative; refusing to create an invalid object is better than letting it exist.

Further reading