Measure before you optimize: timeit and cProfile, what the GIL really blocks, the frugality of generators and the memory pitfalls of strings and slices.
Open this lesson in KodokonNever guess, measure. timeit isolates a reliable micro-benchmark: it disables the garbage collector and repeats execution to smooth out the noise. cProfile maps out the calls of a whole program - sort by cumtime (cumulative time, sub-calls included) to identify the real culprits. The golden rule of any performance work: the slow line is almost never the one you suspect.
python -m timeit "sum(x * x for x in range(1000))"
python -m timeit -s "s = 'abc' * 100" "s.upper()"
python -m cProfile -s cumtime -m timeit "min(range(50))"The GIL (Global Interpreter Lock) guarantees that only one thread executes Python bytecode at a time within a process. Consequence: threads do not speed up pure computation. They remain, however, perfect for I/O, because the GIL is released during network or disk reads, as well as by many C libraries such as NumPy. To saturate several cores with computation, go through multiprocessing or concurrent.futures.ProcessPoolExecutor. Python 3.13 offers an experimental "free-threaded" variant without a GIL, still rare in production.
import sys
squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))
print(sys.getsizeof(squares_list), "bytes")
print(sys.getsizeof(squares_gen), "bytes")
print(sum(squares_gen))A generator weighs a few hundred bytes regardless of the length of the sequence: it stores only its execution state and produces each value on demand. Chain them into pipelines - sum(x * x for x in data) - to process streams without ever materializing an intermediate list. A trade-off to be aware of: a generator is single-use. Once exhausted, it produces nothing more, silently - a classic source of bugs where the second iteration seems "empty".
import timeit
def concat(n: int) -> str:
out = ""
for i in range(n):
out += str(i)
return out
def join(n: int) -> str:
return "".join(str(i) for i in range(n))
t1 = timeit.timeit(lambda: concat(20_000), number=10)
t2 = timeit.timeit(lambda: join(20_000), number=10)
print(f"concat: {t1:.3f} s / join: {t2:.3f} s")sys.getsizeof of a generator stay tiny compared to the equivalent list?''.join(parts) beat out += part repeated in a loop?