Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Structures & Algorithms (DSA) Module 1 – Foundations: problems, correctness and complexity

Correctness: specifications and loop invariants

Write preconditions and postconditions, prove a loop right with an invariant (start, every pass, exit) and catch a broken invariant with assert.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot

What you will learn

  • Write preconditions and postconditions for a function
  • State a loop invariant and check its initialisation, maintenance and termination
  • Use assertions to catch a broken invariant while testing
  • Distinguish partial correctness from total correctness

Before you start

On this page

The last lesson asked for one sentence explaining why each faster solution is right. This lesson makes that sentence precise. It shows how to say exactly what a function promises, and how to argue that a loop keeps the promise for every input, not only for the inputs you happened to test.

Say what the function promises

A specification has two parts:

  • the precondition: what must be true of the input when the function is called;
  • the postcondition: what will be true of the result when it returns, provided the precondition held.

For a function largest(items), a careful specification reads:

  • Precondition: items is a list of numbers with at least one item.
  • Postcondition: the result is one of the items, and no item is larger than it.

Both halves of the postcondition matter. “No item is larger” alone would let the function return a huge number that is not in the list at all. And the precondition is not decoration: Python’s own max() has the same one, and called on an empty list without a default it raises ValueError rather than inventing an answer. A caller who breaks the precondition gets no promise.

Loop invariants

Most algorithms are loops, and a loop is where a promise is easiest to break. A loop invariant is a statement about the loop’s variables that is true every time the loop test is checked: before the first pass, and again after each pass. For the loop inside largest, a good invariant is:

Invariant: best is the largest of items[0:i].

To show that a loop is correct with an invariant, check three things:

  1. Initialisation. It is true before the first pass. With best = items[0] and i = 1, it says that best is the largest of the first item alone, which is true.
  2. Maintenance. If it is true before a pass, it is true after it. The body compares items[i] with best and keeps the larger, then adds 1 to i, so best is again the largest of the first i items.
  3. Termination. The loop stops, and when it does, the invariant together with the reason it stopped gives the postcondition. It stops when i == len(items), and then “the largest of items[0:i]” is the largest of all of them.
A loop invariant is checked three times: when the loop starts, after every pass, and at the exit, where it gives the postcondition.Preconditionitems has at least one item1. Initialisationbest = items[0], i = 1the invariant is truei < len(items)?3. Terminationthe invariant holds andi == len(items)2. Maintenancelook at items[i], then i += 1the invariant is true againPostconditionbest is the largest itemnoyesnext passtogether imply

The three checks of a loop invariant

Text description of the diagram

The diagram follows the loop of largest(), from top to bottom. Its invariant is "best is the largest of items[0:i]".

  1. Initialisation: the precondition says items has at least one item, so best = items[0] and i = 1 make the invariant true before the first pass.
  2. The loop test asks whether i < len(items). While it does, the body runs: it looks at items[i], updates best if needed and adds 1 to i. Maintenance means that if the invariant was true before a pass, it is true again after it. The arrow then leads back to the test.
  3. Termination: when the test fails, the invariant still holds and i == len(items). Together these say that best is the largest of all the items, which is the postcondition.

This is an argument about every possible input at once. Tests can only ever try some of them.

C. A. R. Hoare made the method formal in “An axiomatic basis for computer programming” (Communications of the ACM, volume 12, number 10, pages 576–580), building on Robert Floyd’s earlier work with flowcharts. He wrote a precondition, a program and a postcondition together, and gave a rule for while loops built on an invariant P. In the notation used today:

if{P∧B} S {P}\text{if}\quad \{P \land B\}\ S\ \{P\} then{P} 𝚠𝚑𝚒𝚕𝚎 B 𝚍𝚘 S {¬B∧P}\text{then}\quad \{P\}\ \texttt{while } B \texttt{ do } S\ \{\lnot B \land P\}

In words: if every run of the body S that starts with P true and the loop condition B true ends with P true again, then the whole loop, started with P true, can only end with P still true and B false. The three checks above are this rule applied by hand, plus the reason the loop ends at all, which the rule leaves out (more on that below).

Assertions put the invariant to work

An assert statement checks a condition while the program runs and raises AssertionError when it is false. Writing the invariant as an assertion turns the argument into something the computer checks on every pass:

largest() with its invariant checked on every pass Python · largest.py
def largest(items):
    """Precondition: items is a list of numbers with at least one item.
    Postcondition: the result is one of the items, and no item is larger."""
    best = items[0]
    i = 1
    while i < len(items):
        # Invariant: best is the largest of items[0:i].
        assert best == max(items[:i]), f"invariant broken at i={i}"
        print(f"i={i}: best={best} is the largest of {items[:i]}")
        if items[i] > best:
            best = items[i]
        i += 1
    # The invariant still holds, and the loop stopped because i == len(items).
    assert best == max(items[:i]) and i == len(items)
    return best


print("result:", largest([3, 8, 2, 9, 4]))

Output

i=1: best=3 is the largest of [3]
i=2: best=8 is the largest of [3, 8]
i=3: best=8 is the largest of [3, 8, 2]
i=4: best=9 is the largest of [3, 8, 2, 9]
result: 9

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 largest.py

Each line of the output comes from one pass, just after its invariant check, and the invariant held every time; the assertion after the loop checks the exit as well. max(items[:i]) makes each check cost as much as a whole pass over the list, which is fine while testing and the reason such checks do not belong in production code.

A broken loop that still passes a test

Here is the same function with an easy mistake: the loop starts at index 2 instead of 1, so the second item is never looked at.

The loop starts one place too late Python · largest_bug.py
def largest(items, check=False):
    """Meant to return the largest item, but the loop starts one place too late."""
    best = items[0]
    for i in range(2, len(items)):  # the bug: index 1 is never looked at
        if check:
            assert best == max(items[:i]), f"invariant broken at i={i}: best={best}, max(items[:{i}])={max(items[:i])}"
        if items[i] > best:
            best = items[i]
    return best


print("largest([3, 8, 2, 9]) =", largest([3, 8, 2, 9]))  # right, by luck
print("largest([3, 8, 2]) =", largest([3, 8, 2]))  # wrong: 8 is larger
largest([3, 8, 2, 9], check=True)  # the invariant check stops at the first bad step

Output (exit status 1)

largest([3, 8, 2, 9]) = 9
largest([3, 8, 2]) = 3

Printed as an error (standard error)

Traceback (most recent call last):
  File "largest_bug.py", line 14, in <module>
    largest([3, 8, 2, 9], check=True)  # the invariant check stops at the first bad step
    ~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "largest_bug.py", line 6, in largest
    assert best == max(items[:i]), f"invariant broken at i={i}: best={best}, max(items[:{i}])={max(items[:i])}"
           ^^^^^^^^^^^^^^^^^^^^^^
AssertionError: invariant broken at i=2: best=3, max(items[:2])=8

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 largest_bug.py

The first call returns 9, the right answer, because the largest item happens to come late in the list. A test on [3, 8, 2, 9] alone would pass. The second call shows the bug: 3 instead of 8. With the invariant checked, the very first pass fails, at i = 2: best is still 3 while the first two items include 8. The check catches the mistake on the very input whose final answer happened to be right, because it tests every step, not only the result.

Termination: something must shrink

A loop that never stops never returns a wrong answer, and it is still not correct. To show that a loop stops, find a variant: a whole number that is never negative and gets smaller on every pass, so the loop cannot run forever. In binary search over the positions lo up to hi - 1, the variant is hi - lo, the number of positions left.

Binary search with its variant printed, and a version that gets stuck Python · stuck_search.py
def search(items, target, buggy=False):
    """Index of target in the sorted list items, or -1. Positions lo up to hi - 1 are still to be searched."""
    lo, hi = 0, len(items)
    sizes = []
    while lo < hi:
        sizes.append(hi - lo)  # the variant: it must shrink on every pass
        if len(sizes) > 8:
            return f"no answer: hi - lo went {sizes}, the loop never ends"
        mid = (lo + hi) // 2
        if items[mid] == target:
            return f"found at {mid}: hi - lo went {sizes}"
        if items[mid] < target:
            lo = mid if buggy else mid + 1  # the bug keeps mid in the range
        else:
            hi = mid
    return f"not found: hi - lo went {sizes + [hi - lo]}"


prices = [10, 20, 30, 40]
print("correct, 35:", search(prices, 35))
print("correct, 20:", search(prices, 20))
print("buggy,   20:", search(prices, 20, buggy=True))
print("buggy,   35:", search(prices, 35, buggy=True))

Output

correct, 35: not found: hi - lo went [4, 1, 0]
correct, 20: found at 1: hi - lo went [4, 2]
buggy,   20: found at 1: hi - lo went [4, 2]
buggy,   35: no answer: hi - lo went [4, 2, 1, 1, 1, 1, 1, 1, 1], the loop never ends

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stuck_search.py

In the correct version hi - lo shrinks on every pass until the target is found or nothing is left. The buggy version sets lo = mid instead of lo = mid + 1. Once a single position is left and its value is below the target, mid is lo itself, lo = mid changes nothing, and hi - lo stays 1 forever; the example gives up after eight passes. Notice that the buggy version never returns a wrong position. When it returns at all, its answer is right.

That difference has names:

  • A loop is partially correct if, whenever it stops, its result meets the postcondition.
  • It is totally correct if it is partially correct and also stops on every input that meets the precondition.

Hoare’s rules on their own prove only the first: his paper says a proved result holds provided the program terminates, and calls this “conditional” correctness. The variant supplies the second.

Half-open ranges prevent off-by-one errors

Many off-by-one bugs are invariant bugs: the code means “positions lo to hi, both included” in one place and “lo up to, but not including, hi” in another. Python’s range(lo, hi) and slices such as items[lo:hi] are half-open: they include lo and exclude hi. That convention has useful properties: the length is hi - lo, an empty range is simply lo == hi, and two ranges [a, b) and [b, c) join with no gap and no overlap. Choose one convention for each loop, write it in the invariant, and keep it everywhere in that loop. The binary search above uses the half-open interval of positions from lo up to hi.

Assertions check invariants, never input

Python runs assertions only while __debug__ is true. Started with the -O option, it removes every assert statement before running the program, so a check written as an assertion silently disappears:

The same program with and without -O Python · assert_skipped.py
import subprocess
import sys

program = """
def withdraw(balance, amount):
    assert 0 < amount <= balance, "amount out of range"
    return balance - amount

print(withdraw(500, 800))
"""

for command in (["python3"], ["python3", "-O"]):
    run = subprocess.run([sys.executable, *command[1:], "-c", program], capture_output=True, text=True)
    shown = run.stdout.strip() or run.stderr.strip().splitlines()[-1]
    print(" ".join(command) + ":", shown)

Output

python3: AssertionError: amount out of range
python3 -O: -300

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 assert_skipped.py

Without -O, the assertion stops a withdrawal of 800 from a balance of 500. With -O, the check is gone and the balance goes to −300.

Security

Never use assert to check input from a user, a file or the network, or anything else a program must refuse when it is wrong. Raise an exception such as ValueError instead. Use assertions for what should be true if your own code is correct, such as invariants, so that a failure points at a bug.

Key takeaways

  • A specification is a precondition on the input and a postcondition on the result; the postcondition must pin the answer down completely.
  • A loop invariant is true every time the loop test is checked. Prove it true at the start, show that each pass keeps it, and check that at the exit it gives the postcondition.
  • Asserting the invariant while testing finds a broken loop on the first pass that breaks it, even when the final answer happens to be right.
  • Partial correctness means “right whenever it stops”; total correctness adds “always stops”, shown with a variant that shrinks on every pass.
  • Assertions vanish under python -O: use them for invariants, never to validate input.

Exercise

Exercise · Easy · Python, JavaScript

Find the first negative number, with its invariant

Write two functions in first_negative.py (JavaScript: first_negative.mjs).

  1. no_negative_before(nums, i) (JavaScript: noNegativeBefore(nums, i)) states the invariant of your loop. It returns True when none of nums[0], …, nums[i - 1] is negative, and False otherwise. For i = 0 there is nothing before position 0, so the answer is True.
  2. first_negative(nums) (JavaScript: firstNegative(nums)) returns the position of the first negative number in nums, or -1 when there is none. Zero is not negative.

Use a loop over a position i that starts at 0, and keep no_negative_before(nums, i) true every time the loop test is checked. When the loop stops, the invariant and the reason it stopped must be enough to give the answer.

The sample tests check both functions on small cases. Then, on 300 random lists, they check that your invariant is true at every position your loop passes, and false just after the negative number your function returns.

Python · Starter code · first_negative.py

def no_negative_before(nums, i):
    """True when none of nums[0], ..., nums[i - 1] is negative: the invariant of the loop below."""
    # Replace this line with your code.
    return True


def first_negative(nums):
    """Position of the first negative number in nums, or -1 when there is none."""
    i = 0
    # Write your loop here. Every time its test is checked, no_negative_before(nums, i) must be true.
    return -1
The sample tests · test_first_negative.py
import random

from first_negative import first_negative, no_negative_before


def test_first_negative_cases():
    """returns the first negative position, or -1"""
    assert first_negative([]) == -1
    assert first_negative([3, 1, 4]) == -1
    assert first_negative([-1]) == 0
    assert first_negative([5, 0, -2, -7]) == 2
    assert first_negative([0, 0]) == -1


def test_invariant_cases():
    """the invariant is true for an empty prefix and false once a negative number is inside"""
    assert no_negative_before([5, 0, -2], 0) is True
    assert no_negative_before([5, 0, -2], 2) is True
    assert no_negative_before([5, 0, -2], 3) is False
    assert no_negative_before([-4, 6], 1) is False


def test_invariant_explains_the_answer():
    """on 300 random lists, the invariant holds up to the answer and fails just after it"""
    rng = random.Random(3)
    for _ in range(300):
        nums = [rng.randint(-3, 9) for _ in range(rng.randint(0, 10))]
        answer = first_negative(nums)
        stop = len(nums) if answer == -1 else answer
        for i in range(stop + 1):
            assert no_negative_before(nums, i) is True, (nums, i)
        if answer != -1:
            assert nums[answer] < 0, nums
            assert no_negative_before(nums, answer + 1) is False, nums

JavaScript · Starter code · first_negative.mjs

/** True when none of nums[0], ..., nums[i - 1] is negative: the invariant of the loop below. */
export function noNegativeBefore(nums, i) {
  // Replace this line with your code.
  return true;
}

/** Position of the first negative number in nums, or -1 when there is none. */
export function firstNegative(nums) {
  let i = 0;
  // Write your loop here. Every time its test is checked, noNegativeBefore(nums, i) must be true.
  return -1;
}
The sample tests · first_negative.test.mjs
import { test, assert } from 'toolverse:test';
import { firstNegative, noNegativeBefore } from './first_negative.mjs';

test('returns the first negative position, or -1', () => {
  assert.equal(firstNegative([]), -1);
  assert.equal(firstNegative([3, 1, 4]), -1);
  assert.equal(firstNegative([-1]), 0);
  assert.equal(firstNegative([5, 0, -2, -7]), 2);
  assert.equal(firstNegative([0, 0]), -1);
});

test('the invariant is true for an empty prefix and false once a negative number is inside', () => {
  assert.equal(noNegativeBefore([5, 0, -2], 0), true);
  assert.equal(noNegativeBefore([5, 0, -2], 2), true);
  assert.equal(noNegativeBefore([5, 0, -2], 3), false);
  assert.equal(noNegativeBefore([-4, 6], 1), false);
});

test('on 300 random lists, the invariant holds up to the answer and fails just after it', () => {
  let seed = 3;
  const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
  for (let k = 0; k < 300; k++) {
    const nums = Array.from({ length: Math.floor(random() * 11) }, () => Math.floor(random() * 13) - 3);
    const answer = firstNegative(nums);
    const stop = answer === -1 ? nums.length : answer;
    for (let i = 0; i <= stop; i++) assert.equal(noNegativeBefore(nums, i), true, JSON.stringify([nums, i]));
    if (answer !== -1) {
      assert.ok(nums[answer] < 0, JSON.stringify(nums));
      assert.equal(noNegativeBefore(nums, answer + 1), false, JSON.stringify(nums));
    }
  }
});
A hint

A loop such as while i < len(nums) and nums[i] >= 0: i += 1 keeps the invariant: it only moves past a number after checking that it is not negative. It stops for one of two reasons. Either i == len(nums), and no number at all is negative, or nums[i] is the first negative number.

The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 Which postcondition pins down largest(items) completely?

    Choose one answer.

    Show the answer to question 1

    Answer: The result is one of the items, and no item is larger than it

    "No item is larger" alone allows a number that is not in the list at all, such as 10**9, and "one of the items" alone allows any item. Only both together describe the largest item.

  2. Question 2 of 6 Put the three checks of a loop invariant in the order the loop meets them.

    Give each item its position, from 1 (first).

    Show the answer to question 2

    Answer:

    1. Initialisation: it is true before the first pass
    2. Maintenance: each pass that starts with it true ends with it true
    3. Termination: at the exit, it and the exit condition give the postcondition

    The invariant must hold when the loop starts, survive every pass, and at the end combine with the reason the loop stopped to give the result the function promised.

  3. Question 3 of 6 Which statement is an invariant of this loop, true every time the loop test is checked?

    Read the code, then choose one answer.

    total = 0
    i = 0
    while i < len(xs):
        total += xs[i]
        i += 1
    Show the answer to question 3

    Answer: total == sum(xs[:i])

    Before the first pass, total is 0 and xs[:0] is empty, so total == sum(xs[:0]). Each pass adds xs[i] and moves i on by one, which keeps it true. At the exit i == len(xs), so total == sum(xs). The other statements are false at the start, or refer to xs[i] when i may be past the end.

  4. Question 5 of 6 largest_bug.py starts its loop at index 2. What does it print before the traceback?

    What does this program print? Choose one answer.

    def largest(items, check=False):
        """Meant to return the largest item, but the loop starts one place too late."""
        best = items[0]
        for i in range(2, len(items)):  # the bug: index 1 is never looked at
            if check:
                assert best == max(items[:i]), f"invariant broken at i={i}: best={best}, max(items[:{i}])={max(items[:i])}"
            if items[i] > best:
                best = items[i]
        return best
    
    
    print("largest([3, 8, 2, 9]) =", largest([3, 8, 2, 9]))  # right, by luck
    print("largest([3, 8, 2]) =", largest([3, 8, 2]))  # wrong: 8 is larger
    largest([3, 8, 2, 9], check=True)  # the invariant check stops at the first bad step
    Show the answer to question 5

    Answer: it prints

    largest([3, 8, 2, 9]) = 9
    largest([3, 8, 2]) = 3

    The loop never looks at index 1. For [3, 8, 2, 9] the 9 at index 3 is still found, so the answer is right by luck; for [3, 8, 2] the 8 is skipped and 3 is returned.

  5. Question 6 of 6 What happens to assert statements when Python runs with the -O option?

    Choose one answer.

    Show the answer to question 6

    Answer: They are removed, so their conditions are never checked

    With -O, __debug__ is False and the compiler emits no code for assert statements. That is why assertions can check invariants while testing, but must never be the only check on input.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.