PyPy: Speed Up Python Several Times Without Changing Code

The cheapest way to speed up Python code is not to touch it at all. No profiler, no rewriting hot paths in C, no architecture changes: I simply ran the same scripts with PyPy instead of the regular interpreter. On some tasks that gave a several-fold speedup, on others a tenfold one or more. On a third group PyPy turned out slower than CPython — and that is just as important to know in advance.
Below: how PyPy speeds up Python without code changes, which workloads it shines on, where it is useless, and how to plug it into a project with a couple of commands.
What PyPy is and where the speed comes from
When we type python script.py, it is almost always CPython running — the reference implementation of the language. It interprets bytecode: every loop iteration goes through the interpreter again, even if the function is being called for the millionth time.
PyPy is an alternative Python implementation with a built-in JIT (just-in-time) compiler. The idea is simple: PyPy notices code that runs over and over, compiles it to machine code while the program is running and then reuses that ready-made result. This is called JIT "warm-up": the first iterations take the usual path, and then the hot code starts running many times faster.
According to the developers, PyPy is on average about 4 times faster than CPython 3.11 on their benchmark suite. But "on average" is the key phrase here: the spread between tasks is huge.
JIT warm-up: why long-running tasks speed up more
The first test I ran was a function with arithmetic inside a loop:
import time
def compute(n: int) -> int:
total = 0
for i in range(n):
total += (i * i) % 7
return total
start = time.perf_counter()
compute(500_000)
print(f"{time.perf_counter() - start:.3f} s")
At 500 thousand iterations CPython took about a second, PyPy several times less. Then I started changing the load, and the picture became telling:
- with a small number of iterations the difference was only a few times — the JIT simply doesn't have time to warm up;
- at two million iterations the gap grew noticeably;
- at ten million CPython was clearly struggling, while PyPy finished almost instantly.
The takeaway: if a script runs for a fraction of a second in total, PyPy may give you almost nothing. The longer and heavier the computation, the stronger the effect. The PyPy developers themselves note that the JIT needs at least a second or two of runtime to show what it can do.
Classes and "real" code
Synthetic loops are fine, but real code is made of objects and methods. So the second test was closer to real life: order classes, methods with calculations, and processing a million such orders.
On CPython processing took about 7 seconds, on PyPy roughly three times less. And again: the more orders I fed it, the more noticeable the gap became. Object-oriented code in pure Python is exactly what PyPy optimizes well.
Network and I/O: almost nothing to speed up
The logical question is what happens with network requests. I sent the same HTTP request 50 times in a row: CPython handled it in about 5 seconds, PyPy in about 4. There is a small gain, but most of the time is spent waiting for the network, not running the interpreter. A JIT can't make the server respond faster.
It's a different story if you process the page after downloading it: parse text, compute things, run regular expressions. That part is CPU-bound, and PyPy starts winning again.
Threads, the GIL and processes
PyPy has a GIL too — a global interpreter lock. So five threads, each doing heavy computation, don't start running in parallel. In my test with five workers there was a difference compared to CPython, but a small one: each thread got faster thanks to the JIT, not thanks to parallelism.
It's a completely different picture with multiprocessing. Each process gets its own interpreter and its own GIL, and the JIT works inside each process. On a CPU-bound task — sums, conditions, loops — PyPy with processes gave the most impressive result of the whole test. And I didn't change the code: only the interpreter.
from multiprocessing import Pool
def heavy(n: int) -> int:
return sum(i * i for i in range(n) if i % 3)
if __name__ == "__main__":
with Pool() as pool:
print(sum(pool.map(heavy, [5_000_000] * 8)))
Where PyPy won't help or will make things worse
This is where the pitfalls start, and why "just replace python with pypy" doesn't always work.
C libraries. PyPy is compatible with pure Python, but C extensions run through a compatibility layer, and some packages don't build for PyPy at all. I took a small pygame game — in my case the module didn't install under PyPy, and I couldn't run the game.
Code that already runs in C. Brute-forcing MD5 hashes with itertools.product took about 18 seconds on CPython and about 17 on PyPy. Almost no difference: itertools is implemented in C in CPython, so the bottleneck already runs as machine code. When I rewrote the brute force with plain Python loops, PyPy pulled ahead again, and with long passwords the difference became huge.
Pillow and image processing. Pillow does its main work in C code, so the JIT has nothing to optimize. Worse, because of the compatibility layer, in my test PyPy processed an image about 14 times slower than CPython. Don't use PyPy for scenarios like this.
Short scripts. If the program finishes in a fraction of a second, the JIT has no time to warm up.
How to tell whether PyPy fits your project
| Scenario | What to expect |
|---|---|
| Long pure-Python computations: loops, math, algorithms | Several-fold speedup, sometimes tenfold or more |
| Object-oriented code: classes, methods, collection processing | In my tests, about 3 times faster |
| Network and waiting on I/O | Small gain or none |
| Libraries that do everything in C (Pillow, numpy-like) | No gain or slower |
| Scripts that run for a fraction of a second | The JIT has no time to help |
The most reliable approach is to run your real code on both interpreters and compare the timings. It takes five minutes and answers the question right away.
How to install PyPy
The simplest way is to download a prebuilt release from pypy.org, unpack the archive and run the binary from the bin directory:
./pypy3.11-v7.3.x-linux64/bin/pypy3 script.py
I do it differently — with the uv manager, because it downloads interpreters itself and pins them to the project:
uv init
uv python list pypy # which PyPy builds are available
uv python install pypy # install PyPy
uv python pin pypy # pin PyPy to the project
uv run main.py # the project now runs on PyPy
uv python pin writes the selected interpreter into the .python-version file, and every subsequent uv run uses PyPy. If something goes wrong, you can edit that file by hand.
Conclusion
PyPy isn't a magic button, but a very strong tool for a specific class of tasks: long computations and object-oriented code in pure Python. There it gives you a speedup that would otherwise take days of optimization. In I/O, short scripts and code that already runs in C there is no gain, and sometimes things get worse.
So the rule is: first benchmark your scenario on CPython and PyPy, and only then switch. If your problem isn't interpreter speed but memory, see the breakdown of why a Python list can eat all your memory. And if you want to write object-oriented code more confidently — the kind PyPy speeds up so well — classes, inheritance, SOLID and design patterns are covered in detail in my Python OOP course.