python/

When Python needs Rust: from a Python 2 FTPS library to PyO3

9 min read codestinger updated

Python is where I move fast. Rust and C are where I go when speed, memory safety or direct control matters. The best part is that you don't have to choose: a critical piece can move into Rust while the rest of the application stays comfortably in Python.

I learned how powerful that combination is on a project where Python alone simply couldn't deliver.

The problem: FTPS on Python 2#

An integration had to exchange files with partner systems over FTPS (FTP secured with TLS). The catch was the environment: the servers that would run it were tied to Python 2 and upgrading the surrounding platform wasn't an option.

Python 2's standard library couldn't give us reliable FTPS against the servers we had to talk to. The core problem is TLS session resumption: servers such as FileZilla Server and vsftpd require the data connection to resume the TLS session of the control connection (RFC 4217 allows servers to insist on this). Python's ftplib has never supported it. The gap has been tracked in CPython for years as issue #63699. With Python 2 at end of life, no fix was ever going to reach it. Add an ageing TLS stack underneath and the standard library was a dead end. Waiting wasn't an option. Weakening security to make it work was out of the question.

The solution: a Rust core with a Python wrapper#

Instead of patching around the problem, I built an internal FTPS library in Rust:

  • rustls for TLS: a modern, memory-safe TLS implementation that replaces OpenSSL completely. The library no longer depended on whatever OpenSSL version an old server happened to have.
  • suppaftp for the FTP protocol itself, with full support for both passive and active mode.
  • A thin Python wrapper so the rest of the integration could use the library like any other Python module.

I built this part myself. The team's strength was Python and the integration still had to ship on time, so rather than ask everyone to learn Rust, TLS internals and the Python C API under deadline pressure, I took on the heavy lifting and delivered the library as a building block they could rely on. To the rest of the team it was just another Python import with a handful of well-documented functions, backed by tests and native packages. They carried on building the integration while the hardest part was handled underneath.

Building on open source meant getting involved in it too. Testing against FileZilla Server surfaced two problems in suppaftp itself:

  • Session resumption. Connections failed with "TLS session of data connection not resumed". I traced it to the native-tls backend, which doesn't support session resumption. Then I showed that the rustls backend works because rustls resumes sessions by default (suppaftp #93).
  • Empty uploads over TLS. Uploads created files on the server but with no data. After ruling out the server and network by comparing other clients, I took it to the rustls maintainers (rustls #2297). The cause was that suppaftp closed TLS data connections without sending TLS close_notify, so the server couldn't tell the transfer had finished. I worked through the fix with the suppaftp maintainer and verified it with files up to 5 GB before it was released (suppaftp #95).

The interesting constraint was the bridge. The obvious tool for Rust and Python today is PyO3, but PyO3 no longer supports Python 2. So I built the bindings on Python's C API directly and wrapped them in Python, then shipped the whole thing as a native package for the platforms we needed.

The result was a fast, secure FTPS library that behaved like a normal Python module, running on an interpreter that the standard library had long stopped improving.

Choosing rustls also did away with the dependency on OpenSSL, which turned out to be one of the biggest wins:

  • No system OpenSSL to install, upgrade or patch. Legacy servers often carry old OpenSSL builds that lack modern TLS versions and ciphers. With rustls, TLS support ships inside the library itself.
  • The same TLS behaviour everywhere. Windows and Linux machines used identical TLS code, so a connection that worked in testing worked the same way on every customer server.
  • Self-contained packages. Without a native OpenSSL dependency to match, the library shipped as a single package per platform with nothing else to install.
  • A smaller attack surface. rustls implements only modern, safe protocol versions (TLS 1.2 and 1.3) and leaves out the legacy features behind many historical OpenSSL vulnerabilities.

What that project taught me#

  1. The language boundary is a design decision. Keeping the interface between Python and Rust small (connect, upload, download, list, close) kept the bindings simple and the testing focused.
  2. Rust removes whole classes of bugs. Network protocols and TLS are exactly where memory-safety bugs hurt most. Writing that layer in Rust, on a TLS stack that doesn't rely on OpenSSL, meant not worrying about them.
  3. Don't lower the bar to fit old platforms. Legacy constraints are real, but they're a reason to bring modern tooling in, not a reason to accept weaker security.
  4. Carry the complexity so the team doesn't have to. Building the hard part myself and handing it over as a simple, tested Python module let the team keep its pace and trust what it was building on.
  5. Contribute back. A well-diagnosed bug report with a reproduction and a tested fix helps everyone who uses the library next, including your future self.

The modern route: PyO3 and maturin#

If you're on Python 3, you don't need to go through the C API by hand. PyO3 provides safe, ergonomic bindings between the two languages and maturin builds the result into a normal Python wheel. Here's the whole loop, end to end, with a small example from the world of product serialization.

The example: a GS1 check digit#

Every barcode on a product (GTIN-8, GTIN-13, SSCC and friends) ends with a check digit. The algorithm is simple: from the rightmost digit, multiply alternately by 3 and 1, add everything up and the check digit is whatever brings the sum to the next multiple of ten.

In Python:

def check_digit(body: str) -> int:
    total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body)))
    return (10 - total % 10) % 10


assert check_digit("400638133393") == 1  # GTIN-13 4006381333931

Fine for one barcode. In a serialization system validating millions of codes, it becomes a hot loop worth optimising.

Set up the project#

pipx install maturin
maturin new --bindings pyo3 gs1fast
cd gs1fast

That creates a Cargo.toml, a pyproject.toml and src/lib.rs, already wired together.

Write the Rust#

use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;

fn digit_sum(body: &str) -> Result<u32, char> {
    let mut sum = 0;
    for (i, ch) in body.chars().rev().enumerate() {
        let d = ch.to_digit(10).ok_or(ch)?;
        sum += if i % 2 == 0 { d * 3 } else { d };
    }
    Ok(sum)
}

/// Check digit for one GS1 key body (without its check digit).
#[pyfunction]
fn check_digit(body: &str) -> PyResult<u32> {
    let sum = digit_sum(body).map_err(|c| PyValueError::new_err(format!("not a digit: {c:?}")))?;
    Ok((10 - sum % 10) % 10)
}

/// Validate many complete codes in one call; returns one bool per code.
#[pyfunction]
fn validate_many(codes: Vec<String>) -> Vec<bool> {
    codes
        .iter()
        .map(|code| {
            let mut chars = code.chars();
            match chars.next_back().and_then(|c| c.to_digit(10)) {
                Some(check) => matches!(digit_sum(chars.as_str()), Ok(sum) if (10 - sum % 10) % 10 == check),
                None => false,
            }
        })
        .collect()
}

#[pymodule]
fn gs1fast(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(check_digit, m)?)?;
    m.add_function(wrap_pyfunction!(validate_many, m)?)?;
    Ok(())
}

Two details worth noticing. Invalid input becomes a proper Python ValueError, not a crash. And a &str or Vec<String> argument is converted from Python automatically.

Build and use it#

python -m venv .venv && source .venv/bin/activate
maturin develop --release
import gs1fast

gs1fast.check_digit("400638133393")  # 1
gs1fast.validate_many(["4006381333931", "4006381333932"])  # [True, False]

For distribution, maturin build --release produces a wheel you can pip install anywhere with the same platform, with no Rust toolchain needed on the target machine.

The rule that decides whether it's faster#

Crossing the boundary between Python and Rust costs something on every call: converting arguments, creating result objects. For a function this small, that overhead can be similar to the work itself.

So cross the boundary once per batch, not once per item. validate_many receives the whole list, does all the work in Rust and returns once. Calling check_digit a million times from a Python loop may barely beat pure Python; passing a million codes to validate_many is where the gain is. Always measure with timeit on realistic data before and after, because the numbers depend heavily on your data and machine.

When it's worth it#

Reach for Rust when a profiler points at a CPU-bound hot spot. Reach for it when the capability you need doesn't exist in your Python version, as with FTPS on Python 2. And reach for it when memory safety around binary formats and network protocols matters. For I/O-bound code (waiting on databases, APIs or disks) Python is almost never the bottleneck and staying in one language is the better trade.

Whether you go through PyO3 or the C API, the pattern is the same: keep Python for what it does best, move the critical piece to Rust and make the boundary between them small and well tested.