Skip to content

Code Style and Static Analysis

When writing python code, you first need to make sure your code can run (i.e it respects the python language definition). This guide supposes you already know how to write such functional python code.

If you need other people to understand you code (or even yourself in 6 months or so), you need to make sure it's readable:

  • we can understand its intent
  • we can understand its structure (classes/functions definitions, scopes)
  • we can understand how it works (each individual line of code)

Luckily, there are standards in python and tools to check or even enforce them.

Python Enhancement Proposals (PEP)

The Python Enhancement Proposals or PEP are proposals made by python contributors to enhance the python language. It contains proposals adding features to the language (ex: PEP 634 - Structural Patterm Matching: Specification which specifies structural pattern matching which was introduced with python 3.10), proposals describing standards and best practices for python developers to follow, etc.

In this section, we will focus on some standards described in PEPs.

Source: PEP

PEP 8 – Style Guide for Python Code

This style guide stems from the fact that "code is read much more often than it is written". The goal is therefore to make a reasonable effort when writing your code so that users or contributors will have less trouble reading it.

One key component of a readable code is consistency. If the code in your project changes style evry file or class/function definition, it will be very hard to follow as the user will constantly need to adapt to those changes. Hence defining and enforcing a single coding style is very important for the readability of a project's source code.

PEP 8 makes specific recommendations for writing clean python code. Here is a non-exhaustive list:

  • Indentation: 4 spaces per indentation level
  • Maximum Line Length: limit all lines to a maximum of 79 characters
  • Break a Line Before a Binary Operator
  • Blank Lines: surround top-level functions and classes with 2 blank lines, surround methods defined inside a class with 1 blank line, use other blank lines sparingly
  • Source File Encoding: always use UTF-8, do not declare this encoding
  • Imports are always put on top of the file, on separate lines, and are ordered thusly (each section separated by a blank line):
    • Standard library imports (for example time, os): all imports from native python libraries
    • Related third party imports (for example numpy, matplotlib): all imports from external dependencies
    • Local application/library specific imports: all imports from your own project's code

Another important section is the Naming Conventions for your modules, classes and functions.

Note

As the PEP itself admits, it only offers recommendations for writing python code. However, those rules are considered a standard for python developers. Thus, python developers are accustomed to them, and applying other rules for your project, without some very good reason, will make it harder for most developers to understand your code.

Source: PEP 8 – Style Guide for Python Code

PEP 257 – Docstring Conventions

Docstrings are pieces of documentation that you insert within your python code. They are the first statement in functions, classes, methods or even modules definitions. They become the __doc__ special attribute of that object, and are used to document the object (i.e what it is, what it does, how to use it).

They are surrounded with three double-quotes. There are 2 types of doc-strings:

  • One-line Docstrings: They are for obvious cases and should fit in one line
    1
    2
    def function(a, b):
        """Do X and return a list."""
    
  • Multi-line Docstrings: They consist of a summary line, followed by a blank line, followed by a more elaborate description
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    def complex(real=0.0, imag=0.0):
        """Form a complex number.
    
        Keyword arguments:
        real -- the real part (default 0.0)
        imag -- the imaginary part (default 0.0)
        """
        if imag == 0.0 and real == 0.0:
            return complex_zero
        ...
    

In Writing Documentation for Your Software, we will focus in much more details on how to use doc-strings as part of your project documentation.

Source : PEP 257 – Docstring Conventions

PEP 484 – Type Hints

Type hints allow developers to document the types used inside their code. It can be used for functions definition:

1
2
def greeting(name: str) -> str:
    return 'Hello ' + name
But it can also be used for variable definition (i.e the first time a variable is written in its definition scope):
1
2
3
...
for i in range(10):
    message: str = "Count: " + str(i)

When writing those, you should always put the most generic type compatible (e.g for inputs such as function arguments type) and the most precise possible (e.g for outputs such as function return type).

Those type hints are completely ignored at runtime. As such they are used for documentation purposes, to indicates which types are used throughout your code. Furthermore, those type hints can be statically checked for consistency, using tolls such as mypy.

Source: PEP 484 – Type Hints

Linters

A linter is a static analysis tool that checks source code for potential errors, formatting issues, and deviations from best practices or style guides — without executing the program.

Warning

Those linters often focuses on specific code practices, and are thus complementary. However, they are sometimes opinionated on specific topics and will add rules that are sometimes incompatible. If using multiple linters is advised to catch as many badly written code as possible, you will need to setup them in a way they don't clash together.

flake8

🔍 What it does: flake8 is a lightweight wrapper around pyflakes, pycodestyle, and mccabe. It checks your code for syntax errors, style violations (PEP 8), and complexity.

⚙️ Installation:

1
pip install flake8

🛠️ Basic Usage:

1
flake8 <path to code>

Recommended options:

  • --max-line-length <n>: set maximum line length to n (79 is the recommended max line length, see PEP 8)
  • --ignore=<rule_1,rule_2,...>: ignore the given set of rules (recommend E203,W503 for black compatibility)

🔗 Documentation: https://flake8.pycqa.org

pylint

🔍 What it does: pylint is a comprehensive static analysis tool that checks code quality, programming errors, unused variables, bad coding patterns, and enforces a coding standard.

⚙️ Installation:

1
pip install pylint

🛠️ Basic Usage:

1
pylint <path to code>

You can also generate a config file:

1
pylint --generate-rcfile > .pylintrc

🔗 Documentation: https://pylint.readthedocs.io

ruff

🔍 What it does: ruff is an extremely fast Python linter written in Rust. It integrates many tools (flake8, isort, pyflakes, etc.) and supports auto-fixes.

⚙️ Installation:

1
pip install ruff

🛠️ Basic Usage:

1
ruff check <path to code>

To auto-fix issues:

1
ruff check <path to code> --fix

🔗 Documentation: https://docs.astral.sh/ruff

mypy

🔍 What it does: mypy is a static type checker for Python. It verifies type annotations and finds type errors without running the code.

⚙️ Installation:

1
pip install mypy

🛠️ Basic Usage:

1
mypy <path to code>

Options for using external packages without type checking:

  • --ignore-missing-imports: ignore imported modules which have no type hinting stubs

Use inline or stub files for type hints. See PEP 484 for inline type hints definition.

🔗 Documentation: https://mypy.readthedocs.io

bandit

🔍 What it does: bandit scans your codebase for common security issues in Python code. It’s focused on static analysis for known vulnerability patterns.

⚙️ Installation:

1
pip install bandit

🛠️ Basic Usage:

1
bandit -r <path to code>

🔗 Documentation: https://bandit.readthedocs.io

Python Linter Comparison Table

Feature flake8 pylint mypy ruff bandit
🔍 Purpose Code style & linting (PEP 8) Code style & linting (comprehensive) Static type checking Linting + formatting + type hints Security analysis
🧠 Focus Syntax, style, complexity Full static code analysis Type hints & annotations Linting + selected checks from others Common Python security issues
🛠 Config file setup.cfg, tox.ini, pyproject.toml .pylintrc, pyproject.toml mypy.ini, pyproject.toml pyproject.toml bandit.yaml, CLI args
Performance Moderate (Python) Slower (Python) Fast Very fast (Rust) Fast
📦 Install pip install flake8 pip install pylint pip install mypy pip install ruff pip install bandit
🧪 Dry-run / check mode Default behavior Default behavior Default behavior ruff check Default behavior
🔁 Auto-fix Limited via plugins ruff --fix
🧰 Plugin Ecosystem Large (e.g., flake8-bugbear, flake8-docstrings) Moderate Some (e.g., Django plugin) Built-in rules, no plugins needed Few
📈 Output detail Concise Verbose with code score Focused on type errors Concise, --format options Issue summary by CWE category
🧪 Type Checking Limited ✅ Static types only ✅ Basic type checks (subset of mypy)
🔐 Security linting
🤝 Pre-commit support
🔗 Docs flake8 pylint mypy ruff bandit

Summary

  • flake8: Light, pluggable PEP 8 linter. Ideal for quick, style-focused checks.
  • pylint: Deep static analysis, best for full-featured rule checks and reports.
  • mypy: Strong typing enforcement, perfect if your codebase uses type hints.
  • ruff: Extremely fast all-in-one linter + formatter, replacing flake8, isort, and partially mypy/pylint.
  • bandit: Special-purpose linter to catch common Python security flaws in your code.

Code Formatters

Whereas linters are concerned with pointing out bad coding practices, or other static errors, they often don't come up with a way to automatically fix them.

Furthermore, the end goal when setting up coding style for your project, is to be as thorough as possible in the rules you set up so there is only one way to write any piece of code cleanly. This enforces absolute consistency, and formatters are built to tackle that problem.

Tip

Linters and formatters are often complementary. If set up correctly, the code modified by your formatters should always be validated by your linters. And they should not clash with each other.

isort

🔍 What it does: isort automatically sorts and organizes your Python import statements by module type and alphabetically. It helps enforce a consistent import order across your codebase.

⚙️ Installation:

1
pip install isort

🛠️ Basic Usage:

1
isort <path to code>

You can also configure it with a .isort.cfg or pyproject.toml.

🔗 Documentation: https://pycqa.github.io/isort

black

🔍 What it does: black is an uncompromising Python code formatter. It formats entire files in a consistent way according to the Black style, which is based on PEP 8 but with strict rules. You don’t configure the style—Black does it for you.

⚙️ Installation:

1
pip install black

🛠️ Basic Usage:

1
black <path to code>

To check formatting without modifying:

1
black --check <path to code>

🔗 Documentation: https://black.readthedocs.io

ruff (as a formatter)

🔍 What it does: Besides being a linter, ruff can also act as a code formatter, replacing both black and isort in many use cases. It offers extremely fast formatting and can handle imports and line length consistently.

⚙️ Installation:

1
pip install ruff

🛠️ Basic Usage (formatting mode):

1
ruff format <path to code>

To auto-format the whole project:

1
ruff format .

Ruff can be configured in pyproject.toml (like black and isort).

🔗 Documentation: https://docs.astral.sh/ruff

Python Code Formatter Comparison

Feature black isort ruff
🔧 Purpose Code formatting Sort & group import statements Code formatting + import sorting + linting
📦 Scope Full file formatting Only imports Full formatting (can replace black + isort)
🛠 Configurable Minimal Highly configurable Moderately configurable
Performance Moderate (written in Python) Fast (written in Python) Extremely fast (written in Rust)
🔁 Supports Fixing Yes (autoformats code) Yes (reorders imports) Yes (autoformats and fixes code)
🧪 Dry Run / Check Mode --check --check-only ruff format --check
📄 Config File pyproject.toml .isort.cfg / pyproject.toml pyproject.toml
🤝 Pre-commit Support Yes Yes Yes
🔗 Docs black.readthedocs.io isort docs ruff docs

Summary

  • Use black if you want a strict, no-options, PEP 8-compliant formatter that "just works."
  • Use isort if you need to enforce import grouping and sorting.
  • Use ruff if you want the fastest, all-in-one tool that combines linting and formatting (including isort and black-style output).

Automatize code style

In this section, we consider you already configured your linters and formatters for executing them manually. You already looked for the options to make them compatible with each other.

If this validation of your code style and practices is only done manually, you will often either forget to execute it or choose not to (seems easier right?). Then this formalization efforts and configuration efforts would have been for nothing, leaving your code to become inconsistent.

To that end, it is better to automatize those validations. Even better, it is better to block commits that don't validate linters or are not formatted properly, so only clean commits are made. This can be done server-side, using Continuous Integration, which we will cover in another guide. But it can either (or better also) be applied locally.

Apply formatting automatically (IDE)

Most IDE, such as VS Code, come with formatting options. If you have set a common IDE and list of plugins/options which any contributor must use to work on your project, you can then use your IDE configuration to choose formatting options.

If this is not the case though, we recommend you first manually test and select, both formatting tools and options, and simply set your IDE to apply them. For VS Code, you can install the File Watchers extension, and make it run a bash command executing your formatting tools.

Tip

Doing it like this allow you to reuse the same configuration of formatters for local execution (via VS Code File Watchers, or even manual execution) and for Continuous Integration. So you know that your IDE will format code that won't clash with the remote repository rules.

For File Watchers, please find below a VS Code .vscode/settings.json snippet, which will run isort and black, with compatibility options for the flake8 linter, everytime a .py file is modified:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
    "filewatcher.commands": [


        {
            "match": ".*\\.py",
            "cmd": "cd ${workspaceRoot} && isort --atomic --profile black --combine-star --use-parentheses -m VERTICAL_HANGING_INDENT -w 79 src && black --line-length 79 src",
            "event":"onFileChange"
        }
    ]
}

Note

This command will apply formatting to every source files whenever one is modified. If your project becomes very large, you may need to tweak it so it only apply to the modified file in question. The ${workspaceRoot} variable is a VS Code default variable corresponding to the path to your current workspace (e.g the root of your project).

The advantage of setting automatic formatting of your code is that you don't have to think about it as you code. You can literally write your code in a quick and dirty way, save and it will become clean immediately.

Pre-commit hook

Another, complementary, approach is to perform linting & formatting checks before making a commit, blocking it if they fail. This can be accomplished automatically using a pre-commit hook.

This hook will run everytime you call the git commit command, and will block it if the hook fails.

Here is an example of such a hook, applying flake8 linter, isort and black formatters in validation mode. It must be called .git/hooks/pre-commit and be executable:

1
2
3
4
5
6
#!/bin/bash

# Check linters and formatters before commit
flake8 --max-line-length=79 --ignore=E203,W503 src
isort --atomic --profile black --combine-star --use-parentheses -m VERTICAL_HANGING_INDENT -w 79 --check --diff src
black --line-length 79 --check src

Tip

This hook will block commit that don't pass those checks, but it won't return the logs showing the reasons. You can modify it though, so the logs won't show if the checks pass (so your commit will just work normally), but they will if they fail, providing you with feedback:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/bin/bash

# Check linters and formatters before commit
# Only return stdout and complete stderr when it fails
{
    PRINTOUT=$(flake8 --max-line-length=79 --ignore=E203,W503 src && isort --atomic --profile black --combine-star --use-parentheses -m VERTICAL_HANGING_INDENT -w 79 --check --diff src && black --line-length 79 --check src 2>&1)
} || {
    echo "$PRINTOUT"
    exit 1
}