DEV Community

Theo Hsiung
Theo Hsiung

Posted on

Python Quality Tools for Juniors: Ruff & Mypy

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
$ 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.
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode
$ ruff format bad_code.py
1 file reformatted
Enter fullscreen mode Exit fullscreen mode
# After formatting
def foo(a, b, c):
    return {"key": a, "key2": b, "key3": c}

x = [1, 2, 3, 4, 5]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

No matter how rushed you are, quality has a baseline.

Step 1: Install pre-commit

pip install pre-commit
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Step 4: Test Run

# Manually run against all files (recommended on first setup)
pre-commit run --all-files
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

FAQ

"I'm on a deadline — can I skip the hooks?"

git commit --no-verify -m "hotfix: emergency patch"
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode
# Option 2: Ignore specific packages in pyproject.toml
[[tool.mypy.overrides]]
module = "some_library.*"
ignore_missing_imports = true
Enter fullscreen mode Exit fullscreen mode

"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/
Enter fullscreen mode Exit fullscreen mode

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)