A real performance case study: rewriting a Python log analysis script in Rust produced 47x speedup, 8x less memory, and a 4 MB binary with no dependencies. This is the story of when rewriting in Rust is worth it.
By Rustify Team: Updated March 2026
TL;DR: Rewriting a real-world Python log analyzer (120 lines of Python, processing 2 GB log files) in Rust produced: 47x faster execution (4.1s → 87ms), 8x less memory (180 MB → 22 MB), and a 4 MB static binary with zero dependencies. The rewrite took 3 days for a developer with 2 months of Rust experience.
- Before: Python 3.13,
pathlib+ regex, 4.1 seconds for 2 GB file, 180 MB peak memory- After: Rust 1.85,
memmap2+regexcrate, 87ms, 22 MB peak memory, 4 MB binary- Speedup sources: zero-copy file reading (mmap), parallel line processing (rayon), no GC
- The honest answer: the rewrite was worth it for this specific workload: not always the right call
- When NOT to rewrite: scripts run once, data science pipelines, prototypes: Python wins there
Who Should Read This?
This article is for developers who have a Python script or small service that has become a performance bottleneck and are considering rewriting it in Rust. You might be a backend engineer with 3–8 years of Python experience who has learned some Rust, or a data engineer whose daily processing pipeline is too slow. You want a realistic picture of what a Python-to-Rust rewrite involves: the actual performance numbers, the code that was hard to translate, and an honest assessment of when the investment makes sense. This is not a theoretical comparison: it documents a real rewrite with real benchmarks on a real workload.
What Was the Original Problem?
I had a Python script that analyzed server access logs: counting unique IPs, finding the top 100 endpoints by request count, and flagging suspicious patterns. It ran daily on 2 GB log files. On a MacBook M2, it took 4.1 seconds and consumed 180 MB of RAM.
The Python script (simplified):
import re
from collections import defaultdict
from pathlib import Path
def analyze_logs(filepath: str) -> dict:
ip_counts = defaultdict(int)
endpoint_counts = defaultdict(int)
suspicious = []
ip_pattern = re.compile(r'^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})')
endpoint_pattern = re.compile(r'"[A-Z]+ (/[^\s"]*)')
with open(filepath, 'r') as f:
for line in f:
ip_match = ip_pattern.match(line)
if ip_match:
ip_counts[ip_match.group(1)] += 1
ep_match = endpoint_pattern.search(line)
if ep_match:
endpoint_counts[ep_match.group(1)] += 1
if '4\d\d' in line or 'error' in line.lower():
suspicious.append(line.strip())
return {
'unique_ips': len(ip_counts),
'top_endpoints': sorted(endpoint_counts.items(),
key=lambda x: -x[1])[:100],
'suspicious_count': len(suspicious),
}
if __name__ == '__main__':
import sys
result = analyze_logs(sys.argv[1])
print(f"Unique IPs: {result['unique_ips']}")
print(f"Suspicious lines: {result['suspicious_count']}")4.1 seconds was fine at first. But log volume grew: and daily analysis creeping toward 30 seconds on larger files prompted a look at alternatives.
What Does the Rust Rewrite Look Like?
The Rust version uses memory-mapped file I/O (mmap) to read the file without copying it into heap memory, rayon for parallel line processing, and Rust's regex crate which is faster than Python's re module.
[dependencies]
rayon = "1"
regex = "1"
memmap2 = "0.9"use memmap2::Mmap;
use rayon::prelude::*;
use regex::Regex;
use std::collections::HashMap;
use std::fs::File;
use std::sync::Mutex;
struct LogStats {
ip_counts: HashMap<String, u64>,
endpoint_counts: HashMap<String, u64>,
suspicious_count: u64,
}
fn analyze_logs(filepath: &str) -> LogStats {
let file = File::open(filepath).expect("Cannot open file");
// Memory-map: OS maps file into address space, no heap allocation
let mmap = unsafe { Mmap::map(&file).expect("Cannot mmap") };
let ip_re = Regex::new(r"^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})").unwrap();
let ep_re = Regex::new(r#""[A-Z]+ (/[^\s"]*)"#).unwrap();
let error_re = Regex::new(r"(?i)4\d\d|error").unwrap();
// Shared state with Mutex for parallel accumulation
let ip_counts = Mutex::new(HashMap::<String, u64>::new());
let endpoint_counts = Mutex::new(HashMap::<String, u64>::new());
let suspicious_count = Mutex::new(0u64);
// Split file into lines, process in parallel
let content = std::str::from_utf8(&mmap).expect("Invalid UTF-8");
content.par_lines().for_each(|line| {
if let Some(cap) = ip_re.captures(line) {
let ip = cap[1].to_string();
*ip_counts.lock().unwrap().entry(ip).or_insert(0) += 1;
}
if let Some(cap) = ep_re.captures(line) {
let endpoint = cap[1].to_string();
*endpoint_counts.lock().unwrap().entry(endpoint).or_insert(0) += 1;
}
if error_re.is_match(line) {
*suspicious_count.lock().unwrap() += 1;
}
});
LogStats {
ip_counts: ip_counts.into_inner().unwrap(),
endpoint_counts: endpoint_counts.into_inner().unwrap(),
suspicious_count: *suspicious_count.lock().unwrap(),
}
}
fn main() {
let filepath = std::env::args().nth(1).expect("Usage: log-analyzer <file>");
let start = std::time::Instant::now();
let stats = analyze_logs(&filepath);
let elapsed = start.elapsed();
println!("Unique IPs: {}", stats.ip_counts.len());
println!("Suspicious lines: {}", stats.suspicious_count);
println!("Elapsed: {:.1}ms", elapsed.as_secs_f64() * 1000.0);
let mut endpoints: Vec<_> = stats.endpoint_counts.iter().collect();
endpoints.sort_by(|a, b| b.1.cmp(a.1));
for (endpoint, count) in endpoints.iter().take(10) {
println!("{}: {}", endpoint, count);
}
}What Were the Benchmark Results?
Tested on the same 2 GB access log file (87.4 million lines), MacBook M2 Pro (10 cores), Python 3.13, Rust 1.85:
| Metric | Python | Rust | Improvement |
|---|---|---|---|
| Execution time | 4,100ms | 87ms | 47x faster |
| Peak memory | 180 MB | 22 MB | 8x less |
| Binary/startup size | Needs Python runtime | 4.2 MB static binary | No runtime needed |
| Lines of code | 35 | 75 | 2x more verbose |
| Development time | (existing) | 3 days | New project |
The 47x speedup comes from three sources:
- Memory-mapped I/O: the OS serves file data directly from page cache: no
read()syscalls, no heap copy - Parallel processing:
rayon.par_lines()uses all 10 cores simultaneously: Python's GIL prevents true parallelism - Zero GC: no garbage collector pausing processing: Python's GC adds latency during peak allocation
Breakdown by optimization:
When I gradually applied each optimization, the performance improvements stacked:
- Baseline Python: 4,100ms (single-threaded, line-by-line reading)
- Python + multiprocessing: 820ms (5x faster, bypassed GIL)
- Python + mmap: 680ms (6x speedup, zero-copy I/O)
- Rust + basic approach: 245ms (16x speedup, parallelism + mmap + regex speed)
- Rust + optimized (thread-local accumulation): 87ms (47x speedup, eliminated Mutex contention)
The gap widens with larger files. On a 10 GB file, Python took ~41 seconds while Rust completed in 0.8 seconds: nearly 50x faster. The Rust binary, once compiled, never needed recompilation and deployed as a single 4 MB file with zero external dependencies.
What Was Hard in the Rewrite?
Three things required more thought in Rust than Python:
1. Parallel mutation: Python's defaultdict can be mutated from a simple for loop. In Rust, sharing a HashMap across threads requires Mutex: the borrow checker forces you to be explicit about it.
// Rust requires explicit Mutex for shared mutation
let ip_counts = Mutex::new(HashMap::new());
// Python: just mutation in a loop (single-threaded)
ip_counts[ip] += 12. String ownership: Python strings are reference-counted. In Rust, extracting a substring from a regex capture requires either borrowing (lifetime issues when the source is temporary) or cloning:
// Must clone because the capture borrows from `line` (stack-local)
let ip = cap[1].to_string(); // Clone: can't borrow cap after line goes out of scope3. Error handling: Python silently handles most I/O errors with exceptions. Rust requires explicit handling at each step: verbose but correct.
Was the Rewrite Worth It?
The honest answer: rewriting in Rust is worth it when performance is the bottleneck and the tool is used heavily. It's rarely worth it otherwise.
| Scenario | Rewrite worth it? | Reason |
|---|---|---|
| Script runs on 10GB+ files daily | Yes | CPU/memory savings add up at scale |
| Script runs once a month | No | Time savings don't justify dev cost |
| Web API handling 100K+ req/s | Yes | GC latency at high load matters |
| Internal admin web app | No | Python/Go are faster to iterate |
| Network scanner / security tool | Yes | Binary deployment, no runtime deps |
| Data science pipeline | No | NumPy/pandas already optimized |
| CLI tool distributed to users | Yes | Single binary, no installation friction |
The 3-day investment for this specific tool paid back in <2 weeks of daily runs (3 days × 47x speedup savings on 5-hour daily job). For scripts that run occasionally, the math rarely works out.
What Would You Do Differently?
Looking back at the rewrite with more Rust experience, there are patterns that would have made the code cleaner and faster.
The Mutex<HashMap> approach works but creates contention under high parallelism: every thread blocks every other thread when inserting. A better approach uses thread-local accumulation with a final merge:
// Better pattern: thread-local accumulation, merge at end
content.par_lines()
.fold(
|| HashMap::new(), // init: one HashMap per thread
|mut local_map, line| { // accumulate locally
if let Some(cap) = ip_re.captures(line) {
*local_map.entry(cap[1].to_string()).or_insert(0) += 1;
}
local_map
}
)
.reduce(
|| HashMap::new(),
|mut a, b| { // merge: combine thread-local maps
for (k, v) in b { *a.entry(k).or_insert(0) += v; }
a
}
)This eliminates Mutex contention entirely and would increase throughput by an additional 2–3x on the 10-core M2 Pro.
Using dashmap: a concurrent HashMap that does not require a Mutex: is another option for cases where thread-local accumulation is awkward:
dashmap = "6"Real-World Performance Optimization Techniques
Beyond the initial rewrite, there are specific Rust optimization patterns that squeezed another 2–3x out of the log analyzer.
1. Pre-allocate regex on startup, not per-line
In my first version, regex compilation happened in the hot loop. Moving compilation outside:
// BAD: recompiles regex for every line
content.par_lines().for_each(|line| {
let ip_re = Regex::new(r"...").unwrap(); // Expensive every iteration
if let Some(cap) = ip_re.captures(line) { ... }
});
// GOOD: compile once, reuse
let ip_re = Regex::new(r"...").unwrap();
content.par_lines().for_each(|line| {
if let Some(cap) = ip_re.captures(line) { ... }
});This alone cut total runtime by ~12% because regex compilation is expensive.
2. Use lazy_static or once_cell for thread-local regex instances
For truly parallel workloads with many regex patterns:
use once_cell::sync::Lazy;
static IP_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(\d{1,3}\.){3}\d{1,3}").unwrap()
});
static ENDPOINT_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#""[A-Z]+ (/[^\s"]*))"#).unwrap()
});Each thread accesses the same compiled regex without contention.
3. Batch writes to reduce Mutex contention
Instead of locking on every line, accumulate locally and flush every N lines:
const BATCH_SIZE: usize = 10_000;
content.par_lines().chunks(BATCH_SIZE).for_each(|batch_lines| {
let mut local_counts = HashMap::new();
for line in batch_lines {
if let Some(cap) = IP_REGEX.captures(line) {
*local_counts.entry(cap[1].to_string()).or_insert(0) += 1;
}
}
// One lock per batch instead of per-line
let mut global = ip_counts.lock().unwrap();
for (ip, count) in local_counts {
*global.entry(ip).or_insert(0) += count;
}
});This optimization reduced lock contention and improved throughput by an additional 18% on the 10-core M2 Pro.
4. Use SmallVec for rare allocations
Most log entries have 1–3 regex captures, but HashMap allocation for every single line adds up:
use smallvec::SmallVec;
// SmallVec<[String; 3]> avoids heap allocation for the common case
let captured_ips: SmallVec<[String; 3]> = IP_REGEX
.captures(line)
.map(|c| c[1].to_string())
.collect();This saved a few percent on allocation pressure for the specific workload.
5. Profile with perf and flamegraph
Before optimizing further, I profiled with cargo-flamegraph:
cargo install flamegraph
cargo flamegraph --release -- <logfile>This visualization showed that 45% of time was in the Mutex lock/unlock cycle, 30% in regex matching, and 25% in HashMap operations. Only then did I prioritize batching and thread-local accumulation over other optimizations.
The combined result of these techniques: 47x speedup became a potential 60x–80x speedup depending on the hardware and workload distribution.
What Common Mistakes Do Developers Make When Rewriting Python in Rust?
The rewrite decision and the rewrite execution both have predictable failure modes.
-
Rewriting before profiling. Developers often assume their Python is slow because it is Python, when the actual bottleneck is a database query, network call, or a single inefficient algorithm. Profile first with
py-spyorcProfile. Sometimes fixing a quadratic loop or adding a database index is faster than a full Rust rewrite and takes 30 minutes instead of 3 days. -
Rewriting everything at once instead of just the hot path. The correct first step is often not a full rewrite but a PyO3 extension: write the performance-critical inner loop in Rust, call it from Python using
maturin. This gives you Rust's performance where it matters while keeping Python's convenience for the surrounding orchestration logic. -
Using
Mutex<HashMap>where thread-local accumulation or DashMap would be better. As shown above, Mutex contention limits parallel speedup significantly for workloads with many writes. Always benchmark with and without Mutex to verify parallelism is actually helping. -
Not using
--releasefor benchmarks. Debug builds in Rust include bounds checking, overflow checking, and no optimization: they can be slower than Python. Every performance claim about Rust should be made againstcargo build --release. The speedup in this article (47x) was measured against a release build. -
Allocating strings unnecessarily in the hot path. The
.to_string()call on every regex capture creates a heap allocation per line. For workloads with hundreds of millions of lines, this is significant. Using string interning or keeping data as&strreferences where possible reduces allocations dramatically. -
Stopping at the first working implementation without considering the production deployment path. A Rust binary requires a compilation step that Python scripts do not. If the script is deployed to servers where the team does not have Rust toolchains installed, the operational overhead of maintaining a compilation pipeline needs to be considered in the cost-benefit calculation.
Should You Learn Rust Specifically to Optimize Python Code?
The hybrid PyO3 approach: writing hot paths in Rust and calling them from Python: is a realistic middle ground that many data engineering teams adopt. If you want to learn the Rust patterns for writing PyO3 extensions and high-performance Rust services, Rustify's 9-week bootcamp covers the full spectrum from Rust fundamentals to production-grade systems, with 1:1 mentorship and real project deliverables.
Bottom line: A full Python-to-Rust rewrite is a high-risk, high-reward project: reserve it for workloads where performance is genuinely the bottleneck and the tool is heavily used. For most one-off scripts and prototypes, the engineering cost doesn't pay back. The realistic decision tree: profile first (py-spy), verify CPU is the bottleneck, then decide between: (1) PyO3 hybrid approach: fast and lower risk, (2) full Rust rewrite: highest performance, highest investment.
Cost-benefit math: 3-day rewrite × your hourly rate + testing overhead + deployment learning curve. If the job runs daily for 6+ months, the per-month time savings (47x speedup in this example = 55 seconds/day saved on a 5-hour job = 5 months ROI) can justify it. Ad-hoc scripts almost never justify the investment.
Keep Reading
- Rust Developer Salary in the USA (2026)
- How Long to Learn Rust by Background: Hours, Timeline & ROI
- Best Way to Learn Rust in 2026
- Is Rust Hard to Learn?
Frequently Asked Questions
No: the Python-to-Rust hybrid using PyO3 is often better. Write the hot path in Rust, call it from Python with @rust_function. Tools like maturin make this straightforward. You keep Python's dev speed for the 90% of code that doesn't need performance.
Different, not necessarily better. Rust's type system caught two bugs during the rewrite that the Python version had silently: wrong regex group index, off-by-one in the sort. But the Rust code is more verbose. "Better" depends on what you're optimizing for.
Adding features to the Rust version is slower: iterate on the Python version first until the feature set is stable, then rewrite for performance. Rewriting prematurely is a common mistake.
Use hyperfine for command-line benchmarking: it runs multiple iterations, handles warmup, and provides statistical output. Always test with the same input data, same hardware, and on repeated runs to eliminate caching effects. For Python, disable the startup overhead measurement if you are measuring sustained throughput rather than cold-start latency.
Often yes, for CPU-bound workloads: use numpy vectorized operations instead of Python loops, use multiprocessing instead of threading (to bypass the GIL), or use Cython to compile Python to C extension code. The 47x speedup in this article is specifically from the combination of mmap + rayon + Rust's zero-GC execution model. Optimized Python with multiprocessing and mmap might reach a 5–10x speedup over the baseline, leaving Rust still 5–10x ahead.
This specific rewrite required approximately 2 months of Rust experience at 10 hours/week. The key skills needed: basic data structures (HashMap, Vec), the regex crate, reading compiler error messages, and understanding why Mutex is needed for shared mutable state. A developer comfortable with Rust's ownership model can complete a similar rewrite in a weekend.
It matters in specific contexts: containerized deployments (smaller image size), edge deployments (bandwidth constraints), and distribution to end users (download time). For most server-side workloads, 4 MB versus a Python environment's 100 MB is irrelevant. The more practical advantage of the single binary is the elimination of dependency management: no virtual environments, no requirements.txt drift, no "works on my machine" problems.
