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
pycodestyleandflake8and read their output - How to configure PEP 8 rules in
pyproject.tomland.flake8 - The
# noqaescape 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:
| Limit | Who uses it |
|---|---|
| 79 chars | PEP 8 default, some government/legacy projects |
| 88 chars | Black formatter default (the most common modern choice) |
| 99-100 chars | Django, NumPy, many large open-source projects |
| 120 chars | Some 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:
- Standard library imports
- Third-party library imports
- 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:
| Rule | PEP 8 says | Community practice (2024) |
|---|---|---|
| Line length | 79 chars | 88 chars (Black default) or 99/100 |
| String quotes | Either ' or ", be consistent | Black normalizes to " double quotes |
| Import sorting | Manually sort | Use isort (automated) |
| Formatting | Manual | Use Black (automated, non-negotiable on most teams) |
| Type hints | Optional, PEP 484 compliant | Expected on all public APIs |
| f-strings | Not 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
| Code | Category | Meaning |
|---|---|---|
| E1xx | Indentation | Wrong number of spaces |
| E2xx | Whitespace | Wrong whitespace around operators |
| E3xx | Blank lines | Wrong number of blank lines |
| E4xx | Imports | Import style violations |
| E5xx | Line length | Line too long |
| E7xx | Statement | Multiple statements on one line |
| W1xx-W6xx | Warnings | Trailing whitespace, deprecated features |
| F401 | pyflakes | Imported but unused |
| F821 | pyflakes | Undefined name |
| F841 | pyflakes | Local variable assigned but never used |
| C901 | mccabe | Function 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 lengthextend-ignore = E203, W503- these conflict with Black's formatting; ignore themexclude- directories flake8 should never checkper-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:
- A URL or other string that genuinely cannot be broken without affecting meaning
- An import that exists for side effects (not actually unused)
- Generated code that tools should not touch
- 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:
- As a shortcut to avoid fixing real problems
- On every line in a file (the file should be fixed)
- Without a specific code (
# noqaignores everything;# noqa: E501is 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:
- Run flake8 before reading the code - let the tool find the mechanical violations so you can focus on logic
- Link to the specific PEP 8 rule when commenting - "E711: comparison to None should use
is not None" is more educational than "wrong" - Don't bikeshed on things Black would fix - if the team uses Black, formatting comments are noise; let the tool handle them
- 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-sysimported but unused (assumeosis 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 namecalculatorviolates 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 beis 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:
- Sets line length to 88
- Ignores E203 and W503 (Black-incompatible rules)
- Ignores F401 in
__init__.pyfiles (where re-exporting is intentional) - Excludes migrations, build, and .venv directories
- 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
| Rule | PEP 8 spec | Modern practice |
|---|---|---|
| Indentation | 4 spaces | 4 spaces (enforced by Black) |
| Line length | 79 chars | 88 chars (Black default) |
| Between top-level defs | 2 blank lines | 2 blank lines |
| Between methods | 1 blank line | 1 blank line |
| Imports | stdlib / third-party / local | Same, enforced by isort |
| Wildcard imports | Forbidden | Forbidden |
| String quotes | Consistent | " double quotes (Black) |
== None | E711 violation | Use is None |
== True | E712 violation | Use truthy directly |
Bare except: | E722 violation | Catch specific exceptions |
# noqa | Escape hatch | Use with specific code only |
| Type annotations | Optional (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 Noneand== 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.
flake8combines style checking with real bug detection - F-series codes (undefined names, unused imports) often indicate actual defects, not just style issues.- The
# noqacomment should always include a specific code and a brief justification; bare# noqais a code smell. - Configure flake8 with
max-line-length = 88andextend-ignore = E203, W503to 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.
