Python Primer·Chapter 3

Why Python Won

Python is a slow language that dominates a field obsessed with speed. Understanding that contradiction explains almost everything about how the tools are shaped and how you are meant to use them.

The previous chapter argued that the judgement is still yours even when the typing is not. This one covers the technical puzzle at the centre of Python itself, and the answer shapes everything you will write from here on.

Python is slow.

Not slightly slow. A tight numerical loop in pure Python runs somewhere between fifty and two hundred times slower than the same loop in C. For a field that spends millions on hardware to shave percentages off training time, choosing the slow language looks like a serious mistake.

It isn’t, and the reason it isn’t is the single most important thing to understand before you write any of the code in this book. Get this and the rest of the ecosystem stops looking like a pile of libraries you have to memorise and starts looking like one coherent idea.

The Two-Language Problem

For decades, scientific computing had a structural problem.

The languages that ran fast, C and Fortran, were miserable to explore in. Every change meant editing, compiling, linking, and running. You could not poke at a matrix and see what happened. Meanwhile the languages that were pleasant to think in were far too slow to actually compute anything at scale.

So teams did the obvious thing and used both. Prototype in something comfortable, then hand it to someone to rewrite in C for production. That handoff is where the problems live:

  • It is expensive. You pay for the work twice.
  • It is slow. The rewrite takes weeks, during which the science stops.
  • It introduces bugs. The rewrite is never quite the prototype, and the differences surface months later.
  • It splits the team. The person who understands the problem and the person who understands the code are different people, and they translate through a document.

This was simply accepted as the cost of doing numerical work. The field’s ambition was to eliminate one of the two languages. What actually happened was stranger and better.

The Trick: Move the Loop, Not the Language

Python solved the problem by refusing to solve it. It stayed slow, and pushed all the expensive work into compiled code underneath.

Here is the whole idea in one comparison. Adding a million numbers, first the way a programmer would naturally write it:

import time

n = 1_000_000
a = list(range(n))
b = list(range(n))

start = time.perf_counter()
c = [a[i] + b[i] for i in range(n)]
print(f"pure Python: {time.perf_counter() - start:.4f}s")

Now the same computation with NumPy:

import numpy as np

a = np.arange(n)
b = np.arange(n)

start = time.perf_counter()
c = a + b
print(f"NumPy:       {time.perf_counter() - start:.4f}s")

On a typical machine the second is somewhere between fifty and a hundred times faster, and it is one line.

Nothing about Python got faster. What changed is where the loop lives. In the first version, the Python interpreter runs a million times, and each iteration carries the full overhead of checking types, looking up methods, and allocating boxed integer objects. In the second, Python runs approximately once. It hands two blocks of memory and an instruction to a compiled routine, which loops in C over raw machine integers, quite possibly using vector instructions that process several at a time.

Python is not the language your computation runs in. Python is the language you describe your computation in, and the description is executed by something else entirely.

That reframing is the whole game. Once you accept it, the strange rules of this ecosystem make sense. Loops in Python are bad not because loops are bad, but because a Python-level loop drags the computation out of the fast layer and back into the slow one.

flowchart TB
    subgraph slow["Python layer expressive, slow, where you work"]
        A["Your code<br/>c = a + b"]
    end
    subgraph fast["Compiled layer rigid, fast, where it runs"]
        B["NumPy C routines"]
        C["BLAS / LAPACK<br/>decades of optimised linear algebra"]
        D["SIMD vector instructions<br/>· multi-core threads"]
    end
    A -->|"one call, whole array"| B
    B --> C
    C --> D
    style slow fill:#eef1fc,stroke:#2141c8
    style fast fill:#f0efe9,stroke:#5c6670

The libraries underneath are not new code somebody wrote for Python. BLAS and LAPACK, the linear algebra routines that NumPy calls for matrix operations, date to the 1970s and 1980s and have been tuned by specialists for every processor generation since. Python inherited fifty years of numerical engineering by being a convenient way to call it.

What This Means for How You Write Code

Two rules follow directly, and they govern everything in this book.

Say what you want, not how to compute it. Write the operation over the whole array and let the fast layer decide how to execute it. This is called vectorisation and it is the subject of the next chapter.

Count your crossings. Every time control passes from Python into compiled code and back, you pay overhead. One call that processes a million elements is cheap. A million calls that process one element each is ruinous. When code is unexpectedly slow, this is almost always why.

# Slow: a million crossings into the fast layer
result = [np.sqrt(x) for x in data]

# Fast: one crossing, a million elements
result = np.sqrt(data)

Those two lines produce the same numbers. The second is typically fifty to a hundred times quicker, and it is also shorter and easier to read. That alignment between fast and readable is unusual and it is not an accident. The libraries were designed so that the natural way to express an operation is also the efficient one.

How the Ecosystem Assembled Itself

Python did not arrive with any of this. The stack accumulated over twenty-five years, each layer built on the one below, and knowing the order tells you which library owns which job.

timeline
    title The scientific Python stack, layer by layer
    2001 : IPython — an interactive shell worth living in
    2003 : matplotlib — plotting, modelled on MATLAB
    2006 : NumPy — one array type everything agrees on
    2008 : SciPy matures — optimisation, statistics, signal processing
    2009 : pandas — labelled, heterogeneous, missing-value-aware tables
    2010 : scikit-learn 0.1 — one consistent API for classical ML
    2014 : Jupyter — the notebook leaves the shell for the browser
    2015 : TensorFlow — deep learning arrives with industrial backing
    2016 : PyTorch — and promptly takes research back
    2019 : Hugging Face Transformers — pre-trained models as a package

The pivotal moment is 2006. Before NumPy there were two competing array libraries, and every downstream tool had to pick one or support both. NumPy unified them, and that single agreement is why the ecosystem composes: a matplotlib chart accepts a pandas column which came from a NumPy array which scikit-learn will train on, with no conversion, because underneath they are all the same block of memory.

flowchart TB
    NP["NumPy<br/><i>the array everyone agrees on</i>"]
    PD["pandas<br/><i>labelled tables</i>"]
    SP["SciPy<br/><i>stats, optimisation</i>"]
    MPL["matplotlib · seaborn<br/><i>charts</i>"]
    SK["scikit-learn<br/><i>classical ML</i>"]
    TO["PyTorch<br/><i>deep learning</i>"]
    NP --> PD
    NP --> SP
    NP --> MPL
    PD --> SK
    SP --> SK
    NP --> TO
    PD --> MPL
    style NP fill:#eef1fc,stroke:#2141c8,stroke-width:2px

If you learn NumPy properly, you have learned the substrate of every box above it. That is why the next chapter is the longest in this book.

Setting Up, Briefly

You need surprisingly little. The one rule that matters: never install packages into your system Python. Every project gets its own isolated environment, so that upgrading a library for one project cannot break another.

The modern tool for this is uv, which is dramatically faster than the alternatives and handles both environments and packages:

# install uv once
curl -LsSf https://astral.sh/uv/install.sh | sh

# per project
uv venv                    # creates .venv/
source .venv/bin/activate  # Windows: .venv\Scripts\activate
uv pip install numpy pandas matplotlib scikit-learn jupyterlab

If you would rather use what ships with Python, this is equivalent and slower:

python3 -m venv .venv
source .venv/bin/activate
pip install numpy pandas matplotlib scikit-learn jupyterlab

Or install nothing at all. Every chapter in this book has a notebook that opens directly in Google Colab, which gives you a Python environment in the browser with the libraries already present and a free GPU when you need one. The badge at the top of each chapter is the fastest way to start. Come back and set up a local environment when you have something worth keeping.

A note on Jupyter

Notebooks are wonderful for exploration and genuinely dangerous for anything else. They encourage running cells out of order, which means the state in memory can reflect a sequence of operations that exists nowhere in the file. Code that appears to work will fail for the next person, or for you tomorrow.

The habit that saves you: restart the kernel and run everything, top to bottom, before you believe any result. If it fails, your notebook was lying to you. This is a five-second check and it catches an embarrassing proportion of mistakes.

When Not to Use Python

Honesty is more useful than advocacy, and the boundaries are real.

Situation Why Python struggles What people use instead
Sub-millisecond latency Interpreter overhead and garbage collection pauses dominate C++, Rust
The work cannot be vectorised If the loop is irreducibly sequential, you are stuck in the slow layer Rust, C++, or Numba to compile the hot part
Embedded or memory-constrained The runtime alone is tens of megabytes C, Rust
Browser-side inference No Python runtime in the browser worth deploying TypeScript with ONNX Runtime Web
Large existing JVM estate Crossing the boundary costs more than it saves Scala, Java

Notice what is absent from that table: the model is too big, or the data is too large. Neither is a Python problem. Training runs consuming thousands of GPUs are orchestrated from Python, because the orchestration is a rounding error next to the arithmetic and the arithmetic was never happening in Python anyway.

The honest summary is that Python is the wrong choice when the overhead of the interpreter is a meaningful fraction of the total work. In data science that is rare, because the total work is usually enormous.

What to Take Into the Next Chapter

Three things, and the rest of this book assumes them.

Python is a control language. You are writing instructions for compiled code, not performing the computation yourself. Your job is to describe operations clearly and let the fast layer execute them.

Vectorised code is both faster and clearer. These usually trade against each other. Here they do not, which is worth exploiting.

NumPy is the foundation, not one library among many. Everything above it speaks its language. Time spent there pays back everywhere else.

Next we go into NumPy itself: what an array actually is in memory, why shapes matter more than values, and the broadcasting rules that let you write operations on mismatched arrays and have them mean exactly what you intended.