What is it about 2025 python that makes me fall for it again?

In February 2025’s journal, I talked a bit about the features that have landed in python and its ecosystem since I last wrote non-trivial python back sometime around 2017 or 2018. And how they’d attracted me back to python.

So what were the things I found that I liked so much, that prompted me to say that I’d enjoy getting back to writing python day-to-day again? Let’s talk about three of them:

  1. The type system
  2. Language features: match in particular.
  3. Tooling: pyright, uv, ruff

There’s sure to be other nice things, but these are the things I like the most after working in 2025 python for a few weeks’ worth of evenings.

The type system

Over time, I’ve become firmly on the side of statically, strongly typed languages. Easily knowing the shape of data at any point in the program just makes life easier. Needing to backtrack through functions to find the right key to use in a dictionary has gotten old.

Back in 2017, most of our code at Cloudant was still written in python 2. While types had begun to exist, and mypy was usable, my recollection of the situation was that using types was promising but rather awkward.

Asking Claude (via rapport) confirms what was in my memory, that it was done with comments:

def example_function(x, y):  # type: (int, str) -> bool
    return len(y) > x

items = []  # type: List[str]

It’s about the best you could do, but it looks weird to my eyes, both then and now.

Running mypy at the time was quite slow. And of course things like LSP didn’t exist back then, so editor support was flaky at best.

Now syntax has caught up, and LSP means that editors can easily display issues inline. Both mypy and pyright are fast. Writing in python with types feels downright modern (albeit, my colleague points out that 1999 is calling with its Visual Studio intellisense that did a lot of this a quarter of a century ago).

But more than that, the python type system has learned from others and taken on some of my favourite parts of Rust: union types and optional types. I find that I miss these types in languages like Go.

I find Union types and structural subtyping work fantastically together to bring the value of types while retaining the good parts of python’s duck-typing roots.

  • Union types allow expressing the notion that a variable or function argument can be more than one type. This allows for common python idioms while still documenting the expected types.

  • Optional is a special case of Union: one of the allowed types is None. Type-checkers can ensure that your code checks for the None case.

  • For when you don’t know the exact types, structural subtyping (via Protocols) allows definition of expected types based on their structure — the methods they implement — rather than their type hierarchy.

    Because the passed argument doesn’t need to implement an interface explicitly (it just needs to “look right”), this slots right into python’s duck-typing philosophy. You are still saying you expect objects that look like a duck, but at the same time defining in the code just what properties the duck must have. (Go also has this).

In addition, the way that dictionaries can be “typed” via literals is a stroke of genius for bringing types into older codebases, my own very much included, that are infested with data passed via dicts:

# Strict configuration dictionary
type Config = Dict[Literal["theme", "notifications", "language"], str]
user_settings: Config = {
    "theme": "dark",
    "notifications": "on",
    "language": "en"
}

Language features

I’m sure there are more features that I could talk about. I only wrote production code in python 2, so I’ve missed out on every new python 3.x feature. But let’s look at match.

I love the match statement. It has a lot of the power of its Rust or Erlang cousins. Particularly, the way that it so naturally expresses unpacking a union type and extracting data from the type all in one:

match m:
    case SystemMessage(message=message):
        # the SystemMessage object's message field
        # is captured in variable `message`
    case IncludedFile(name=name, ext=ext, data=data, role=role):
        # use name, ext, data, or role
        # the variable names for captures can be anything, they
        # don't need to match the fields in the object.
    case AssistantMessage() | UserMessage():
        # Just use m.role or whatever

More about match in PEP 636 – Structural Pattern Matching: Tutorial

There are lot of niceties in python3 now the 2 to 3 transition is finally complete. I’ve enjoyed the process of learning some of the new language features. They are well thought out, and manage to add a lot to the language without making it feel like it’s not python any more.

I kind of come out of this feeling that they took a lot of nice things about Rust’s type system — while managing to avoid ending up with something that’s as complicated as Rust’s type system.

Tooling

There isn’t that much to say here. It just feels like there is now a solid set of tools that work together to improve the developer experience:

  • ruff. I loved the black python code formatter back in 2018, and I love ruff now. It’s the same, but faster and catches other errors too by virtue of containing a bunch of linters too. Having become used to Go and Rust’s standardised formatting, it’s something I don’t want to think too much about any more. ruff does that.
  • uv. As a package manager, uv appears to have learned a lot from cargo, Rust’s package manager. And cargo learned a lot from the history of every other attempt to make a package manager. So there’s lots of learning in uv. I haven’t fully explored uv yet, but it’s been an easier tool to adopt than poetry was. (That’s not a fair comparison, as poetry back then was inventing and then leading the charge on adoption of pyproject.toml; things were pretty rocky in packaging back then as a result).
  • pyright. It was easy to set up pyright and its type-checking is fast. I’m sure py-lsp is good too, but I’m happy with pyright. I still find that being told in-editor rather than at runtime that I’m missing a method argument or that I mistyped a kwarg name somehow magical. Because, even though I’m used to it from other languages, I’m not used to it in python.
← Older
February Journal: building my own chatbot, and falling for python all over again