Python Linting
Linting is like a spell check for your code. It scans your files for common errors, formatting issues, and bad practices before they ever reach production. By enforcing consistent style and catching bugs early, linting tools help teams write cleaner, more maintainable code. When integrated into a build pipeline, linting acts as a gatekeeper; automatically flagging issues before code is merged or deployed. This reduces review overhead, speeds up debugging, and ensures that every change meets a baseline of quality and readability.
🔍 Top Python 3 Linting Tools
Linting tools help enforce code quality, catch bugs early, and ensure consistent formatting across a codebase. Here’s a curated list of the top linting tools used in Python:
1. Ruff
- Ultra-fast linter written in Rust
- Combines linting, formatting, and type-checking (partial)
- Drop-in replacement for tools like Flake8, isort, pycodestyle
- Ideal for modern, high-performance CI pipelines
2. Flake8
- Classic and widely adopted linter
- Checks for PEP 8 style, logical errors, and complexity
- Extendable with plugins (e.g.,
flake8-bugbear
,flake8-docstrings
)
3. Pylint
- Comprehensive linter with configurable rules
- Detects code smells, style issues, and logical errors
- Outputs a numeric “code quality” score
4. Black (formatter) + --check
mode
- Enforces consistent formatting
black --check
ensures formatting before commit/deploy- Often paired with Ruff or Flake8
5. Mypy (static type checker)
- Optional typing enforcement for Python 3
- Catches type-related bugs before runtime
- Integrates well with Ruff and other linters
6. Bandit
- Security-focused linter for detecting common Python vulnerabilities
- Useful in CI/CD pipelines for production or cloud apps
7. isort
- Automatically sorts and groups imports
- Compatible with Ruff and Black
- Helps maintain consistent imports
8. Pyright
- Fast type checker originally from Microsoft
- Strong type inference and performance
- Available as a VS Code extension or CLI
9. Doc8
- Linter for docstrings and reStructuredText files
- Ensures documentation syntax doesn’t break builds
âś… Recommended Stack
For my recent Python projects, I'm using Ruff + Black + Mypy
. They offer a fast, powerful trio that covers linting, formatting, and type checking.
Let's start with a sample project, following along from: https://www.markcallen.com/python-project-setup-with-uv/
First, we need to use uv to add ruff, black and mypy as development dependencies:
uv add --dev ruff
uv add --dev black
uv add --dev mypy
This adds a [dependency-groups]
section to the pyproject.toml
[dependency-groups]
dev = [
"black>=25.1.0",
"mypy>=1.16.1",
"ruff>=0.12.2",
]
We can add some tool-specific configuration to the pyproject.toml
:
[tool.black]
line-length = 88
[tool.ruff]
line-length = 88
indent-width = 4
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
[tool.ruff.format]
indent-style = "space"
[tool.mypy]
strict = true
warn_return_any = false
Now, we can create a main.py
file with a bunch of known errors to test out using all three:
# main.py
import sys,os
print( "This is an intentionally long line that exceeds the default Black limit of eighty-eight characters to trigger a formatting warning or auto-fix.")
def greet(name:str)->str:
return "Hello,"+name+"!"
def add(a,b): return a+b
def main():
unused = 42
name='Bob' ; age =22
print( greet( name ) )
print("Sum: ",add(10, 5))
print( greet( age ) )
if __name__=='__main__':main()
This file can run, but does have a run time error:
uv run main.py
This is an intentionally long line that exceeds the default Black limit of eighty-eight characters to trigger a formatting warning or auto-fix.
Hello,Bob!
Sum: 15
Traceback (most recent call last):
File "/Users/mark/src/everydaydevops/python-linting-example/main.py", line 21, in <module>
if __name__=='__main__':main()
~~~~^^
File "/Users/mark/src/everydaydevops/python-linting-example/main.py", line 19, in main
print( greet( age ) )
~~~~~^^^^^^^
File "/Users/mark/src/everydaydevops/python-linting-example/main.py", line 8, in greet
return "Hello,"+name+"!"
~~~~~~~~^~~~~
TypeError: can only concatenate str (not "int") to str
Let's try to catch the error before running the code.
Starting with black to check the formatting:
uv run black --check main.py
would reformat main.py
Oh no! đź’Ą đź’” đź’Ą
1 file would be reformatted.
Using the --check
command line parameter since black
will automatically reformat the file.
Running without --check
will now format the file correctly.
uv run black .
# main.py
import sys, os
print(
"This is an intentionally long line that exceeds the default Black limit of eighty-eight characters to trigger a formatting warning or auto-fix."
)
def greet(name: str) -> str:
return "Hello," + name + "!"
def add(a, b):
return a + b
def main():
unused = 42
name = "Bob"
age = 22
print(greet(name))
print("Sum: ", add(10, 5))
print(greet(age))
if __name__ == "__main__":
main()
Now let's check the file with ruff check
:
uv run ruff check main.py
This shows that we have a lot of syntax errors:
main.py:2:1: E401 [*] Multiple imports on one line
|
1 | # main.py
2 | import sys, os
| ^^^^^^^^^^^^^^ E401
3 |
4 | print(
|
= help: Split imports
main.py:2:8: F401 [*] `sys` imported but unused
|
1 | # main.py
2 | import sys, os
| ^^^ F401
3 |
4 | print(
|
= help: Remove unused import
main.py:2:13: F401 [*] `os` imported but unused
|
1 | # main.py
2 | import sys, os
| ^^ F401
3 |
4 | print(
|
= help: Remove unused import
main.py:18:5: F841 Local variable `unused` is assigned to but never used
|
17 | def main():
18 | unused = 42
| ^^^^^^ F841
19 | name = "Bob"
20 | age = 22
|
= help: Remove assignment to unused variable `unused`
Found 4 errors.
[*] 3 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).
We can use ruff check --fix
to fix most of the errors:
uv run ruff check --fix .
Which still leaves us with one error:
main.py:17:5: F841 Local variable `unused` is assigned to but never used
|
16 | def main():
17 | unused = 42
| ^^^^^^ F841
18 | name = "Bob"
19 | age = 22
|
= help: Remove assignment to unused variable `unused`
Found 4 errors (3 fixed, 1 remaining).
No fixes available (1 hidden fix can be enabled with the `--unsafe-fixes` option).
Editing that now gives us well formatted and has removed any possible programming issues.
# main.py
print(
"This is an intentionally long line that exceeds the default Black limit of eighty-eight characters to trigger a formatting warning or auto-fix."
)
def greet(name: str) -> str:
return "Hello," + name + "!"
def add(a, b):
return a + b
def main():
name = "Bob"
age = 22
print(greet(name))
print("Sum: ", add(10, 5))
print(greet(age))
if __name__ == "__main__":
main()
The updated file now passes ruff:
uv run ruff check --fix .
All checks passed!
Finally, on to type checking.
uv run mypy main.py
main.py:12: error: Function is missing a type annotation [no-untyped-def]
main.py:16: error: Function is missing a return type annotation [no-untyped-def]
main.py:16: note: Use "-> None" if function does not return a value
main.py:20: error: Call to untyped function "add" in typed context [no-untyped-call]
main.py:21: error: Argument 1 to "greet" has incompatible type "int"; expected "str" [arg-type]
main.py:25: error: Call to untyped function "main" in typed context [no-untyped-call]
Found 5 errors in 1 file (checked 1 source file)
Now, if we open this in our IDE we can go and fix the issues:

# main.py
print(
"This is an intentionally long line that exceeds the default Black limit of eighty-eight characters to trigger a formatting warning or auto-fix."
)
def greet(name: str) -> str:
return "Hello," + name + "!"
def add(a: int, b: int) -> int:
return a + b
def main() -> None:
name = "Bob"
age = 22
print(greet(name))
print("Sum: ", add(10, 5))
print(greet(str(age)))
if __name__ == "__main__":
main()
And running mypy
uv run mypy main.py
Success: no issues found in 1 source file
One final step. Add pre-commit
to run these commands for us so that errors don't creep into our code. I've detailed why using pre-commit hooks helps out with a team development process in The Hidden Cost of Complexity: Why Seamless Development Environments Drive Engineering Productivity
Create a .pre-commit-config.yaml
file:
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-json
- id: check-yaml
- id: check-added-large-files
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/adrienverge/yamllint
rev: v1.33.0
hooks:
- id: yamllint
args: [--strict]
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.2
hooks:
- id: ruff
args: [--fix]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.16.1
hooks:
- id: mypy
Install pre-commit and configure it.
brew install pre-commit
pre-commit install
Now, trying to add our bad main.py
file to git will throw some errors. Some are fixed automatically, and some we need to fix ourselves.
git commit -m "running pre-commit hooks" -a
check json...........................................(no files to check)Skipped
check yaml...........................................(no files to check)Skipped
check for added large files..............................................Passed
trim trailing whitespace.................................................Failed
- hook id: trailing-whitespace
- exit code: 1
- files were modified by this hook
Fixing main.py
fix end of files.........................................................Passed
yamllint.............................................(no files to check)Skipped
black....................................................................Failed
- hook id: black
- files were modified by this hook
reformatted main.py
All done! ✨ 🍰 ✨
1 file reformatted.
ruff (legacy alias)......................................................Failed
- hook id: ruff
- exit code: 1
- files were modified by this hook
main.py:17:5: F841 Local variable `unused` is assigned to but never used
|
16 | def main():
17 | unused = 42
| ^^^^^^ F841
18 | name = "Bob"
19 | age = 22
|
= help: Remove assignment to unused variable `unused`
Found 4 errors (3 fixed, 1 remaining).
No fixes available (1 hidden fix can be enabled with the `--unsafe-fixes` option).
mypy.....................................................................Failed
- hook id: mypy
- exit code: 1
main.py:12: error: Function is missing a type annotation [no-untyped-def]
main.py:16: error: Function is missing a return type annotation [no-untyped-def]
main.py:16: note: Use "-> None" if function does not return a value
main.py:21: error: Call to untyped function "add" in typed context [no-untyped-call]
main.py:22: error: Argument 1 to "greet" has incompatible type "int"; expected "str" [arg-type]
main.py:26: error: Call to untyped function "main" in typed context [no-untyped-call]
Found 5 errors in 1 file (checked 1 source file)
Now editing the file and checking it in:
check json...........................................(no files to check)Skipped
check yaml...........................................(no files to check)Skipped
check for added large files..............................................Passed
trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
yamllint.............................................(no files to check)Skipped
black....................................................................Passed
ruff (legacy alias)......................................................Passed
mypy.....................................................................Passed
[main 63dccdf] running pre-commit hooks
1 file changed, 17 insertions(+), 13 deletions(-)
Now we have a project set up a python project with black, ruff and mypy and used pre-commit to make sure they are run before committing code.
I've shared this project on github as an example: https://github.com/markcallen/python-linting-example