| name | clean-python-functions |
| description | Use when writing, fixing, editing, or refactoring Python functions with too many parameters, boolean flags, parameter mutation, deep nesting, mixed abstraction levels, complex conditionals, hidden side effects, dead helpers, unused public functions, mutable defaults, or unclear call sites. |
Clean Python Functions
F1: Too Many Arguments
More than 3 arguments is a design smell. Python keyword arguments help call-site readability, but they do not fix a function that accepts several facts that belong together.
def create_user(name: str, email: str, age: int, country: str, timezone: str, language: str, newsletter: bool) -> User:
...
@dataclass(frozen=True)
class UserData:
name: str
email: str
age: int
country: str
timezone: str
language: str
newsletter: bool
def create_user(data: UserData) -> User:
...
Do not wrap a stable framework callback or public API signature just to satisfy the count. Do extract a parameter object when arguments form a concept or callers cannot read the meaning at a glance.
F2: No Output Arguments
Don't modify arguments as side effects. Return values instead.
@dataclass(frozen=True)
class Report:
content: str
def append_footer(report: dict[str, str]) -> None:
report["content"] += "\n---\nGenerated by System"
def with_footer(report: Report) -> Report:
return replace(report, content=f"{report.content}\n---\nGenerated by System")
Mutation is acceptable for explicit collection APIs, builders, or performance-critical code when the name and contract make it obvious.
F3: No Flag Arguments
Boolean flags mean your function does at least two things.
def render(is_test: bool) -> Page:
if is_test:
return render_test_page()
return render_production_page()
def render_test_page() -> Page:
...
def render_production_page() -> Page:
...
Boolean state is fine when it is the domain value being set or returned. It becomes a flag argument when it selects different behavior inside the function.
F4: Delete Dead Functions
If it's not called, delete it. No "just in case" code. Git preserves history.
F5: Reduce Nesting
Aim for at most one or two nested levels in control flow or iterations. Use guard clauses, helper functions, generator expressions, extraction, or a real refactor when nesting makes intent hard to scan.
def get_invoice_total(invoice: Invoice) -> Money:
if invoice.status != "void":
if invoice.items:
return sum(item.price for item in invoice.items)
return Money.zero()
def get_invoice_total(invoice: Invoice) -> Money:
if invoice.status == "void":
return Money.zero()
if not invoice.items:
return Money.zero()
return sum(item.price for item in invoice.items)
F6: Keep One Level Of Abstraction Per Function
A function should not mix high-level policy with low-level details. Extract the details or inline the abstraction so every line reads at the same conceptual level.
def activate_user(raw_user: str, storage: Storage) -> None:
user_id, email = raw_user.split(",")
if "@" not in email:
raise ValueError("Invalid email")
storage.set(f"user:{user_id}", json.dumps({"id": user_id, "email": email, "active": True}))
def activate_user(raw_user: str, storage: UserStorage) -> None:
user = parse_user(raw_user)
assert_can_activate(user)
storage.save(activate(user))
F7: Name And Simplify Complex Conditions
Extract complex conditions into named predicates when the condition represents a domain idea. Use De Morgan's laws when a negated compound condition is harder to read than the equivalent positive form.
is_privileged_user = user.role in {"admin", "owner"}
if not (is_privileged_user or user.has_billing_access):
return False
if not is_privileged_user and not user.has_billing_access:
return False
Prefer positive condition names such as can_manage_account or is_eligible_for_retry. Avoid names like is_not_invalid unless the domain already uses that wording.
F8: Separate Commands From Queries
A function should usually either answer a question or change state, not both. If a read also creates, saves, logs, caches, navigates, or mutates, make that behavior explicit in the name or split the operation.
def get_session(user_id: str) -> Session:
return sessions.get(user_id) or create_session(user_id)
def get_or_create_session(user_id: str) -> Session:
return sessions.get(user_id) or create_session(user_id)
F9: Keep Side Effects Explicit And Isolated
Prefer pure transforms for domain calculations. When a function must touch I/O, time, randomness, storage, logging, global state, or mutation, keep that effect near a boundary or make it obvious at the call site.
def calculate_invoice_total(invoice: Invoice) -> Money:
audit_log.write("invoice-total-calculated")
return sum_invoice_lines(invoice.lines)
def calculate_invoice_total(invoice: Invoice) -> Money:
return sum_invoice_lines(invoice.lines)
PY4: Avoid Mutable Default Arguments
Mutable defaults are shared across calls. Use None and allocate inside the function.
def collect_errors(error: str, errors: list[str] = []) -> list[str]:
errors.append(error)
return errors
def collect_errors(error: str, errors: list[str] | None = None) -> list[str]:
collected_errors = [] if errors is None else list(errors)
collected_errors.append(error)
return collected_errors
This is the function-level form of PY4. Hidden module-level shared state belongs in module review, and shared state crossing await boundaries should be reported as A3 first.