Start with private first!

Rust and Python make opposite bets about what developers will do with access to internals. Understanding why teaches you something deeper than syntax — it reveals two coherent but competing philosophies about how software should be built.

The Python Default: Open Until Proven Closed

In Python, everything is public unless you say otherwise. Write a function, a class, a module — it's accessible. The community uses naming conventions (_private, __mangled) to signal intent, but the runtime enforces nothing.

class BankAccount:
    def __init__(self, balance: float):
        self.balance = balance       # public (by default)
        self._owner = "Alice"        # "private" by convention
        self.__pin = 1234            # name-mangled, but still reachable

account = BankAccount(100.0)
print(account.balance)              # works
print(account._owner)               # works (just rude)
print(account._BankAccount__pin)    # works (name mangling, not privacy)

Python trusts developers. The Zen of Python says "We're all consenting adults here." The underscore is a handshake, not a lock. This keeps the language flexible and great for prototyping — you can reach in and patch anything, which is exactly what makes Python powerful for testing, debugging, and dynamic metaprogramming.

Python does offer @property for more controlled access — you can expose a read-only view or add validation on set. But it's opt-in, and only as disciplined as the developer who reaches for it.

The Rust Default: Closed Until Proven Open

Rust inverts this completely. Every field, function, struct, and module is private by default. You must explicitly opt into visibility with pub. This isn't a style convention — the compiler enforces it.

mod bank {
    pub struct BankAccount {
        pub balance: f64,   // public field
        owner: String,      // private — inaccessible outside this module
        pin: u32,           // private — compiler error if accessed externally
    }

    impl BankAccount {
        pub fn new(balance: f64, owner: &str, pin: u32) -> Self {
            BankAccount {
                balance,
                owner: owner.to_string(),
                pin,
            }
        }

        pub fn balance(&self) -> f64 {
            self.balance
        }

        // Private helper — not part of the public API
        fn validate_pin(&self, attempt: u32) -> bool {
            self.pin == attempt
        }
    }
}

fn main() {
    let account = bank::BankAccount::new(100.0, "Alice", 1234);
    println!("{}", account.balance());   // works
    // println!("{}", account.owner);    // compile error: field `owner` is private
    // account.validate_pin(1234);       // compile error: method `validate_pin` is private
}

There's no workaround. No account._BankAccount__pin escape hatch. The compiler treats private fields as genuinely inaccessible from outside the module.

Two Different Bets

These aren't arbitrary design choices. Each reflects a deliberate philosophy.

Python bets on developer judgment. Trust people to know when not to touch _private things. Make everything reachable because the flexibility is worth the occasional footgun. This pays off in Python's dynamism — monkey-patching, mocking, metaprogramming, and REPL exploration all rely on nothing being truly hidden.

Rust bets on explicit contracts. Every pub marker is a promise: "I intend for this to be part of my public API." Every missing pub is equally intentional: "This is an implementation detail — I reserve the right to change it." The compiler holds you to those promises.

The practical impact is significant. In Python you often have to read documentation (or source code) to know if something is "really" public. In Rust, the compiler tells you directly: pub means yes, no annotation means no.

What This Means in Practice

When you're writing a Rust library, you start closed and open up deliberately. Your internal helpers, intermediate state, and implementation details stay invisible to callers by default. Refactoring internals doesn't risk breaking users — they couldn't see the internals to begin with.

Privacy also complements Rust's ownership system. When fields are private, all mutations flow through controlled methods — which gives the borrow checker cleaner invariants to reason about. This is part of what makes Rust's fearless concurrency possible, alongside the ownership and borrowing rules themselves.

In Python, it's the reverse. You start open and add _ as you learn what's an implementation detail. The discipline has to come from the developer, not the tooling.

Neither approach is wrong. Python's openness is a feature — it enables the dynamic, expressive style Python is loved for. Rust's privacy-by-default is also a feature — it enables fearless refactoring and clearly bounded APIs.

Key Takeaways

  • In Python, everything is public unless prefixed with _ (convention only — not enforced)
  • In Rust, everything is private unless marked pub (compiler-enforced)
  • Python trusts developer discipline; Rust encodes the contract in the type system
  • Rust's pub markers are explicit API surface declarations — marking something public is a deliberate decision
  • Python's openness enables powerful dynamic patterns; Rust's privacy enables fearless refactoring

Try It Yourself

Take a small Python class you've written and mentally port it to Rust. Ask yourself: which fields and methods would you mark pub? Which would you leave private? That exercise often reveals how much of your "API" is actually implementation detail that you'd rather hide — something Python's defaults let slip through.


Coming from Python and exploring Rust? The privacy model is one of the first things that feels foreign — but once it clicks, you'll start seeing your Python code differently too.

Practice this with a hands-on exercise: Module Basics on RustPlatform

Adding API Key Authentication to a Rust CLI

Our exercise downloader started as a simple tool that fetched free exercises from an API.

When we added premium exercises behind authentication this week, I needed to add API key support — without breaking the free tier experience.

The Design

The requirements were straightforward:

  • No API key? Download free exercises (existing behavior, unchanged)
  • API key set? Send it as a header, get all exercises
  • Key comes from an environment variable (PYBITES_API_KEY)

No config files, no interactive prompts, no flags. Just an env var.

Reading the Key

use std::env;

let api_key = env::var("PYBITES_API_KEY").ok();

env::var returns Result<String, VarError>. Calling .ok() converts it to Option<String>Some("the-key") if set, None if not. No error handling needed because a missing key is a valid state, not an error.

Coming from Python where you'd write os.environ.get("KEY") and get None back, I found this two-step dance surprising at first. But it makes sense — Rust forces you to acknowledge that reading an env var can fail, then lets you explicitly opt into treating "missing" as None (related exercise: Option Handling).

Conditional Header

The build_request function attaches the header only when a key exists:

fn build_request(
    client: &reqwest::blocking::Client,
    url: &str,
    api_key: Option<&str>,
) -> reqwest::blocking::RequestBuilder {
    let mut request = client.get(url);
    if let Some(key) = api_key {
        request = request.header("X-API-Key", key);
    }
    request
}

The function takes Option<&str> rather than Option<String> — the caller passes api_key.as_deref() to borrow rather than move the value. This keeps ownership with main() so the key can be used elsewhere (like in status messages).

I initially passed Option<String> and hit a "value used after move" error. In Python you never think about this — everything is a reference. In Rust, I learned to reach for borrows (&str) by default and only pass owned data when the function needs to keep it (learn about Ownership and Borrowing in our exercise track).

User Feedback

A small touch that matters in CLIs: tell the user what mode they're in.

fn auth_status_message(api_key: &Option<String>) -> &'static str {
    if api_key.is_some() {
        "Authenticating with API key"
    } else {
        "No API key set (PYBITES_API_KEY), downloading free exercises only"
    }
}

The message includes the env var name so users know exactly what to set if they want premium exercises.

One thing I learned here: idiomatic Rust prefers Option<&str> over &Option<String> in function signatures (clippy will even flag it). A cleaner version would be:

fn auth_status_message(api_key: Option<&str>) -> &'static str {
    if api_key.is_some() {
        "Authenticating with API key"
    } else {
        "No API key set (PYBITES_API_KEY), downloading free exercises only"
    }
}

The caller would pass api_key.as_deref() — the same pattern we used in build_request.

Testing Without a Server

I learned that you don't need to make HTTP requests to test request construction. Build the request, inspect it:

#[test]
fn test_build_request_without_api_key() {
    let client = reqwest::blocking::Client::new();
    let request = build_request(&client, "https://example.com/api/", None);
    let built = request.build().unwrap();
    assert!(built.headers().get("X-API-Key").is_none());
}

#[test]
fn test_build_request_with_api_key() {
    let client = reqwest::blocking::Client::new();
    let request = build_request(
        &client,
        "https://example.com/api/",
        Some("test-key-123"),
    );
    let built = request.build().unwrap();
    assert_eq!(
        built.headers().get("X-API-Key").unwrap().to_str().unwrap(),
        "test-key-123"
    );
}

RequestBuilder::build() gives you the final Request object without sending it. You can assert on URL, method, headers — everything except the actual response.

Key Takeaways

  • Use env::var("KEY").ok() to get an Option<String> — missing env vars aren't errors when they're optional
  • Option<&str> in function signatures avoids unnecessary ownership transfer
  • RequestBuilder::build() lets you test HTTP request construction without making network calls
  • Always tell the user what mode the CLI is operating in

The Open Source Side

This feature came out of iterating on the tool together with Giuseppe Cunsolo, who originally built the CLI downloader. We added the auth support, full test coverage, and a CI gate that fails below 80% coverage (using tarpaulin). Then we published it to crates.io so you can install it with a single command instead of cloning a repo.

It reminded me how much you learn from open source collaboration — and how nice the Rust tooling is (no wonder uv got its inspiration from Cargo).

Try It Yourself

If you have a CLI that talks to an API, try adding optional authentication. The pattern is always the same: env var to Option, conditional header, clear status message.


If you're a Pythonista curious about Rust, you'll feel right at home — Option is like Python's Optional, pattern matching replaces your if/else chains, and Cargo works like the beloved uv. Our exercises at rustplatform.com are designed with that Python-to-Rust bridging in mind.

Use the exercise downloader to code locally — it's a real-world Rust CLI you can learn from too:

cargo install pybites-rust-download
# free exercises
pybites-rust-download
# premium exercises with API key
PYBITES_API_KEY=your_key pybites-rust-download

Happy coding!

Rust Makes None.attribute Crashes Impossible — Your Code Won't Even Compile

Every Python developer has seen this traceback:

AttributeError: 'NoneType' object has no attribute 'name'

The problem is that tests might miss this so it shows up in production, and that could be at 2 am in the morning.

The pattern that causes it is everywhere:

def find_user(user_id):
    return db.get(user_id)  # might return None

user = find_user(42)
print(user.name)  # boom

Nothing in Python warns you that find_user might return None. No type error, no warning, no compile-time check. The code looks perfectly fine — until it isn't.

Tony Hoare, who invented null references, called it his "billion-dollar mistake". Every language that has implicit nulls inherits this problem. Python included.

Rust refuses to compile the bug

Rust does have a None scenario, but it's handled differently. It has Option<T> — an enum with exactly two variants:

enum Option<T> {
    Some(T),  // a value is present
    None,     // no value
}

When a function might not have a value to return, it says so in its type signature:

fn find_user(user_id: u64) -> Option<User> {
    db.get(user_id)
}

Fair enough, Pythonistas certainly want to add type hints to express this too:

def find_user(user_id: int) -> Optional[User]:
    return db.get(user_id)

Or more modern syntax:

def find_user(user_id: int) -> User | None:
    return db.get(user_id)

But here is the key difference: where in Python you're relying on tooling (mypy, ty) to catch this, in Rust using the result type without handling the None case, the code does not compile:

let user = find_user(42);
println!("{}", user.name);  // compile error: Option<User> has no field `name`

The compiler forces you to acknowledge that the value might not be there:

match find_user(42) {
    Some(user) => println!("{}", user.name),
    None => println!("User not found"),
}

This means no AttributeError and no unexpected runtime crash. The bug literally cannot exist in the compiled program.

It's not just match statements

If match on every Option sounds tedious, it is — and Rust has ergonomic shortcuts. The .map() combinator transforms the inner value while keeping the Option wrapper:

let name: Option<String> = Some("alice".to_string());
let upper = name.map(|n| n.to_uppercase());
// Some("ALICE")

let empty: Option<String> = None;
let upper = empty.map(|n| n.to_uppercase());
// None — the closure never runs, no crash

This is an elegant way to express Python's x.upper() if x is not None else None which might feel a bit clunky at times.

There's also .unwrap_or() for defaults, .is_some() for checks, and if let for quick pattern matches. The full API has dozens of combinators — all with the advantage of compile-time safety.

What this means in practice

Think about how many defensive checks you write in Python:

user = find_user(42)
if user is not None:
    print(user.name)

config = load_config("app")
if config is not None:
    apply_settings(config)

Each is not None check exists because the language can't guarantee the value is there. You're doing the compiler's job manually — and across a larger codebase, it's easy to miss one.

In Rust, the type system tracks presence and absence through every function call, every transformation, every return value. If you forget to handle a None, you find out immediately — in your editor, not your error logs.

This isn't just theory. It's the reason Rust programs don't have an entire class of bugs right out of the box.

Try it yourself

We built a hands-on exercise that lets you practice this pattern. You'll implement three functions that return Option<T> — finding values in slices, handling division by zero, and extracting fields from optional structs.

Option Handling

I hope this gives you a feel for what it's like to write safer code.


If you're a Python developer curious about Rust, our Rust Platform teaches Rust concepts through small, focused exercises — each one bridging what you already know in Python to how Rust does it differently (and often better).

Rust iterators over loops: one function that shows why

Most developers reaching for Rust write their first for loop within minutes. But Rust's iterator methods let you express the same logic in fewer lines, with less room for bugs. One small function shows the difference clearly.

The Imperative Instinct

When asked to sum a slice of integers, the instinct is a classic accumulator loop:

pub fn sum_slice(v: &[i32]) -> i32 {
    let mut total = 0;
    for item in v {
        total += item;
    }
    total
}

A mutable variable, an explicit loop, and a trailing return. It works, but it forces the reader to trace state through the body to understand intent.

The Functional Alternative

Here's the same logic using Rust's iterator combinators:

pub fn sum_slice(v: &[i32]) -> i32 {
    v.iter().sum()
}

That's it. One line that reads almost like English: iterate over the slice, sum everything.

No mutable accumulator. No explicit loop. No room for bugs.

Why This Works

Rust's Iterator trait provides a rich set of composable methods.

iter().sum() works because sum() is a consuming adaptor. It takes ownership of the iterator, accumulates every element using the Sum trait, and returns the result. The type system infers i32 from the function signature, so you don't even need a turbofish (::<Type> annotation). If the compiler couldn't infer the type, you'd write v.iter().sum::<i32>() instead.

The Sum trait is more powerful than it looks. It's also implemented for Option<T> and Result<T, E>, which means you can sum a list that might contain missing or failed values — no manual if checks needed:

let v = vec![Some(1), Some(2), None];
let total: Option<i32> = v.into_iter().sum(); // Returns None

If all elements are Some(n), you get Some(total). If any element is None, the whole result is None. In many languages, this would require a messy loop with null checks.

Zero-Cost Abstractions

In Python or JavaScript, chaining higher-order functions like map and filter typically allocates intermediate collections at each step. In Rust, it doesn't. Rust iterators use a pull model — nothing happens until something consumes the chain. The compiler inlines the entire pipeline and optimizes it into a single tight loop, the same machine code you'd get from writing the for loop by hand.

This is what Rust means by zero-cost abstractions: you don't pay a runtime price for the higher-level expression. No heap allocations for intermediate results, no virtual dispatch, no hidden overhead. The abstraction exists at compile time and disappears in the binary.

In fact, the iterator version can sometimes be faster than a hand-written loop. The compiler may apply SIMD (Single Instruction, Multiple Data) optimizations to iter().sum(), summing multiple numbers in a single CPU instruction — an optimization that's harder to trigger with a manual accumulator loop.

That's the deal Rust offers — write the clearer version and trust the compiler to make it at least as fast, sometimes faster.

When to Reach for Iterators

Iterators shine when the operation maps to a well-known pattern:

  • Summing: iter().sum()
  • Transforming: iter().map(f).collect()
  • Filtering: iter().filter(p).collect()
  • Mutating in place: iter_mut().for_each(f)
  • Finding: iter().find(p)
  • Checking conditions: iter().all(p) / iter().any(p) (just like Python's all() and any(), but as iterator methods instead of free functions)

If your loop body does one of these things, the iterator version is almost always clearer.

That said, a for loop is still the right choice when the logic is highly stateful, involves complex early breaks, or doesn't map cleanly to a single combinator. Iterators replace boilerplate loops, not all loops.

Key Takeaways

  • Rust iterators express intent directly, removing boilerplate state management
  • iter() borrows, iter_mut() borrows mutably, into_iter() takes ownership — pick the one that matches your access pattern (see Ownership and Borrowing for a refresher)
  • Zero-cost abstractions mean you get clarity without sacrificing performance
  • One-liner iterator chains are not "clever code" — they're idiomatic Rust

Practice This

Try the Vectors and Slices exercise on Pybites Rust Platform and see how far you can get without writing a single for loop.

Your first Rust function (from a Python perspective)

Here's a Python function:

def greet() -> str:
    return "Hello, Rustacean!"

Here's the Rust equivalent:

fn greet() -> String {
    "Hello, Rustacean!".to_string()
}

Four differences, all visible in three lines:

1. fn instead of def. Cosmetic, easy.

2. -> String is required. Python type hints are optional documentation. Rust return types are mandatory — the compiler uses them.

3. No return keyword. The last expression (without a semicolon) is the return value. Add a semicolon and it becomes a statement that returns nothing — a common gotcha.

4. String vs str. Python has one string type. Rust has two: &str (borrowed slice, like a read-only view) and String (owned, heap-allocated). The literal "Hello" is a &str. .to_string() converts it to an owned String.

That fourth point is the one that will take the longest to internalize. For now, just remember: Rust has two string types, and you need to choose the right one for your function's return type: literals are &str, owned data is String, and .to_string() bridges them.

Practice this

Try these exercises on the Pybites Rust Platform: