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:
pip install ruffCreate a pyproject.toml at your project root and configure ruff:
[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:
ruff check . --fixThe --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:
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 userRunning 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:
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 userYour 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:
pip install mypyCreate a mypy.ini or add to pyproject.toml:
[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 = trueRun mypy on your codebase:
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 userThe 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:
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 userOr with explicit None handling:
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 userType 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:
pip install pre-commitCreate a .pre-commit-config.yaml file at your project root:
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-endingInstall the git hooks:
pre-commit installNow, 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:
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:
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:
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-filesonce 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.






Comments
Loading comments…