Making My Own Python Linter: Pyleft
I have a weird gripe with Pyright, arguably one of the most widely used type checkers in the Python ecosystem, so I decided to make my own.
Introducing Pyleft.
A good first question is why I dislike Pyright so much. It mostly comes down to three reasons:
- No plugin system
- Some of its warnings are extremely unpythonic
- It is very slow
Let’s go through each of these briefly.
No Plugin System
Section titled “No Plugin System”Most mature tools in this space expose a plugin API mypy and pylint both do — mostly to accommodate use cases the tool doesn’t support yet. Unfortunately, according to this thread, Microsoft does not seem interested in supporting any plugin system in the future.
I have to admit that Pyright supports almost everything you want out of the box. But the modern consensus in linting is that no tool can anticipate every codebase’s conventions, and the escape hatch for that is plugins.
Unpythonic Warnings
Section titled “Unpythonic Warnings”Seeing is believing, so here is an example you can reproduce right now.
from abc import ABC, abstractmethod
class Pet(ABC): def __init__(self, name: str) -> None: self.name = name
@abstractmethod def make_sound(self) -> str: raise NotImplementedError("Subclasses must implement this method!")
class Dog(Pet): def make_sound(self) -> str: return "Woof Woof"
class Cat(Pet): def make_sound(self) -> str: return "Meow Meow"This code is valid Python and runs exactly as expected. If you’re not familiar with the pattern: it defines an abstract base class Pet that both Dog and Cat inherit from.
This way, we force Dog and Cat to abide by the contract defined by Pet, and we can write functions like def remove_animal(inventory, animal: Pet) instead of maintaining def remove_cat(), def remove_dog(), and so on for every pet we add.
If you run this through Pyright, you get a warning along the lines of reportMissingSuperCall. Pyright is complaining that Dog and Cat don’t explicitly write out the inherited method and delegate to the parent. In my opinion this is redundant, especially when the methods in question have many parameters or long names, it is pure boilerplate. And imagine a class with a hundred methods: now I’m writing two hundred lines of this.
def some_method(self, x, y): super().some_method(x, y)The Pyright folks argue it prevents bugs. But the whole purpose of polymorphism is to abstract common behavior and share it without restating it at every subclass. The pattern Pyright pushes here is unpythonic, and it increases the programmer’s burden to work around the type checker rather than with it.
It Is Very Slow
Section titled “It Is Very Slow”To back this up, I benchmarked four checkers: pyrefly, ty, pyright, and mypy.
Concretely, I used pyrefly 0.60.0, ty 0.0.29, pyright 1.1.408, and mypy 1.19.1 across 53 popular open-source packages. Here’s how they compare head to head.
Small and medium packages (under 1s for the fastest checker — flask, requests, sentry-sdk): pyrefly and ty both finish in a fraction of a second, typically within 100ms of each other. Pyright and mypy take 2–20x longer on the same packages.
Large packages (1–5s for the fastest checker — pandas, tensorflow, homeassistant): pyrefly and ty stay in the same ballpark, usually finishing within a few seconds of each other. Pyright and mypy can take 10–50x longer. Pyright needs 144s for pandas, where pyrefly takes 1.9s and ty takes 1.5s.
Extra-large packages (scipy, numpy, sympy): these stress-test overload resolution and union handling. Both newer checkers stay under 5s on most of them, though individual packages can be outliers — ty takes 30.6s on numpy, likely a bug that will be fixed in a later release, while pyrefly takes 4.8s. Conversely, ty checks sympy in 1.6s against pyrefly’s 4.0s. Pyright can take over two minutes on these packages.
Pyright is essentially the slowest checker on every codebase I tested, which I think makes the point.
Introducing Pyleft
Section titled “Introducing Pyleft”While learning Haskell, I came to appreciate how naturally functional programming fits linting and because I was fed up with Pyright, I ended up making Pyleft.
Pyleft is a Python linter written in Haskell. It parses Python source into an AST serialized as JSON, then runs a set of rules over that tree and reports diagnostics.
Why Haskell? A linter is, at its core, a pattern match over a tree. Algebraic data types describe an AST almost exactly, and every rule reduces to a pure function from that tree to a list of diagnostics. That buys a few things for free: rules have no shared mutable state, so they are trivially testable in isolation and embarrassingly parallel; they compose, so a rule set is just a list; and exhaustive pattern matching means the compiler tells me when I’ve forgotten to handle a node type instead of silently skipping it at runtime. Writing the first rule took maybe over an hour. Writing the fifth took twenty minutes.
Today Pyleft ships six rules:
- bare-except
- wildcard-import
- mutable-defaults
- unused-import
- unused-local
- shadow-builtins
Configuration lives in a pyleft.toml file, where you can pick a theme, disable individual rules, and set severity per rule — error, warning, or info.
Running it requires GHC, Cabal, and Python 3:
cabal run pyleft -- examples/
It accepts multiple paths, and a flag to control how deep it descends into directories.
Pyleft is early — six rules is not a replacement for anything, and it is BSD-3-Clause licensed and open to contributions. But the architecture is the point. Because every rule is a pure function over an AST, adding one is a matter of writing a function and registering it, which is exactly the extension story that made me want to build this in the first place. Getting that from a config file to a real plugin interface is the next thing I’m working on. I am also planning to add even more rules very soon.
If you want to try it, break it, or tell me my opinions about super() are wrong and that you have a gripe with Pyleft, the repo is here.