Best Practices
Writing Python that merely runs is easy. Writing Python that stays readable, debuggable, and safe to change six months from now takes discipline. This page pulls together the habits that consistently separate maintainable codebases from fragile ones — treat it as a practical checklist to run your own code against.
1. Follow PEP 8
PEP 8 is Python's official style guide — 4-space indentation, snake_case for functions and variables, PascalCase for classes, sensible line lengths, consistent whitespace. Consistency matters more than any individual rule: it means anyone (including future you) can read unfamiliar code without friction. Tools like black and ruff format enforce this automatically so it stops being a manual chore.
2. Write docstrings
def calculate_discount(price: float, percent: float) -> float:
"""Return the price after applying a percentage discount.
Args:
price: Original price, must be non-negative.
percent: Discount percentage between 0 and 100.
Returns:
The discounted price.
"""
return price * (1 - percent / 100)A good docstring explains why and what, not a restatement of the code. It also powers help(), IDE tooltips, and auto-generated documentation for free.
3. Use type hints
def find_user(user_id: int) -> dict[str, str] | None:
...
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: floatType hints don't change runtime behavior, but they let tools like mypy and your editor catch entire categories of bugs — passing a str where an int is expected — before the code ever runs.
4. Prefer composition over deep inheritance
# Fragile: deep inheritance chains couple unrelated behavior together
class Animal: ...
class Swimmer(Animal): ...
class Flyer(Swimmer): ... # a Duck needs both, forcing an awkward chain
# Sturdier: compose small, focused pieces
class Duck:
def __init__(self):
self.swim_behavior = SwimBehavior()
self.fly_behavior = FlyBehavior()Deep inheritance hierarchies tend to become rigid and hard to change safely. Favoring composition — building objects out of smaller, swappable pieces — keeps each piece easy to test and reuse independently.
5. Use context managers for resources
# Risky: file stays open if an exception is raised before close()
f = open('data.txt')
data = f.read()
f.close()
# Safe: the file is always closed, even on error
with open('data.txt') as f:
data = f.read()Files, database connections, locks, and network sockets should almost always be managed with with blocks so cleanup happens automatically, even when something goes wrong halfway through.
6. Avoid mutable default arguments
# Bug: the same list is reused across every call!
def add_item(item, items=[]):
items.append(item)
return items
# Fix: use None as the sentinel, create a fresh list each call
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items7. Use virtual environments, always
python -m venv .venv source .venv/bin/activate # macOS/Linux .venv\Scripts\activate # Windows pip install -r requirements.txt
Every project should have its own isolated set of dependencies. Installing packages globally leads to version conflicts between projects and makes it impossible to reliably reproduce someone else's environment.
8. Write tests
# test_discount.py
from discount import calculate_discount
def test_calculate_discount_applies_percentage():
assert calculate_discount(100, 20) == 80
def test_calculate_discount_zero_percent():
assert calculate_discount(50, 0) == 50Tests (with pytest or the built-in unittest) turn "I think this still works" into "I know this still works." They're what make refactoring and upgrading dependencies safe instead of terrifying.
9. Use pathlib over raw string paths
# Old style: string concatenation, platform-specific separators
path = base_dir + "/" + "data" + "/" + "file.csv"
# pathlib: readable, cross-platform, object-oriented
from pathlib import Path
path = Path(base_dir) / "data" / "file.csv"
if path.exists():
contents = path.read_text()pathlib handles path separators correctly across operating systems and bundles common filesystem operations (.exists(), .read_text(), .mkdir()) directly onto the path object.
10. Prefer f-strings
name = "Ada"
score = 97.5
# Old styles
"Hello, %s! Score: %.1f" % (name, score)
"Hello, {}! Score: {:.1f}".format(name, score)
# f-strings: clearer, and evaluated fastest
f"Hello, {name}! Score: {score:.1f}"11. Use logging, not print
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("Starting job for user %s", user_id)
logger.warning("Retrying after transient error")
logger.error("Job failed", exc_info=True)print() is fine for a quick throwaway script, but real applications need severity levels, timestamps, the ability to turn noisy output off in production, and a way to route messages to files or external log aggregators — all of which logging provides and print() does not.
The checklist
Follow PEP 8 (or let
black/ruff formatdo it for you).Write docstrings for public functions, classes, and modules.
Add type hints and check them with
mypyor your editor.Prefer composition over deep inheritance chains.
Use
withblocks for files, connections, and locks.Never use a mutable object (
[],{}) as a default argument.Always develop inside a virtual environment.
Write tests for the logic you care about, and run them before merging.
Use
pathlibinstead of manual string path manipulation.Prefer f-strings for formatting text.
Use the
loggingmodule instead ofprint()for anything beyond a quick script.
Good Python habits compound: consistent style, clear types, safe resource handling, and real tests all make a codebase easier to hand off, easier to debug at 2am, and easier to trust as it grows.