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

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:

vscode: showing mypy errors
# 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