The Algorithms - Python Python TheAlgorithms/Python

How Insertion Sort Works

Pick the next element, shift everything larger one position right, drop the element into the gap. The algorithm that outperforms quicksort on small runs.

6 stops ~14 min Verified 2026-05-05
What you will learn
  • Why insertion sort traces back to John Mauchly's 1946 Moore School lectures and why it remains relevant in Timsort
  • How the Comparable Protocol and PEP 695 type-parameter syntax make the function work on any ordered type
  • Why shifting elements right rather than swapping reduces write operations and why that matters on slow memory
  • How the while loop invariant keeps the left portion sorted at every step
  • Why insertion sort outperforms quicksort and mergesort below roughly 10 elements
  • How the six doctests cover strings, negatives, and 100-element random samples as well as integers
Prerequisites
  • Comfort reading Python while loops with index arithmetic
  • No prior knowledge of insertion sort required
The story behind insertion sort

Insertion sort is documented in John Mauchly's 1946 lectures at the Moore School of Electrical Engineering, the same course that introduced binary search and several other foundational algorithms to the first generation of programmers working with stored-program computers. The algorithm is a direct formalization of how most people sort a hand of playing cards: pick up the next card, slide it left until it is in the right position among the cards already held.

The algorithm has O(n squared) average-case complexity, which disqualifies it for large inputs. On nearly-sorted input, however, the inner while loop rarely executes more than a few steps, and the total work is close to O(n). That best-case behavior is the reason Timsort, Python's built-in sort algorithm, uses insertion sort for short runs (typically fewer than 64 elements). The cache-friendly sequential memory access pattern also makes insertion sort faster than theoretically superior algorithms on inputs small enough to fit in L1 cache.

The shift-rather-than-swap implementation you see in this file writes each element at most once per pass, reducing memory traffic compared with a swap-based version. That distinction matters when writes are expensive, such as on flash storage or in certain embedded contexts.

1 / 6

Module Docstring: Algorithm Description and Run Commands

sorts/insertion_sort.py:1

The module docstring explains the backward-shift mechanic in four sentences and provides both the doctest and manual-run commands.

The module docstring is the most expansive in the sorts/ directory because the backward-shift mechanic is genuinely non-obvious on first reading. Lines 4 through 7 describe the motion in plain English: compare an element against its left neighbor, move it backward until the order is correct, then return to the original position and advance. That four-sentence paragraph is what makes the implementation legible to a reader who has not seen the algorithm before. Most other sort files in this directory skip the prose description entirely and rely on the doctest to communicate the contract. The run commands on lines 9 and 12 follow the same two-line pattern every sort file uses.

Key takeaway

A four-sentence prose description in the module docstring earns its place when the algorithm's inner motion (shift back, resume forward) is not apparent from the code alone.

"""
A pure Python implementation of the insertion sort algorithm

This algorithm sorts a collection by comparing adjacent elements.
When it finds that order is not respected, it moves the element compared
backward until the order is correct.  It then goes back directly to the
element's initial position resuming forward comparison.

For doctests run following command:
python3 -m doctest -v insertion_sort.py

For manual testing run:
python3 insertion_sort.py
"""
2 / 6

The Comparable Protocol and Type-Parameter Syntax

sorts/insertion_sort.py:16

A Protocol class defines Comparable as any type supporting __lt__. TypeVar bound to it lets the signature accept any ordered type.

Most sort files in this repository use list as the collection type with no constraint, accepting any input and relying on Python's dynamic dispatch to raise at runtime if the elements are not comparable. This file takes a different approach. The Comparable Protocol on lines 20 and 21 declares that the only requirement is a __lt__ method (less-than). The TypeVar on line 24 is bound to that Protocol, which means mypy can verify at type-check time that any type passed to insertion_sort supports <. The positional-only slash in the __lt__ signature (/) is a Python 3.8+ convention for parameters that must be passed by position; Protocols use it to avoid requiring the parameter name to match. This is the canonical contribution checklist pattern for a generic sort function.

Key takeaway

The Comparable Protocol requires only __lt__. Binding the TypeVar to it lets mypy catch non-comparable input at type-check time rather than at runtime.

from collections.abc import MutableSequence
from typing import Any, Protocol, TypeVar


class Comparable(Protocol):
    def __lt__(self, other: Any, /) -> bool: ...


T = TypeVar("T", bound=Comparable)
3 / 6

Signature and the Six Doctests

sorts/insertion_sort.py:27

PEP 695 type-parameter syntax in the signature. Six doctests cover integers, empty input, negatives, strings, and two random 100-element samples.

The PEP 695 type-parameter syntax on line 27 (def insertion_sort[T: Comparable]) is Python 3.12+ shorthand for the TypeVar declared above. Both forms are valid at the pinned SHA's minimum Python 3.14; the TypeVar is kept in the file for clarity. The six doctests expand the contract beyond integers. The empty-list test on line 37 verifies the function returns an empty list rather than raising a division-by-zero or index error. The string test on line 41 verifies that the Comparable bound works at runtime for types other than int. The two 100-element random sample tests at the end serve as a statistical check: if the sort were wrong for some input shapes, a random sample would eventually catch it.

Key takeaway

Six doctests expand the contract from integers to empty lists, negatives, strings, and random samples. Each extra case covers a class of input that integer tests alone cannot pin.

def insertion_sort[T: Comparable](collection: MutableSequence[T]) -> MutableSequence[T]:
    """A pure Python implementation of the insertion sort algorithm

    :param collection: some mutable ordered collection with heterogeneous
    comparable items inside
    :return: the same collection ordered by ascending

    Examples:
    >>> insertion_sort([0, 5, 3, 2, 2])
    [0, 2, 2, 3, 5]
    >>> insertion_sort([]) == sorted([])
    True
    >>> insertion_sort([-2, -5, -45]) == sorted([-2, -5, -45])
    True
    >>> insertion_sort(['d', 'a', 'b', 'e', 'c']) == sorted(['d', 'a', 'b', 'e', 'c'])
    True
    >>> import random
    >>> collection = random.sample(range(-50, 50), 100)
    >>> insertion_sort(collection) == sorted(collection)
    True
    >>> import string
    >>> collection = random.choices(string.ascii_letters + string.digits, k=100)
    >>> insertion_sort(collection) == sorted(collection)
    True
    """
4 / 6

The Shift Loop: Why Shift Instead of Swap

sorts/insertion_sort.py:53

The while loop shifts elements right until the insertion point is found, then places the saved value in the gap. One write per step.

Line 54 saves the current element into insert_value before the while loop overwrites it. That saved copy is the key to the shift approach. The while loop on lines 55 and 56 does not swap; it copies the left neighbor into the current slot and decrements the index. Each step shifts one element one position to the right, opening a gap for insert_value. When the while condition fails (either the index reaches zero or the left neighbor is already smaller), line 58 drops insert_value into the gap. A swap-based sort would write three times per step (temp = a; a = b; b = temp); this shift approach writes once per step. That matters when writes are expensive. The loop invariant is that collection[:insert_index] is sorted at the start of every outer-loop iteration, and the inner loop preserves it.

Key takeaway

Shifting right is one write per step; swapping is three. The loop invariant is that the prefix to the left of insert_index is sorted at every outer iteration start.

    for insert_index in range(1, len(collection)):
        insert_value = collection[insert_index]
        while insert_index > 0 and insert_value < collection[insert_index - 1]:
            collection[insert_index] = collection[insert_index - 1]
            insert_index -= 1
        collection[insert_index] = insert_value
    return collection
5 / 6

The __main__ Guard: testmod and an Interactive Driver

sorts/insertion_sort.py:62

Direct execution first runs doctest.testmod() from an inline import, then reads comma-separated integers and prints the sorted result.

This __main__ block imports testmod with a targeted from doctest import testmod rather than the module-level import doctest you see in merge_sort.py. Both patterns are equivalent at runtime; the from-import style is marginally cleaner because it avoids the doctest. prefix on line 65. After the doctests run, the driver reads a comma-separated string, strips trailing whitespace, splits on commas, converts each token to an integer, and prints the sorted result using the debug f-string format (= suffix). Unlike the bubble sort file, there is no guard against an empty input: pressing Enter without typing anything raises ValueError from int(item) on the empty string. That difference is a contributor style choice, not a policy requirement.

Key takeaway

from-import and module-level import are equivalent. The driver prints insertion_sort(unsorted) = [...] using the debug f-string suffix for a more informative output than bare print.

if __name__ == "__main__":
    from doctest import testmod

    testmod()

    user_input = input("Enter numbers separated by a comma:\n").strip()
    unsorted = [int(item) for item in user_input.split(",")]
    print(f"{insertion_sort(unsorted) = }")
6 / 6

Why Timsort Uses Insertion Sort for Short Runs

sorts/insertion_sort.py:53

On inputs below roughly 10 elements, insertion sort's low overhead and sequential memory access beat quicksort and mergesort despite the O(n squared) label.

The O(n squared) label is accurate for large input but misleading for small input. On a list of ten elements the outer loop runs nine times and the inner loop runs at most nine times per outer iteration, for at most 81 comparisons. Quicksort on ten elements pays the cost of a pivot selection, two recursive calls, and the function-call overhead for each sub-list. Mergesort allocates new sublists on every recursive level. Insertion sort avoids both: no allocation, no recursion, and a sequential memory-access pattern that fits in L1 cache. CPython's built-in sorted() uses Timsort, which identifies nearly-sorted runs in the input and uses a galloping merge; for runs shorter than 64 elements it falls back to insertion sort. Reading this loop is reading the inner engine of Python's own sort. See the Sorting Algorithms category tour for the full picture across sorts/.

Key takeaway

Below about 10 elements, insertion sort's sequential access and zero allocation beat quicksort and mergesort. Timsort uses it for short runs for exactly this reason.

    for insert_index in range(1, len(collection)):
        insert_value = collection[insert_index]
        while insert_index > 0 and insert_value < collection[insert_index - 1]:
            collection[insert_index] = collection[insert_index - 1]
            insert_index -= 1
        collection[insert_index] = insert_value
    return collection
Your codebase next

Create 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