5 Rust patterns that replaced my Python scripts

# rust# python# cli# devtools
5 Rust patterns that replaced my Python scriptsNox Craft

I used to reach for Python every time I needed a quick script. File renaming, log parsing, API...

I used to reach for Python every time I needed a quick script.
File renaming, log parsing, API polling, directory cleanup --
Python was the default because it was fast to write and good enough to run.

That changed gradually.
Not because I decided to rewrite everything in Rust,
but because I kept running into the same friction points:
shipping the script to another machine, handling errors properly,
or running it somewhere Python wasn't available.

Here are five patterns where Rust has genuinely replaced Python for me.


1. Error handling that forces you to think

In Python, the path of least resistance is letting exceptions propagate and hoping for the best.

import json

def load_config(path):
    with open(path) as f:
        return json.load(f)

config = load_config("config.json")
print(config["database"]["host"])
Enter fullscreen mode Exit fullscreen mode

This crashes at runtime with a different error depending on which thing goes wrong:
FileNotFoundError, JSONDecodeError, KeyError -- each one needs a different handler,
and you usually find out the hard way.

In Rust, the type system prevents this from being an afterthought.

use std::fs;
use serde::Deserialize;
use anyhow::{Context, Result};

#[derive(Deserialize)]
struct DatabaseConfig {
    host: String,
}

#[derive(Deserialize)]
struct Config {
    database: DatabaseConfig,
}

fn load_config(path: &str) -> Result<Config> {
    let contents = fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {path}"))?;
    let config: Config = serde_json::from_str(&contents)
        .context("failed to parse config JSON")?;
    Ok(config)
}

fn main() -> Result<()> {
    let config = load_config("config.json")?;
    println!("{}", config.database.host);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The ? operator propagates errors without hiding them.
anyhow::Context adds human-readable messages to each failure point.
When something breaks, you get a proper error chain, not a bare traceback pointing at line 4.

The upfront cost is real -- you have to define your types and think about what can fail.
The payoff is that production surprises mostly stop happening.


2. Type safety for configuration and data

Python's dynamic typing is convenient until you're debugging a config key that was renamed three months ago.

# Python -- works until it doesn't
def send_notification(config):
    url = config["webhook_url"]  # was this renamed to "webhook"?
    timeout = config.get("timeout", 30)
    requests.post(url, timeout=timeout)
Enter fullscreen mode Exit fullscreen mode

Rust forces you to be explicit about what your data looks like.

use serde::Deserialize;

#[derive(Deserialize)]
struct NotificationConfig {
    webhook_url: String,
    #[serde(default = "default_timeout")]
    timeout_secs: u64,
}

fn default_timeout() -> u64 { 30 }

fn send_notification(config: &NotificationConfig) {
    // webhook_url is guaranteed to exist
    // timeout_secs has a known default
    // these facts are checked at deserialization, not at call time
}
Enter fullscreen mode Exit fullscreen mode

If the config schema changes, the compiler tells you everywhere it breaks.
You don't find out at 2am when the script runs in production.


3. Single binary distribution

This is the practical one.
Shipping a Python script means: "Install Python 3.11, pip install these dependencies, create a venv..."
Then debug why it works on your machine and not theirs.

With Rust you cargo build --release and copy one file.

# build for linux on your mac
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

# ship it
scp target/x86_64-unknown-linux-musl/release/my-tool user@server:/usr/local/bin/
Enter fullscreen mode Exit fullscreen mode

The musl target produces a fully statically linked binary.
No shared libraries, no runtime, no dependency installation.
It runs on any Linux machine with the same architecture.

This matters most for small operational tools that run in Docker containers
or on servers where you don't control the environment.
The binary is typically 3-8 MB and starts in under 10ms.


4. Cross-compilation that actually works

Related to the previous point, but worth calling out separately.

# .cargo/config.toml
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
Enter fullscreen mode Exit fullscreen mode
# add targets
rustup target add aarch64-unknown-linux-gnu
rustup target add x86_64-pc-windows-gnu

# build
cargo build --release --target aarch64-unknown-linux-gnu
cargo build --release --target x86_64-pc-windows-gnu
Enter fullscreen mode Exit fullscreen mode

A GitHub Actions workflow that cross-compiles and uploads binaries on each release
means you can install any tool with a single curl command, regardless of what machine you're on.

Python can cross-compile too (via PyInstaller and friends), but it's fragile and the output is bloated.
Rust cross-compilation is boring and reliable.
Boring and reliable is what you want in tooling.


5. Performance where it actually matters

Rust isn't always faster than Python in ways you'll notice.
For most scripts, performance doesn't matter.

But there's a category where it does:
anything processing large files, running in a tight loop, or doing work on every commit in a git hook.
Here's a real example: a git hook that scans for accidentally committed secrets.

# Python version -- runs on every commit
import re
import subprocess

PATTERNS = [
    r'AKIA[0-9A-Z]{16}',  # AWS key
    r'sk-[a-zA-Z0-9]{48}',  # OpenAI key
]

result = subprocess.run(['git', 'diff', '--staged'], capture_output=True, text=True)
for pattern in PATTERNS:
    if re.search(pattern, result.stdout):
        print(f"Potential secret found: {pattern}")
        exit(1)
Enter fullscreen mode Exit fullscreen mode
// Rust version
use regex::Regex;
use std::process::Command;

fn main() {
    let patterns = [
        Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(),
        Regex::new(r"sk-[a-zA-Z0-9]{48}").unwrap(),
    ];

    let output = Command::new("git")
        .args(["diff", "--staged"])
        .output()
        .expect("failed to run git diff");

    let diff = String::from_utf8_lossy(&output.stdout);

    for pattern in &patterns {
        if pattern.is_match(&diff) {
            eprintln!("Potential secret found matching: {}", pattern.as_str());
            std::process::exit(1);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Rust version is roughly 10-20x faster on large diffs.
On a repo with many files staged at once, that's the difference between a hook that feels instant
and one that adds a noticeable pause to every commit.


When Python is still the right call

These are the cases where I still reach for Python without hesitation:

  • Data analysis and anything NumPy/Pandas -- the ecosystem has no real equivalent in Rust yet
  • One-off scripts I'll run once and delete -- setup cost doesn't pay off
  • Anything that needs heavy library support Rust doesn't have
  • Prototyping something where the shape isn't clear yet

The switch to Rust isn't ideological.
It's about identifying the specific friction ahead of time -- distribution, correctness, performance, long-term maintenance -- and picking the tool that removes it.

Once you have a few patterns memorized, the startup cost drops fast.
The first tool takes a weekend.
The fifth takes an afternoon.


The code examples above are copy-paste ready.
Starter templates for each pattern are at github.com/noxcraftdev.