Python code quality usually means juggling flake8 for linting, black for formatting, isort for sorting imports, and pyupgrade for modernizing syntax. Four tools, four config files, four chances for them to disagree with each other. The python ruff linter exists so you can throw all of that out and use one fast tool instead, then pair it with mypy to catch the type bugs that style checkers never see.
“Beautiful is better than ugly.”
Tim Peters, The Zen of Python (PEP 20)
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Advanced | Reading Time: 17 minutes
Think of these tools as the two inspectors a house passes before anyone moves in. Ruff is the building inspector: it checks that the wiring and the plumbing follow the building code. It finds unused imports, messy formatting, and risky patterns. Mypy is the structural engineer: it checks that the beams actually hold the weight you claim they do, that is, your type hints match reality. One looks at style and common bugs, the other looks at whether your types line up. You want both, and you want them to run on their own so nobody has to remember.
Ruff is written in Rust, which is why it lints a 100,000 line project in well under a second. It replaces flake8, isort, black, pyupgrade, and a pile of plugins, all driven by a single section in your pyproject.toml. A developer I worked with, Niranjan, added Ruff and mypy to a 50,000 line codebase at his company and the very first mypy run surfaced 23 real bugs, functions that returned None where the caller expected a number, quietly handing back wrong results for months. The 15 minute setup paid for itself before lunch.
The diagram shows the whole pipeline. Your code flows into two checkers: Ruff for fast linting and formatting, mypy for type checking. Both feed a pre-commit hook that runs the moment you type git commit. If everything passes, the commit goes through. If anything fails, the commit is blocked until you fix it. The point of wiring it into the hook is simple: nobody has to remember to run the tools, because Git runs them for you.
Table of Contents
Prerequisites
You need our type hints tutorial first, because mypy only checks what your hints claim. No hints, nothing to check. You also want the virtual environments tutorial so these tools install into the project, not your system Python. Everything here was tested on Python 3.14.6 with Ruff 0.15.18 and mypy 2.1.0.
Install and Verify
Install both tools into your active virtual environment, then check the versions to confirm they are ready.
📄 Terminal: install both tools
pip install ruff mypy ruff version mypy --version
▶ Output
ruff 0.15.18 (6686f6340 2026-06-18) mypy 2.1.0 (compiled: yes)
What happened here: If you see those two version lines, you are ready. Your exact build hash and date will differ from mine, and that is fine. The (compiled: yes) on mypy means it is running its faster compiled build rather than pure Python, so type checks finish quicker. If ruff version reports “command not found”, your virtual environment is not active. Activate it and try again.
The Quick Win: Ruff in 30 Seconds
Before any config or theory, let us feel what Ruff does. Save this deliberately messy file as circle.py. The math is correct, but the spacing is a mess and there is no blank line between the import and the function.
📄 circle.py: correct logic, messy style
import math
def area(r):
return math.pi*r**2
print( area(3) )
Now ask Ruff to show you exactly what it would change, without touching the file yet, using ruff format --diff. It works like a tailor chalking alteration marks on a shirt before making a single cut: you see every planned change first, then decide.
📄 Terminal: preview the formatting changes
ruff format --diff circle.py
▶ Output
--- circle.py +++ circle.py @@ -1,4 +1,8 @@ import math + + def area(r): - return math.pi*r**2 -print( area(3) ) + return math.pi * r**2 + + +print(area(3)) 1 file would be reformatted
What happened here: Lines starting with - are what you wrote, lines starting with + are what Ruff wants instead. It added two blank lines around the function (the standard spacing), put spaces around the * operator, and cleaned the stray spaces inside print( area(3) ). Notice it left r**2 tight with no spaces, because the formatter intentionally keeps power operators snug. Drop the --diff flag and run ruff format circle.py to actually apply it. That is the whole point: one command, instant clean code, and it did not change a single character of behaviour.
Ruff: Linting and Formatting in One Tool
Ruff wears two hats. As a linter it hunts for problems: unused imports, bad comparisons, likely bugs. As a formatter it rewrites your spacing and line breaks to one consistent style, the same job black used to do. These three commands cover most of the python code quality work you will do in a normal day.
📄 Terminal: the daily Ruff commands
ruff check . # Lint every file, report problems ruff check --fix . # Lint, and auto-fix the safe ones ruff format . # Format every file (the black-style pass)
You steer Ruff from a single [tool.ruff] section in pyproject.toml. The select list turns on rule groups by their short code. Think of each code as a department: E and W are the style police, F finds dead and broken code, I sorts imports, B catches bug-prone patterns, and UP nudges old syntax toward modern Python.
📄 pyproject.toml: Ruff configuration
[tool.ruff]
target-version = "py314"
line-length = 88
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes (unused imports, undefined names)
"I", # isort (import sorting)
"B", # flake8-bugbear (common bugs)
"SIM", # flake8-simplify
"UP", # pyupgrade (modern Python syntax)
]
ignore = ["E501"] # Line length is handled by the formatter
[tool.ruff.lint.isort]
known-first-party = ["my_package"]
Now the part that actually teaches you something. Save this file as messy.py. It has a duplicate import, two unused imports, an outdated Optional type hint, a dead local variable, and a == None comparison that should be is None.
📄 messy.py: six problems for Ruff to find
import os
import sys
import json
import os # duplicate import!
from typing import Optional # deprecated style
def get_config(path: Optional[str] = None): # should be str | None
settings = {"debug": True}
extra = "unused" # this variable is never used
if path == None: # should be 'is None'
return settings
📄 Terminal: lint it
ruff check messy.py
▶ Output (trimmed to the summary)
I001 [*] Import block is un-sorted or un-formatted F401 [*] `sys` imported but unused F401 [*] `json` imported but unused F811 [*] Redefinition of unused `os` from line 1 F401 [*] `os` imported but unused UP045 [*] Use `X | None` for type annotations F841 Local variable `extra` is assigned to but never used E711 Comparison to `None` should be `cond is None` Found 8 errors. [*] 6 fixable with the `--fix` option (2 hidden fixes can be enabled with the `--unsafe-fixes` option).
What happened here: You planted six problems, but Ruff reports eight errors: the doubled os line breaks two rules at once (F811 for the redefinition and F401 because it is also unused), and the unsorted import block earns its own code (I001). Crucially, Ruff also told you which ones it is willing to fix on its own. The [*] marker means “safe auto-fix”. Six are starred: the duplicate os, the three unused imports, the import ordering, and the Optional to str | None upgrade.
The two without a star, the unused extra variable (F841) and the == None comparison (E711), are deliberately held back. Ruff calls them “hidden fixes” because removing a variable or rewriting a comparison could, in rare cases, change what your code does, so it refuses to do that silently. The real output prints a code snippet under each error too; it is trimmed above to keep the summary readable.
Run the safe auto-fix and the formatter, and the six starred issues vanish.
📄 Terminal: auto-fix the safe issues, then format
ruff check --fix messy.py ruff format messy.py
📄 messy.py after the safe fixes
def get_config(path: str | None = None): # should be str | None
settings = {"debug": True}
extra = "unused" # this variable is never used
if path == None: # should be 'is None'
return settings
What happened here: The whole import mess is gone and Optional[str] became str | None, all automatically. But notice what stayed: extra is still sitting there unused, and path == None is still the wrong comparison. Those are the two “hidden” fixes Ruff would not apply on its own. This is the honest reality that trips people up. Plain ruff check --fix does not clean everything; it cleans only what is provably safe. You finish the last two by hand, or you tell Ruff you accept the risk with ruff check --fix --unsafe-fixes, which would delete extra and rewrite the comparison to path is None. Read the diff before you trust unsafe fixes on a real codebase.
mypy: Static Type Checking
Ruff checks how your code looks and a few patterns it knows are risky. It does not understand whether your types make sense. That is mypy’s whole job. The word “static” just means it reads your code without running it, the same way a proofreader catches a typo without acting out the sentence. Here is the kind of bug it catches that no linter ever will: a small age lookup for three users named Rahul, Anvi, and Aditi, plus a discount function. Save it as type_bugs.py.
📄 type_bugs.py: two bugs that run fine until they do not
def get_user_age(name: str) -> int:
users = {"Rahul": 28, "Anvi": 25, "Aditi": 30}
return users.get(name) # bug: .get() returns int | None, not int
def apply_discount(price: float, label: str) -> float:
return price + label # bug: cannot add a float and a str
📄 Terminal: type-check it
mypy type_bugs.py
▶ Output
type_bugs.py:3: error: Incompatible return value type (got "int | None", expected "int") [return-value]
type_bugs.py:7: error: Unsupported operand types for + ("float" and "str") [operator]
Found 2 errors in 1 file (checked 1 source file)
What happened here: Both functions import fine, run fine, and would crash or return garbage in production. mypy caught them at your desk instead. The first error is the sneaky one: dict.get() returns int | None because the key might be missing, but you promised the function returns a plain int. If name is not in the dictionary, you hand back None and the next line of code that does math on it explodes.
The second error is blunter: you cannot add a number and a string. The [return-value] and [operator] tags at the end of each line are the error codes, handy when you want to look up a rule or silence one on a specific line. The fix for the first is to handle the missing case, for example return users.get(name, 0).
Like Ruff, mypy reads its settings from pyproject.toml. Turning on strict = true is the single best move: it switches on a bundle of careful checks at once, including refusing to let any function go without type hints.
📄 pyproject.toml: mypy configuration
[tool.mypy] python_version = "3.14" strict = true warn_return_any = true warn_unused_configs = true
When your types do line up, mypy says so plainly. A correctly typed file gives you this:
▶ Output (a clean file)
Success: no issues found in 1 source file
What happened here: That one-line success is what you want every commit to print. Note the modern style throughout: write int | None, not Optional[int], and list[float], not typing.List[float]. The old typing.Optional and typing.List forms still work, but they are the legacy spelling. Ruff’s UP rules will even rewrite them for you, as you saw above.
Pre-commit Hooks: Automate Everything
Running Ruff and mypy by hand works right up until the afternoon you forget. A pre-commit hook removes the memory step entirely. Picture the metal detector at an airport: you do not get to choose whether to walk through it, you simply cannot reach the gate without passing. A pre-commit hook is that gate for your repository. Type git commit and the checks run first; fail any of them and the commit is refused.
You list the hooks in a file named .pre-commit-config.yaml at the root of your repo. Each rev pins an exact version so every teammate runs the same checks.
📄 .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.18
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.1.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: debug-statements # Catches a forgotten breakpoint()
Install the pre-commit tool, then run pre-commit install once. That second command writes a small script into .git/hooks/ so Git knows to run your checks.
📄 Terminal: wire the hooks into Git
pip install pre-commit pre-commit install # From now on, every 'git commit' runs the checks first
Now try to commit a file with a bad comparison still in it. The hook stops you cold.
▶ Output (illustrative: a blocked commit)
ruff.....................................................................Failed - hook id: ruff - exit code: 1 E711 Comparison to `None` should be `cond is None` ruff-format..............................................................Passed mypy.....................................................................Passed
What happened here: The ruff hook failed, so Git refused the commit. Fix the comparison, stage the file again, and re-run the commit; this time every line reads Passed and the commit goes through. That blocked-commit output above is illustrative, because pre-commit drives a live Git repository and downloads each hook’s pinned version on first run, which we cannot reproduce inside a sandbox. The ruff, mypy, and ruff format outputs everywhere else in this post are real runs on Python 3.14.6. The format of the pass and fail lines matches what pre-commit prints, so it is what you will see, just generated on your machine rather than ours.
Common Mistakes
Mistake 1: Expecting ruff check –fix to clean everything
People run ruff check --fix, see some errors remain, and assume Ruff is broken. It is not. By default Ruff applies only the fixes it considers safe and leaves the riskier ones (like deleting a variable) for you. Read the summary line: it tells you how many fixes are “hidden” behind --unsafe-fixes. Review those by hand before you enable them, because “unsafe” means the fix could change behaviour in an edge case.
Mistake 2: Running mypy on code with no type hints
mypy only checks the promises you write down. A function with no type hints makes no promises, so mypy mostly shrugs and passes it. New users run mypy on an untyped file, see “Success”, and think their code is verified. It is not; there was simply nothing to verify. Add hints to your function signatures first, or turn on disallow_untyped_defs (which strict = true already does) so mypy complains about the missing hints instead of staying silent.
Mistake 3: Installing the tools globally instead of per project
If you pip install ruff into your system Python, two projects on your machine can end up linted by two different Ruff versions, and your config will drift from your teammates’. Install Ruff and mypy into each project’s virtual environment, and pin the same versions in .pre-commit-config.yaml. Then everyone, including your Continuous Integration (CI) server, runs identical checks.
Conclusion
You now have a complete python code quality pipeline: Ruff lints and formats in one fast pass, mypy reads your type hints and catches the bugs that only surface in production, and pre-commit wires both into Git so nobody has to remember to run them. You also know the honest edges: ruff check --fix only applies safe fixes, and mypy is silent on code with no hints, so strict = true is your friend. Next we put this discipline to work on Python security basics: secrets, input validation, and dependency scanning. And if you want the full learning path from beginner to AI/ML, visit the Python + AI/ML tutorial series home.
Frequently Asked Questions
What is the python ruff linter?
Ruff is a Python linter and formatter written in Rust. It replaces flake8, isort, black, and pyupgrade with one tool that runs 10 to 100 times faster, all driven by a single section in pyproject.toml. It was created by Charlie Marsh at Astral.
What is mypy?
mypy is a static type checker for Python. It reads your type hints and finds type errors without running the code: returning None where an int is expected, adding incompatible types, calling a missing attribute. It catches bugs at your desk that would otherwise surface in production.
Do I need both Ruff and mypy?
Yes, they catch different things. Ruff checks style and common bugs like unused imports and bad comparisons. mypy checks whether your types actually line up. Neither one does the other’s job, so use both for full coverage.
Does ruff check –fix fix every problem?
No. By default Ruff applies only the fixes it knows are safe and marks them with an asterisk in the output. Riskier fixes, such as deleting an unused variable, are held back as hidden fixes. Enable them with –unsafe-fixes after reviewing the diff.
Will Ruff slow down my workflow?
No. Ruff is written in Rust and lints a 100,000 line codebase in well under a second, far faster than any Python-based linter. It is fast enough to run on every keystroke in your editor.
Can Ruff replace black for formatting?
Yes. ruff format is a drop-in replacement for black and produces nearly identical output. Running one tool for both linting and formatting means one less dependency and one config file instead of two.
Try It Yourself
Pick one of your own projects and give its python code quality the full treatment. Drop the [tool.ruff] and [tool.mypy] sections into its pyproject.toml, run ruff check --fix . and ruff format ., then run mypy and fix the type errors it reports. Finally, add the .pre-commit-config.yaml, run pre-commit install, and prove it works: write a function that returns None where its hint promises an int, stage it, and watch the commit get blocked.
Interview Questions on Python Code Quality
Scenario questions, not trivia: this is the form this topic takes in a real interview.
Q: mypy prints “Success: no issues found” on a 5,000 line legacy module you know is full of type bugs. What is the most likely reason, and how do you tighten it?
The module almost certainly has no type hints, and mypy only verifies the promises you write down: an unannotated function is skipped, not verified. Turn on disallow_untyped_defs (or the whole strict = true bundle) so mypy reports every unannotated function instead of staying silent, then add hints file by file. On a large legacy codebase, teams usually enable strict mode per module with [[tool.mypy.overrides]] sections so the whole build does not turn red on day one.
Q: Your pre-commit hooks pass locally, but the same Ruff check fails on the exact same commit in CI. What do you check first?
Check the versions first: your .pre-commit-config.yaml pins Ruff to an exact rev, but CI often does a plain pip install ruff and gets a newer release with new or changed rules. Pin the same version in both places. The second suspect is file coverage: pre-commit only checks the files staged in that commit, while CI typically runs ruff check . over the whole repository, so run pre-commit run --all-files locally to reproduce what CI sees.
Q: A teammate keeps committing with git commit –no-verify, and broken lint lands on the main branch anyway. How do you stop that class of problem?
Accept that pre-commit hooks are client-side and advisory: any developer can bypass them with --no-verify, and a fresh clone has no hooks until pre-commit install is run. The real gate has to live server-side, so run ruff check, ruff format --check, and mypy in your CI pipeline and make those jobs required status checks before a merge is allowed. Hooks then become what they should be, fast local feedback, while CI is the enforcement layer nobody can skip.
Q: How do you silence one false positive without disabling the rule for the whole project?
Suppress it on the exact line, with the exact code. For Ruff that is a # noqa: F401 style comment, and for mypy it is # type: ignore[return-value] with the error code in brackets. Never use a bare # type: ignore or blanket noqa, because that hides every future error on that line too. If a rule is wrong for a whole file, such as unused imports in an __init__.py, use Ruff’s per-file-ignores table in pyproject.toml instead of littering comments.
Q: What does target-version in the Ruff config actually control, and why should it match your runtime Python?
It tells Ruff which language features it may assume when linting and applying fixes, which mostly matters for the UP (pyupgrade) rules. With target-version = "py314", Ruff will happily rewrite Optional[str] to str | None because that syntax exists there. If your code actually runs on an older interpreter than the one you configured, those auto-fixes can produce code that crashes at import time on the real deployment target, so the setting must match the oldest Python you support.
Q: Why does the mypy hook in .pre-commit-config.yaml need additional_dependencies like types-requests?
pre-commit runs each hook in its own isolated virtual environment, separate from your project’s environment, so mypy in the hook cannot see the packages or stub files you installed locally. Any third-party library your code imports needs its type stubs declared in additional_dependencies, for example types-requests for the requests library. Miss one and the hook fails with import errors or silently treats that library as Any, which quietly weakens every check that touches it.
Further reading: for the full reference, see the official Python documentation.
Related Posts
Previous: Python: Dockerizing Python Apps with Dockerfile, Compose, and Multi-Stage Builds
Next: Python: CI/CD for Python with GitHub Actions, Pre-commit, Automated Testing
Series Home: Python + AI/ML Tutorial Series

No comment