Nox CraftI 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.
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"])
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(())
}
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.
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)
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
}
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.
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/
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.
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"
# 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
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.
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)
// 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);
}
}
}
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.
These are the cases where I still reach for Python without hesitation:
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.