How Bubble Sort Works
Compare adjacent elements, swap if out of order, repeat. The algorithm that appears in every first-year course because the swapping motion is easy to picture.
What you will learn
- Why the name bubble sort comes from the way large values migrate toward the end of the list
- How the outer loop shrinks the unsorted region on each pass and why the inner loop only runs to i
- How the early-exit flag converts O(n squared) average to O(n) on already-sorted input
- Why bubble sort is stable and what that means for equal elements in the output
- How the recursive variant encodes the same logic with a base-case return instead of a loop counter
- How the __main__ benchmark quantifies the iterative versus recursive trade-off with timeit
Prerequisites
- Comfort reading nested Python for loops
- No prior knowledge of bubble sort required
The story behind bubble sort
The name bubble sort first appeared in print in 1956 in a paper by Friend in Journal of the ACM, though the algorithm itself was described in various forms before that. It became the standard first sorting algorithm taught in computer science courses because the motion is easy to picture: a large value at the left end of a list bubbles rightward one position per pass, like an air bubble rising through water.
What makes bubble sort pedagogically central is not its performance but its simplicity. The inner loop compares each adjacent pair and swaps them if they are out of order. One full pass guarantees that the largest unsorted element ends up at the back. Repeat n times and the list is sorted. That reasoning is direct enough to trace by hand on a five-element example in under a minute.
The algorithm's O(n squared) average case makes it unsuitable for production use. The early-exit optimization (the swapped flag you see in this file) brings it to O(n) on already-sorted input, which is its only practical advantage. Python's built-in sorted() uses Timsort and is faster for every realistic input shape. Bubble sort's value is in the classroom, not the codebase.
bubble_sort_iterative: Signature and the Doctest Battery
sorts/bubble_sort.py:4The function takes any ordered collection with comparable items. Seventeen doctests cover duplicates, empty, negatives, strings, floats, and random samples.
The doctest battery here is the most extensive in the sorts/ directory. Most sort files test three or four input shapes; this one tests thirteen. The reason is coverage: bubble sort's correctness claim includes stability (equal elements stay in original order), and the doctest on line 22 verifying [3, 3, 3, 3] pins that. The already-sorted test on line 20 verifies the early-exit behavior does not corrupt a list that needed no swaps. String and float inputs prove that Any in the type hint is real: the function works on any type whose > operator is defined. The two random 100-element samples at the end (one of integers, one of mixed ASCII) are what the insertion sort doctest uses too, and they function as a statistical sanity check against Python's built-in sorted().
Seventeen doctests cover stability, already-sorted early exit, strings, floats, and random samples. Each additional case pins a property the two-sentence description alone cannot verify.
def bubble_sort_iterative(collection: list[Any]) -> list[Any]:
"""Pure implementation of bubble sort algorithm in Python
:param collection: some mutable ordered collection with heterogeneous
comparable items inside
:return: the same collection ordered in ascending order
Examples:
>>> bubble_sort_iterative([0, 5, 2, 3, 2])
[0, 2, 2, 3, 5]
>>> bubble_sort_iterative([])
[]
>>> bubble_sort_iterative([-2, -45, -5])
[-45, -5, -2]
>>> bubble_sort_iterative([-23, 0, 6, -4, 34])
[-23, -4, 0, 6, 34]
>>> bubble_sort_iterative([1, 2, 3, 4])
[1, 2, 3, 4]
>>> bubble_sort_iterative([3, 3, 3, 3])
[3, 3, 3, 3]
>>> bubble_sort_iterative([56])
[56]
>>> bubble_sort_iterative([0, 5, 2, 3, 2]) == sorted([0, 5, 2, 3, 2])
True
>>> bubble_sort_iterative([]) == sorted([])
True
>>> bubble_sort_iterative([-2, -45, -5]) == sorted([-2, -45, -5])
True
>>> bubble_sort_iterative([-23, 0, 6, -4, 34]) == sorted([-23, 0, 6, -4, 34])
True
>>> bubble_sort_iterative(['d', 'a', 'b', 'e']) == sorted(['d', 'a', 'b', 'e'])
True
>>> bubble_sort_iterative(['z', 'a', 'y', 'b', 'x', 'c'])
['a', 'b', 'c', 'x', 'y', 'z']
>>> bubble_sort_iterative([1.1, 3.3, 5.5, 7.7, 2.2, 4.4, 6.6])
[1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7]
>>> bubble_sort_iterative([1, 3.3, 5, 7.7, 2, 4.4, 6])
[1, 2, 3.3, 4.4, 5, 6, 7.7]
>>> import random
>>> collection_arg = random.sample(range(-50, 50), 100)
>>> bubble_sort_iterative(collection_arg) == sorted(collection_arg)
True
>>> import string
>>> collection_arg = random.choices(string.ascii_letters + string.digits, k=100)
>>> bubble_sort_iterative(collection_arg) == sorted(collection_arg)
True
"""The Iterative Body and the Early-Exit Flag
sorts/bubble_sort.py:51The outer loop shrinks pass length each round. The swapped flag exits early after a no-swap pass, reducing best-case cost to O(n).
The outer loop calls reversed(range(length)), which iterates i from length-1 down to 0. The inner loop runs from j=0 to j=i-1, which means each outer pass considers one fewer comparison than the last: after the first pass, the largest element is guaranteed to be in its final position, so the inner loop can skip it. That shrinking bound takes the comparison count from n squared to n(n-1)/2, but the asymptotic class stays O(n squared). The swapped flag on lines 53 and 58 is what achieves the O(n) best case. If a full inner pass makes no swaps, the list is already sorted and the break on line 59 exits immediately. The already-sorted doctest on line 20 verifies that this path executes correctly.
The swapped flag exits after one no-swap pass, making already-sorted input O(n). Without it, the algorithm always does O(n squared) comparisons.
length = len(collection)
for i in reversed(range(length)):
swapped = False
for j in range(i):
if collection[j] > collection[j + 1]:
swapped = True
collection[j], collection[j + 1] = collection[j + 1], collection[j]
if not swapped:
break # Stop iteration if the collection is sorted.
return collectionbubble_sort_recursive: The Same Logic Without a Loop Counter
sorts/bubble_sort.py:63The recursive variant runs one forward pass per call and recurses only if a swap occurred. The base case is an implicit no-swap return.
The recursive variant is a different encoding of the same algorithm. Where the iterative version uses a break to exit early, this version uses the conditional at the tail: return collection if not swapped else bubble_sort_recursive(collection). One full forward pass runs in the body (lines 107 through 110), and the result of that pass determines whether another pass is needed. The function recurses on the same collection object, not a copy, so the list is modified in place across all recursive frames. The doctest battery mirrors the iterative version with one addition: line 94 tests a mixed-case ASCII list (['a', 'Z', 'B', 'C', 'A', 'c']) and shows that Python sorts uppercase letters before lowercase, which is a fact about ord() values rather than anything bubble sort does.
The recursive version replaces the outer loop with a tail call. The early-exit condition is the same swapped flag, but expressed as a conditional return instead of a break.
def bubble_sort_recursive(collection: list[Any]) -> list[Any]:
"""It is similar iterative bubble sort but recursive.
:param collection: mutable ordered sequence of elements
:return: the same list in ascending order
Examples:
>>> bubble_sort_recursive([0, 5, 2, 3, 2])
[0, 2, 2, 3, 5]
>>> bubble_sort_recursive([])
[]
>>> bubble_sort_recursive([-2, -45, -5])
[-45, -5, -2]
>>> bubble_sort_recursive([-23, 0, 6, -4, 34])
[-23, -4, 0, 6, 34]
>>> bubble_sort_recursive([0, 5, 2, 3, 2]) == sorted([0, 5, 2, 3, 2])
True
>>> bubble_sort_recursive([]) == sorted([])
True
>>> bubble_sort_recursive([-2, -45, -5]) == sorted([-2, -45, -5])
True
>>> bubble_sort_recursive([-23, 0, 6, -4, 34]) == sorted([-23, 0, 6, -4, 34])
True
>>> bubble_sort_recursive(['d', 'a', 'b', 'e']) == sorted(['d', 'a', 'b', 'e'])
True
>>> bubble_sort_recursive(['z', 'a', 'y', 'b', 'x', 'c'])
['a', 'b', 'c', 'x', 'y', 'z']
>>> bubble_sort_recursive([1.1, 3.3, 5.5, 7.7, 2.2, 4.4, 6.6])
[1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7]
>>> bubble_sort_recursive([1, 3.3, 5, 7.7, 2, 4.4, 6])
[1, 2, 3.3, 4.4, 5, 6, 7.7]
>>> bubble_sort_recursive(['a', 'Z', 'B', 'C', 'A', 'c'])
['A', 'B', 'C', 'Z', 'a', 'c']
>>> import random
>>> collection_arg = random.sample(range(-50, 50), 100)
>>> bubble_sort_recursive(collection_arg) == sorted(collection_arg)
True
>>> import string
>>> collection_arg = random.choices(string.ascii_letters + string.digits, k=100)
>>> bubble_sort_recursive(collection_arg) == sorted(collection_arg)
True
"""The Recursive Loop Body
sorts/bubble_sort.py:105One forward pass over the collection, swapping adjacent elements if out of order, then returning the result or recursing.
The forward pass on lines 107 through 110 always scans the full length - 1 adjacent pairs, regardless of how many swaps the previous call made. That is the structural difference from the iterative version, which shrinks the inner loop on each outer pass. The recursive version does more comparison work per level because it does not track how far the sorted suffix extends. In exchange, the code is six lines instead of ten, and the stopping condition is a single expression on line 112. The benchmark in the __main__ block below quantifies the cost of that trade-off with timeit over 10,000 runs. On random input, the iterative version is typically faster because fewer comparisons are needed in the final passes.
The recursive body always scans all length-1 pairs. The iterative version's shrinking inner bound does fewer comparisons per pass; the benchmark in __main__ measures the gap.
length = len(collection)
swapped = False
for i in range(length - 1):
if collection[i] > collection[i + 1]:
collection[i], collection[i + 1] = collection[i + 1], collection[i]
swapped = True
return collection if not swapped else bubble_sort_recursive(collection)The __main__ Block: doctest and timeit Benchmark
sorts/bubble_sort.py:115Direct execution validates doctests with doctest.testmod(), then benchmarks iterative vs recursive over 10,000 runs using timeit.
Most algorithm files in this repository end with a simple read-sort-print driver. This one adds a timeit benchmark. The benchmark passes unsorted[:] rather than unsorted to the sort function inside the timed string, because both sort functions mutate the collection in place and a reused sorted list would make every subsequent sort trivially fast. The slice copy is the correct control. Running python3 sorts/bubble_sort.py prints both sorted results and their timing across 10,000 runs, confirming the comment on line 122: iterative is slightly faster because it does fewer total comparisons. The imports of sample and timeit are inside the if __name__ block to avoid loading them when the module is imported by another file or by pytest.
Passing unsorted[:] to the timed call is the correct control for in-place sorts. Without the copy, each run after the first starts with an already-sorted list.
if __name__ == "__main__":
import doctest
from random import sample
from timeit import timeit
doctest.testmod()
# Benchmark: Iterative seems slightly faster than recursive.
num_runs = 10_000
unsorted = sample(range(-50, 50), 100)
timer_iterative = timeit(
"bubble_sort_iterative(unsorted[:])", globals=globals(), number=num_runs
)
print("\nIterative bubble sort:")
print(*bubble_sort_iterative(unsorted), sep=",")
print(f"Processing time (iterative): {timer_iterative:.5f}s for {num_runs:,} runs")
unsorted = sample(range(-50, 50), 100)
timer_recursive = timeit(
"bubble_sort_recursive(unsorted[:])", globals=globals(), number=num_runs
)
print("\nRecursive bubble sort:")
print(*bubble_sort_recursive(unsorted), sep=",")
print(f"Processing time (recursive): {timer_recursive:.5f}s for {num_runs:,} runs")Why Two Implementations in One File
sorts/bubble_sort.py:4The file holds iterative and recursive variants side by side to make the structural correspondence visible and to let the benchmark quantify the trade-off.
Most files in sorts/ hold exactly one function. The contribution guide says identical reimplementations of an existing file are rejected, but variants that encode the same algorithm differently add value. The iterative and recursive forms of bubble sort make the same logical steps visible from two angles: one controls repetition with a loop counter and a break, the other with a tail call and a conditional return. Placing them side by side in the same file lets a reader compare them directly without switching files, and the timeit benchmark in __main__ gives a concrete measurement rather than a prose claim about which is faster. That pairing of two perspectives plus a benchmark is what separates this file from a single-function stub and justifies the larger doctest count. See the Sorting Algorithms category tour for the full set of variants across sorts/.
Two variants in one file let readers compare iterative and recursive encodings directly. The benchmark turns the prose claim about speed into a measurement.
def bubble_sort_iterative(collection: list[Any]) -> list[Any]:
"""Pure implementation of bubble sort algorithm in Python
:param collection: some mutable ordered collection with heterogeneous
comparable items inside
:return: the same collection ordered in ascending orderYou've walked through 6 key areas of the The Algorithms - Python codebase.
Continue: How Insertion Sort Works → Browse all projectsCreate code tours for your project
Intraview lets AI create interactive walkthroughs of any codebase. Install the free VS Code extension and generate your first tour in minutes.
Install Intraview Free