Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 060 / 100

Code quality: ruff, mypy and pre-commit in Python Backend

Automate style and type checks.

Expona Admin8 min read

The need for consistency

By day 60 of backend development, your Python codebase has grown. Multiple team members push code with different formatting preferences, inconsistent type hints, and linting issues that slip through code review. Without enforced standards, you waste time in pull request discussions about semicolons and import ordering instead of focusing on logic and architecture. Code quality: ruff, mypy and pre-commit in Python Backend is your answer—a practical automation layer that catches style violations and type errors before they reach your repository.

We've all seen projects where the first commit has clean code, but by month six, everyone writes differently. Pre-commit hooks and automated tooling solve this by making quality checks non-negotiable. This guide walks you through setting up ruff, mypy, and pre-commit so your team ships consistent, type-safe code from the first commit.

Ruff replaces flake8, isort and more

Ruff is a Rust-based linter and code formatter that bundles the functionality of flake8, isort, autopep8, and several other Python tools into a single, blazingly fast executable. Instead of maintaining separate configurations for multiple linters, ruff handles style enforcement, import sorting, and common errors in one pass.

In a typical project, you might have had three separate tools:

  • flake8 for linting and style checks
  • isort for import organization
  • black for code formatting

With ruff, you get all of this unified. More importantly, ruff runs in milliseconds on large codebases, whereas chaining multiple tools can take several seconds.

Installing and configuring ruff

Start by adding ruff to your project dependencies:

python
pip install ruff

Create a pyproject.toml at your project root and configure ruff:

python
[tool.ruff]
line-length = 100
target-version = "py311"
select = [
    "E",    # pycodestyle errors
    "W",    # pycodestyle warnings
    "F",    # Pyflakes
    "I",    # isort
    "N",    # pep8-naming
    "UP",   # pyupgrade
    "B",    # flake8-bugbear
]
ignore = [
    "E501",  # line too long (handled by formatter)
]

[tool.ruff.isort]
known-first-party = ["myapp"]

Run ruff on your codebase:

python
ruff check . --fix

The --fix flag automatically corrects many issues. For import sorting and formatting, ruff can sort them in-place, enforcing your team's import style consistently across all files.

Real-world example: catching common mistakes

Imagine a service that handles user authentication. A junior developer writes this:

python
import os
import sys

from myapp import models
import json
from datetime import datetime
from myapp.utils import validate_email


def authenticate_user(username, password):
    user = models.User.query.filter_by(username=username).first()
    if user:
        return user

Running ruff check --fix on this file:

  • Reorders imports (stdlib first, then third-party, then local)
  • Removes unused imports (sys, json)
  • Flags the missing password check (if using flake8-bugbear rules)

The corrected version becomes:

python
import os
from datetime import datetime

from myapp import models
from myapp.utils import validate_email


def authenticate_user(username, password):
    user = models.User.query.filter_by(username=username).first()
    if user:
        return user

Your team never argues about import order again—ruff does it automatically and consistently.

Mypy or pyright for type checking

Linting catches style issues; type checking catches logic errors before runtime. Python is dynamically typed, but with type hints, you can opt into static analysis. Mypy and pyright both parse type annotations and find mismatches that could cause production bugs.

Mypy is the long-established tool; pyright (from Microsoft) is newer and faster. For most teams, mypy is sufficient. We'll focus on mypy here, but the setup is similar for pyright.

Installing and configuring mypy

Add mypy to your dependencies:

python
pip install mypy

Create a mypy.ini or add to pyproject.toml:

python
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
strict_equality = true

Run mypy on your codebase:

python
mypy .</code><p></p>

<h3>Real-world example: catching a silent bug</h3>

<p>Consider an API endpoint that retrieves a user by ID:</p>

<pre><code class="language-python">from typing import Optional
from myapp import models


def get_user(user_id: int) -> models.User:
    user = models.User.query.get(user_id)
    return user

The function is declared to return models.User, but query.get() can return None. Without type checking, this silently fails at runtime. With mypy running in strict mode, you get an error:

error: Incompatible return value type (got "Optional[User]", expected "User")

You're forced to either handle the None case or update the return type:

python
from typing import Optional
from myapp import models


def get_user(user_id: int) -> Optional[models.User]:
    user = models.User.query.get(user_id)
    return user

Or with explicit None handling:

python
from myapp import models


def get_user(user_id: int) -> models.User:
    user = models.User.query.get(user_id)
    if user is None:
        raise ValueError(f"User {user_id} not found")
    return user

Type checking turns vague runtime errors into compile-time feedback.

Pre-commit runs them before each commit

Ruff and mypy are only effective if developers actually run them. Pre-commit is a framework that automates running checks before code enters git. It prevents bad commits from ever reaching your repository.

Setting up pre-commit

Install the pre-commit framework:

python
pip install pre-commit

Create a .pre-commit-config.yaml file at your project root:

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

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.7.1
    hooks:
      - id: mypy
        additional_dependencies: [types-all]
        args: [--strict]

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-json
      - id: debug-statements
      - id: mixed-line-ending

Install the git hooks:

python
pre-commit install

Now, whenever a developer runs git commit, pre-commit automatically runs ruff, mypy, and other checks. If any check fails, the commit is blocked and the developer must fix the issues.

Example workflow

A developer writes code and commits:

python
git commit -m "Add user authentication"

Pre-commit intercepts the commit and runs all checks. If ruff finds import sorting issues or mypy finds type errors, the commit fails with output:

python
ruff.....................................................................FAILED
mypy.....................................................................FAILED

Fixed 5 issues in 2 files.
Check the output above to understand what went wrong.

The developer fixes the issues, stages them, and commits again. This time, all checks pass and the commit succeeds.

Same checks in CI

Pre-commit is a local safety net, but CI (continuous integration) is your second line of defense. Team members can bypass pre-commit with git commit --no-verify, or work on machines where it isn't installed. Your CI pipeline ensures no code reaches main without passing the same checks.

GitHub Actions example

Add a workflow file at .github/workflows/quality.yml:

python
name: Code Quality

on: [push, pull_request]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          pip install ruff mypy

      - name: Ruff check
        run: ruff check .

      - name: Mypy check
        run: mypy .

      - name: Ruff format check
        run: ruff format --check .

Now every push and pull request runs the same checks that pre-commit ran locally. If a developer somehow bypassed pre-commit, CI catches it and prevents the merge.

Making CI checks optional or required

In your repository settings, mark the quality workflow as a required status check. This prevents merging any pull request that fails code quality checks, enforcing the standard across your entire team.

Common mistakes

  • Not excluding test directories from type checking: Test code often has different patterns (fixtures, mocks). Add this to mypy config: exclude = ["tests/", ".venv/"]
  • Running pre-commit without fixing first: If your existing codebase is messy, run pre-commit run --all-files once to fix everything before installing hooks. Otherwise, developers immediately hit failures.
  • Using conflicting formatters: If you use both ruff and black, disable ruff's formatting and use only ruff for linting. Mixing formatters causes continuous conflicts.
  • Ignoring all mypy errors with type: ignore: This defeats the purpose of type checking. Instead, fix the underlying type issues or use type: ignore[specific-error] for specific cases with comments explaining why.
  • Not updating pyproject.toml when upgrading tools: Ruff and mypy release new rules regularly. Review release notes and update your configuration to enable new checks as your codebase matures.

Key takeaways

  • Ruff consolidates linting, import sorting, and code style into one fast tool, replacing flake8, isort, and related tools.
  • Mypy provides static type checking that catches logic errors before they reach production, especially in strict mode.
  • Pre-commit automates running checks before each commit, preventing bad code from entering your repository.
  • CI pipelines enforce the same checks on all pushes and pull requests, ensuring no one bypasses local checks.
  • Configure all three tools in version control (pyproject.toml, .pre-commit-config.yaml) so the entire team has identical environments.
  • Start with sensible defaults, then gradually enable stricter rules as your team adopts the practice and fixes existing issues.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles