Python's forgiving nature is also its trap: a script with a few functions in it runs just fine, so there's rarely a forcing function that makes a team stop and structure the project properly — until imports start tangling and nobody's sure where a piece of logic actually lives.
src layout over a flat package
Putting the actual package under src/your_package instead of at the repo root forces it to be installed (even in editable mode with pip install -e .) before it can be imported, which catches accidental reliance on the current working directory early — a class of bug that's otherwise invisible until deployment.
pyproject.toml as the single source of truth
Consolidating dependencies, build config, and tool settings (ruff, mypy, pytest) into one pyproject.toml instead of a scattered setup.py, requirements.txt, and half a dozen ini files makes onboarding a new environment a one-command affair, and stops config from drifting out of sync across files nobody remembers to update together.
Type hints don't make Python statically typed — but running mypy or pyright in CI turns them from documentation nobody trusts into a check that actually fails the build.
Virtual environments per project, always
Installing packages globally works fine for exactly one project at a time. The moment a second project needs a different version of the same dependency, a shared global environment turns into a debugging session about why code that worked yesterday doesn't today. Tools like uv or poetry make per-project environments close to zero-overhead.
Dataclasses and enums over dicts and magic strings
A dict passed around as an ad-hoc data structure has no schema the editor or type checker can see. Reaching for a dataclass (or Pydantic model, in an API context) the moment a dict has more than two or three known keys turns typos in key names into an immediate error instead of a None that surfaces three function calls later.



