10 Essential Python Best Practices for Clean Code

The rain is drumming against the corrugated tin roof of the shed. It’s a cold, rhythmic sound that reminds me why I left Palo Alto. Out here, if a fence post rots, the cows get out. There’s no “fail-fast” philosophy when you’re chasing a half-ton of beef through a muddy creek at 3:00 AM. There is only the reality of the work.

I’m sitting at a scarred oak table, my old ThinkPad X220 glowing in the dim light. It’s air-gapped. No Wi-Fi, no Bluetooth, no distractions. Just the hum of the solar inverter and the click of the mechanical keyboard. I spent fifteen years building “scalable solutions” for companies that didn’t have a problem to scale. I wrote code that lived in the “cloud,” which is just a fancy word for someone else’s basement. Now, I write code to manage my irrigation and track the soil pH in the north pasture.

I saw a repo yesterday. A “modern” Python project. A simple microservice to fetch weather data. It was a crime. It was a bloated, over-engineered monument to human ego. It’s time for a reckoning.

THE WALL OF SHAME

Look at this. Really look at it. This is what the industry calls “clean code.” I call it a dumpster fire.

from typing import Optional, Union, Protocol, TypeVar, Generic
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import wraps
import asyncio

T = TypeVar("T")

class IWeatherProvider(Protocol):
    async def get_temperature(self, lat: float, lon: float) -> float:
        ...

@dataclass(frozen=True)
class WeatherResponse:
    temp: float
    unit: str = "C"

class WeatherServiceException(Exception):
    """Base exception for the weather service."""
    pass

def retry_on_failure(retries: int = 3):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            for i in range(retries):
                try:
                    return await func(*args, **kwargs)
                except Exception as e:
                    if i == retries - 1:
                        raise WeatherServiceException(f"Failed after {retries} attempts: {e}")
            return await func(*args, **kwargs)
        return wrapper
    return decorator

class AbstractWeatherManager(ABC, Generic[T]):
    @abstractmethod
    async def fetch_data(self, coords: T) -> WeatherResponse:
        pass

class OpenWeatherMapManager(AbstractWeatherManager[tuple[float, float]]):
    def __init__(self, api_key: str):
        self._api_key = api_key

    @retry_on_failure(retries=5)
    async def fetch_data(self, coords: tuple[float, float]) -> WeatherResponse:
        # Imagine 20 more lines of async aiohttp boilerplate here
        return WeatherResponse(temp=22.5)

async def main():
    manager = OpenWeatherMapManager(api_key="SECRET_STUFF")
    result = await manager.fetch_data((45.523062, -122.676482))
    print(f"The temperature is {result.temp}{result.unit}")

if __name__ == "__main__":
    asyncio.run(main())

Fifty lines. Fifty lines of code to print a string. You’ve got generics, protocols, abstract base classes, custom decorators, and a frozen dataclass. You’re treating a simple API call like it’s the flight control system for a Mars lander. It’s a joke. If you actually cared about python best practices, you’d stop importing every library on PyPI just to left-pad a string.

Let’s start the teardown.


The Type Hinting Delusion

Type hints were supposed to be a suggestion. A way to help the IDE help you. But the “modern” developer has turned them into a religion. They’ve turned Python into a worse version of Java. Look at that Generic[T] and Protocol nonsense. Why? Are you planning on swapping out your coordinate system for a 4D hypercube next week? No. You’re fetching a latitude and a longitude. They are floats. They will always be floats.

Python is a dynamic language. That is its strength. When you clutter the screen with Union[Optional[List[Dict[str, Any]]], None], you aren’t making the code safer; you’re making it unreadable. You’re forcing the reader to parse a wall of syntax before they can even see the logic.

The “python best” way to handle types is to use them where they clarify, not where they perform. If a function takes a user_id, I know it’s an integer or a string. I don’t need a NewType wrapper to tell me that. You’re adding cognitive load for zero runtime benefit. Python’s type hints are ignored by the interpreter anyway. You’re writing a novel that the computer doesn’t even read.

Let’s look at the first terminal log. I’m running a check on this garbage using ruff.

silas@thinkpad:~/farm_ops$ ruff check weather_bloat.py --select ALL
weather_bloat.py:1:1: D100 Missing docstring in public module
weather_bloat.py:10:1: D101 Missing docstring in public class
weather_bloat.py:14:1: D101 Missing docstring in public class
weather_bloat.py:19:1: D101 Missing docstring in public class
weather_bloat.py:23:1: D103 Missing docstring in public function
weather_bloat.py:41:5: D107 Missing docstring in __init__
weather_bloat.py:44:5: D102 Missing docstring in public method
Executed in 0.0008s

Even the linter knows this code is trying too hard. It’s demanding docstrings for classes that shouldn’t exist. I’m deleting the Protocol. I’m deleting the Generic. I’m deleting the ABC.


Dependency Hell is a Choice

The “Wall of Shame” snippet didn’t show the requirements.txt, but I can smell it from here. It probably has pandas for no reason, pydantic for “validation” of two fields, and request-promise-whatever because the dev forgot how urllib works.

In the Valley, we used to joke that a project’s complexity is proportional to the size of its node_modules or its site-packages. It’s not a joke anymore. It’s a pathology. Every dependency you add is a security hole, a maintenance burden, and a potential breaking change.

You don’t need a framework to make an HTTP request. You don’t need a library to parse a config file. Python’s standard library is massive. Use it.

I switched from pip to uv recently. Not because I like new tools, but because it’s written in Rust and it’s fast enough to keep up with my train of thought. But even with a fast tool, your pyproject.toml should be lean.

silas@thinkpad:~/farm_ops$ cat pyproject.toml
[project]
name = "farm-weather"
version = "0.1.0"
dependencies = [
    "httpx",
]

[tool.ruff]
target-version = "py312"
line-length = 80

That’s it. One dependency. httpx because requests is synchronous and I actually do want to fetch three sensors at once without waiting for the first one to time out. But I’m not adding pydantic. I’m not adding marshmallow. I’m using a dictionary.

Why a dictionary? Because I know how a dictionary works. I know that in CPython, a dictionary is a compact hash table. I know that since Python 3.6, they preserve insertion order. I know that looking up a key is O(1). When you wrap a dictionary in a Pydantic model, you’re adding layers of __getattr__ and __setattr__ calls that slow everything down. For what? A “validation error” that you’re just going to log and ignore?


The Asyncio Cargo Cult

Look at the asyncio.run(main()) in the snippet. This is a CRUD app. It’s doing one thing. Why is it async?

“But Silas, it’s non-blocking!”

Shut up. You’re running it on a single-core VPS or a Lambda function. You aren’t handling 10,000 concurrent connections. You’re fetching one JSON blob. By making it async, you’ve just made the stack traces unreadable and the debugging twice as hard.

Asyncio is for high-concurrency network servers. It is not a “go fast” button for your script. In fact, for most CPU-bound tasks, it’s slower because of the event loop overhead. And don’t get me started on the GIL (Global Interpreter Lock). Most of these kids don’t even know what the GIL is. They think async means “parallel.” It doesn’t. You’re still on one thread, buddy. You’re just jumping between tasks like a caffeinated squirrel.

If you want parallelism, use multiprocessing. If you want to fetch five URLs, use a ThreadPoolExecutor. It’s built-in. It’s stable. It doesn’t require you to rewrite your entire codebase with await keywords like a stuttering robot.


Classes are Overrated (and Memory is Finite)

The WeatherResponse dataclass in the snippet. It’s “frozen.” It’s “clean.” It’s also a memory hog if you’re not careful.

Every instance of a class in Python has a __dict__ unless you tell it otherwise. That dictionary takes up memory. If you’re processing a million weather records on a solar-powered ThinkPad, that memory matters.

If you absolutely must use a class, use __slots__.

class WeatherRecord:
    __slots__ = ("temp", "unit", "timestamp")
    def __init__(self, temp, unit, timestamp):
        self.temp = temp
        self.unit = unit
        self.timestamp = timestamp

By defining __slots__, you tell Python not to create a __dict__ for every instance. You’re pre-allocating space for the attributes. It’s faster and it uses a fraction of the RAM. But even better? Use a namedtuple. Or just a tuple.

Modern developers are terrified of raw data. They want to wrap everything in an object. They want weather.get_temperature_display(). Just print the float, you coward.

Let’s look at the memory difference.

silas@thinkpad:~/farm_ops$ python3.12 -c "
import sys
class Normal:
    def __init__(self, a, b): self.a, self.b = a, b
class Slotted:
    __slots__ = ('a', 'b')
    def __init__(self, a, b): self.a, self.b = a, b
print(f'Normal: {sys.getsizeof(Normal(1, 2)) + sys.getsizeof(vars(Normal(1, 2)))} bytes')
print(f'Slotted: {sys.getsizeof(Slotted(1, 2))} bytes')
"
Normal: 152 bytes
Slotted: 48 bytes

A 3x reduction in memory just by being intentional. That’s the difference between a script that runs on a Raspberry Pi in the greenhouse and a script that crashes because it ran out of swap space.


Global State and Other Lies

The snippet uses a “Manager” class. Why? Because some book on Design Patterns told them that “Managers” are how you organize code.

A “Manager” is usually just a collection of functions that share some state. In this case, the api_key. So you instantiate the class, pass the key to the constructor, and then call a method.

Just use a function.

“But Silas, how do I handle the API key without passing it everywhere?”

Use a module-level constant or an environment variable. “Global state is evil,” they scream. You know what’s more evil? Passing a Config object through fifteen layers of abstraction just to reach a function that needs a single string. That’s not “clean,” that’s a bucket brigade.

If a function needs an API key, give it the API key.

def fetch_weather(api_key: str, lat: float, lon: float):
    # logic here
    pass

It’s testable. It’s pure. It doesn’t require me to instantiate a WeatherManagerFactory to see if it works.


The “Clean Code” Industrial Complex

The biggest lie in the industry is that “clean code” is a set of rules you can follow to make your software better. It’s not. It’s a way for consultants to sell books and for junior devs to feel like they’re doing something productive while they’re actually just moving code from one file to another.

Real clean code is code that can be deleted.

If I can replace your 50-line “WeatherService” with a 5-line function and the system still works, your code wasn’t clean. It was clutter. It was noise.

Let’s look at the refactored version of that weather script. No decorators. No classes. No async unless we actually need it.

import httpx # The only thing we actually need

def get_weather(api_key, lat, lon):
    url = f"https://api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lon}&appid={api_key}"
    try:
        response = httpx.get(url)
        response.raise_for_status()
        data = response.json()
        return data['main']['temp'] - 273.15 # Convert Kelvin to Celsius
    except Exception as e:
        print(f"Error fetching weather: {e}")
        return None

if __name__ == "__main__":
    temp = get_weather("SECRET_STUFF", 45.523, -122.676)
    if temp:
        print(f"The temperature is {temp:.1f}C")

Twelve lines. It does the same thing. It’s easier to read. It’s easier to debug. It doesn’t require a PhD in Type Theory to understand.

“But Silas, what about the retries?”

If the API is down, the API is down. If you’re in a production environment where 99.999% uptime matters, use a library like tenacity. But don’t write a custom decorator for a script that runs once an hour. You’re over-solving a problem you don’t have.


The Reality of the Machine

Most developers today have no idea how the computer actually executes their code. They think Python is magic. They think the “Cloud” is an infinite resource.

It’s not.

Python is a wrapper around C. When you create a list, you’re allocating a contiguous block of memory for pointers. When you append to that list and it exceeds its capacity, Python has to allocate a new, larger block and copy everything over. If you knew that, you’d pre-allocate your lists.

When you use a dictionary, you’re using a hash table. If your keys have bad hash functions, you get collisions. If you have too many collisions, your O(1) lookup becomes O(n).

If you actually cared about python best practices, you’d spend more time reading the CPython source code and less time reading Medium articles about “10 Tips for Cleaner Code.”

You’d realize that import statements are expensive. Every time you import a module, Python has to find it, compile it to bytecode, and execute it. If you import pandas just to read a 10-line CSV, you’re burning CPU cycles for nothing. Use the csv module. It’s in the standard library. It’s fast. It’s fine.

Let’s look at a final terminal log. This is the execution time of the “Modern” version vs. my “Refactored” version.

silas@thinkpad:~/farm_ops$ time python3 weather_bloat.py
The temperature is 22.5C
real    0m0.412s

silas@thinkpad:~/farm_ops$ time python3 weather_lean.py
The temperature is 22.5C
real    0m0.084s
Executed in 0.084s

Five times faster. Why? Because it’s not loading asyncio, abc, dataclasses, and typing. It’s not setting up an event loop. It’s just doing the work.

In the Valley, they’d say 0.4 seconds is “fast enough.” Out here, when the sun is going down and the battery on the solar array is at 10%, every millisecond of CPU time is a millisecond of light I won’t have later.

Efficiency isn’t just a technical goal. It’s a moral one.

Stop over-complicating your scripts. Stop building “architectures” for things that are just tasks. Stop using Python like it’s Java.

Python was designed to be simple. It was designed to be readable. It was designed to get out of your way so you could solve problems. Somewhere along the way, we forgot that. We started building cathedrals when all we needed was a shed.

I’m going back outside. The rain has stopped, and I need to check the irrigation lines. The code is done. It’s simple. It works. And most importantly, I can forget about it and do the real work.

If you want to be a better developer, stop looking for the next framework. Pick up a shovel. Learn how the soil works. Then come back and look at your code. You’ll see the rot. And you’ll know exactly what to prune.


Final Pip Freeze (The “Clean” Version):

silas@thinkpad:~/farm_ops$ pip freeze
httpx==0.27.0
ruff==0.3.0

No pydantic. No fastapi. No sqlalchemy. Just the tools I need.

The farm is still standing. The code is still running. That’s the only metric that matters.

Now get out of my shed.

Related Articles

Explore more insights and best practices:

Leave a Comment