OO in Python is mostly pointless

Discussion here and here. Call to arms for idiomatic OO code here.

People bash OO a lot these days, I'm increasingly coming to the opinion they're right, at least in Python. My point here is not to argue that OO is bad per se, more that its introduction is simply unnecessary, AKA not useful.

Oli's Conjecture

All OO code can be refactored into equivalent non-OO code that's as easy or more easy to understand.

Let's take an example that should pan out in OO's favour, we've all seen/written code somewhat like the following:

class ApiClient:
    def __init__(self, root_url: str, session_cls: sessionmaker):
        self.root_url = root_url
        self.session_cls = session_cls

    def construct_url(self, entity: str) -> str:
        return f"{self.root_url}/v1/{entity}"

    def get_items(self, entity: str) -> List[Item]:
        resp = requests.get(self.construct_url(entity))
        return [Item(**n) for n in resp.json()["items"]]

    def save_items(self, entity: str) -> None:
        with scoped_session(self.session_cls) as session:

class ClientA(ApiClient):
    def construct_url(self, entity: str) -> str:
        return f"{self.root_url}/{entity}"

class ClientB(ApiClient):
    def construct_url(self, entity: str) -> str:
        return f"{self.root_url}/a/special/place/{entity}"

client_a = ClientA("https://client-a", session_cls)

We chose OO it because we wanted to bind the root_url to something and we didn't want to pass around the sessionmaker. We also wanted to utilise inheritance to hook into a method halfway through the call stack.

But what if we do just pass data around, and write 'boring' functions, what happens then?

class Client:
    root_url: str
    url_layout: str

client_a = Client(

client_b = Client(

def construct_url(client: Client, entity: str) -> str:
    return client.url_layout.format(root_url=client.root_url, entity=entity)

def get_items(client: Client, entity: str) -> List[Item]:
    resp = requests.get(construct_url(client, entity))
    return [Item(**n) for n in resp.json()["items"]]

def save_items(client: Client, session_cls: session_cls, entity: str) -> None:
    with scoped_session(session_cls) as session:
        session.add(get_items(client, entity))

save_items(client_a, session_cls, "bars")

We had to pass round the Client and the session_cls around.


Who cares? We even wrote like 10% fewer characters. Also, the conjecture stands, the resulting code is at least as easy to understand and we didn't need any OO.

I've heard this style referred to as the bag-of-functions style. That is to say, all your code just consists of typed data and module-namespaced-bags-of-functions.

What about long lived global-y things?

Use this pattern to reuse config/db session classes over the lifetime of an application.

What about interfaces/abstract base classes?

Just try writing without them, I promise it's going to be OK. (To be fair, it's only the introduction of type hints to Python that has made the bag-of-functions style so pleasant).

What about impure things?

If you've taken the pure-FP/hexagonal-architecture pill, you want to write pure classes that take impure 'adapter' instances for getting-the-current-datetime/API-calls/talking-to-the-db/other-impure-stuff. The idea is nice in principle - should be good for testing right? - in practice, you can just use freezegun / use responses / test with the db (the other-impure-stuff tends to not actually exist) and save yourself a lot of hassle.


I'd like to make exceptions for the following:

I lied.

My point here is not to argue that OO is bad per se.

OK, I lied, it's not just a case of OO being a largely futile addition to the language, it's that it often obscures the problem at hand and encourages bad behaviours: