Stop Writing Python Like This, Do It Instead.

Python best practices My Code Diary

Stop Writing Python Like This, Do It Instead.

I was three years into Python when a senior engineer looked at my code and said, “This works. But working and good are two very different things.”

That sentence lived in my head for weeks. I had been building things, shipping things, solving things. But somewhere between the first “Hello, World!” and a 4,000-line codebase, I had picked up habits that made my code look like it was written by someone who learned Python from Stack Overflow. (Because, honestly, I did.)

This is not a beginner’s list. You already know about list comprehensions and f-strings. This is about the subtler stuff, the things that separate Python that merely runs from Python that other developers actually want to read.


1. You Are Not Using __slots__ and Your Classes Are Paying the Price

Every time you create a Python object, Python stores its attributes in a hidden dictionary called __dict__. It is flexible, but it is also slow and memory-hungry. For classes you instantiate hundreds or thousands of times, this adds up fast.

# The expensive way — Python allocates a dict per instance
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

# The better way
class Point:
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

With __slots__, Python pre-allocates memory for exactly the attributes you declare. In benchmarks, this can cut memory usage by 40–50% for large numbers of instances. I discovered this when a data pipeline I wrote was mysteriously eating 3 GB of RAM. The fix took 30 seconds.


2. enumerate() Is Not Optional

I still see this in production codebases:

# Cringe-worthy
for i in range(len(items)):
    print(i, items[i])

# The actual Python way
for i, item in enumerate(items):
    print(i, item)

This is not just aesthetic. The range(len(...)) pattern is fragile, it breaks if items is a generator or any non-subscriptable iterable. enumerate() works on anything iterable and is cleaner to read. There is no argument for the old way.

Pro Tip: enumerate(items, start=1) lets you start counting from any number. Useful more often than you think.


3. You Are Letting Exceptions Evaporate

The single most dangerous line in a Python codebase is the bare except:

# This is silent data corruption waiting to happen
try:
    result = risky_function()
except:
    pass

When you write except: pass, you are telling Python to swallow every possible error, including KeyboardInterrupt, SystemExit, memory errors, everything. You will spend hours debugging something that failed silently three function calls ago.

# Catch what you mean. Log what you catch.
import logging

try:
    result = risky_function()
except ValueError as e:
    logging.error("Invalid value encountered: %s", e)
    raise

If you do not know what exception to catch, that is a signal to go read the docs of whatever you are calling. Uncertainty is not a reason to catch everything.


4. Context Managers Are Not Just for Files

Everyone learns with open(...). But context managers are a general-purpose tool that most developers underuse. Any time you have a setup-and-teardown pattern, a context manager makes it safer and cleaner.

from contextlib import contextmanager
import time

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: {elapsed:.4f}s")

# Now you can time any block of code
with timer("data processing"):
    process_large_dataset()

I use this pattern constantly. Database connections, temporary directories, mocking, locking. the moment your code has a “start this, do stuff, stop this” shape, reach for a context manager.


5. dataclasses Make Your Life Easier. Use Them.

Writing __init__, __repr__, and __eq__ by hand for every class is a ritual that Python abolished in 3.7. Meet dataclasses:

from dataclasses import dataclass, field

@dataclass
class Config:
    host: str
    port: int = 8080
    tags: list = field(default_factory=list)

You get __init__, __repr__, and __eq__ for free. Add frozen=True and you get immutability. Add order=True and you get comparison operators. This is not a convenience feature. It is how Python expects you to write value objects in modern code.


6. Generator Expressions Are Not the Same as List Comprehensions

Both look almost identical. One of them can crash your program with a large enough dataset.

# Loads everything into memory at once
total = sum([x**2 for x in range(10_000_000)])

# Computes one value at a time — uses almost no memory
total = sum(x**2 for x in range(10_000_000))

The difference is the square brackets. A list comprehension builds the entire list in memory before passing it to sum. A generator expression yields one value at a time. For large data, the second version uses kilobytes instead of gigabytes. Drop the brackets when you do not need the list itself.


7. functools.lru_cache Is a Free Performance Upgrade

If you have a function that gets called repeatedly with the same arguments and computes the same result, you are doing unnecessary work. lru_cache memoizes the results so subsequent calls just do a dictionary lookup.

from functools import lru_cache

@lru_cache(maxsize=128)
def compute_similarity(doc_id_a, doc_id_b):
    # Expensive operation
    return heavy_nlp_computation(doc_id_a, doc_id_b)

I added this decorator to a recommendation engine once and the response time dropped from 800ms to 40ms without touching any other code. The function was being called with the same pairs over and over. The cache handled the rest.


8. Dictionary .get() Is Safer Than []

This is a small habit with outsized impact:

user = {"name": "Shaw", "role": "engineer"}

# Raises KeyError if 'age' is missing
age = user["age"]

# Returns None (or your default) if 'age' is missing
age = user.get("age", 0)

The [] operator is fine when you are certain the key exists. But in any situation where the data comes from outside your code an API, a config file, user input .get() is the honest choice. It acknowledges that the world is uncertain.


9. Type Hints Are Documentation That Never Goes Out of Date

Comments lie. Type hints are enforced by tools. When you add a type hint to a function signature, every major IDE and linter can now check that you are calling it correctly.

# Ambiguous
def process(data, limit):

# Self-documenting, tooling-friendly
def process(data: list[dict], limit: int = 100) -> list[str]:

You do not need to add type hints to every variable. Start with function signatures inputs and return types. That is where the most confusion happens, and that is where type hints provide the most value.


The Pattern Behind All of This

Looking at these nine tricks together, there is one theme: Python already solved the problem you are solving by hand.

__slots__ instead of unbounded dicts. enumerate() instead of index arithmetic. dataclasses instead of manual __init__. Generators instead of lists you do not need. Every one of these is the language telling you, “I built a better tool for this. Trust me.”

The engineers I respect most are not the ones who write the most code. They are the ones who know which lines to delete.

Pick one of these today. Refactor something small. Commit it. Then pick another next week.

That is how “it works” turns into “it’s good.”


My Code Diary is a space for honest lessons from real code. No fluff, no tutorials recycled from 2019, just what actually changed how I write.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top