Python Project Setup 2026: uv + Ruff + ty + Polars

by ai-intensify
0 comments
Python Project Setup 2026: UV + Rough + Ty + Polars

Setting up a Python project used to mean making a dozen small decisions before writing a single useful line of code: which environment manager, which dependency tool, which formatter, which linter, which type checker, and, for data work, whether to start with pandas, DuckDB, or something newer. In 2026, that setup can be considerably simpler.

For most new projects, a clean default stack is uv for Python installation, environments, dependency management, locking, and command running; Ruff for linting and formatting; ty for type checking; and Polars for dataframe work. The stack is fast, modern, and highly compatible. Three of the four tools, uv, Ruff, and ty, come from the same company, Astral, so they integrate cleanly with each other and with a single pyproject.toml. This walkthrough adapts a setup popularised on KDnuggets.

Why this stack works

Older setups often combined several independent tools, each with its own configuration:

pyenv + pip + venv + pip-tools or Poetry + Black + isort + Flake8 + mypy + pandas

That approach worked but spread configuration across many files and relied on tools that did not always agree with one another. Ruff is an all-in-one tool for code quality: it is extremely fast, flags issues, fixes many automatically, and formats code. ty is a Rust-based type checker that Astral reports is substantially faster than established checkers such as mypy and Pyright, with strong type inference and clear diagnostics. Polars is a modern dataframe library built around lazy execution, meaning it optimises a query before running it, which makes it faster and more memory-efficient than pandas, especially on large datasets.

Prerequisites

The setup is light. It needs a terminal (macOS Terminal, Windows PowerShell, or any Linux shell), an internet connection for the one-time uv installer and package downloads, a code editor (VS Code integrates well with Ruff and ty, but any editor works), and Git for version control; uv initialises a Git repository automatically. Python itself does not need to be installed beforehand, and there is no need for pip, venv, pyenv, or conda, because uv handles installation and environment management.

Step 1: Installing uv

uv ships a standalone installer for macOS, Linux, and Windows that does not require Python or Rust to be present. On macOS and Linux:

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

On Windows PowerShell:

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

After installation, restart the terminal and verify the version:

uv 0.8.0 (Homebrew 2025-07-17)

This single binary replaces the project-management role of pyenv, pip, venv, pip-tools, and Poetry.

Step 2: Creating a new project

From the working directory, create a new project:

uv init my-project
cd my-project

uv generates a clean initial structure:

my-project/
├── .python-version
├── pyproject.toml
├── README.md
└── main.py

A src/ layout is preferable, as it improves imports, packaging, test isolation, and type-checker configuration:

mkdir -p src/my_project tests data/raw data/processed
mv main.py src/my_project/main.py
touch src/my_project/__init__.py tests/test_main.py

The structure should now look like this:

my-project/
├── .python-version
├── README.md
├── pyproject.toml
├── uv.lock
├── src/
│   └── my_project/
│       ├── __init__.py
│       └── main.py
├── tests/
│   └── test_main.py
└── data/
    ├── raw/
    └── processed/

If a specific interpreter version is required (for example 3.12), uv can install and pin it:

uv python install 3.12
uv python pin 3.12

Pinning writes the chosen version to .python-version, ensuring every team member uses the same interpreter.

Step 3: Adding dependencies

Adding a dependency is a single command that resolves, installs, and locks at once. If no virtual environment exists, uv creates one (.venv/), resolves the dependency tree, installs the packages, and updates uv.lock with exact, pinned versions. For tools needed only during development, use the –dev flag:

uv add --dev ruff ty pytest

These are recorded separately under dependency groups in pyproject.toml, keeping development tooling out of the production dependency set.

Step 4: Configuring Ruff for linting and formatting

Ruff is configured directly inside pyproject.toml. Add the following sections:

(tool.ruff)
line-length = 100
target-version = "py312"

(tool.ruff.lint)
select = ("E4", "E7", "E9", "F", "B", "I", "UP")

(tool.ruff.format)
docstring-code-format = true
quote-style = "double"

A 100-character line length is a reasonable compromise for modern screens. Rule sets such as flake8-bugbear (B), isort (I), and pyupgrade (UP) add real value without imposing excessive friction. Run Ruff with:

# Lint your code
uv run ruff check .

# Auto-fix issues where possible
uv run ruff check --fix .

# Format your code
uv run ruff format .

Note the pattern: uv run executes tools inside the managed environment, so there is no need to install tools globally or activate environments manually.

Step 5: Configuring ty for type checking

ty is also configured in pyproject.toml. Add these sections:

(tool.ty.environment)
root = ("./src")

(tool.ty.rules)
all = "warn"

((tool.ty.overrides))
include = ("src/**")

(tool.ty.overrides.rules)
possibly-unresolved-reference = "error"

(tool.ty.terminal)
error-on-warning = false
output-format = "full"

This starts ty in a warning-oriented mode that is well suited to gradual adoption: obvious issues are fixed first, then rules are progressively promoted to errors. Excluding directories such as data/** prevents type-checker noise from non-code paths.

Step 6: Configuring pytest

Add a section for pytest so tests are discovered consistently:

(tool.pytest.ini_options)
testpaths = ("tests")

The test suite can then be run through uv run.

Step 7: Reviewing the full pyproject.toml

With everything in place, the configuration lives in one file, each tool configured, with no scattered config files:

(project)
name = "my-project"
version = "0.1.0"
description = "Modern Python project with uv, Ruff, Ty, and Polars"
readme = "README.md"
requires-python = ">=3.13"
dependencies = (
    "polars>=1.39.3",
)

(dependency-groups)
dev = (
    "pytest>=9.0.2",
    "ruff>=0.15.8",
    "ty>=0.0.26",
)

(tool.ruff)
line-length = 100
target-version = "py312"

(tool.ruff.lint)
select = ("E4", "E7", "E9", "F", "B", "I", "UP")

(tool.ruff.format)
docstring-code-format = true
quote-style = "double"

(tool.ty.environment)
root = ("./src")

(tool.ty.rules)
all = "warn"

((tool.ty.overrides))
include = ("src/**")

(tool.ty.overrides.rules)
possibly-unresolved-reference = "error"

(tool.ty.terminal)
error-on-warning = false
output-format = "full"

(tool.pytest.ini_options)
testpaths = ("tests")

Step 8: Writing code with Polars

Replace the contents of src/my_project/main.py with code that exercises the Polars side of the stack:

"""Sample data analysis with Polars."""

import polars as pl

def build_report(path: str) -> pl.DataFrame:
    """Build a revenue summary from raw data using the lazy API."""
    q = (
        pl.scan_csv(path)
        .filter(pl.col("status") == "active")
        .with_columns(
            revenue_per_user=(pl.col("revenue") / pl.col("users")).alias("rpu")
        )
        .group_by("segment")
        .agg(
            pl.len().alias("rows"),
            pl.col("revenue").sum().alias("revenue"),
            pl.col("rpu").mean().alias("avg_rpu"),
        )
        .sort("revenue", descending=True)
    )
    return q.collect()

def main() -> None:
    """Entry point with sample in-memory data."""
    df = pl.DataFrame(
        {
            "segment": ("Enterprise", "SMB", "Enterprise", "SMB", "Enterprise"),
            "status": ("active", "active", "churned", "active", "active"),
            "revenue": (12000, 3500, 8000, 4200, 15000),
            "users": (120, 70, 80, 84, 150),
        }
    )

    summary = (
        df.lazy()
        .filter(pl.col("status") == "active")
        .with_columns(
            (pl.col("revenue") / pl.col("users")).round(2).alias("rpu")
        )
        .group_by("segment")
        .agg(
            pl.len().alias("rows"),
            pl.col("revenue").sum().alias("total_revenue"),
            pl.col("rpu").mean().round(2).alias("avg_rpu"),
        )
        .sort("total_revenue", descending=True)
        .collect()
    )

    print("Revenue Summary:")
    print(summary)

if __name__ == "__main__":
    main()

Before running, pyproject.toml needs a build system so uv installs the project as a package; this example uses hatchling:

cat >> pyproject.toml << 'EOF'

(build-system)
requires = ("hatchling")
build-backend = "hatchling.build"

(tool.hatch.build.targets.wheel)
packages = ("src/my_project")
EOF

Then sync and run:

uv sync
uv run python -m my_project.main

The output should be a formatted Polars table:

Revenue Summary:
shape: (2, 4)
┌────────────┬──────┬───────────────┬─────────┐
│ segment    ┆ rows ┆ total_revenue ┆ avg_rpu │
│ ---        ┆ ---  ┆ ---           ┆ ---     │
│ str        ┆ u32  ┆ i64           ┆ f64     │
╞════════════╪══════╪═══════════════╪═════════╡
│ Enterprise ┆ 2    ┆ 27000         ┆ 100.0   │
│ SMB        ┆ 2    ┆ 7700          ┆ 50.0    │
└────────────┴──────┴───────────────┴─────────┘

Managing the daily workflow

Once the project is established, the day-to-day cycle is short and repeatable:

# Pull latest, sync dependencies
git pull
uv sync

# Write code...

# Before committing: lint, format, type-check, test
uv run ruff check --fix .
uv run ruff format .
uv run ty check
uv run pytest

# Commit
git add .
git commit -m "feat: add revenue report module"

How Polars changes the way code is written

The biggest shift in this stack is on the data side. With Polars, a few defaults are worth adopting: prefer expressions over row-wise operations, since Polars expressions let the engine vectorise and parallelise work; avoid user-defined functions (UDFs) unless no native alternative exists, as UDFs are comparatively slow; prefer lazy execution over eager loading by using scan_csv() rather than read_csv(), which produces a LazyFrame and a query plan that the optimiser can use to push down filters and drop unused columns; and favour a Parquet-first workflow for CSV-heavy pipelines.

When this setup is not the best fit

A different approach may be preferable when a team already has a mature Poetry or mypy workflow that is working well, when a codebase relies heavily on pandas-specific APIs or ecosystem libraries, when an organisation has standardised on Pyright, or when working in a legacy repository where changing tooling would cause more disruption than value. It is also worth noting that ty is comparatively new, so teams that need a long track record or specific edge-case behaviour from an established type checker should evaluate it carefully before committing.

Practical tips

A few habits make the stack smoother in practice: never activate the virtual environment manually, relying on uv run to select the correct environment; always commit uv.lock to version control so the project runs identically on every machine; use the –frozen flag in CI to install from the lockfile for faster, more reproducible builds; use uvx for one-off tools without adding them to the project; apply ruff –fix freely to clean up unused imports and outdated syntax; default to the lazy Polars API and call .collect() only at the end; and centralise everything in pyproject.toml as a single source of truth.

Closing thoughts

The 2026 default stack reduces setup effort while encouraging good practices: locked environments, a single configuration file, fast feedback, and optimised data pipelines. As with any toolchain, the specifics will keep evolving, and ty in particular is still maturing, so it is worth tracking its release notes. For a related look at writing robust Python, see the guide to advanced data validation scripts.

Related Articles