Your code runs — that doesn't mean it's correct. These two tools catch problems before they become bugs.
The Problem: Python Is Too Forgiving
Python is a very "permissive" language. It lets you do a lot of things without telling you something is wrong:
def add(a: int, b: int) -> int:
return a + b
result = add("hello", "world") # Python won't complain — it just runs
print(result) # "helloworld" — you said int, it got str, nobody cares
import json # imported but never used — Python doesn't care
import os # same here — Python doesn't care
def process(data ,count): # extra space before comma — Python doesn't care
temp = 42 # variable defined but never used — Python doesn't care
result = data * count
return result
These issues won't crash your program, but they slowly rot your codebase — until one day you spend three hours debugging only to find out it was a wrong type all along.
Ruff and Mypy catch these problems before they become bugs.
Mypy: The Type Checker
What Problem Does It Solve?
Python's type hints (a: int) are just annotations for humans — they're completely ignored at runtime. Mypy is the tool that actually enforces them.
Without Mypy
def get_user_name(user_id: int) -> str:
if user_id <= 0:
return None # you said str, but returned None
return "Alice"
name = get_user_name(1)
print(name.upper()) # works fine most of the time
print(get_user_name(-1).upper()) # 💥 AttributeError: 'NoneType' has no attribute 'upper'
# crashes at 3 AM in production
Python won't warn you. It blows up only when execution hits that exact line.
With Mypy
$ mypy your_code.py
your_code.py:3: error: Incompatible return value type (got "None", expected "str")
It tells you what's wrong before the program even runs. Here's the fix:
def get_user_name(user_id: int) -> str | None: # explicitly declare it can return None
if user_id <= 0:
return None
return "Alice"
name = get_user_name(1)
if name is not None: # Mypy forces you to handle the None case
print(name.upper())
What Mypy Catches
# ① Type mismatch
def add(a: int, b: int) -> int:
return a + b
add("hello", "world")
# mypy: Argument 1 has incompatible type "str"; expected "int"
# ② Forgetting to handle None
def find_user(user_id: int) -> dict | None:
...
user = find_user(1)
print(user["name"])
# mypy: Value of type "dict | None" is not indexable
# ③ Wrong return type
def calculate_price(quantity: int, unit_price: float) -> int:
return quantity * unit_price # returns float, but you said int
# mypy: Incompatible return value type (got "float", expected "int")
# ④ Missing return statement
def validate(value: int) -> bool:
if value > 0:
return True
# forgot the else — implicitly returns None
# mypy: Missing return statement
# ⑤ Accessing non-existent attribute
class User:
def __init__(self, name: str):
self.name = name
user = User("Alice")
print(user.email)
# mypy: "User" has no attribute "email"
How to Use It
# Install
pip install mypy
# Check a single file
mypy your_code.py
# Check the entire project
mypy src/
# Strict mode (all functions must have type annotations)
mypy --strict src/
Configuration (in pyproject.toml)
[tool.mypy]
python_version = "3.12"
strict = true # enable strict mode
warn_return_any = true # warn when returning Any type
warn_unused_ignores = true # warn on unnecessary type: ignore
disallow_untyped_defs = true # all functions must have type annotations
# If a third-party package doesn't have type stubs, ignore it
[[tool.mypy.overrides]]
module = "some_untyped_library.*"
ignore_missing_imports = true
Ruff: The Blazing-Fast Linter + Formatter
What Problem Does It Solve?
Before Ruff, you needed multiple tools for complete code quality checks:
| Tool | What It Does | Speed |
|---|---|---|
pylint |
Find bugs and style issues | Slow |
flake8 |
Find style violations | Medium |
isort |
Auto-sort imports | Medium |
black |
Auto-format code | Medium |
pyflakes |
Find unused variables/imports | Medium |
pyupgrade |
Auto-upgrade old syntax | Medium |
bandit |
Find security issues | Medium |
Ruff is written in Rust and combines all of the above into a single tool, running 10–100x faster. What takes pylint minutes on a large project, Ruff finishes in seconds.
Ruff as a Linter: Finding Problems
# bad_code.py
import os
import json # ← imported but unused
def calculate(x ,y): # ← extra space before comma
temp = 42 # ← variable defined but never used
result = x + y
return result
class user: # ← class name should use CapWords
pass
$ ruff check bad_code.py
bad_code.py:2:8: F401 `json` imported but unused
bad_code.py:4:18: E231 missing whitespace after ','
bad_code.py:5:5: F841 local variable `temp` is assigned but never used
bad_code.py:9:7: N801 class `user` should use CapWords convention
Found 4 errors.
Auto-fix what can be fixed:
$ ruff check --fix bad_code.py
Found 4 errors (2 fixed, 2 remaining).
# Auto-removed unused import, fixed spacing
# Class naming and unused variable need manual fixing (Ruff doesn't know your intent)
Ruff as a Formatter: Unifying Style
# Before formatting
def foo( a,b, c ):
return {"key":a,"key2": b,"key3":c}
x=[1,2,
3,4,5]
$ ruff format bad_code.py
1 file reformatted
# After formatting
def foo(a, b, c):
return {"key": a, "key2": b, "key3": c}
x = [1, 2, 3, 4, 5]
Common Issues Ruff Catches
# F401: Unused import
import os # you never use os — remove it
# F841: Unused variable
result = compute() # you computed but never used it
# E711: Comparison to None using ==
if value == None: # should be: if value is None
# B006: Mutable default argument
def foo(items=[]): # should use None
# UP035: Deprecated import
from typing import List # Python 3.9+ — just use list
# N801: Class name doesn't follow CapWords
class my_class: # should be MyClass
# I001: Imports not sorted
import os
import abc # abc should come before os
# S101: Using assert in non-test code (security issue)
assert user.is_admin # use raise, not assert
How to Use It
# Install
pip install ruff
# Lint: find problems
ruff check .
# Lint + auto-fix
ruff check --fix .
# Format: unify style
ruff format .
# Dry run (show what would change, don't modify)
ruff format --check .
Configuration (in pyproject.toml)
[tool.ruff]
line-length = 80 # max 80 chars per line
target-version = "py312" # target Python version
[tool.ruff.lint]
select = [
"E", # pycodestyle errors (spacing, indentation, etc.)
"F", # pyflakes (unused imports, variables, etc.)
"I", # isort (import sorting)
"N", # pep8-naming (naming conventions)
"UP", # pyupgrade (replace old syntax with new)
"B", # flake8-bugbear (common bug patterns)
"S", # bandit (security issues)
"SIM", # flake8-simplify (simplifiable code)
]
# Want stricter? Enable everything:
# select = ["ALL"]
ignore = [
"S101", # allow assert in tests
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"] # allow assert in test files
Ruff vs Mypy: What Does Each Cover?
These two tools are complementary, not alternatives:
Ruff Mypy
┌──────────────────┐ ┌──────────────────┐
│ Style & Format │ │ Type Correctness │
│ ─────────────── │ │ ─────────────── │
│ ✓ Import sorting │ │ ✓ Type mismatch │
│ ✓ Unused vars │ │ ✓ Unhandled None │
│ ✓ Naming rules │ │ ✓ Wrong return │
│ ✓ Spacing/indent │ │ ✓ Missing attr │
│ ✓ Security issues│ │ ✓ Wrong arg type │
│ ✓ Old syntax │ │ │
└──────────────────┘ └──────────────────┘
Does it look right? Does it work right?
A concrete example:
import json # ← Ruff catches this (F401: unused import)
def add(a: int, b: int) -> int:
return a + b
result = add("hello", "world") # ← Mypy catches this (type mismatch)
temp = 42 # ← Ruff catches this (F841: unused variable)
Use both for complete quality coverage.
Integrating with Git Pre-commit Hooks
Why?
You can run ruff check and mypy manually, but you'll forget — especially when you're rushing to meet a deadline.
A pre-commit hook means: every time you run git commit, these checks run automatically. If they fail, the commit is blocked.
git commit
│
├── ① ruff check (lint) → unused import? ❌ blocked
├── ② ruff format (formatter) → bad formatting? auto-fixed, re-commit needed
└── ③ mypy (type check) → type error? ❌ blocked
│
✅ All passed → commit succeeds
No matter how rushed you are, quality has a baseline.
Step 1: Install pre-commit
pip install pre-commit
Step 2: Create .pre-commit-config.yaml
Create this file in your project root:
# .pre-commit-config.yaml
repos:
# Ruff: Lint + Format
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.1 # use latest version; run `pre-commit autoupdate` to update
hooks:
# Run linter first (with auto-fix)
- id: ruff-check
args: [--fix]
# Then run formatter
- id: ruff-format
# Mypy: Type checking
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0 # use latest version
hooks:
- id: mypy
args: [--strict, --ignore-missing-imports]
# If your code uses typed third-party packages, add them here
additional_dependencies:
- pydantic
- types-requests
Order matters: ruff-check (with --fix) should come before ruff-format. Lint fixes may produce code that needs reformatting.
Step 3: Activate the Hooks
# Install hooks into .git/hooks/
pre-commit install
# Done! Every git commit will now run these checks automatically.
Step 4: Test Run
# Manually run against all files (recommended on first setup)
pre-commit run --all-files
Output looks like this:
ruff-check...............................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Failed
- hook id: mypy
- exit code: 1
src/main.py:15: error: Incompatible return value type (got "None", expected "str")
Mypy failed — the commit is blocked. Fix the issue, then git add and git commit again.
Real-World Development Flow
# 1. Write code (or have AI write it for you)
# 2. Stage changes
git add .
# 3. Commit — pre-commit runs automatically
git commit -m "feat: add user authentication"
# Scenario A: All passed ✅
# → commit succeeds
# Scenario B: ruff format auto-fixed formatting ⚠️
# → commit fails, but files are already fixed
# → just run: git add . && git commit again
# Scenario C: mypy found a type error ❌
# → commit fails, you need to fix it manually
# → fix the code, then: git add . && git commit
Complete pyproject.toml Example
# pyproject.toml
[project]
name = "my-project"
version = "0.1.0"
requires-python = ">=3.12"
# ── Ruff Config ──
[tool.ruff]
line-length = 80
target-version = "py312"
[tool.ruff.lint]
select = [
"E", # pycodestyle
"F", # pyflakes
"I", # isort
"N", # naming
"UP", # pyupgrade
"B", # bugbear
"S", # bandit (security)
"SIM", # simplify
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"] # allow assert in tests
# ── Mypy Config ──
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true
[[tool.mypy.overrides]]
module = "some_untyped_library.*"
ignore_missing_imports = true
FAQ
"I'm on a deadline — can I skip the hooks?"
git commit --no-verify -m "hotfix: emergency patch"
--no-verify skips all pre-commit hooks. But this should be a rare exception, not the norm. If you're skipping frequently, either your rules are too strict or you need to address accumulated issues.
"Mypy is complaining about third-party packages"
Some packages don't provide type information. Two solutions:
# Option 1: Install type stubs
pip install types-requests types-redis types-PyYAML
# Option 2: Ignore specific packages in pyproject.toml
[[tool.mypy.overrides]]
module = "some_library.*"
ignore_missing_imports = true
"Should I use Ruff or Black?"
In 2025, pick Ruff. Its formatter is nearly 100% compatible with Black, but faster, and you don't need a separate tool. If your project already uses Black, migrating to Ruff is straightforward.
"The hooks are too slow"
Ruff is written in Rust — it's essentially instant. Mypy can be slower, especially on the first run. Use the mypy daemon for incremental checking:
# dmypy only checks files you've changed
dmypy run -- src/
Summary
| Tool | What It Does | Analogy |
|---|---|---|
| Ruff | Style, formatting, common mistakes | Copy editor: typos, formatting, layout |
| Mypy | Type correctness | Logic reviewer: does what you say match what you do? |
| Pre-commit | Automated enforcement | Security gate: no pass, no entry |
Together, these three give your codebase an automated quality baseline — whether the code was written by you, your teammates, or AI, it all passes through the same gates before entering the codebase.
Top comments (0)