I Rewrote My Python Script in Rust: Here's What Happened

Max WellsMax WellsFounder of Rustify

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 + regex crate, 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:

MetricPythonRustImprovement
Execution time4,100ms87ms47x faster
Peak memory180 MB22 MB8x less
Binary/startup sizeNeeds Python runtime4.2 MB static binaryNo runtime needed
Lines of code35752x more verbose
Development time(existing)3 daysNew project

The 47x speedup comes from three sources:

  1. Memory-mapped I/O: the OS serves file data directly from page cache: no read() syscalls, no heap copy
  2. Parallel processing: rayon.par_lines() uses all 10 cores simultaneously: Python's GIL prevents true parallelism
  3. 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] += 1

2. 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 scope

3. 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.

ScenarioRewrite worth it?Reason
Script runs on 10GB+ files dailyYesCPU/memory savings add up at scale
Script runs once a monthNoTime savings don't justify dev cost
Web API handling 100K+ req/sYesGC latency at high load matters
Internal admin web appNoPython/Go are faster to iterate
Network scanner / security toolYesBinary deployment, no runtime deps
Data science pipelineNoNumPy/pandas already optimized
CLI tool distributed to usersYesSingle 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-spy or cProfile. 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 --release for 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 against cargo 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 &str references 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

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.


Sources

Ready to Land a $80-120k Rust Job?