Skip to content
Happy Programming Guide
Start learning
Python

Python Best Practices

Naming, structure, error handling, virtual environments, type hints and testing — the habits that make Python code easy to come back to.

An open laptop on a wooden desk

These are the habits that separate Python that works from Python you can return to in six months. None is difficult. Most take a few seconds once they are automatic, and together they remove a large share of the bugs and confusion beginners run into.

Naming#

Python
# Good
user_count = 10
def calculate_total(items): ...
class ShoppingCart: ...
MAX_RETRIES = 3

# Avoid
uc = 10
def calcTot(i): ...
class shopping_cart: ...

Snake case for variables and functions, capitalised words for classes, upper case for constants. Beyond convention, names should say what the thing is: days_until_expiry needs no comment; d needs a paragraph.

Never name a file or variable after something in the standard library. A file called random.py or csv.py shadows the real module, and the resulting errors are baffling.

Let a formatter do the formatting#

Terminal
python -m pip install ruff
ruff format .        # formats
ruff check . --fix   # finds problems and fixes what it can

The value is not that any particular style is better — it is that formatting stops being a decision. Set it to run on save and you never think about line breaks again.

Functions should return, not print#

Python
# Hard to reuse or test
def show_average(numbers):
    print(sum(numbers) / len(numbers))


# Easy to do both
def average(numbers):
    if not numbers:
        return 0
    return sum(numbers) / len(numbers)


print(average(scores))

The second version can be tested in one line, used inside another calculation, and its output formatted differently in different places. Print at the edges of your program; return everywhere else.

Keep functions to one job. If the name needs an “and” in it, that is two functions.

Handle specific exceptions#

Python
# Hides everything, including your own typos
try:
    value = int(user_input)
except:
    value = 0

# Says what you expected to go wrong
try:
    value = int(user_input)
except ValueError:
    value = 0

A bare except catches KeyboardInterrupt and SystemExit too, so it can make a program impossible to stop with Ctrl+C. It also swallows the NameError from a misspelled variable, which turns a five-second fix into an hour of confusion.

Python
try:
    data = json.loads(text)
except json.JSONDecodeError as error:
    log.error("Bad JSON at position %s", error.pos)
    raise                      # re-raise if you cannot actually handle it

One virtual environment per project#

Terminal
python -m venv .venv
source .venv/bin/activate        # .venv\Scripts\activate on Windows

python -m pip install requests
python -m pip freeze > requirements.txt

Always python -m pip rather than bare pip — it guarantees the install goes to the interpreter you just ran, which prevents most “it says it is installed but the import fails” problems.

Add .venv/ and __pycache__/ to .gitignore. Commit requirements.txt.

Keep secrets out of your code#

Python
import os

# Never
API_KEY = "sk-1234567890abcdef"

# Instead
API_KEY = os.environ["API_KEY"]                 # required
DEBUG = os.environ.get("DEBUG", "false") == "true"   # optional

Using os.environ["..."] for required values means the program fails immediately with a clear message rather than making a request with an empty key and getting a confusing 401.

Type hints#

Python
def average(numbers: list[float]) -> float:
    if not numbers:
        return 0.0
    return sum(numbers) / len(numbers)


def find_user(user_id: int) -> dict | None:
    ...

Python ignores them at run time, so they cost nothing. What they buy you is real: your editor can autocomplete and catch mistakes, and a checker such as mypy finds a class of bug before you run anything.

Terminal
python -m pip install mypy
mypy .

Add them to new code as you write it. Retrofitting a whole codebase at once is rarely worth the effort.

Prefer the idiom#

Python
# Iterating
for i in range(len(items)):        # C-style
    print(items[i])

for item in items:                 # Pythonic
    print(item)

for i, item in enumerate(items):   # when you need the index
    print(i, item)

# Building a list
result = []
for n in numbers:
    if n > 0:
        result.append(n * 2)

result = [n * 2 for n in numbers if n > 0]      # clearer

# Checking emptiness
if len(items) == 0: ...
if not items: ...                               # clearer

# Opening files
f = open("data.txt")
data = f.read()
f.close()                                       # not closed if read() raises

with open("data.txt") as f:                     # always closed
    data = f.read()

Write one test#

Python
# tests/test_average.py
from mymodule import average


def test_average_of_numbers():
    assert average([2, 4, 6]) == 4


def test_average_of_empty_list():
    assert average([]) == 0
Terminal
python -m pytest

Two tests on the function most likely to break are worth far more than none. The empty-list case above is exactly the kind of thing that quietly stops working when someone refactors.

Comment the why#

Python
# Adds one to i
i += 1

# Skip the header row - this supplier includes it in every export
i += 1

The code already says what it does. A comment earns its place by explaining why — a workaround, a business rule, a decision that looks wrong until you know the context.

Docstrings on anything non-obvious:

Python
def calculate_discount(price: float, tier: str) -> float:
    """Return the discounted price for a customer tier.

    Unknown tiers get no discount rather than raising, because
    supplier feeds occasionally contain tiers we have not seen.
    """

Project structure#

Output
myproject/
    .venv/
    .gitignore
    README.md
    requirements.txt
    pyproject.toml
    myproject/
        __init__.py
        core.py
        cli.py
    tests/
        test_core.py

Run things with python -m myproject.cli from the root. That keeps the project directory on the import path and avoids most import errors.

Questions people ask#

What is PEP 8?

Python’s official style guide — naming, indentation, line length, import order. You do not need to memorise it; a formatter applies it for you.

Are type hints worth it on small scripts?

On a twenty-line script, not really. On anything with functions other code calls, yes — the editor support alone repays the effort.

How much test coverage should I aim for?

Not a number. Test the parts where a bug would cost you the most, and anything you have already had to fix once. High coverage of trivial code is a poor use of time.

Should I use f-strings everywhere?

For building strings, yes — they are clearer and faster. The exception is logging, where log.info("User %s", name) lets the logger skip formatting entirely when that level is disabled.

Where to go next#

Building a complete Python project, properly structuredRead next

Keep reading

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

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