SPy: a better Python, not a worse Rust

.

About these notes

This post was generated by a LLM (generative AI) from an exploratory conversation. I guided the discussion, challenged the AI’s conclusions, and verified the technical content.

SPy is a statically typed, compiled variant of Python with a focus on performance. A natural question when encountering it is: how does it compare to Rust? Both languages aim at C-level performance with stronger safety guarantees than C. But this framing is misleading. SPy’s goal is not to replicate Rust in Python syntax. Its goal is to be the best possible companion language for Python — and those are very different targets.

This post explores that distinction, drawing on SPy’s design documents and blog posts. The central claim is:

SPy dramatically improves on Python’s type system weaknesses, and it can incorporate good ideas from Rust — but it neither needs nor wants to be Rust.


What makes Rust’s type system exceptional

Before comparing, it helps to be precise about what Rust actually offers.

Compile-time enforcement with no escape hatches. Types are checked before the program runs, and there is no implicit way to bypass this. Every conversion is explicit.

Algebraic data types. Rust enums can carry data: Option<T> , Result<T, E> . The compiler enforces exhaustive pattern matching — you cannot use a value that might be None without first handling that case. This eliminates an entire class of null-pointer bugs.

The borrow checker. The type system tracks ownership and lifetimes of every value at compile time. No two parts of the program can mutate the same data simultaneously. Memory is never freed while a reference to it is still alive. All of this is verified statically, with zero runtime cost.

Traits with bounds. Generic functions declare their constraints upfront: fn add<T: Add>(a: T, b: T) -> T documents and enforces that T must support addition. The compiler checks the function body in isolation , without needing to know what T will be at the call site.

Zero-cost generics. Generics are monomorphized at compile time — a separate concrete version is generated for each type used. No runtime dispatch overhead.

These features make Rust suitable for operating systems, embedded firmware, hard real-time systems, and any domain where predictable latency and absence of a garbage collector are mandatory.


Python’s type system: the pain points

Python’s approach is almost the opposite.

Type annotations exist but are ignored at runtime by design. x: int = "hello" raises no error in Python — it is merely metadata for optional external tools. Type checkers like mypy and pyright are add-ons, not part of the language.

Dynamic dispatch is always on. a + b in Python always performs a runtime lookup of __add__ , regardless of whether the types are statically known.

dynamic is the default, not the exception. Everything is dynamically typed unless you opt in to annotations, and even then the runtime doesn’t enforce them.

Metaprogramming breaks type checkers. When a function uses decorators, closures that return types, or other patterns that construct types at runtime, mypy typically infers the result as Any and stops checking. You lose type safety for exactly the most powerful Python patterns.

Any is a silent hole. Once a value is typed Any , type checking stops propagating through it entirely — and there is no warning that this happened.


How SPy improves the situation

SPy directly addresses each of these weaknesses.

Annotations are enforced

The most immediate departure from Python: in SPy, type annotations are enforced. Always. Running:

def main() -> None:
    x: int = "hello"

raises a real TypeError with a precise error message:

TypeError: mismatched types
  |     x: int = "hello"
  |              |_____| expected `i32`, got `str`

This is not a linter warning. It happens at runtime in interpreted mode and at compile time in compiled mode.

Static operator dispatch via redshifting

SPy introduces redshifting — a compile-time partial evaluation pass that resolves all type-dependent operations before runtime. After redshifting, a + b for two integers becomes a direct call to i32_add ; for two strings it becomes str_add . The generic operator no longer exists at runtime.

This has a crucial consequence: a type mismatch like 1 + "hello" is caught at compile time, not at runtime, because the resolution of + is a @blue (compile-time) function that raises a TypeError when it cannot find a valid implementation for the given types.

Dynamic dispatch is opt-in

In Python, everything is dynamic and you opt in to static typing. SPy inverts this. Code is statically typed by default. If you genuinely need dynamic dispatch, you annotate with the explicit dynamic type:

def add(x: dynamic, y: dynamic) -> dynamic:
    return x + y

The rationale, stated directly in the SPy documentation, is that dynamic dispatch “is costly and prevents many other optimizations.” Making it explicit makes the cost visible and intentional.

Metaprogramming is fully type-safe

This is SPy’s most distinctive improvement over Python’s type situation. In Python, a make_adder factory that returns different function types depending on its argument will cause mypy to give up and return Any . In SPy, @blue functions are evaluated by the interpreter at compile time, with the concrete types fully known. The type checker is aware of this:

@blue
def make_adder(y: dynamic):
    T = type(y)
    def add(x: T) -> T:
        return x + y
    return add

add5 = make_adder(5)      # type: def(i32) -> i32  — fully known
add_w = make_adder(" w")  # type: def(str) -> str  — fully known

There is no Any leakage. The compiler knows the precise type of add5 and add_w because it evaluated make_adder itself. If you then write add5 + "hello" , you get a compile-time TypeError — adding a function and a string — with no special support needed.

This is a qualitative improvement over any Python type checker, not merely a quantitative one. The fundamental reason Python checkers fail here is architectural: they analyse code without running it. SPy’s @blue mechanism runs the metaprogramming code at compile time, which is why typechecking can follow it.

Compile-time errors are programmable

Because type checking flows through the same @blue mechanism as everything else, library authors can raise StaticError from any @blue function to produce compile-time errors with domain-specific messages:

@blue.generic
def SmallString(size: int):
    if size > 32:
        raise StaticError("SmallString must be at most 32 chars")
    return str

def say_hello(name: SmallString[64]) -> None:  # fails at build time
    print(name)

This is a capability Python has no equivalent for.


Where SPy deliberately diverges from Rust

Garbage collection instead of a borrow checker

SPy’s memory model is explicitly two-level:

  • Safe high-level code uses a garbage collector. Memory is managed automatically; lifetimes are not tracked by the type system.

  • Low-level unsafe code uses manual allocation ( raw_alloc , raw_free ) with no borrow checker — essentially C with Python syntax.

This is the single largest divergence from Rust, and it is a conscious trade-off, not an oversight.

What is lost: Hard real-time guarantees are impossible with a GC, since collection pauses can occur at any time. SPy cannot target operating system kernels, embedded firmware, or hard real-time audio pipelines — Rust’s home territory.

What is gained: The borrow checker is Rust’s steepest learning curve by far. By using a GC, SPy remains approachable to Python programmers. It also makes Python interoperability far simpler, since CPython objects are themselves GC-managed.

The honest scope: The workloads where a GC is disqualifying are also workloads where Python-like ergonomics would feel alien. SPy is not trying to replace Rust in Rust’s home territory. For server software, scientific computing, data pipelines, and most application code, a well-implemented GC is entirely adequate — as Go has demonstrated.

No borrow checker in unsafe code either

In unsafe SPy, the programmer is responsible for avoiding use-after-free and out-of-bounds access. The interpreter and debug compiled mode add runtime bounds checks (pointers track their array length as a fat pointer). Release mode strips these checks and emits plain C pointers — exactly as fast as C, and exactly as unguarded.

The CPython parallel is intentional: CPython’s C internals are unsafe, yet most Python users never touch them. SPy’s unsafe layer is for library authors, not end users.


Features SPy could take from Rust

Two Rust features are absent from SPy today but would fit its design naturally.

Algebraic data types

Python 3.10 introduced match statements and union types ( int | str | None ). The concept is already in the Python vocabulary. The question for SPy is whether to enforce exhaustive matching statically — and this hinges on a single design decision: whether None should be opt-in rather than universally valid.

In Rust, you cannot use a value that might be None without pattern matching it first, because there is no implicit null. If SPy made nullability explicit — only int | None can be None , not bare int — then the static enforcement of pattern matching would provide real safety guarantees. This would be a meaningful break from Python semantics, but SPy already accepts breaking compatibility where the benefit is clear.

ADTs are compatible with having a GC : they are purely a type-system feature. Haskell has one of the most powerful ADT systems of any language, and it is garbage-collected.

The main design question is cultural: Python programmers are accustomed to None appearing anywhere. Making it opt-in would take adjustment. But it is technically clean and would eliminate a large category of AttributeError: 'NoneType' object has no attribute '...' bugs that Python programmers encounter daily.

Traits as structural protocols

Python already has typing.Protocol (PEP 544): a structural interface mechanism that type checkers understand. This is the natural SPy equivalent of Rust traits, and it would be far more Pythonic than Rust’s trait syntax.

SPy’s @blue operator dispatch already implicitly checks trait-like constraints. If you pass a type to a generic @blue function and that type does not support the required operations, you get a StaticError at compile time. What is missing is the ability to declare the constraint upfront:

# Hypothetical SPy syntax, following Python's Protocol
from typing import Protocol

class Addable(Protocol):
    def __add__(self, other: Self) -> Self: ...

@blue.generic
def add(T: type[Addable]):  # constraint declared, not discovered
    def impl(a: T, b: T) -> T:
        return a + b
    return impl

The benefit is twofold: the API is self-documenting, and errors surface at the call site with a clear message (“T does not implement Addable”) rather than somewhere inside the function body.

The full Rust trait system — with associated types, blanket implementations, trait objects, and the orphan rule — would be both overkill and un-Pythonic. But a lightweight Protocol -based constraint system would fit SPy naturally and significantly improve the library authoring experience.

Crucially, like ADTs, traits are orthogonal to memory management . A GC-based language can have an excellent trait/protocol system.


Summary

SPy is not Rust-for-Python-programmers. It is a statically typed Python variant that makes deliberate trade-offs: a GC instead of a borrow checker, simplicity instead of maximal safety. These choices make it unsuitable for the domains where Rust excels (systems programming, hard real-time, bare metal). But for the domains where Python is already used — and where the demand for performance is growing — SPy’s approach is coherent and well-targeted.

The improvements over Python are substantial and architectural, not cosmetic:

  • Annotations are enforced, always.

  • Operator dispatch is resolved at compile time.

  • Dynamic typing is an explicit opt-in, not the default.

  • Metaprogramming is fully type-safe through @blue evaluation — a capability no Python type checker can match.

And the features it does not yet have — algebraic data types and trait-style protocol bounds — are not blocked by the GC choice. They are type-system features, orthogonal to memory management, and they fit SPy’s Pythonic direction well. The question is not whether they make sense for SPy, but when.

The right mental model for SPy is not “Rust with Python syntax” but rather “what if Python’s type system were taken seriously from the start?” That turns out to be a genuinely different language — and a promising one.