Skip to main content

PEP 8 - Python's Style Guide for Production Code

Reading time: ~22 minutes | Level: Foundation → Engineering

x=1;y=2;z=x+y
if(z>0):print("positive")
import os,sys,json
def foo( x,y ):
return(x+y)

This is syntactically valid Python. It runs without error. It would also be rejected instantly in any serious code review. PEP 8 is why.

PEP 8 (Python Enhancement Proposal 8) is Python's official style guide, written by Guido van Rossum, Barry Warsaw, and Nick Coghlan. It is not a moral document. It is an engineering document. Its purpose is to reduce the cognitive overhead of reading Python code by making it look the same everywhere - so engineers can focus on the logic rather than the style.

What You Will Learn

  • Why style guides exist and what problem they solve at team scale
  • Every major PEP 8 rule with concrete before/after examples
  • The difference between what PEP 8 mandates and what the community has evolved
  • How to run pycodestyle and flake8 and read their output
  • How to configure PEP 8 rules in pyproject.toml and .flake8
  • The # noqa escape hatch and when it is appropriate
  • Common violations engineers make and how to spot them in code review

Prerequisites

  • Ability to write basic Python functions, classes, and modules
  • Familiarity with running Python scripts from the terminal
  • Optional: a project or file you can run flake8 against for practice

Why Style Guides Exist

Style guides solve a communication problem, not a technical one. Python's parser does not care whether you use 2 spaces or 4. The interpreter does not care if your imports are sorted. But the humans who read the code do.

Consider what happens in a code review when every engineer formats code differently:

  • Engineer A uses 2-space indentation
  • Engineer B uses 4-space indentation
  • Engineer C uses tabs
  • Engineer D puts spaces inside parentheses: foo( x, y )
  • Engineer E writes single-letter variables; Engineer F writes 40-character variable names

Every style difference is a micro-friction that the reviewer's brain must process before reaching the actual logic. A code review should be about correctness, design, and edge cases - not about whether there should be a space before a colon.

Style guides eliminate style decisions entirely. They are the format and the format is agreed upon. Engineers stop bikeshedding and start engineering.

Guido van Rossum said it best in PEP 8 itself:

"Code is read much more often than it is written."

The cost of writing PEP 8-compliant code is small. The benefit - readable, consistent code that any Python engineer in the world can pick up and understand - is enormous.

Indentation: 4 Spaces, Never Tabs

PEP 8 is unambiguous: use 4 spaces per indentation level. Never use tabs. Never mix tabs and spaces (Python 3 raises TabError if you do).

# WRONG - 2-space indentation
def calculate_discount(price, rate):
if rate > 0:
discount = price * rate
return price - discount
return price

# WRONG - tab indentation (renders differently in every editor)
def calculate_discount(price, rate):
if rate > 0:
discount = price * rate
return price - discount
return price

# CORRECT - 4-space indentation
def calculate_discount(price: float, rate: float) -> float:
if rate > 0:
discount = price * rate
return price - discount
return price

Why 4 spaces specifically? Two spaces save horizontal room but make it hard to visually parse nesting levels at a glance. Eight spaces (an old Unix convention) push deeply nested code far off-screen. Four is the empirical sweet spot between readability and horizontal space economy.

Continuation Lines

When a line is too long to fit on one line, continuation lines should align with the opening delimiter or use a hanging indent:

# WRONG - continuation with no alignment
result = some_function(argument_one, argument_two, argument_three,
argument_four)

# CORRECT - aligned with opening delimiter
result = some_function(argument_one, argument_two, argument_three,
argument_four)

# ALSO CORRECT - hanging indent (4 extra spaces)
result = some_function(
argument_one,
argument_two,
argument_three,
argument_four,
)

# For if-statements that span multiple lines, add a comment
# to visually distinguish the condition from the body:
if (
condition_one
and condition_two
and condition_three
):
do_the_thing()

Line Length: 79 Characters (and the Modern Reality)

PEP 8 originally specified a maximum line length of 79 characters for code and 72 characters for docstrings and comments. This dates from the era when terminals were 80 columns wide and diff tools expected lines under 80 characters.

The rationale still holds partly: shorter lines are more readable in split-screen editors, side-by-side diffs, and code review tools. They also work better on documentation websites and presentations.

However, modern monitors are wide. Modern editors wrap gracefully. The community has evolved:

LimitWho uses it
79 charsPEP 8 default, some government/legacy projects
88 charsBlack formatter default (the most common modern choice)
99-100 charsDjango, NumPy, many large open-source projects
120 charsSome enterprise teams

The most pragmatic approach: use Black (covered in the next lesson), which defaults to 88 characters, and configure flake8's max-line-length = 88 to match.

# A line just over 79 chars - PEP 8 would flag this
user_message = f"Hello {user.first_name}, your subscription renews on {renewal_date}."

# Fine at 88 chars - Black's default
user_message = (
f"Hello {user.first_name}, your subscription renews on {renewal_date}."
)

# For very long strings, use implicit concatenation
error_message = (
"The payment could not be processed because the card was declined. "
"Please check your billing information and try again."
)

Blank Lines: Separating Logical Units

Blank lines are not decoration. They are visual structure that tells the reader where one logical unit ends and another begins.

PEP 8 rules:

  • 2 blank lines between top-level definitions (functions and classes)
  • 1 blank line between methods inside a class
  • Blank lines inside functions to separate logical steps (use sparingly)
# WRONG - no separation between top-level definitions
import os
def foo():
pass
def bar():
pass
class MyClass:
def method_a(self):
pass
def method_b(self):
pass

# CORRECT - proper blank line separation
import os


def foo():
pass


def bar():
pass


class MyClass:

def method_a(self):
pass

def method_b(self):
pass

Inside a function, blank lines can signal the separation of setup, processing, and return:

def process_payment(order_id: str, card_token: str) -> dict:
# Fetch order
order = Order.get(order_id)
if not order:
raise OrderNotFoundError(order_id)

# Charge the card
charge = payment_gateway.charge(card_token, order.total_cents)
if not charge.success:
raise PaymentDeclinedError(charge.error_code)

# Update order status and return confirmation
order.mark_paid(charge.transaction_id)
return {"order_id": order_id, "transaction_id": charge.transaction_id}

This blank-line structure reads like paragraphs. Each paragraph has a heading (the comment) and a body.

Imports: Ordered, Grouped, One Per Line

PEP 8 mandates a specific import structure. Imports should appear at the top of the file (after module docstrings and __future__ imports), organized into three groups separated by blank lines:

  1. Standard library imports
  2. Third-party library imports
  3. Local application/library imports

Within each group, imports should be in alphabetical order.

# WRONG - mixed groups, multiple on one line, unsorted
import os, sys
import requests
from myapp.utils import helpers
import json
from django.db import models
from myapp.models import User

# CORRECT - three groups, alphabetical, one per line
import json
import os
import sys

import requests
from django.db import models

from myapp.models import User
from myapp.utils import helpers

One import per line (for import statements, not from ... import):

# WRONG
import os, sys, json

# CORRECT
import os
import sys
import json

# OK - from imports can import multiple names on one line
from os.path import join, exists, dirname

Wildcard imports are forbidden in production code:

# NEVER DO THIS in production code
from os.path import *
from mymodule import *

Wildcard imports pollute the namespace with unknown names, break grep, and make it impossible for tools and readers to determine where a name comes from.

Whitespace Rules: The Details That Signal Care

PEP 8 specifies exact whitespace rules for expressions and statements. These rules sound pedantic but matter enormously in code review - they signal whether an engineer is paying attention to detail.

No Space Before Colons or Parentheses

# WRONG
if x == 1 :
foo ()
bar [0]
d ['key']

# CORRECT
if x == 1:
foo()
bar[0]
d['key']

Space After Commas, Not Before

# WRONG
foo(a ,b ,c)
x = [1,2,3]
d = {'a':1, 'b':2}

# CORRECT
foo(a, b, c)
x = [1, 2, 3]
d = {'a': 1, 'b': 2}

No Space Around = in Default Arguments

This is a common source of confusion. In function calls and definitions, keyword arguments and default values do not get spaces around the =:

# WRONG
def greet(name = "World"):
print(f"Hello, {name}!")

greet(name = "Alice")

# CORRECT
def greet(name="World"):
print(f"Hello, {name}!")

greet(name="Alice")

Exception: when a type annotation is present, the = gets spaces:

# With type annotation - spaces around =
def greet(name: str = "World") -> None:
print(f"Hello, {name}!")

Space Around Binary Operators

Use single spaces around binary operators (=, +=, ==, !=, and, or, in, not in, is, is not):

# WRONG
x=1
y = x*2+1
if x==1 and y!=0:
pass

# CORRECT
x = 1
y = x * 2 + 1
if x == 1 and y != 0:
pass

Exception: when combining operators of different precedence, you may omit spaces around lower-priority operators to show grouping:

# Both are valid, but the second is clearer
hypot = x * x + y * y
hypot = x*x + y*y # visual grouping of multiplication

Avoid Trailing Whitespace

No trailing spaces at end of lines. Most editors highlight these or remove them automatically. In diffs, trailing whitespace shows up as changes even when the logic hasn't changed.

Comments: Inline vs Block vs Docstrings

PEP 8 distinguishes three kinds of comments, and they have different rules:

Block Comments

Block comments explain the code below them. They start with a # and a single space, and they indent to the same level as the code they describe.

# WRONG - no space after #, wrong indentation
def process():
x = get_data()
#process the data
result = transform(x)

# CORRECT - space after #, same indentation as code
def process():
x = get_data()

# Transform the raw data into the normalized format expected by the API.
result = transform(x)

Inline Comments

Inline comments appear on the same line as code. PEP 8 says: use them sparingly, separate them from code by at least two spaces, and never state the obvious.

# WRONG - states the obvious
x = x + 1 # increment x

# WRONG - comment compensates for bad naming
x = x + 1 # add one to the retry counter

# CORRECT - explains why, not what (and the name already says "what")
retry_count += 1 # exponential backoff starts on the second attempt

# CORRECT - explains a non-obvious decision
buffer_size = 8192 # optimal for most kernel page sizes

The rule of thumb: if your comment explains what the code does, the code should be rewritten to explain itself. If the comment explains why, it may be necessary.

Docstrings

Docstrings are string literals at the start of a module, class, or function. They are accessible at runtime via .__doc__. PEP 8 mandates docstrings for all public modules, classes, methods, and functions.

# One-liner docstring - for simple, obvious functions
def double(value: float) -> float:
"""Return the value multiplied by two."""
return value * 2


# Multi-line docstring - closing """ on its own line
def calculate_compound_interest(
principal: float,
annual_rate: float,
years: int,
compounds_per_year: int = 12,
) -> float:
"""
Calculate compound interest and return the total accrued amount.

Args:
principal: Initial investment amount in dollars.
annual_rate: Annual interest rate as a decimal (e.g., 0.05 for 5%).
years: Number of years to compound.
compounds_per_year: Number of compounding periods per year.

Returns:
Total amount including principal and interest.

Example:
>>> calculate_compound_interest(1000, 0.05, 10)
1647.0094...
"""
rate_per_period = annual_rate / compounds_per_year
total_periods = compounds_per_year * years
return principal * (1 + rate_per_period) ** total_periods

Docstring style (Google, NumPy, Sphinx) is covered in detail in Lesson 06.

What PEP 8 Says vs What the Community Has Evolved

PEP 8 was written in 2001 and has been updated, but its evolution has been conservative. In practice, the community's norms have drifted in a few specific areas:

RulePEP 8 saysCommunity practice (2024)
Line length79 chars88 chars (Black default) or 99/100
String quotesEither ' or ", be consistentBlack normalizes to " double quotes
Import sortingManually sortUse isort (automated)
FormattingManualUse Black (automated, non-negotiable on most teams)
Type hintsOptional, PEP 484 compliantExpected on all public APIs
f-stringsNot mentioned (added in 3.6)Preferred over % and .format()

The most important shift: formatting is now automated. The "tabs vs spaces" debate that consumed countless hours of engineering time ended when Black was released in 2018. Black is opinionated, deterministic, and non-configurable on most style choices. You run it, the code is formatted, discussion is over.

This lesson focuses on understanding PEP 8 because you will encounter its violations in code review, in legacy code, and in error messages from flake8. But in new projects, you should use Black to enforce formatting automatically.

Running pycodestyle and flake8

pycodestyle (formerly pep8)

pycodestyle is the reference implementation of PEP 8 checking. It checks only style issues:

pip install pycodestyle
pycodestyle your_file.py

Example output:

your_file.py:3:1: E302 expected 2 blank lines, found 1
your_file.py:7:20: E211 whitespace before '('
your_file.py:12:5: W291 trailing whitespace
your_file.py:15:1: E401 multiple imports on one line

Each error has a code:

  • E codes: errors (style violations)
  • W codes: warnings (less severe)

flake8

flake8 combines pycodestyle with pyflakes (which finds actual bugs like undefined names, unused imports) and mccabe (which measures cyclomatic complexity). It is strictly more useful than pycodestyle alone:

pip install flake8
flake8 your_file.py

Example output from a real messy file:

mymodule.py:1:1: F401 'os' imported but unused
mymodule.py:1:10: E401 multiple imports on one line
mymodule.py:4:1: E302 expected 2 blank lines, found 1
mymodule.py:6:16: E211 whitespace before '('
mymodule.py:9:80: E501 line too long (93 > 79 characters)
mymodule.py:12:9: F821 undefined name 'conifg'
mymodule.py:15:5: E711 comparison to None (use "is not None")
mymodule.py:18:1: W291 trailing whitespace

Reading flake8 output:

mymodule.py:12:9: F821 undefined name 'conifg'
^ ^ ^ ^ ^
file | col code message
line

The F821 code is particularly valuable - it caught a typo (conifg instead of config) that would have caused a NameError at runtime. flake8 found it statically, before you even ran the code.

flake8 Code Reference

CodeCategoryMeaning
E1xxIndentationWrong number of spaces
E2xxWhitespaceWrong whitespace around operators
E3xxBlank linesWrong number of blank lines
E4xxImportsImport style violations
E5xxLine lengthLine too long
E7xxStatementMultiple statements on one line
W1xx-W6xxWarningsTrailing whitespace, deprecated features
F401pyflakesImported but unused
F821pyflakesUndefined name
F841pyflakesLocal variable assigned but never used
C901mccabeFunction too complex (cyclomatic complexity)

Configuring flake8 and pycodestyle

.flake8 configuration file

Place in your project root:

[flake8]
max-line-length = 88
extend-ignore = E203, W503
exclude =
.git,
__pycache__,
.venv,
migrations,
build,
dist
per-file-ignores =
tests/*: F401, F811
scripts/*: T201

Key settings explained:

  • max-line-length = 88 - match Black's line length
  • extend-ignore = E203, W503 - these conflict with Black's formatting; ignore them
  • exclude - directories flake8 should never check
  • per-file-ignores - ignore specific codes only in specific directories

pyproject.toml configuration

Modern Python projects consolidate all tool config in pyproject.toml. However, flake8 does not natively support pyproject.toml (a historical quirk). Use flake8-pyproject plugin or keep .flake8 alongside pyproject.toml:

# pyproject.toml - for Black and isort (covered in Lesson 03)
[tool.black]
line-length = 88
target-version = ["py311"]

[tool.isort]
profile = "black"
line_length = 88

# flake8 still needs .flake8 file (as of 2024)

The # noqa Escape Hatch

# noqa ("no quality assurance") tells flake8 to ignore a violation on that line:

import requests # noqa: F401 - imported for side effects (registers plugin)

VERY_LONG_URL = "https://api.example.com/v2/endpoint/with/many/path/segments?key=value" # noqa: E501

When # noqa is appropriate:

  1. A URL or other string that genuinely cannot be broken without affecting meaning
  2. An import that exists for side effects (not actually unused)
  3. Generated code that tools should not touch
  4. A deliberate decision that would be wrong to "fix" (e.g., a comparison that looks like a bug but is intentional)

When # noqa is NOT appropriate:

  1. As a shortcut to avoid fixing real problems
  2. On every line in a file (the file should be fixed)
  3. Without a specific code (# noqa ignores everything; # noqa: E501 is more precise)

A # noqa without a code is a code smell. It means "I don't know what's wrong here but I want it to stop complaining." Add the specific code.

Common Violations Engineers Make in Code Review

1. Comparison to None with == instead of is

# WRONG (E711)
if result == None:
return

# CORRECT
if result is None:
return

None is a singleton in Python - there is exactly one None object. is checks identity (same object), which is both semantically correct and marginally faster.

2. Comparison to True/False

# WRONG (E712) - double negation, verbose
if enabled == True:
run()

# CORRECT - booleans are truthy by definition
if enabled:
run()

3. Mutable default arguments

PEP 8 does not explicitly call this out, but flake8's B006 (from flake8-bugbear) does. This is one of Python's most common bugs:

# WRONG - the list is shared across all calls
def append_item(item, result=[]):
result.append(item)
return result

# CORRECT
def append_item(item, result=None):
if result is None:
result = []
result.append(item)
return result

4. Bare except:

# WRONG (E722) - catches everything including SystemExit, KeyboardInterrupt
try:
risky_operation()
except:
pass

# CORRECT - catch the specific exception you expect
try:
risky_operation()
except ValueError as exc:
logger.warning("Invalid value: %s", exc)

5. Multiple statements on one line

# WRONG (E701)
if condition: do_thing()
x = 1; y = 2

# CORRECT
if condition:
do_thing()
x = 1
y = 2

6. Missing whitespace around operators

# WRONG (E225)
x=1
y=x+2
if x>0 and y<10:
pass

# CORRECT
x = 1
y = x + 2
if x > 0 and y < 10:
pass

7. Trailing whitespace in blank lines between methods

class MyClass:
def method_a(self):
pass

def method_b(self): # the blank line above has trailing spaces (W293)
pass

This is invisible in most editors unless whitespace is shown. Git diffs reveal it as suspicious changes. Configure your editor to strip trailing whitespace on save.

The PEP 8 Contract in Code Review

When you give feedback on PEP 8 violations in code review, be systematic:

  1. Run flake8 before reading the code - let the tool find the mechanical violations so you can focus on logic
  2. Link to the specific PEP 8 rule when commenting - "E711: comparison to None should use is not None" is more educational than "wrong"
  3. Don't bikeshed on things Black would fix - if the team uses Black, formatting comments are noise; let the tool handle them
  4. Distinguish style from bugs - F-codes (pyflakes) often indicate actual bugs. E/W-codes are style. Prioritize accordingly.

Interview Questions

Q1: What is PEP 8 and why was it written?

Answer: PEP 8 is Python Enhancement Proposal 8, "Style Guide for Python Code," authored by Guido van Rossum, Barry Warsaw, and Nick Coghlan. It was written because Python's flexibility in formatting meant that every engineer could (and did) write code differently, creating friction in code review, onboarding, and maintenance. PEP 8 establishes a single, agreed-upon set of formatting conventions so that Python code looks consistent across projects, teams, and organizations. The motivation is explicit: code is read far more often than it is written, and consistency reduces the cognitive overhead of reading. Following PEP 8 does not make code correct, but it makes it accessible.

Q2: Why does PEP 8 specify 4 spaces for indentation instead of 2 or 8?

Answer: Four spaces is an empirical compromise. Two spaces save horizontal real estate but make it difficult to visually distinguish nesting levels at a glance, especially in deeply nested code. Eight spaces (a legacy Unix convention) push code far to the right on modern monitors and are too extreme for most use cases. Four spaces provides enough visual separation to make nesting clear without consuming excessive horizontal space. The prohibition on tabs is absolute: tabs render differently in different editors (2, 4, or 8 spaces depending on configuration), making code look correct in one editor and broken in another. Spaces always render identically.

Q3: What is the difference between pycodestyle, flake8, and Black?

Answer: pycodestyle (formerly pep8) is the reference implementation that checks only PEP 8 style compliance - whitespace, line length, blank lines, etc. It does not find bugs. flake8 wraps pycodestyle and adds pyflakes (which finds real bugs: undefined names, unused imports, unreachable code) and mccabe (which measures cyclomatic complexity). flake8 is a linter - it reports problems but does not fix them. Black is an opinionated auto-formatter - it rewrites your code to match its style (which is broadly PEP 8-compatible, with some differences like 88-char line length and normalized quotes). In practice, teams use both: Black to autoformat, flake8 to catch bugs and remaining style issues that Black does not address.

Q4: When is it appropriate to use # noqa?

Answer: # noqa suppresses flake8 warnings on a specific line. It is appropriate in specific, justified cases: a URL or identifier that genuinely cannot be broken without changing meaning; an import that exists for side effects rather than direct use; auto-generated code that should not be manually edited; or a deliberate pattern that looks like a violation but is intentionally correct. It is not appropriate as a shortcut to silence warnings without understanding them, or applied broadly to avoid doing real cleanup work. Best practice is to always specify the exact code being silenced (# noqa: E501) rather than using bare # noqa, which silences everything and obscures what was suppressed.

Q5: What flake8 error codes indicate actual bugs (not just style)?

Answer: The F-series codes from pyflakes indicate actual bugs rather than style violations. The most important are: F401 (imported but unused - may indicate an accidentally omitted import or a forgotten cleanup), F811 (redefinition of unused name - often indicates a copy-paste error), F821 (undefined name - catches typos in variable and function names that would cause NameError at runtime), F841 (local variable assigned but never used - often indicates dead code or a forgot-to-return mistake), and F811 in combination with F401 (imported then immediately shadowed). These are the codes to prioritize in code review. E/W codes are style; F codes are correctness.

Q6: Why does PEP 8 say to use is None instead of == None?

Answer: None is a singleton in Python - there is exactly one None object in the interpreter's memory. The is operator checks identity (whether two names refer to the exact same object). The == operator checks equality (which calls __eq__, potentially triggering user-defined comparison logic). Using == None is semantically imprecise because a misbehaving class could define __eq__ to return True when compared to None, producing a bug. Using is None is precise: it checks whether the object is literally the one None singleton. It is also marginally faster because it avoids a method call. PEP 8 E711 flags == None as a violation for this reason.

Practice Challenges

Beginner - Fix Every flake8 Violation

The following file has multiple PEP 8 violations. List each violation by its code, then fix all of them:

import os,sys
import json

x=1
y=2

def add(a,b):
return(a+b)

def multiply( a, b ):
result=a*b
return result

class calculator:
def __init__(self,x,y):
self.x=x
self.y=y
def compute(self):
if self.x==None:
return 0
return self.x+self.y
Solution

Violations identified:

  • Line 1: E401 - multiple imports on one line
  • Line 1: F401 - sys imported but unused (assume os is also unused if not used)
  • Line 4-5: E302 - expected 2 blank lines before function definition
  • Line 4: E225 - missing whitespace around operator (x=1)
  • Line 5: E225 - missing whitespace around operator (y=2)
  • Line 7: E302 - expected 2 blank lines before function definition
  • Line 7: E231 - missing whitespace after , in (a,b)
  • Line 8: Parentheses around return value - not a PEP 8 violation but poor style
  • Line 10: E211 - whitespace before (
  • Line 11: E225 - missing whitespace around =
  • Line 14: E302 - expected 2 blank lines before class definition
  • Line 14: E101 / class name calculator violates CapWords convention (not a PEP 8 E-code but a naming convention violation)
  • Line 15: E231 - missing whitespace after ,
  • Line 16: E225 - missing whitespace around =
  • Line 18: E301 - expected 1 blank line before method definition
  • Line 19: E711 - comparison to None should be is None
  • Line 21: E225 - missing whitespace around +

Fixed version:

import os
import json


x = 1
y = 2


def add(a, b):
return a + b


def multiply(a, b):
result = a * b
return result


class Calculator:

def __init__(self, x, y):
self.x = x
self.y = y

def compute(self):
if self.x is None:
return 0
return self.x + self.y

Intermediate - Configure a Project

Create a complete project configuration that:

  1. Sets line length to 88
  2. Ignores E203 and W503 (Black-incompatible rules)
  3. Ignores F401 in __init__.py files (where re-exporting is intentional)
  4. Excludes migrations, build, and .venv directories
  5. Runs flake8 and reports the result
Solution

.flake8 file:

[flake8]
max-line-length = 88
extend-ignore =
E203,
W503
per-file-ignores =
*/__init__.py: F401
*/tests/*: S101
exclude =
.git,
__pycache__,
.venv,
venv,
.eggs,
*.egg,
build,
dist,
migrations

pyproject.toml file (for Black and isort):

[tool.black]
line-length = 88
target-version = ["py311"]
include = '\.pyi?$'

[tool.isort]
profile = "black"
line_length = 88
known_first_party = ["myapp"]

Running checks:

# Install tools
pip install flake8 black isort

# Check current state
flake8 src/

# Auto-format (Black rewrites files)
black src/

# Sort imports
isort src/

# Re-check - only real issues remain
flake8 src/

Why E203 and W503? Black formats slice notation as a[1 : 3] (space around colon in slice), which triggers E203 ("whitespace before ':'"). Black also puts binary operators at the start of a continuation line (W503, "line break before binary operator") which is now the PEP 8-preferred style but some tools still flag it. Ignoring these two ensures Black and flake8 agree.

Advanced - Write a flake8 Plugin

Write a minimal flake8 plugin that checks for a custom rule: any function whose name starts with get_ must have a return type annotation. Functions without get_ are ignored.

Solution
# flake8_return_annotation.py
"""
flake8 plugin: ENG001 - get_ functions must have return type annotations.

Install:
pip install -e . # with setup.cfg entry point configured

Usage:
flake8 --select=ENG src/
"""

import ast
from typing import Generator, Any


class GetFunctionAnnotationChecker:
"""
Flake8 plugin to enforce that functions named get_* have return annotations.
"""

name = "flake8-return-annotation"
version = "0.1.0"

def __init__(self, tree: ast.AST) -> None:
self.tree = tree

def run(self) -> Generator[tuple[int, int, str, Any], None, None]:
"""Yield (line, col, message, type) tuples for each violation."""
for node in ast.walk(self.tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.name.startswith("get_") and node.returns is None:
yield (
node.lineno,
node.col_offset,
f"ENG001 Function '{node.name}' starts with 'get_' "
f"but has no return type annotation.",
type(self),
)


# setup.cfg entry point (add to your project's setup.cfg):
# [options.entry_points]
# flake8.extension =
# ENG = flake8_return_annotation:GetFunctionAnnotationChecker

Test cases:

# should_pass.py - no violations
def get_user(user_id: int) -> dict:
return {"id": user_id}

def process_data(data): # doesn't start with get_ - ignored
pass

async def get_items(page: int) -> list:
return []

# should_fail.py - violations
def get_user(user_id: int): # ENG001 - missing return annotation
return {"id": user_id}

def get_price(product_id): # ENG001 - missing return annotation
return 9.99

Testing the plugin:

# Install in development mode
pip install -e .

# Run against test files
flake8 --select=ENG should_pass.py # no output
flake8 --select=ENG should_fail.py
# should_fail.py:2:1: ENG001 Function 'get_user' starts with 'get_' but has no return type annotation.
# should_fail.py:5:1: ENG001 Function 'get_price' starts with 'get_' but has no return type annotation.

This demonstrates that flake8's plugin system is based on AST visitors - the same mechanism Python itself uses to analyze code. Writing custom rules is accessible to any engineer who understands the AST.

Quick Reference

RulePEP 8 specModern practice
Indentation4 spaces4 spaces (enforced by Black)
Line length79 chars88 chars (Black default)
Between top-level defs2 blank lines2 blank lines
Between methods1 blank line1 blank line
Importsstdlib / third-party / localSame, enforced by isort
Wildcard importsForbiddenForbidden
String quotesConsistent" double quotes (Black)
== NoneE711 violationUse is None
== TrueE712 violationUse truthy directly
Bare except:E722 violationCatch specific exceptions
# noqaEscape hatchUse with specific code only
Type annotationsOptional (PEP 484)Expected on public APIs

Key Takeaways

  • PEP 8 is an engineering document, not an aesthetic one - its purpose is to reduce cognitive friction in code review and maintenance.
  • The most important rules are: 4-space indentation, proper import grouping, whitespace around operators, and the distinction between is None and == None.
  • Modern practice has evolved from PEP 8's original 79-character line limit to 88 characters (Black's default), and formatting is now automated rather than manual.
  • flake8 combines style checking with real bug detection - F-series codes (undefined names, unused imports) often indicate actual defects, not just style issues.
  • The # noqa comment should always include a specific code and a brief justification; bare # noqa is a code smell.
  • Configure flake8 with max-line-length = 88 and extend-ignore = E203, W503 to be compatible with Black - the two tools should agree, not conflict.
  • The goal is not to memorize every PEP 8 rule but to internalize the principle: consistent code is readable code, and readable code has fewer bugs.
© 2026 EngineersOfAI. All rights reserved.