When I learned Python, the standard setup was python -m venv and pip. If you were using black in your project, you were already ahead of the curve. Over time the ecosystem filled in: pyenv for managing Python versions, Poetry for dependency management, isort for import ordering, flake8 for linting, mypy for types. So all in all you had to be aware of all of these tools, their configs and how to use them before you could set up a “best-practice” project.

In the last year or so I kept seeing uv and Ruff come up in projects I was working on. I wanted to actually use them from scratch rather than just read the docs, to understand what the benefit was over the old tools. They also kept turning up in AI-generated project scaffolds. And it does make me a bit uncomfortable using something that I don’t understand (though I guess it is a bit of a theme these days with the AI tooling becoming better).

Here is what I found when I actually went through it.

The Two Tools That Replace (Mostly) Everything

uv is a Python package manager and environment manager written in Rust by Astral. It replaces pip, pip-tools, virtualenv, and pyenv. Dependency resolution that used to take seconds runs in well under a second on most projects. It produces a cross-platform lockfile and manages Python versions itself, so you stop relying on whatever version happened to be installed on the machine.

Ruff is a linter and formatter that replaces flake8, black, isort, and around thirty flake8 plugins. Everything runs from a single [tool.ruff] section in pyproject.toml. On a codebase with a few thousand lines, it finishes in milliseconds.

Starting a Project

# Install uv once
curl -LsSf https://astral.sh/uv/install.sh | sh

# Create the project
uv init my-project
cd my-project

# Pin the Python version
uv python pin 3.13

# Add runtime dependencies
uv add requests polars

# Add dev-only dependencies (not shipped with the package)
uv add --dev ruff mypy pytest

uv init creates four things: pyproject.toml, .python-version, .venv/, and uv.lock. Commit uv.lock and .python-version. Add .venv/ to .gitignore.

From there, uv run handles the rest:

uv run python -m my_project.main   # runs in the project venv
uv run pytest                       # same
uv run ruff check --fix .           # lint and auto-fix
uv run ruff format .                # format
uv run mypy src/                    # type checking

uv run checks the lockfile before executing and syncs the environment if anything is out of date. You never have to activate the venv manually (which is something I constantly forget and end up installing dependencies globally that I then need to remove) or remember to run pip install after pulling changes.

When onboarding to an existing project, uv sync is the equivalent of pip install -r requirements.txt: it installs everything in the lockfile into the local venv. Run it once after cloning. After that, uv run keeps the environment current automatically.

Configuring Ruff

The defaults cover the basics. A minimal config that adds the rules worth having:

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

[tool.ruff.lint]
# extend-select adds to the defaults; select replaces them entirely
extend-select = ["E4", "E7", "E9", "F", "B", "I", "UP"]
ignore = ["ISC001"]  # ISC001 conflicts with the formatter on implicit string concatenation

[tool.ruff.format]
quote-style = "double"
docstring-code-format = true

ruff check --fix is a linter: it finds code quality issues (unused imports, wrong import order, outdated syntax, bugbear patterns) and auto-fixes the ones it can safely handle. ruff format is a formatter: it makes code look consistent without changing what it does. Indentation, line wrapping, quote style, trailing commas.

The rule codes worth knowing:

CodeOriginWhat it catches
FPyflakesUnused imports, undefined names
IisortImport ordering
Bflake8-bugbearLikely bugs and design issues
UPpyupgradeOutdated syntax (e.g. % string formatting)
E4, E7, E9pycodestyleImport errors, statement errors, runtime errors

When a rule fires on your code, you need to decide whether to fix it or add an ignore. Know what they stand for before asking your AI tool to fix all issues.

One gotcha: you have to run ruff check --fix first, then ruff format. Import sorting has to happen before formatting; otherwise the formatter lays out the unsorted imports and check wants to re-sort them.

The Full pyproject.toml

This is the file uv init generates and AI drops into scaffolds without explanation. Each section annotated:

# Build backend: what pip/uv uses to package your code into a wheel.
# hatchling is the uv default. Don't touch this unless switching backends.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
	
# Standard package metadata. requires-python matches whatever "uv python pin" set.
[project]
name = "uv-check"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
    "polars>=1.40.1",
    "requests>=2.33.1",
]

# uv-specific dev dependencies. Not shipped with the package, not installed by pip.
# Use [project.optional-dependencies] instead if you're publishing a library.
[dependency-groups]
dev = [
    "mypy>=1.20.2",
    "pytest>=9.0.3",
    "ruff>=0.15.12",
    "types-requests>=2.33.0.20260408",
]

# Tells hatchling where the source lives under the src/ layout.
# Without this it looks at the project root and finds nothing to package.
[tool.hatch.build.targets.wheel]
packages = ["src/uv_check"]

# Ruff config: covered in the section above.
[tool.ruff]
line-length = 100
target-version = "py313"

[tool.ruff.lint]
extend-select = ["E4", "E7", "E9", "F", "B", "I", "UP"]
ignore = ["ISC001"]

[tool.ruff.format]
quote-style = "double"

# strict = true turns on the full mypy rule set.
# warn_return_any: catches functions that silently return Any when callers expect a type.
# warn_unused_ignores: removes # type: ignore comments that no longer apply; without it they accumulate.
[tool.mypy]
python_version = "3.13"
strict = true
warn_return_any = true
warn_unused_ignores = true

# Tells pytest where to look for tests. Without it, pytest scans the whole project tree.
[tool.pytest.ini_options]
testpaths = ["tests"]

Once you can read this it’s not as scary making changes that suit your needs.

Project Layout

my-project/
├── src/
│   └── my_project/
│       ├── __init__.py
│       └── main.py
├── tests/
│   └── test_main.py
├── pyproject.toml
├── uv.lock           # commit
├── .python-version   # commit
└── .venv/            # gitignore

While I was at it, I also looked into layout. Even if AI generates the code, it helps to know where things live.

I settled on the src/ layout. Main thing is that without it, running tests from the project root can accidentally import the local directory instead of the installed package, which hides import errors that only appear in CI or production. It’s a subtle failure mode and the fix is one extra folder.

Mileage for this may vary depending on what you are doing with python. If you are writing Airflow DAGs, you do not care in particular to have some src/ directory.

Pre-commit Integration

The last piece I added to the setup was pre-commit, a framework that runs checks automatically before each git commit.

The value is that problems get caught before they ever reach the repository: no CI failure to investigate, no “fix lint” commit cluttering the log. You see the issue immediately, in context, before moving on.

Install it once globally, then once per project:

uv tool install pre-commit
pre-commit install

That registers the hooks with git. From that point, hooks run automatically on git commit. No other command to remember.

This is what the hooks look like for my small project repo:

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.12
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: local
    hooks:
      - id: mypy
        name: mypy
        entry: uv run mypy src
        language: system
        types: [python]
        pass_filenames: false
        always_run: true

To run all hooks manually without committing:

pre-commit run --all-files

If you or the people you work with are not ready to enforce hooks locally, the same Ruff and mypy checks in CI can work as a gate. The difference is feedback timing: CI catches it after a push, pre-commit catches it before.

Migrating an Existing Project

So what do you do if all your life you’ve been keeping your dependencies in requirements.txt like a caveman (like me):

uv add -r requirements.txt

uv imports the dependencies, resolves them, and generates a uv.lock. The original file can stay in place temporarily if other tooling depends on it, but it’s no longer the source of truth.

For projects with mixed black and ruff config: remove black and isort from your dev dependencies, drop their config sections, and let ruff handle both.

ty

One more tool worth watching: ty, Astral’s new type checker. It’s the same team, same idea: rewrite the slow Python tooling in Rust. mypy is not going anywhere yet and ty is still in early development, but if the pattern holds, it will eventually slot in where mypy sits today.

Less Moving Parts

The old Python setup didn’t have any fundamental problems, it was just more moving parts than the problem required. One thing worth noticing: uv, Ruff, and ty are all the same company. So is that too much concentrated power? Maybe, but so far it makes developer life easier.

AI will generate a pyproject.toml that looks like the one above. Knowing what’s in it is still on you.