All measurements were taken on the same machine. Terminal output was copied from recorded sessions without editing. The test system configuration is listed at the end of the article.
Python is an excellent choice for image processing. It makes it easy to open files, transform data, assemble pipelines, and experiment with algorithms. At first glance, however, there seems to be a contradiction: how can an interpreted language that is not designed for heavy computational loops process millions of pixels efficiently?
There is no contradiction. Each tool simply has its place.
Consider a common task: we have a large image and want to apply a filter to it—for example, detect edges with the Sobel operator. The important parts of the code will appear later in the article; the complete source files are collected in the appendix at the end.
Starting with a Python implementation is perfectly reasonable. The code is easy to write, verify, and modify. It is ideal for prototyping: you can confirm that the algorithm works correctly, compare results, and fix mistakes quickly.
The problem appears later, when the same algorithm has to run on a sixteen-megapixel image. The nested loop processes millions of pixels, and the convenient implementation suddenly takes more than ten seconds:
$ python edges.py input.jpg output-python.png --engine python
engine: python
size: 5000 × 3333
load: 0.189 s
compute: 11.476 s
save: 0.726 s
total: 12.392 s
sha256: f8adca809695a5f2f8d935185cb2dfcf50d1977fceda4b01ce4056d7a8a75dfc
We could rewrite the entire application in a faster language, but there is no need. Image loading, argument handling, library calls, and result saving can all remain in Python. Only one small section needs to be accelerated: the compute function in which the program spends 93% of its time.
This is where Rust can help. We will keep the application in Python, move one self-contained operation into native code, and expose it as an ordinary function. The calling code will barely change, the result will remain identical, and the computation itself will become hundreds of times faster:
$ python edges.py input.jpg output-native.png --engine native
engine: native
size: 5000 × 3333
load: 0.186 s
compute: 0.032 s
save: 0.752 s
total: 0.971 s
sha256: f8adca809695a5f2f8d935185cb2dfcf50d1977fceda4b01ce4056d7a8a75dfc
On a cold run, the compute stage became 358 times faster; comparing medians, it was almost 500 times faster. The two implementations produced byte-for-byte identical output.
Yet the complete program became only 12.8 times faster.
At first, this may seem disappointing: the function became almost five hundred times faster, while the application improved by only a factor of thirteen. In reality, that difference is the clearest illustration of how real optimization works: how to identify the part worth moving, how to define the boundary between languages, and where the bottleneck moves after a successful speedup.
Why a Simple Loop Takes Eleven Seconds
Let us begin with the original implementation. Its core looks roughly like this:
def sobel_python(data: bytes, width: int, height: int) -> bytes:
output = bytearray(width * height)
for y in range(1, height - 1):
for x in range(1, width - 1):
# Eight neighbors in a 3×3 window: p00..p22, using ordinary bytes indexing
gx = -p00 + p02 - 2 * p10 + 2 * p12 - p20 + p22
gy = -p00 - 2 * p01 - p02 + p20 + 2 * p21 + p22
output[row + x] = min(255, abs(gx) + abs(gy))
return bytes(output)
The algorithm itself is extremely simple. For every non-border pixel, we read eight neighboring values from a 3×3 window, calculate the horizontal and vertical gradients, add their absolute values, and cap the result at 255.
There is no complicated mathematics here, nor is there an obviously inefficient algorithm. Even so, the function takes eleven seconds.
The reason is that CPython executes a loop like this very differently from a compiled program.
When native code reads the next byte from an array, the operation may be a straightforward load from memory. When Python does the same thing, an expression such as data[upper + x - 1] involves interpreter work: executing bytecode, checking types, calculating the index, checking bounds, and creating or retrieving a Python object.
The same applies to arithmetic. Additions and multiplications are not performed directly on values in CPU registers. They go through Python's object model. Values have types and reference counts, and operations on them pass through the corresponding interpreter logic.
Calls to abs and min are not free either. To a human reader, they look like small parts of a single line. To CPython, they are full function calls.
Even the for loop is not merely a machine-level jump back to the start of the next iteration. It involves the iterator protocol and retrieving the next value.
For a 5000×3333 image—16.67 megapixels at a 3:2 aspect ratio—the inner loop runs approximately 16.6 million times. Processing one pixel takes about 690 ns. That sounds small in isolation, but repeated sixteen million times, it becomes eleven seconds.
A compiled implementation of the same loop takes about 1.3 ns per pixel.
That is why the difference is so large. The point is not the abstract claim that “Rust is faster than Python.” We are simply no longer paying the cost of interpretation on every one of millions of iterations.
Before moving anything, however, we need to make sure we have found the right place.
Measure First, Optimize Second
When we see two nested loops, the culprit may look obvious. But optimization based solely on intuition often leads to unnecessary work.
We could immediately decide to rewrite the code in Rust. We could try multiprocessing, add threads, or buy a faster processor. But all of those would be solutions chosen before identifying the actual problem.
If the program spends most of its time waiting for a database response, moving computations to Rust will change almost nothing. If it is waiting on the network, another language will not make the internet faster. If the main delay comes from reading files, optimizing arithmetic is pointless.
So first, let us divide program execution into its main stages and measure each one with perf_counter():
load: 0.189 s
compute: 11.476 s
save: 0.726 s
total: 12.392 s
The picture is now quite clear. Loading takes less than two tenths of a second, saving takes about seven tenths, and computation takes almost eleven and a half seconds.
The program spends roughly 93% of its total runtime inside the Sobel function.
Even this rough measurement is enough to determine where to focus. If we eliminated file-saving time entirely, the program would improve only from about 12.4 s to 12.0 s. Technically, the result would be better, but the user would hardly notice.
To examine the program in more detail, we can use cProfile from the standard library:
$ python -m cProfile -o profile.dat edges.py input.jpg profiled.png --engine python
$ python -m pstats profile.dat
Welcome to the profile statistics browser.
profile.dat% strip
profile.dat% sort cumulative
profile.dat% stats 8
Sat Aug 1 01:07:51 2026 profile.dat
50016220 function calls (50014440 primitive calls) in 25.059 seconds
Ordered by: cumulative time
List reduced from 1079 to 8 due to restriction <8>
ncalls tottime percall cumtime percall filename:lineno(function)
65/1 0.003 0.000 25.059 25.059 {built-in method builtins.exec}
1 0.000 0.000 25.059 25.059 edges.py:1(<module>)
1 0.005 0.005 25.002 25.002 edges.py:114(main)
1 0.000 0.000 24.996 24.996 edges.py:67(process_image)
1 18.173 18.173 24.038 24.038 edges.py:20(sobel_python)
33296676 3.723 0.000 3.723 0.000 {built-in method builtins.abs}
16648719 2.142 0.000 2.142 0.000 {built-in method builtins.min}
1 0.000 0.000 0.722 0.722 Image.py:2592(save)
profile.dat% quit
Goodbye.
The profile confirms the initial measurement. Almost all of the time is spent in sobel_python.
During a single run, the program calls abs about 33 million times and min 16.6 million times. In total, the profiler counted about 50 million function calls.
That is the cost of implementing a tight computational loop in pure Python.
There is another important detail, however. A normal run took 12.4 s, while under cProfile the program took 25 s. The profiler had to track tens of millions of calls, nearly doubling the execution time.
For that reason, profiling results should not be used as ordinary benchmark results. A profiler answers the question, “Where does the program spend its time?” It is not suitable for answering, “How much faster is the new version?”
Performance comparisons require separate runs without the profiler and without unnecessary I/O.
Now that we have found the hot path, we can decide how to move it.
The Language Boundary Matters as Much as the Language
You can write a function in Rust and still get no speedup.
For example, we could keep the loops in Python and move only the processing of a single pixel into Rust. Python would then call a native function sixteen million times, passing neighboring values to it and receiving a result in return.
The computation inside Rust would be fast. But every transition across the language boundary has a cost. The arguments must be prepared, their types checked and converted, control passed to the extension, a result created, and that result returned to Python.
One such call is cheap. Sixteen million calls may cost more than the original computation.
For this kind of task, the boundary therefore needs to be broad.
Python should pass the complete image buffer, its width, and its height to Rust once. Rust should then iterate over all pixels, perform the indexing and arithmetic itself, and return the completed output buffer once.
In other words, the entire tight loop—not an individual operation—needs to move to the native side.
With this division, each language handles the work it is best suited for. Python remains at the application level: it parses command-line arguments, opens files, calls libraries, and manages the overall sequence of operations. Rust receives a self-contained computational task over a contiguous byte array.
The contract between the two parts is very simple: a byte buffer and two numbers as input, and a byte buffer as output.
Boring interfaces like this are often the most successful. They are easy to verify, use, and maintain.
Move One Function, Not the Entire Application
To connect Rust code to Python, we will use PyO3. It allows Rust functions to be exported so that, from Python, they look like functions from an ordinary module.
Maturin will handle building and installing the extension into the virtual environment:
$ pip install maturin
$ maturin new -b pyo3 edge_native
The application itself remains in Python. A small Rust module is added beside it, containing only the computational part:
#[pyfunction]
fn sobel(data: &[u8], width: usize, height: usize) -> PyResult<Vec<u8>> {
// Checks: dimensions are at least 3×3, and buffer length equals width * height
let mut output = vec![0_u8; data.len()];
for y in 1..height - 1 {
for x in 1..width - 1 {
// The same eight neighbors p00..p22, represented as i32 values
let gx = -p00 + p02 - 2 * p10 + 2 * p12 - p20 + p22;
let gy = -p00 - 2 * p01 - p02 + p20 + 2 * p21 + p22;
output[row + x] = (gx.abs() + gy.abs()).min(255) as u8;
}
}
Ok(output)
}
Placed side by side, the Python and Rust implementations are almost identical line for line. The algorithm has not changed. We did not replace the Sobel operator with another method, use a GPU, or reduce numerical accuracy to produce a more impressive benchmark figure.
Only the execution model changed.
In Rust, pixel values are read from a contiguous buffer. Arithmetic operates on ordinary integers, while abs and min compile down to machine instructions. The loop becomes tight native code without millions of trips through Python's object model.
In addition to the main logic, the Rust function should validate its input. For example, it should confirm that the image dimensions are at least 3×3 and that the buffer length is equal to the product of width and height.
Native code should not blindly trust values received across an external boundary.
The module must be built in release mode:
$ cd edge_native
$ maturin develop --release
📦 Built wheel for CPython 3.14
🛠 Installed edge_native-0.1.0
A debug build is intended for development and troubleshooting. Optimizations may be disabled and additional checks may be enabled. Comparing the performance of a debug build with an optimized version is therefore meaningless.
After that, only a few lines need to change in the Python code:
try:
from edge_native import sobel as sobel_native
except ImportError:
sobel_native = None
Everything else remains the same. The program still opens the image through a Python library, parses command-line arguments, and saves a PNG file.
We have replaced only the executor of one hot function.
Verify the Result Before Celebrating the Speed
After the move, the function runs much faster. But speed alone proves nothing.
The optimized implementation must produce the same result as the original. Otherwise, this is not an optimization of the program—it is a change in its behavior.
To verify correctness, both functions receive the same input buffer. Their outputs are then compared using SHA-256:
$ python verify.py input.jpg
python sha256: f8adca809695a5f2f8d935185cb2dfcf50d1977fceda4b01ce4056d7a8a75dfc
native sha256: f8adca809695a5f2f8d935185cb2dfcf50d1977fceda4b01ce4056d7a8a75dfc
MATCH
Matching hashes mean that the results are identical byte for byte.
This is more meaningful than a visual comparison. Two images can look identical even if individual pixels differ slightly. Here, we know that the native implementation's output is completely identical to the Python version's output.
There were no approximations and no compromises in accuracy. We obtained the same result, only much faster.
Now let us see what happened to the complete program.
The Function Became 358× Faster. The Program Became 12.8× Faster
Let us place the results of the two complete runs side by side:
python: load 0.189 compute 11.476 save 0.726 total 12.392
native: load 0.186 compute 0.032 save 0.752 total 0.971
In the original version, computation took 11.476 s. In the native version, it took 0.032 s. On a cold run, the computational stage became approximately 358 times faster.
But total runtime changed from 12.392 s to 0.971 s. In other words, the entire application became only 12.8 times faster.
This is not a measurement error, nor is it performance lost in the Rust call. It is Amdahl's law: the total speedup of a program is limited by the part that was not accelerated.
Before optimization, the Sobel operator consumed almost all of the runtime. Loading and saving looked insignificant by comparison.
After optimization, computation nearly disappeared from the total. Loading still took 0.186 s, while saving took 0.752 s. Together, these two stages now account for about 97% of the program's total runtime.
Moreover, PNG encoding became approximately 23 times more expensive than edge computation.
Before optimization, the Python loop was the main bottleneck. After optimization, the PNG codec became the bottleneck.
The bottleneck did not disappear—it moved.
This is what successful optimization usually looks like. When one part of a program becomes dramatically faster, the next most expensive operation moves into the foreground.
Two practical conclusions follow from this.
First, we now know exactly what to investigate next. There is no reason to move random parts of the application to Rust. If one second per image is still too slow, we need to examine PNG saving specifically: compare codecs, compression levels, and existing libraries.
Writing a custom PNG codec would probably be a mistake. It would be more sensible to find a library that already solves this problem faster.
The second conclusion is even more important: perhaps there is no need to continue at all.
If one second per photograph satisfies the application's requirements, the optimization effort can stop. The work should be driven by the actual requirement, not by the desire to produce an even more impressive number.
The individual function really did become hundreds of times faster. But the user interacts with the complete program, not with an isolated function. That is why the final 12.8× speedup matters more than the local 358× or 498× figure.
How to Measure the Speedup Fairly
A single program run is not a complete benchmark. Background processes, CPU frequency, temperature, filesystem cache, and other factors can affect the result.
A full run also includes loading the JPEG and saving the PNG. These stages matter to the user, but they make it harder to compare the two implementations of the algorithm itself precisely.
For a clean benchmark, the image should therefore be loaded once, after which only the Sobel function should be measured separately.
Measurements should also be performed without cProfile, because the profiler noticeably changes execution time. The native function should be warmed up before timing: the first call was more expensive than later calls—32 ms versus 22 ms.
After warming up, we perform several runs and compare the medians:
$ python benchmark.py input.jpg
input: 5000 x 3333 (16.7 MP)
Python:
10.84
10.81
10.73
median: 10.81 s
Native:
0.022
0.022
0.022
0.022
0.022
median: 0.022 s
speedup (compute only): 497.9x
For computation alone, the native version was almost 500 times faster.
The Python implementation is run three times because each run takes about eleven seconds. The native version can be measured five times at almost no additional cost.
Why do we compare the median rather than selecting the single best result?
Because laptop performance can vary noticeably even on the same machine. A week earlier, the same test reported 13.95 s for Python instead of today's 10.81. Temperature and overall CPU load affect the result.
Within a single session, the values are fairly stable. Between different sessions, absolute timings may differ by tens of percent.
It would therefore be too strong to claim that “this function always runs in 10.81 seconds.” The ratio between the medians of two implementations measured back to back in the same session is a much more reliable indication of their real performance difference.
When Moving Code to Rust Will Not Help
A nearly 500× result looks impressive, but it does not mean that every slow part of a Python application should be rewritten in Rust.
This approach works only for a particular class of problems.
If the program is waiting for a database query, the main delay lies outside the Python process. If it is waiting for a network response, native code will not speed up the connection. If disk throughput is the limiting factor, moving arithmetic to Rust will not change the characteristics of the storage device.
Moving code that already calls an optimized library will not produce a major improvement either.
For example, many NumPy and OpenCV operations only look like Python code. The heavy work inside them is already performed by compiled implementations. In such cases, Python merely passes the data and calls an existing native function.
There is usually no reason to rewrite such a thin wrapper.
The exact placement of the boundary matters as well. If Rust is called millions of times with one pixel or a pair of numbers at a time, the cost of crossing between languages may consume the entire speedup.
A good candidate for moving to native code usually has three properties.
First, it is genuinely CPU-bound rather than waiting for external resources. Second, it performs a large amount of repetitive work over a substantial volume of data. Third, it can be isolated as a self-contained operation with simple input and output values.
Image and audio processing, parsers, compression, simulations, and computational geometry all fit this category.
Even for these tasks, however, a custom extension should not be the first solution.
Start by examining the algorithm. Sometimes a better algorithm produces a larger gain than changing the implementation language. Then check whether an appropriate built-in function or existing library already exists. The operation you need may already have an efficient implementation in NumPy, OpenCV, or another package.
Custom native code is the next step only after measurements have shown that existing solutions are insufficient.
Do Not Rewrite the Application—Move the Work
The main lesson from this example is not that Rust is faster than Python. That will surprise very few people.
The more useful lesson is that making a Python application dramatically faster usually does not require rewriting the entire application.
Python can remain where developer productivity, library access, and application orchestration matter. A small computational section that performs the same kind of work millions of times can be moved to an environment where it runs as tight machine code.
The sequence of steps matters more than the choice of language.
First, measure the program and find the true source of the delay. Then choose a self-contained operation and define a broad boundary between Python and native code. After moving it, verify the correctness of the result, run a clean benchmark, and measure the complete program again.
In our case, that section was a single function of roughly fifty lines.
The computation became almost 500 times faster. The complete application became about 13 times faster. The output did not change by a single byte.
You do not have to move one hundred percent of the code to achieve a major speedup. Sometimes it is enough to find the small section that performs almost all of the work.
That is why the question “Which language should we rewrite the application in?” is usually the wrong one.
A much more useful question is: “Where does the program spend its time, and which self-contained operation can we move without breaking everything else?”
Once that question has been answered, choosing the tool becomes the final—and often the easiest—step.
Appendix: Full Source Files
edges.py — the application
from __future__ import annotations
import argparse
import hashlib
from pathlib import Path
from time import perf_counter
from typing import Callable
from PIL import Image
try:
from edge_native import sobel as sobel_native
except ImportError:
sobel_native = None
EdgeDetector = Callable[[bytes, int, int], bytes]
def sobel_python(data: bytes, width: int, height: int) -> bytes:
if width < 3 or height < 3:
raise ValueError("image is too small")
if len(data) != width * height:
raise ValueError("invalid image buffer length")
output = bytearray(width * height)
for y in range(1, height - 1):
row = y * width
upper = row - width
lower = row + width
for x in range(1, width - 1):
p00 = data[upper + x - 1]
p01 = data[upper + x]
p02 = data[upper + x + 1]
p10 = data[row + x - 1]
p12 = data[row + x + 1]
p20 = data[lower + x - 1]
p21 = data[lower + x]
p22 = data[lower + x + 1]
gx = -p00 + p02 - 2 * p10 + 2 * p12 - p20 + p22
gy = -p00 - 2 * p01 - p02 + p20 + 2 * p21 + p22
output[row + x] = min(255, abs(gx) + abs(gy))
return bytes(output)
def select_detector(engine: str) -> EdgeDetector:
if engine == "python":
return sobel_python
if sobel_native is None:
raise RuntimeError(
"Native module is unavailable. "
"Build it with: cd edge_native && maturin develop --release"
)
return sobel_native
def process_image(
input_path: Path,
output_path: Path,
engine: str,
) -> None:
load_started = perf_counter()
with Image.open(input_path) as source:
image = source.convert("L")
data = image.tobytes()
load_time = perf_counter() - load_started
detector = select_detector(engine)
compute_started = perf_counter()
edges = detector(data, image.width, image.height)
compute_time = perf_counter() - compute_started
save_started = perf_counter()
Image.frombytes("L", image.size, edges).save(output_path)
save_time = perf_counter() - save_started
total = load_time + compute_time + save_time
digest = hashlib.sha256(edges).hexdigest()
print(f"engine: {engine}")
print(f"size: {image.width} × {image.height}")
print(f"load: {load_time:8.3f} s")
print(f"compute: {compute_time:8.3f} s")
print(f"save: {save_time:8.3f} s")
print(f"total: {total:8.3f} s")
print(f"sha256: {digest}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument(
"--engine",
choices=("python", "native"),
default="python",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
process_image(args.input, args.output, args.engine)
if __name__ == "__main__":
main()verify.py — byte-for-byte comparison
"""§9:30 "The Result" — an optimization that changes the result is called a bug.
Both implementations receive the SAME buffer and must return byte-for-byte
identical output: we compare SHA-256 of the raw edge data, not pictures by eye.
"""
from __future__ import annotations
import hashlib
import sys
from pathlib import Path
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "01-python-app"))
from edges import sobel_python # noqa: E402
from edge_native import sobel as sobel_native # noqa: E402
def main() -> None:
path = sys.argv[1] if len(sys.argv) > 1 else "../input.jpg"
with Image.open(path) as source:
image = source.convert("L")
data = image.tobytes()
python_edges = sobel_python(data, image.width, image.height)
native_edges = sobel_native(data, image.width, image.height)
python_digest = hashlib.sha256(python_edges).hexdigest()
native_digest = hashlib.sha256(native_edges).hexdigest()
print(f"python sha256: {python_digest}")
print(f"native sha256: {native_digest}")
print("MATCH" if python_digest == native_digest else "MISMATCH")
sys.exit(0 if python_digest == native_digest else 1)
if __name__ == "__main__":
main()benchmark.py — the clean benchmark
"""§11:35 "How to measure fairly" — a single fast run is not a benchmark.
ONLY the computation is measured, on one input, on one machine; the report is
the median. Native is warmed up before timing; the slow version runs three
times, the fast one runs more often — its runs are cheap. And all of it
happens outside the profiler.
"""
from __future__ import annotations
import sys
from pathlib import Path
from statistics import median
from time import perf_counter
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "01-python-app"))
from edges import sobel_python # noqa: E402
from edge_native import sobel as sobel_native # noqa: E402
def measure(function, data, width, height, runs):
function(data, width, height) # warm-up
samples = []
for _ in range(runs):
started = perf_counter()
function(data, width, height)
samples.append(perf_counter() - started)
return samples
def main() -> None:
path = sys.argv[1] if len(sys.argv) > 1 else "../input.jpg"
with Image.open(path) as source:
image = source.convert("L")
data = image.tobytes()
megapixels = image.width * image.height / 1e6
print(f"input: {image.width} x {image.height} ({megapixels:.1f} MP)
")
python_samples = measure(sobel_python, data, image.width, image.height, runs=3)
print("Python:")
for sample in python_samples:
print(f"{sample:8.2f}")
python_median = median(python_samples)
print(f"median: {python_median:.2f} s
")
native_samples = measure(sobel_native, data, image.width, image.height, runs=5)
print("Native:")
for sample in native_samples:
print(f"{sample:8.3f}")
native_median = median(native_samples)
print(f"median: {native_median:.3f} s
")
print(f"speedup (compute only): {python_median / native_median:.1f}x")
if __name__ == "__main__":
main()Test system: Intel i7-10750H (6 cores / 12 threads), 62 GB RAM, Fedora 43; Python 3.14.6 (venv), PyO3 + Maturin, Rust 1.97.0, release build with LTO and codegen-units = 1; input: a 5000×3333, 16.7 MP grayscale photograph. All terminal output was copied from real recorded sessions without editing.