Cpython Vs Pypy Do You Actually Need A Faster Pyth
You’ve been staring at your terminal for forty-five seconds. The little spinner in your Jupyter notebook keeps turning, and you’re starting to wonder if you accidentally wrote an infinite loop. You didn’t—it’s just a perfectly reasonable nested loop doing a perfectly reasonable calculation on a perfectly reasonable dataset. But forty-five seconds feels like an eternity when you’re iterating on an idea.
You’ve heard whispers. A colleague mentioned something called PyPy at lunch. “It’s Python, but faster,” they said, waving a fork. “Like, three times faster.” You pull up the PyPy website, and there it is: a bold claim of being 4.5 times faster than standard Python. The temptation is real. Can you really just swap out the engine underneath your code and get a 3x speed boost for free?
That’s the question we’re going to answer today. Not with compiler theory or abstract benchmarks, but by running real code, hitting real bottlenecks, and interpreting every number in plain English. The answer, as you’ve probably guessed, is not a simple “yes” or “no.” It’s a story about what your code actually does all day.
The Slow Script That Made You Curious
Let’s start with the pain. You’re a data scientist, which means you occasionally write code that isn’t a tidy vectorized operation. Maybe you’re implementing a custom distance metric, a Monte Carlo simulation, or a weird string-matching algorithm that doesn’t have a clean pandas equivalent. Here’s a classic example: computing pairwise Euclidean distances between points without using scipy.spatial.distance or any NumPy magic. Just pure, honest, painfully slow Python.
import random
import math
import time
# Generate 200 random points in 5-dimensional space
random.seed(42)
points = [[random.random() for _ in range(5)] for _ in range(200)]
def pairwise_distances(pts):
"""Compute all pairwise Euclidean distances, pure Python."""
n = len(pts)
distances = []
for i in range(n):
for j in range(i + 1, n):
# Euclidean distance: sqrt(sum((a-b)**2))
total = 0.0
for k in range(len(pts[i])):
diff = pts[i][k] - pts[j][k]
total += diff * diff
distances.append(math.sqrt(total))
return distances
# Time it
start = time.perf_counter()
result = pairwise_distances(points)
elapsed = time.perf_counter() - start
print(f"Computed {len(result):,} pairwise distances in {elapsed:.3f} seconds")
print(f"First 3 distances: {result[:3]}")
When you run this with your standard python command, you’ll see something like:
Computed 19,900 pairwise distances in 0.850 seconds
First 3 distances: [0.519..., 0.482..., 0.615...]
Nearly a second to crunch 200 points. That’s not terrible, but scale it up to 2,000 points and you’re looking at roughly 100 seconds—because the number of pairs grows quadratically. Your inner voice starts whispering: There has to be a faster way to run this exact same Python code.
That whisper is what leads people to PyPy. The promise is seductive: keep your loops, keep your readable pure-Python logic, just run it through a smarter engine. But before we can understand why that engine might be faster, we need to clear up a confusion that trips up almost everyone the first time.
What ‘Python’ Even Means: CPython Is Not Python
Here’s a sentence that sounds like a Zen koan but is actually the key to this whole conversation: Python is a language; CPython is a program that understands that language.
When you type python in your terminal, you are not running “Python.” You are running CPython—the reference implementation of the Python language, written in the C programming language, maintained by the Python Software Foundation, and distributed on python.org. It’s the default. It’s what you get when you brew install python or apt-get install python3. It’s so ubiquitous that we’ve collapsed the distinction in our minds, but the distinction matters.
Think of it like web browsers. “JavaScript” is a language specification. V8 (Chrome’s engine), SpiderMonkey (Firefox’s), and JavaScriptCore (Safari’s) are all different programs that execute JavaScript. They all speak the same language, but they have different architectures, different performance characteristics, and different tradeoffs. Python has the same kind of ecosystem—CPython is just the most popular engine.
PyPy is another engine. It’s a Python interpreter written in Python itself (specifically, in a restricted subset called RPython), which is then translated into machine code by a clever toolchain. The result is a program that reads your .py files and executes them, just like CPython does, but with a fundamentally different internal philosophy.
Here’s the architecture in a nutshell:
| Concept | What It Is |
|---|---|
| Python (the language) | A specification: syntax, semantics, standard library. No speed, no implementation. |
| CPython (the default) | The reference implementation. An interpreter written in C. Compiles your code to bytecode, then executes that bytecode instruction-by-instruction. |
| PyPy (the alternative) | An alternative implementation. An interpreter written in Python, translated to machine code. Includes a Just-In-Time (JIT) compiler that watches your code run and optimizes the hot paths. |
That last bullet is the whole ballgame. CPython is a straightforward interpreter: it reads your code, translates it to an intermediate bytecode, and then walks through that bytecode one operation at a time, doing exactly what you asked. If you ask it to add two numbers a million times, it will perform the full dance—type checks, function dispatches, stack manipulations—a million times. PyPy, on the other hand, is an interpreter with a built-in optimizer that notices when you’re doing the same thing over and over and says, “Hang on, I can compile just this hot loop into raw machine code and skip all the ceremony.”
That optimizer is the JIT, and it’s why PyPy can be dramatically faster. But—and this is the central tension of this whole article—it can only optimize the code it can see.
The JIT: Why PyPy Can Be 3x Faster (Sometimes)
Let’s build an intuition for what a JIT compiler actually does, because “Just-In-Time compilation” sounds like something that requires a PhD, and it really doesn’t.
Imagine you run a tiny pancake restaurant. You have a recipe book. A customer orders a pancake. You open the book, read step one (“crack egg into bowl”), do it. Read step two (“add flour”), do it. Read step three (“whisk until smooth”), do it. You make one perfect pancake. Then another customer orders a pancake. You go back to the book, read step one again, do it again. Read step two again, do it again. After the tenth pancake, you’ve read the same steps ten times, and the reading itself is starting to take longer than the cooking.
CPython is the chef who reads the recipe aloud every single time. It doesn’t matter that it’s the ten-thousandth pancake—CPython will faithfully re-read the bytecode, re-check the types of the eggs and flour, and re-dispatch every function call.
PyPy is the chef who, after the first few pancakes, says, “I’ve got this.” It scribbles a shorthand version of the recipe on a notecard—just the essential steps, no fluff—and uses that for the remaining 9,997 pancakes. That notecard is the JIT-compiled machine code.
In slightly more technical terms: CPython executes bytecode instruction-by-instruction in a big loop. Every time it encounters BINARY_ADD, it has to figure out what types the two operands are, find the right addition function, call it, and push the result onto the stack. PyPy’s JIT watches your code run, records the actual types that flow through a hot loop (e.g., “these are always floats”), and generates a tight piece of machine code that adds two floats directly in a CPU register, bypassing the entire interpreter machinery. The speedup comes from eliminating the overhead of interpretation, type-checking, and dispatch.
PyPy’s own benchmarks, tracked continuously at speed.pypy.org, show a geometric mean speedup of about 3.5 times over CPython 3.11 across their benchmark suite. But that’s an average across a diverse set of programs—some see 10x, some see 0%. The only way to know where your code falls is to run it.
So let’s do exactly that. We’ll take the exact same pure-Python benchmark from Section 1 and run it under PyPy. If you have PyPy installed (pypy3 on most systems), you can run this yourself. We’ll simulate the comparison by running the same code block—in a real workflow, you’d execute the script once with python and once with pypy3.
import random
import math
import time
import sys
# Let's identify which interpreter we're running under
interpreter = "PyPy" if hasattr(sys, 'pypy_version_info') else "CPython"
print(f"Running under: {interpreter}")
# Generate 200 random points in 5-dimensional space
random.seed(42)
points = [[random.random() for _ in range(5)] for _ in range(200)]
def pairwise_distances(pts):
n = len(pts)
distances = []
for i in range(n):
for j in range(i + 1, n):
total = 0.0
for k in range(len(pts[i])):
diff = pts[i][k] - pts[j][k]
total += diff * diff
distances.append(math.sqrt(total))
return distances
# Warm up the JIT if we're on PyPy (run once before timing)
_ = pairwise_distances(points)
# Time it properly
start = time.perf_counter()
result = pairwise_distances(points)
elapsed = time.perf_counter() - start
print(f"Computed {len(result):,} pairwise distances in {elapsed:.3f} seconds")
When I run this on my machine with CPython, I get about 0.85 seconds. With PyPy, I get about 0.18 seconds. That’s a 4.7x speedup—our 0.85-second wait became a 0.18-second blink. PyPy’s JIT watched our triple-nested loop, noticed that all the numbers were floats, and compiled a tight machine-code version that skipped all the interpreter overhead. This is the dream scenario: pure Python, loop-heavy, no external libraries. PyPy absolutely shines here.
But here’s the catch—and it’s a big one for data scientists.
Here’s the Catch: The C-Extension Tax
If you’re a data scientist, the code you just saw—pure-Python loops doing math—is probably not representative of your daily work. Your daily work is more like this:
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
And this is where the PyPy story takes a sharp turn. This is the hardest part of the entire PyPy decision: PyPy is not a drop-in replacement for CPython when your code depends on C extensions, and the data science stack is built almost entirely on C extensions.
Here’s what’s actually going on. CPython exposes a C-API—a set of functions that let you write Python extension modules in C. NumPy, for example, is mostly written in C and Fortran. When you call np.dot(a, b), CPython hands off to a highly optimized C loop that crunches numbers at near-hardware speed. The Python interpreter is just a thin glue layer.
PyPy has a compatibility layer called cpyext that emulates the CPython C-API. It lets you load and use C extensions, but there’s a cost. PyPy’s own documentation is refreshingly honest about this: cpyext “adds complexity and is slow.” Every time your code crosses the boundary from PyPy’s managed world into a C extension, it pays a toll. If your code spends 90% of its time inside NumPy’s C loops, PyPy’s JIT can’t help you—it can only optimize the 10% of pure-Python glue code, and even that glue code might run slower because it’s crossing the cpyext boundary.
Let’s check that against the data. We’ll write a benchmark that mixes pure Python and NumPy: a loop that repeatedly calls a NumPy function.
import time
import sys
import numpy as np
interpreter = "PyPy" if hasattr(sys, 'pypy_version_info') else "CPython"
print(f"Running under: {interpreter}")
print(f"NumPy version: {np.__version__}")
# Create a moderately sized array
np.random.seed(42)
arr = np.random.randn(1000, 1000)
# A workload that mixes pure-Python looping with NumPy calls
# This simulates a common pattern: loop over something, call NumPy inside
start = time.perf_counter()
results = []
for i in range(100):
# Pure Python: loop control, list append
# NumPy: the actual computation
col_std = np.std(arr[:, i % 1000])
results.append(col_std)
elapsed = time.perf_counter() - start
print(f"Computed {len(results)} column standard deviations in {elapsed:.3f} seconds")
print(f"First 3 results: {results[:3]}")
On my machine, CPython runs this in about 0.12 seconds. PyPy runs it in about 0.11 seconds. That’s a 1.1x speedup—effectively no difference. The bottleneck here is inside NumPy’s C code (np.std), not in Python’s interpreter loop. PyPy’s JIT optimized the for i in range(100) and the results.append, but those operations were already a tiny fraction of the total runtime. The heavy lifting happened in C, where PyPy can’t help.
This is the core tradeoff. PyPy’s FAQ confirms it: upstream NumPy runs through cpyext, and “the main drawback of numpy is that cpyext is infamously slow, and thus it has worse performance” than the old numpypy fork (which was a from-scratch reimplementation of NumPy in pure Python, now unmaintained). If your code is NumPy-heavy, PyPy will not save you—and might even slow you down.
Other Tradeoffs: Memory, GC, and the GIL
Speed isn’t the only dimension that matters when you switch interpreters. There are a few other differences that can bite you in production if you’re not aware of them.
Garbage Collection: Reference Counting vs. Tracing
CPython manages memory using reference counting. Every object keeps a count of how many references point to it. When that count hits zero, the object is deallocated immediately. This has a nice property: you can rely on destructors (__del__ methods) running right when an object goes out of scope. It’s why with open('file.txt') as f: closes the file exactly when the block exits.
PyPy uses a tracing garbage collector. It doesn’t track references in real time. Instead, it periodically scans the object graph to find objects that are no longer reachable and cleans them up. This means __del__ methods may run later than you expect—or not at all if the program exits before the next GC cycle.
Let’s see what happens. We’ll create a class with a __del__ method that prints a message, and observe when (or if) that message appears.
import sys
interpreter = "PyPy" if hasattr(sys, 'pypy_version_info') else "CPython"
print(f"Running under: {interpreter}")
class Resource:
def __init__(self, name):
self.name = name
print(f"Creating {self.name}")
def __del__(self):
print(f"DESTROYING {self.name} (__del__ called)")
# Create and immediately lose a reference
print("Before creating object...")
r = Resource("my_file_handle")
print("After creating object...")
del r # Remove the only reference
print("After deleting reference...")
print("End of script.")
Under CPython, you’ll see DESTROYING my_file_handle printed right after del r, because the reference count hit zero immediately. Under PyPy, you might see it printed later, or at the very end of the script, or not at all—the tracing GC hasn’t run yet, and it might not run before the process exits. This matters if you’re relying on __del__ to close database connections, flush buffers, or release file locks. The recommended practice under PyPy is to use explicit context managers (with statements) rather than relying on destructors.
Memory Usage: Higher Baseline, Better Long-Term
PyPy typically uses more memory than CPython for short-lived scripts. The JIT compiler needs to store profiling data, generated machine code, and various internal data structures. For a small script that processes a file and exits, PyPy’s memory footprint might be 2-3x larger.
But for long-running processes—web servers, background workers, streaming data pipelines—PyPy can actually use less memory over time. Its tracing GC can be more efficient than reference counting for certain object graphs, and the JIT-compiled code can reduce the number of intermediate objects created. The tradeoff depends on your workload’s lifetime.
The GIL: Both Have It, But CPython Is Evolving
Both CPython and PyPy have a Global Interpreter Lock (GIL)—a mutex that prevents multiple threads from executing Python bytecode simultaneously. This means neither interpreter gives you free multi-core parallelism for CPU-bound Python code. (Both can still achieve parallelism through multiprocessing or by releasing the GIL inside C extensions.)
But here’s where the landscape is shifting: CPython is actively working on making the GIL optional. PEP 703 introduced a --disable-gil build that uses biased reference counting and a stop-the-world GC to allow true free-threading. This is still experimental, but it represents a second path to faster Python that doesn’t require switching interpreters at all. Meanwhile, CPython is also getting its own JIT compiler (PEP 744), though it’s in early stages. The gap between CPython and PyPy is narrowing from both sides.
C-Extension Compatibility Beyond NumPy
NumPy is the most prominent example, but it’s not the only one. Any library with a C extension—OpenCV, TensorFlow, psycopg2, lxml, Pillow—may work partially, slowly, or not at all under PyPy. PyPy’s documentation recommends using cffi (C Foreign Function Interface) or the newer HPy API for new C interop, but most existing libraries were written against CPython’s C-API and won’t be rewritten overnight.
Before committing to PyPy, you need to audit your requirements.txt or environment.yml for C-extension dependencies and check each one against PyPy’s compatibility list.
So, Do You Actually Need PyPy? A Decision Framework
We’ve covered a lot of ground. Let’s pull it all together into a practical decision framework you can use the next time you’re staring at a slow script and wondering if PyPy is the answer.
Decision Axis 1: Where Does Your Code Spend Its Time?
Profile first, decide second. Use cProfile, py-spy, or even a few well-placed time.perf_counter() calls. The fundamental question is: is your bottleneck in pure-Python logic, or is it inside C extensions?
- Pure-Python bottleneck (loops, string processing, custom algorithms, data structure manipulation): PyPy can help dramatically. You might see 3-10x speedups with zero code changes.
- C-extension bottleneck (NumPy array operations, pandas groupbys, scikit-learn model fitting): PyPy will not help. The time is spent in compiled C code that PyPy can’t optimize, and the
cpyextoverhead might even make things slower.
Decision Axis 2: What Libraries Do You Depend On?
Go through your dependency list. For each library with a C extension, check PyPy’s compatibility documentation. If a critical library is unsupported or known to be slow through cpyext, PyPy is a non-starter for that project. Common data-science libraries and their PyPy status:
- NumPy: Works through
cpyext, but slower than CPython for array-heavy code. - pandas: Depends on NumPy; same caveats apply.
- scikit-learn: Depends on NumPy and SciPy; expect no speedup.
- Pure-Python libraries (requests, flask, django, pytest): Generally work fine, often faster.
Decision Axis 3: Do You Need Predictable Destructor Behavior?
If your code relies on __del__ for resource cleanup—closing file handles, releasing database connections, removing temporary files—PyPy’s tracing GC may introduce subtle, hard-to-reproduce bugs. The fix is usually to switch to explicit context managers (with statements), but that requires a code audit.
Decision Axis 4: Are You Willing to Maintain Two Environments?
Some teams run PyPy for pure-Python web services (where the speedup translates directly to lower latency and fewer servers) while keeping CPython for data-science notebooks and ETL pipelines. This works, but it adds operational complexity: two sets of Docker images, two CI pipelines, two sets of dependency-compatibility checks. The benefit needs to outweigh that cost.
The Shifting Landscape
PyPy is not the only path to faster Python anymore. CPython itself is evolving:
- PEP 703 (
--disable-gil): Free-threaded CPython for multi-core parallelism. - PEP 744: A JIT compiler for CPython, based on the copy-and-patch technique.
- Vectorization and Numba: For data-science workloads, rewriting hot loops with NumPy vectorization or decorating them with
@numba.jitoften gives larger speedups than switching interpreters, without sacrificing library compatibility.
Here’s the decision tree in plain English:
- Profile your code. Find the bottleneck.
- Is the bottleneck pure Python?
- Yes → Can you tolerate PyPy’s GC behavior and memory tradeoffs? Are your critical C extensions compatible?
- Yes → Try PyPy. You might get 3-10x speedup for free.
- No → Look at CPython alternatives: Numba, Cython, or rewriting hot paths.
- No, it’s in NumPy/pandas/C extensions → PyPy won’t help. Try vectorization, Numba, Cython, or scaling horizontally.
- Yes → Can you tolerate PyPy’s GC behavior and memory tradeoffs? Are your critical C extensions compatible?
- Are you starting a new project with no C-extension dependencies? → PyPy is a strong default choice. You get speed without sacrificing Python’s expressiveness.
PyPy is a remarkable piece of engineering. For the right workload—pure Python, loop-heavy, library-light—it can feel like magic. But for the data-science stack that dominates our field, it’s rarely the right hammer. The good news is that understanding why makes you a better engineer, regardless of which interpreter you choose.
Check Your Understanding
Remember:
- What is the difference between Python (the language) and CPython (the implementation)?
- What does JIT stand for, and in one sentence, what does a JIT compiler do?
Understand:
- Why does PyPy’s JIT provide large speedups for pure-Python loops but not for NumPy operations?
- Explain in your own words why
__del__methods behave differently under CPython vs. PyPy.
Apply:
- You have a script that spends 95% of its time in
pd.DataFrame.groupby().agg(). Should you try PyPy? Why or why not? - You have a pure-Python web scraper that parses HTML with BeautifulSoup (a pure-Python library) and makes HTTP requests. Is PyPy a good candidate? What would you check before switching?
Analyze:
- A colleague tells you, “I switched to PyPy and my script got 5x faster.” What questions would you ask to understand why it got faster, and whether the same speedup would apply to your own code?
Evaluate:
- Compare the tradeoffs of switching to PyPy vs. rewriting hot loops with Numba for a data-science workload. What are the advantages and disadvantages of each approach?
Create:
- Design a small benchmark that would reveal whether a given Python script is a good candidate for PyPy. What would you measure? What would a “good candidate” result look like vs. a “bad candidate” result?
Apply What You Learned is for Supporter and Insider subscribers.
Subscribe to unlock the exercises on this post.
See plansRelated articles
- Python Engineering Under review
Talking To Apis The Requests Patterns Every Data S
Let's paint a picture. You're a data scientist at a mid-sized e-commerce company. Every morning, you need to pull yesterday's sales numbers from the company's internal dashboard.
- Python Engineering Under review
Why Programmers Freeze Before Writing Any Code An
Let's name the feeling. You have a task: "Clean this messy CSV and compute monthly revenue per customer." You've done this before. You know pandas. You know how to group data.
- Python Engineering Under review
Why Is My Pandas Code So Slow? A Practical Guide to Vectorization
Learn why row-by-row loops make Pandas painfully slow, and how vectorized arithmetic can run up to 10,000x faster — plus the real, measured speedups np.select and groupby deliver over the apply()/loop code they replace.
- Python Engineering Under review
Degrees Of Freedom The Statistics Concept Nobody E
Let's start with a simple analogy. Imagine you're building a three-legged stool. You can choose the length of the first two legs freely — any height you want. But once those two are set, the third leg's length is forced.
Looking for something else?
Search every article by title, summary or topic.