Introduction
You've written Python before, maybe in notebooks or quick scripts. Now you're building a backend that others will use—or that you'll maintain six months from now. The difference between those two worlds starts with setting up a Python project the modern way: a deep dive into virtual environments, dependency management, and project structure. Get this right from day one, and you'll avoid dependency conflicts, broken deployments, and hours wasted debugging environment mismatches. Get it wrong, and you'll spend weeks untangling mess.
Use a virtual environment — Isolate dependencies per project
A virtual environment is a self-contained Python installation per project. Without it, every package you install lands in your system Python or global user site-packages. Two projects needing different versions of Django? Conflict. Dependency X that broke in version 2.0? You upgrade for project A and break project B. Virtual environments solve this completely.
The built-in venv module is reliable and requires no extra installation. Create one by running:
python -m venv .venvThis creates a .venv directory in your project root. The dot prefix marks it as hidden on Unix-like systems, keeping your project directory clean. Activate it:
source .venv/bin/activate # macOS and Linux
.venv\Scripts\activate # WindowsWhen active, your shell prompt changes (usually showing (.venv)), and python and pip now refer to the isolated environment. Any package you install stays inside .venv and doesn't touch your system Python.
Always add .venv to .gitignore. Virtual environments are machine-specific and reproducible from a lock file—there's no reason to commit them to version control.
# .gitignore
.venv/
__pycache__/
*.pyc
.env
.DS_StoreA teammate clones your repo, runs python -m venv .venv, activates it, and installs dependencies fresh. No conflicts, no "but it works on my machine" arguments.
Use uv or Poetry — Fast installs and a lock file for reproducible builds
Once you have a virtual environment, you need a way to declare and lock dependencies. pip alone isn't enough: it installs packages but doesn't guarantee reproducibility across machines or over time.
uv is a modern Python package installer written in Rust. It's blazingly fast—often 10x faster than pip—and handles locking naturally. Poetry is a mature, Pythonic alternative that's been around longer and has a larger ecosystem of plugins.
For new projects, we typically choose uv for its speed and simplicity. Install it once system-wide (it's a single binary, no Python dependency), then initialize a project:
uv init api
cd apiThis creates a skeleton project with a pyproject.toml file and uv.lock. Add your first dependency:
uv add fastapi uvicornuv installs FastAPI, Uvicorn, and all transitive dependencies into your virtual environment (creating one automatically if needed), then writes exact versions to uv.lock:
| File | Purpose | Commit to Git? |
pyproject.toml |
Human-readable dependency declarations and tool config | Yes |
uv.lock (or poetry.lock) |
Machine-readable, exact versions for reproducible installs | Yes |
.venv/ |
Isolated Python installation | No |
A teammate pulls your code and runs uv sync (or poetry install). They get the exact same versions you had, deterministically. No "but you have version 1.2.3 and I have 1.2.4" surprises.
If you choose Poetry instead, the workflow is nearly identical:
poetry init
poetry add fastapi uvicorn
poetry installPoetry has a larger community and more third-party integrations (for example, many deployment tools recognize poetry.lock out of the box). uv is faster and newer. Both are solid choices—pick one and stick with it for a project.
src layout — Put code under src/ so tests import the installed package, not local files
How you organize files matters. A common layout is to dump everything in the project root:
my_api/
main.py
models.py
database.py
tests/
test_main.py
This seems convenient until tests import your code. In test_main.py, you write from my_api import main. Python finds my_api/ in the current directory and imports from there, not from the installed package. This works in your local environment but fails in CI, in Docker, or when someone installs your package from PyPI. The test suite isn't actually testing the installed product.
The modern approach is the src layout:
my_api/
src/
my_api/
__init__.py
main.py
models.py
database.py
tests/
test_main.py
pyproject.toml
README.md
Now your source code lives under src/. When you run uv add, your package is installed into the virtual environment in editable mode (the -e flag), so imports work everywhere:
uv add --editable .In test_main.py, you import from the installed package:
from my_api import mainPython finds it in site-packages (or the editable install symlink), exactly as it would in production. If your code isn't importable as an installed package, tests catch it immediately. This catches a whole class of packaging bugs before they reach production.
The src layout is standard in mature Python projects (Django, Flask, and many others use it). It's worth the small overhead of an extra directory level.
pyproject.toml — One file for dependencies, tooling and metadata
pyproject.toml is the single source of truth for your project. It specifies dependencies, build settings, tool configuration, and metadata in one place.
Here's a typical modern pyproject.toml for a FastAPI backend:
[project]
name = "my-api"
version = "0.1.0"
description = "A backend API service"
requires-python = ">=3.11"
dependencies = [
"fastapi==0.104.1",
"uvicorn==0.24.0",
"sqlalchemy==2.0.23",
"pydantic==2.5.0",
]
[project.optional-dependencies]
dev = [
"pytest==7.4.3",
"pytest-cov==4.1.0",
"ruff==0.1.8",
"mypy==1.7.0",
]
[tool.uv]
dev-dependencies = [
"pytest",
"pytest-cov",
"ruff",
"mypy",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.mypy]
python_version = "3.11"
strict = true
Key sections:
[project]— Package metadata and production dependencies.[project.optional-dependencies]— Groups of optional dependencies (e.g., dev, docs, testing).[tool.*]— Configuration for linting, type checking, testing, and other tools.
With everything in one file, a new team member can understand your project's dependencies, Python version, and tooling setup in seconds. Most modern tools (pytest, ruff, mypy, etc.) auto-discover settings from pyproject.toml, so you don't need separate setup.cfg, .flake8, or pytest.ini files scattered around.
When using uv, you typically don't write pyproject.toml by hand for dependencies—uv add and uv add --group dev manage it. But understanding the structure helps when you need to pin versions, add constraints, or configure tools.
Common mistakes
Committing .venv to version control. Your virtual environment is machine-specific and reproducible from pyproject.toml and lock file. Committing it bloats the repo and causes merge conflicts when teammates install different packages.
Using pip freeze for production lock files. pip freeze dumps all installed packages, including transitive dependencies. It works but doesn't distinguish between direct dependencies and their dependencies, making it hard to update individual packages later. A lock file from uv or Poetry is cleaner.
Importing from the project root instead of an installed package. If your code only works when run from the project directory but breaks in CI or when installed, your tests aren't testing the real product. The src layout prevents this.
Not specifying a minimum Python version. Always set requires-python in pyproject.toml. If you use f-strings, you need 3.6+. If you use the walrus operator, you need 3.8+. Be explicit so your CI and package metadata reflect reality.
Mixing dependency declaration styles. Don't use both requirements.txt and pyproject.toml. Pick one (uv or Poetry with pyproject.toml) and stick with it. Mixing creates confusion and version mismatches.
Key takeaways
- Always use a virtual environment per project.
python -m venv .venvtakes seconds and prevents conflicts across your machine. - Choose uv or Poetry for dependency management. Both create lock files for reproducible installs. uv is faster; Poetry has a larger ecosystem.
- Use the src layout. Put source code under
src/package_name/so tests import the installed package, not local files. This catches packaging bugs early. - Centralize everything in
pyproject.toml. Dependencies, metadata, and tool configuration belong in one file that your team can understand at a glance. - Commit
pyproject.tomland your lock file (uv.lockorpoetry.lock) to Git. Don't commit.venv. - Specify
requires-pythonexplicitly. Make your Python version requirement clear in your project metadata.






Comments
Loading comments…