---
title: "Python: Best Practices"
slug: "python"
category: "coding"
tags: ["coding", "python", "types", "async", "testing", "ruff"]
status: "stable"
last_updated: 2026-05-29
summary: "Python 3.13+ defaults: pyproject and uv, ruff, strict pyright, pytest, type hints, and structured concurrency."
related: ["[[coding/general-principles]]", "[[backend/fastapi]]", "[[backend/sqlite]]", "[[ai-agents/ollama]]"]
---

## Overview

Modern Python is Python 3.13+ (3.14 is the current stable release), a `pyproject.toml`, `uv` for package and environment management, `ruff` for lint and format, `pyright` in strict mode for types, and `pytest` for tests. Everything below assumes those choices. Read [[coding/general-principles]] first for the language-agnostic rules this page builds on.

## Use `pyproject.toml` and `uv`

Every project ships a `pyproject.toml`. No `requirements.txt`, no `setup.py`, no `Pipfile`. See [[file-organization/config-files]] for the broader one-source-of-truth rule and [[file-organization/project-structure]] for the surrounding layout.

```toml
[project]
name = "myproj"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
  "httpx>=0.27",
  "pydantic>=2.7",
]

[tool.uv]
dev-dependencies = [
  "pytest>=8",
  "ruff>=0.5",
  "pyright>=1.1.370",
]
```

Use `uv` for installs, virtualenvs, and lockfiles. `uv sync` creates a `.venv` and installs from `uv.lock`. `uv add httpx` adds a dependency and updates both files in one step. Poetry is the fallback if a team already standardizes on it; do not mix the two in one repo.

## One virtualenv per project, never global

Activate the project's `.venv` before running anything. Global `pip install` is a foot-gun: it pollutes the system Python, masks dependency conflicts, and breaks the next project. `uv run <cmd>` runs inside the project venv without an explicit activate step.

## Ruff for lint and format

Ruff replaces flake8, isort, black, pyupgrade, and a long tail of plugins. Configure it once in `pyproject.toml`:

```toml
[tool.ruff]
line-length = 100
target-version = "py313"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
ignore = ["E501"]
```

Run `ruff check --fix` and `ruff format` on every commit. Wire both into a pre-commit hook and into CI. A diff that mixes formatting with logic changes is unreviewable; format first, commit, then change.

## Pyright in strict mode

Use `pyright` (or `mypy --strict`) and make it part of CI. Strict mode forces explicit types, catches `Optional` mistakes, and refuses untyped function definitions.

```toml
[tool.pyright]
include = ["src", "tests"]
strict = ["src"]
pythonVersion = "3.13"
```

Type hints are mandatory on every public function. Internal helpers can rely on inference, but the moment a function crosses a module boundary, annotate the parameters and return type. See [[coding/general-principles]] on naming; the same rule applies to type aliases.

## Pytest with fixtures, not setUp

`pytest` is the only test runner worth using. Avoid `unittest.TestCase`. Use fixtures for shared setup, parametrize for input matrices, and `tmp_path` for filesystem tests.

```python
import pytest

@pytest.fixture
def client(tmp_path):
    return Client(db_path=tmp_path / "app.db")

@pytest.mark.parametrize("n, expected", [(0, 1), (1, 1), (5, 120)])
def test_factorial(n: int, expected: int) -> None:
    assert factorial(n) == expected
```

Test the cases from [[coding/general-principles]]: golden path, empty, boundary, error. Run with `pytest -x --ff` locally to fail fast on the previously failing test.

## Async with `asyncio.TaskGroup`

For concurrent I/O in Python 3.11+, use `asyncio.TaskGroup`. It handles cancellation, exception aggregation, and structured cleanup automatically.

```python
import asyncio
import httpx

async def fetch_all(urls: list[str]) -> list[bytes]:
    async with httpx.AsyncClient() as client, asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(client.get(u)) for u in urls]
    return [t.result().content for t in tasks]
```

Avoid bare `asyncio.gather(*tasks)`. If one task raises, `gather` cancels the others without surfacing the partial state; `TaskGroup` cancels and re-raises an `ExceptionGroup` you can pattern-match on.

For HTTP, use `httpx` (sync and async in one library). For async Postgres, use `asyncpg` or `psycopg[async]`. See [[backend/fastapi]] for the request-handler patterns.

## No `print` inside libraries

A library function that prints corrupts every caller's stdout. Use `logging` with a module-level logger:

```python
import logging
logger = logging.getLogger(__name__)

def parse(payload: str) -> Invoice:
    logger.debug("parsing payload of %d bytes", len(payload))
    ...
```

The application entry point configures handlers and levels. Library code only emits records. `print` is fine in scripts, CLIs at the entry point, and notebooks; nowhere else.

## Related

- [[coding/general-principles]]
- [[backend/fastapi]]
- [[backend/sqlite]]
- [[ai-agents/ollama]]
