Combinations Method in Itertools Module in Python: Complete Combinatorics and Permutations Guide

Combinations method in Itertools Module in python

Combinations method in Itertools Module in python

When I first started working with data processing tasks that involved picking subsets of elements from a larger collection, I found myself writing nested loops that quickly turned into unreadable spaghetti code. That changed the day I discovered the itertools module, and specifically the combinations() function. It’s one of those tools that, once you understand it, you wonder how you ever lived without it. In this guide, I’m going to walk through everything I know about itertools.combinations() — from the absolute basics to the internal mechanics that make it so memory-efficient.

What Is the itertools Module?

Before diving into combinations specifically, it helps to understand where it lives. itertools is part of Python’s standard library, and it’s a collection of fast, memory-efficient tools for working with iterators. I like to think of it as Python’s toolbox for combinatorial problems — permutations, combinations, cartesian products, and infinite sequences all live here. Because it’s implemented in C under the hood, it’s significantly faster than writing equivalent pure-Python loops.

To use it, I just import it like any other standard library module:

import itertools

Or, if I only need the combinations function, I import it directly:

from itertools import combinations

What Does combinations() Actually Do?

The combinations() function returns all possible ways to choose r items from an iterable, without regard to order, and without repeating any element. This is the mathematical concept of “n choose r,” written as C(n, r) or sometimes as a binomial coefficient.

The syntax looks like this:

itertools.combinations(iterable, r)

Here’s a simple example I like to use when teaching this concept:

import itertools

letters = ['A', 'B', 'C']
result = itertools.combinations(letters, 2)

for combo in result:
    print(combo)

Output:

('A', 'B')
('A', 'C')
('B', 'C')

Notice that ('B', 'A') never appears. That’s the key distinction between combinations and permutations — order doesn’t matter here. If I picked A then B, that’s considered the same as picking B then A.

Combinations vs Permutations: The Distinction That Trips Everyone Up

I remember confusing these two for weeks when I was learning combinatorics in Python. Here’s the rule I eventually settled on to keep them straight:

Python gives me both:

import itertools

items = ['A', 'B', 'C']

print("Combinations:")
print(list(itertools.combinations(items, 2)))

print("Permutations:")
print(list(itertools.permutations(items, 2)))

Output:

Combinations:
[('A', 'B'), ('A', 'C'), ('B', 'C')]
Permutations:
[('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B')]

Three combinations versus six permutations for the same input — that difference is exactly the factor of r! (2! = 2 in this case) that separates the two formulas.

The Math Behind It

The number of combinations follows the formula:

C(n, r) = n! / (r! * (n - r)!)

I can verify this in Python using the math module:

import math
import itertools

n, r = 5, 2
formula_result = math.comb(n, r)
actual_combos = list(itertools.combinations(range(n), r))

print(f"Formula says: {formula_result}")
print(f"Actual count: {len(actual_combos)}")

Output:

Formula says: 10
Actual count: 10

Fun fact: Python 3.8+ ships math.comb() directly, so if I just need the count of combinations (not the actual tuples), I don’t even need itertools — math.comb() is faster since it doesn’t generate anything.

Internal Working: How combinations() Is Implemented

This is the part that fascinated me most once I looked under the hood. itertools.combinations() is written in C (in CPython), but the documentation provides a pure-Python equivalent that explains the algorithm precisely:

def combinations(iterable, r):
    pool = tuple(iterable)
    n = len(pool)
    if r > n:
        return
    indices = list(range(r))
    yield tuple(pool[i] for i in indices)
    while True:
        for i in reversed(range(r)):
            if indices[i] != i + n - r:
                break
        else:
            return
        indices[i] += 1
        for j in range(i+1, r):
            indices[j] = indices[j-1] + 1
        yield tuple(pool[i] for i in indices)

What’s happening here is essentially a lexicographic ordering algorithm over indices. It starts with the indices [0, 1, ..., r-1], yields that combination, then finds the rightmost index that can be incremented without exceeding its bound, bumps it up, and resets everything to its right. This is why combinations always come out in a predictable, sorted order relative to the input.

Why It’s a Generator (and Why That Matters for Memory)

combinations() doesn’t return a list — it returns an iterator. This is a deliberate design decision, and it matters a lot for memory management. If I have 1,000 elements and want combinations of 5, that’s over 8 billion possible tuples. Storing them all in a list would crash most machines. Because combinations() yields one tuple at a time, I can process massive combinatorial spaces without ever holding more than a handful of tuples in memory at once.

import itertools
import sys

combo_iterator = itertools.combinations(range(100), 5)
print(sys.getsizeof(combo_iterator))  # tiny, fixed size regardless of n and r

The iterator object itself has a small, constant memory footprint. It’s the consumption of it — turning it into a list — that can blow up memory.

Performance Considerations

The time complexity to generate all combinations is O(C(n, r)) since that’s the number of tuples produced, and each tuple takes O(r) time to build, giving an overall complexity of O(r * C(n, r)). In practice, since it’s implemented in C, it dramatically outperforms a hand-rolled recursive Python function.

Here’s a quick benchmark comparing my own naive recursive implementation to itertools:

import itertools
import time

def my_combinations(pool, r):
    if r == 0:
        yield ()
        return
    for i in range(len(pool)):
        for rest in my_combinations(pool[i+1:], r - 1):
            yield (pool[i],) + rest

data = list(range(20))

start = time.perf_counter()
list(itertools.combinations(data, 10))
print("itertools:", time.perf_counter() - start)

start = time.perf_counter()
list(my_combinations(data, 10))
print("custom:", time.perf_counter() - start)

On my machine, itertools consistently comes out several times faster — sometimes an order of magnitude — because it avoids Python function-call overhead and tuple concatenation costs.

Real-World Use Cases I’ve Actually Used This For

  1. Feature selection in machine learning — testing which subsets of features produce the best model performance.
import itertools

features = ['age', 'income', 'education', 'location']
for r in range(1, len(features) + 1):
    for subset in itertools.combinations(features, r):
        print(subset)
  1. Generating test case pairs for pairwise testing in QA automation.
  2. Lottery/probability simulations where I need every possible ticket combination.
  3. Building all possible team pairings in a scheduling script I wrote for a small sports league.

combinations_with_replacement

A close cousin worth mentioning: sometimes I want combinations where an element can be reused. That’s combinations_with_replacement():

import itertools

flavors = ['vanilla', 'chocolate']
scoops = itertools.combinations_with_replacement(flavors, 2)
print(list(scoops))

Output:

[('vanilla', 'vanilla'), ('vanilla', 'chocolate'), ('chocolate', 'chocolate')]

This came in handy once when I was generating all possible two-scoop ice cream combos for a menu-planning script — repeats (double vanilla) are valid there, but order still doesn’t matter.

Common Mistakes I’ve Made (and Seen Others Make)

import itertools

combo = itertools.combinations([1, 2, 3], 2)
print(list(combo))  # [(1, 2), (1, 3), (2, 3)]
print(list(combo))  # [] -- already exhausted!

Debugging Tips

When my combination output doesn’t look right, I check these things in order:

  1. Am I passing the correct r value?
  2. Did I accidentally exhaust the iterator earlier in the code?
  3. Is my input iterable actually in the order I expect (since combinations respects input position)?
  4. Am I comparing tuples correctly (tuples are compared element-wise, so type mismatches like 1 vs '1' will cause unexpected inequality)?

FAQs

Does combinations() sort my data first? No. It respects the original order of the iterable’s positions. If you want alphabetically or numerically sorted output, sort your input first.

Can I use combinations() with a string? Yes, strings are iterables. itertools.combinations('ABC', 2) gives [('A','B'), ('A','C'), ('B','C')].

What happens if r is larger than the length of the iterable? It simply returns an empty iterator — no error is raised.

Is combinations() thread-safe? Iterators in general aren’t designed for concurrent access from multiple threads without external locking, so I avoid sharing one across threads.

How do I count combinations without generating them? Use math.comb(n, r) — it’s O(1) relative to generating anything and avoids memory overhead entirely.

Summary

itertools.combinations() is one of the most practical tools in Python’s standard library for anyone dealing with combinatorial problems — whether that’s feature selection, test case generation, probability simulations, or scheduling logic. It’s implemented efficiently in C, returns a lazy iterator to keep memory usage minimal, and pairs naturally with related tools like permutations() and combinations_with_replacement(). Once this clicked for me, it replaced dozens of lines of nested-loop code with a single, readable line — and that’s a trade I’ll take every time.

References

Exit mobile version