Personal Programming Guidelines

“The secret of good writing is to strip every sentence to its cleanest components.”

— William Zinsser, On Writing Well

Programmers are known to often obsess over what is and is not "good code." Google is famous for its Readability process, which is seen as a role model but has some critics even inside the company (see also Software Engineering at Google and Readability: Google's Temple to Engineering Excellence). I have seen disagreements on best practices lead to lengthy, exhausting discussions in meetings or during code review, delaying important work without leading anywhere. I have also seen some colleagues, for these reasons, develop the reactionary view that insisting on clean code and enforcing a lot of software patterns are impractical for real-world projects and something that most enterprises outside Big Tech cannot afford. I recognise that an over-obsession with coding standards can be detrimental to project success, and that the requirements for code quality and maintainability should take the expected scope and lifetime of the code (one-off notebook vs. PoC vs. core infrastructure) into account. But I'm not a big fan of the "we'll fix it later" mentality.

In my opinion, there is real business value in enforcing a high level of code quality, even for small companies. The trick is to make the coding standard very explicit by having a detailed code style guide, and then build a process that enforces this style guide for all code that has a realistic chance of ending up in production. It is also very important to onboard each developer on this style guide, explaining the reasoning and making sure that the team remains in agreement, both on the importance and the contents of the style guide, throughout a project's lifetime. This adds an overhead, but it saves time and costs in the long run.

I know that some developers don't want to have a formal coding style guide. They argue that good code depends on the context, and that having a list of blanket statements doesn't take this into account. I disagree. A lot of stylistic decisions can be made universally. Having an explicit coding style is mainly not about making the best trade-off on each facet; it's about reducing mental load. Every stylistic rule corresponds to a trade-off or decision. Making decisions takes mental energy. Style guides make the process of writing and reviewing code more efficient because they reduce the number of decisions that need to be made. If there were only one way to do something, it would be easier to understand why something was built the way it is. On the other hand, a good style guide creates a tension that forces the developer to actively think about aspects like coupling, encapsulation, interfaces, and the direction of dependencies. A detailed style guide makes code reviews more efficient because it reduces the risk that a single comment leads to a tedious discussion on clean code. If the style guide is enforced by each developer and supported by tooling, it makes reviewing code cognitively less demanding, because the reviewer does not need to check all the points described by the style guide and can focus on the actual change in functionality. To achieve this, the team must commit to a style guide. This means that in an active pull request, the current version of the style guide is treated as given. Any discussion or objections to the rule can still be done by opening a separate issue or discussion on the style guide itself, but as long as no decision is made, the current version still applies and the pull request is not blocked.

Especially in the age of coding assistants, explicit style guides become more important, because code is written by low-context agents. At the same time, it has become easier to enforce even very detailed style guides: on one hand through advancements in linters, on the other hand through coding agents themselves, as a machine-readable style guide can be repeated or referenced by suitable instruction or skill files. While it makes sense to have at least a basic style guide that applies to the whole team or even company, having a project-specific style guide (that might inherit from the global guide) can have the further advantage of documenting specific trade-offs made in the project. Every project should declare its style guide explicitly and reference it in the project README. Ideally, the full style guide is stored as a single markdown file in the codebase, which makes it easier to use in agentic workflows and serves as a constant reminder. If the style guide is kept in a separate place, it makes sense to version it so that a repo can make clear which version of the guide it adheres to.

But for starters, remember that "perfect is the enemy of good". It can be absolutely sufficient to put a single line such as "This project follows the Google Python Style guide." in the README. Then in the next step, document project-specific additions or deviations, and grow the style guide from there.

In the following, I have included my personal style guide. Since I have mostly worked in Python projects so far, it is specific to Python, but a few of the rules are also applicable to other programming languages. For ease of reference, every rule has an identifier. I also inlined all the rules that are taken verbatim from the Google Python Style Guide so that the style guide is self-contained. On the other hand, I left out some rules that are trivially covered by basic linting or IDE settings, such as rules on indentation, whitespace, sorted imports, and so on.

The rules are grouped into 3 categories: architectural guidelines, recommendations, and rules. Architectural Guidelines are high-level best practices that require some experience to implement. Recommendations are less hard rules than "lessons learned" or personal taste. I'm much less dogmatic about these, but have found that they can often guide me to better code. In contrast, Rules are restrictions on how code should look, sufficiently precise that many of them could probably be enforced by linters.

A - Architecture Guidelines

A1 - Prefer deep modules with compact interfaces

Rule: Prefer deep modules with compact interfaces.

Reasoning: Modules should hide complexity, making it easier to reason about their interaction even in complex codebases. They should only expose the functionality that is actually used by other parts of the code, and do that in the simplest way possible.

A2 - Keep modules loosely coupled

Rule: Modules should be loosely coupled. Dependencies should be constrained to the surface of each module if possible. Write adapters instead of reusing an external module's data types deep within another module.

Reasoning: The best coupling is no coupling. If modules share a lot of common functionality and common data structures, change in one module can affect many other modules. This means that changes in one part of the code require changes in unrelated parts of the code, making the overall codebase harder to maintain. Code should only be shared between components if it actually corresponds to shared logic.

A3 - Use namespaces for layers

Rule: Separate the various layers of your application (domain, persistence, application, presentation, ...) into separate, loosely coupled namespaces.

Reasoning: The structure of the application should be represented by the structure of the codebase. In particular, if the codebase uses a layered architecture, these layers should correspond to namespaces that collect all modules in each layer. This makes it easy to catch violations in the layer direction (e.g., if a domain function imports code from the application layer).

A4 - Organise layers into vertically integrated sub-modules

Rule: Within a single layer, modules should contain all their components.

Reasoning: Related code should be close together. Structuring code by logical units instead of object types leads to tighter interfaces and makes internal refactoring easier.

Example:

# BAD
app/
  - service/
     - __init__.py          # Everyone who wants to use OrderService needs to import all of `service`.
     - _order_service.py
     - _message_service.py
     - _order_utils.py      # This is only used by _order_service.py and shouldn't be depended on by other modules.
  - schemas
     - __init__.py
     - _internal_order.py
     - _order.py
     - _message.py

# GOOD
app/
  - ordering
     - __init__.py
     - _internal_order.py
     - _order.py
     - _order_service.py
  - messaging
     - __init__.py
     - _message.py
     - _message_service.py

B - Recommendations

B1 - Use Semantic Versioning

Rule: Use Semantic Versioning with the schema "MAJOR.MINOR.PATCH" or "MAJOR.MINOR.PATCH+build". Be generous with version 1.0.0 and increment the version often. The version is increased on releases, the build number is increased on every pull request.

Reasoning: Semantic versioning is an industry standard, and most developers have an intuition for how to read it. Personally, I prefer not to increment the version on every code change and to increment only on releases, because this makes it much easier to understand changes from release to release. Since it's still useful to be able to identify exactly which state of the code is deployed, it's valuable to use an additional build number that gets incremented automatically on every PR.

B2 - Keep a changelog

Rule: Use a CHANGELOG.md file in the project root. A git log is not sufficient. See keep a changelog. Use tools like Towncrier to make the process seamless and avoid merge conflicts.

Reasoning: Changelogs are extremely useful when debugging problems in a new release. While the git history already provides something similar, it is often too granular and doesn't convey the developer's intent, especially if the commit discipline is not that high. Also, the changelog entry can serve as a PR description.

B3 - Recommended project structure

For a pure Python project, I recommend the source layout

- data/
- scripts/
- src/
  - namespace/
    - package1/
        - __init__.py
    - package2/
        - __init__.py
- tests/
  - e2e/
  - integration/
  - unit/
- .gitignore
- README.md
- CHANGELOG.md
- mise.toml
- pyproject.toml
- uv.lock
...

The main folders are:

  • data: This should store any data files used by the program. Exceptions are data used by tests (these should go into a _data folder next to the dependent test), data used by scripts (these should go into scripts/_data), and things like LLM prompts.
  • scripts: This folder contains all scripts that are not part of the source code. A typical structure looks like this: shell scripts/ |-- examples/ |-- _data/ |-- example1.py |-- notebooks/ |-- _data/ |-- notebook1.ipynb
  • src: This contains the source code. For larger projects, I typically use a namespace layout: shell src/ |-- namespace/ # no init here |-- package/ |-- __init__.py |-- ... The namespace folder collects loosely connected sub-packages that can be independently installed, used, and versioned. In most cases, however, it only contains a single package that corresponds to the main application.
  • tests: This contains tests that are part of the automated test suite. Other tests such as longer acceptance tests or extensive demos should go into scripts/. The folder tests should mirror the folder src one-to-one: tests/ |-- __init__.py |-- namespace/ |-- __init__.py |-- project/ |-- __init__.py |-- test_module.py For more mature projects, it often becomes necessary to distinguish between unit, integration, and end-to-end tests. In that case, the layout of the tests folder might look as follows: shell tests/ |-- e2e/ |-- e2e_test1.py |-- integration/ |-- integration_test1.py |-- integration_test2.py |-- unit/ |-- namespace/ |-- package/ |-- __init__.py |-- test_module.py

The different components of a layer should be organised into separate modules.

For organising modules, there are basically two main styles:

Style 1 (File Modules): Here, modules correspond to files:

layer/
|-- __init__.py
|-- module1.py
|-- module2.py

These files can typically be rather large and contain many classes and functions, usually grouped together (e.g., one file services.py, one file models.py).

Style 2 (Package modules): Modules correspond to folders with many small, internal files, often containing only a single class or function:

layer/
|-- __init__.py
|-- module1/
    |-- __init__.py
    |-- _some_function.py
    |-- _some_class.py
|-- module2/
    |-- __init__.py
    |-- _some_other_function.py
    |-- _some_other_class.py

The public interface of a module is then declared using the __all__ attribute in the __init__.py. All internal modules should be prefixed with an underscore. See also the corresponding section of PEP 8.

Here, the underlying philosophy is that "files are implementation details". Also, it makes it easier to abstract away details: A useful pattern is to put the main functionality of a module in a _main.py, and move auxiliary/internal functions into a _utils.py (or even an _internal/ subfolder). This way, a team member knows to look first in the _main.py when trying to get a high-level understanding of your module.

I recommend using style 2: I have found that this way of structuring files is more scalable and easier to maintain in the long run. It's more modular and allows for easier refactoring, but does require more discipline in naming and organisation.

B4 - Use uv for environment management

Rule: Use uv for environment management.

Reasoning: Among existing Python dependency management tools (pip, conda, poetry), uv is currently the fastest and is quickly becoming industry standard.

B5 - Use ruff for linting

Rule: Use ruff for linting. Strive for as many active rules as feasible.

Reasoning: Similar to uv, ruff is superior to older linters (black, flake8) both in terms of speed and customisability. It is recognised as industry standard.

B6 - Use pyrefly for type checking

Rule: Use pyrefly for type checking. Strive for the strictest settings.

Reasoning: See How we chose Positron’s Python type checker. See also Comparing Pyrefly with Ty for a more neutral comparison.

B7 - Prefer the Python standard library over custom code or extra dependencies

Rule: Do not write custom code or add external dependencies if the same behaviour can be achieved using the Python standard library (in particular collections, dataclasses, functools, itertools).

Reasoning: The Python standard library contains well-engineered modules that are familiar to most programmers and extremely well tested. Creating custom functions or adding dependencies should only be considered after you have checked that there is no standard library component that provides the desired functionality out of the box.

Example:

# task: flatten nested list

# Don't: Custom implementation
flat = []
for row in rows:
    for x in row:
        flat.append(x)

#  Do: use itertools
import itertools

flat = list(itertools.chain.from_iterable(rows))

B8 - Avoid functions with too many arguments

Rule: Don't create functions with more than 5 arguments.

Reasoning: Functions with many arguments can be hard to understand and lead to long function call statements. Too many arguments are typically an indication that the function violates the single responsibility principle. Refactor the function into smaller focused functions and/or create suitable data objects to organise the input.

B9 - Do not use default values in internal code

Rule: Avoid default values for any non-user facing code.

Reasoning: Default values can quickly lead to errors during refactoring. Since it's easy to forget to set the default arguments, more care has to be taken when using a function with many default values. Default values should only be reserved for user-facing APIs or libraries where the definition of the default behaviour is an explicit part of the interface. In functions that are only used internally, the "default" behaviour should instead be explicitly called, e.g., by setting an argument to None.

B10 - Add dependencies sparingly

Rule: Treat every dependency as a liability. Before you add a new dependency, first check if the needed functionality is already provided by existing dependencies or the Python standard library. Only use popular libraries or libraries from personally trusted sources.

Reasoning: Every dependency added to a project can lead to dependency conflicts with existing libraries, and due to potential conflicts makes it harder to update existing libraries in the future. If your project is a library, it also increases the chance of dependency conflicts for your users. Finally, every dependency can introduce security risks into your codebase. Therefore, the addition of a dependency should always be a conscious choice, and it sometimes can make sense to implement a functionality from scratch instead of using a sketchy library.

B11 - Prefer serializable data objects for public APIs

Rule: Public APIs of the main modules should only receive and return serializable data objects, ideally Pydantic models. Avoid Pandas dataframes at all costs. Only use them internally if needed for data manipulation.

Reasoning: Using serializable Pydantic objects as the basic data types for public interfaces leads to much better validation and type transparency. Making data serializable by default allows saving/loading intermediate results at a later stage (or for debugging). It also makes it much easier to turn existing functionality into parts of a web API. Finally, it enables efficient data storage for regression testing. Working with non-serializable data representations (like Pandas dataframes) can be more convenient but should only be done if necessary for performance. In such cases, the dataframe manipulation should happen internally and the data should be converted back to a serializable format before being exposed to the user. The only exception I can think of is for packages which are intended to be used purely as Python runtime libraries.

B12 - Use dataclasses

Rule: Prefer frozen dataclasses over simple classes for data objects.

Reasoning: Immutability is a good thing for data containers, and data classes from the standard library are the easiest way to obtain it.

B13 - Prefer Pydantic dataclasses when Pydantic is already a dependency

Rule: If pydantic is already a dependency, use Pydantic Dataclasses instead of standard library dataclasses.

Reasoning: Pydantic dataclasses provide additional validation functionality that should be used generously for defensive programming. However, if Pydantic is not used elsewhere, this benefit alone might not justify the additional dependency and it is fine to use the dataclass implementation from the standard library. Whenever Pydantic dataclasses are used, make this clear by using the fully qualified decorator @pydantic.dataclasses.dataclass.

Example:

# --- Bad: Validation of obvious constraints is missing.
import dataclasses


@dataclasses.dataclass
class User:
    email: str
    age: int


# --- Better: use Pydantic data class with validation
import pydantic


@pydantic.dataclasses.dataclass
class User:
    email: str
    age: int

    @pydantic.field_validator("age")
    @classmethod
    def age_non_negative(cls, value: int) -> int:
        if value < 0:
            msg = "age must be >= 0"
            raise ValueError(msg)
        return value

    @pydantic.field_validator("email")
    @classmethod
    def email_must_resemble_address(cls, value: str) -> str:
        if "@" not in value:
            msg = "email must look like an address"
            raise ValueError(msg)
        return value

B14 - Do not call the same attribute multiple times in one function

Rule: Instead of calling the same attribute (or even method) of an object multiple times, load it once into a local variable, and then only reference this local variable.

Reasoning: This makes later refactoring and variable renaming much easier, because the dependencies on the data object are isolated at a central position.

Example:

# Don't do this:
x = function1(data.var)
y = function2(data.var)
z = function3(data.var)

# Do this:
var = data.var
x = function1(var)
y = function2(var)
z = function3(var)

B15 - Prefer nested data structures over wide flat ones

Rule: Avoid data structures with a large number of fields. Instead, group related fields together in sub-structures.

Reasoning: This makes the code easier to extend and refactor. Also, it helps satisfy the interface segregation principle, since a function that only needs the variables in SchoolPersonnel does not have to depend on SchoolInterior. Finally, nested data structures help when implementing the Pure Pipeline pattern.

Example:

# Don't do this:
@dataclass
class School:
    teachers
    pupils
    directors
    parents
    rooms
    tables
    chalkboards
    playgrounds


# Do this:
@dataclass
class SchoolPersonnel:
    teachers
    pupils
    directors
    parents

@dataclass
class SchoolInterior:
    rooms
    tables
    chalkboards
    playgrounds

@dataclass
class School:
    personnel: SchoolPersonnel
    interior: SchoolInterior

B16 - A function should be either a "calculation" or an "action"

Rule: Every function should either be a "calculation" (a pure function that acts call-by-value and always returns something, or raises an error) or an "action" (a function without output that acts on its input argument). Prefer calculations and use actions only if there is a good reason for it (such as performance).

Reasoning: It is in general unexpected if a function mutates its arguments or has other side effects. Such behaviour can lead to very subtle bugs and makes code harder to read. However, it can sometimes be necessary or lead to much simpler code. In such a situation, the function name and docstring should clearly indicate that the function mutates its input arguments, and the two behaviour types should never be mixed.

Example:

# Bad: Function mutates input argument and returns result.
def discounted_price(price: float, pct: float, promotions: list[float]) -> float:
    result = price * (1 - pct / 100)
    promotions.append(result)
    return result

applied = []
charge = discounted_price(100.0, 10.0, applied)


# Good: Split calculation and action into separate functions. Clear logic flow.
def discounted_price(price: float, pct: float) -> float:
    return price * (1 - pct / 100)

def record_promotion_price(promotions: list[float], price: float) -> None:
    promotions.append(price)

applied = []
charge = discounted_price(100.0, 10.0)
record_promotion_price(applied, charge)

B17 - Do not use a class where a function or namespace suffices

Rule: Use classes only if you need the features of a Python class, e.g., if you need to manage state or define an interface. If your class can be rewritten as a collection of functions, use a namespace instead.

Reasoning: Classes should not be misused to keep related functions together. Using a module with top-level functions is the better solution, both in terms of testability and reuse.

B18 - Avoid "impure pipelines"

Rule: If your code consists of a sequence of steps, where each step is represented by a pure function, each function should only depend on the result of the previous step.

Example:

# DON'T:
result_1 = first_step(data)
result_2 = second_step(data, result_1)
result_3 = third_step(data, result_1)
result_4 = fourth_step(data, result_2, result_3)

# DO:
result_1 = first_step(data)
result_2 = second_step(result_1)
result_3 = third_step(result_2)
result_4 = fourth_step(result_2, result_3)

B19 - Default to dicts for child objects

Rule: Default to dicts over lists to store data objects in the parent object. Use a list only if there are strong reasons to prefer it over a dict.

Reasoning: When designing structured data objects, one often has a degree of freedom on how to store child objects in the parent structure. In the past, I often defaulted to using a simple list, because it might be the most natural choice. However, using a dict has multiple advantages. It allows for more efficient search without reducing the performance of iteration. Also, the dict key shows every reader explicitly what the suitable identifier for the child items is. For lists, this has to be inferred from field names, and there is no guarantee of uniqueness without additional validation logic. Finally, having the identifier as dict key means it can be removed from the child item, turning it into a proper value object. In summary, one should always default to dicts if there are no strong reasons for using a list (such as ordering).

Example:

# DON'T:

class ChildItem:
    id: str
    name: str
    value: float

class ParentItem:
    description: str
    children: list[ChildItem]


# DO:

class ChildItem:    # pure value object, ID field would be redundant to dict key.
    name: str
    value: float

class ParentItem:
    description: str
    children: dict[str, ChildItem]

C - Rules

C1 - Default to PEP 8

Rule: Adhere to PEP 8 if not specified otherwise.

Reasoning: PEP 8 encodes idiomatic Python. Having code that deviates from PEP 8 will look weird to other developers and might lead to misunderstandings that make it difficult to understand code and harder to find bugs (e.g., when starting a function name with upper-case letters).

C2 - Use a maximum line length of 88

Rule: Prefer a maximum line length of 88 characters where feasible (necessary exceptions are long imports, URLs etc.). Break lines using implicit continuation inside parentheses, brackets, or braces — not backslash continuations outside string literals.

Reasoning: For me, the perfect line length is the maximum possible length that still allows you to view two files side-by-side on most modern screens. The Google Style Guide (3.2 Line length) recommends 80 characters, this feels a bit short for modern screens. In my experience, using about 88 characters per line is the sweet spot (this is also the ruff default).

C3 - Use short names and follow the idiomatic Python naming conventions

Rule: Follow the Python naming conventions where possible and avoid overly verbose function and variable names.

Reasoning: By default, I try to follow the guide on name format (§ GPSG 3.16). However, I sometimes find myself straying from these rules for the sake of library conformity (for example, using from pyspark.sql import functions as F even though upper-case letters are reserved for classes). Regarding the actual names, I'm not a fan of the very verbose the_function_name_is_an_exact_description_of_what_the_function_does naming style because I find these long function and variable names clutter the code, and the docstring is only one hover away. Personally, I strive for function names that are unique in the codebase and hint at what the function does, but I think it's okay, for example, to use abbreviations in function and variable names.

C4 - Mark internal functions and classes using a single leading underscore

Rule: Every internal (i.e., not used outside the module) function, class and constant should be marked with a single leading underscore (_internal_function).

Reasoning: When reading and reviewing code, the first thing one needs to understand is which parts of a module make up its public interface and which parts are implementation details that are not used by other modules in the codebase. Marking internal module members by a leading underscore is a common best practice that is also explicitly mentioned in PEP 8.

C5 - Every module must declare its interface through __all__

Rule: Every Python module must explicitly declare its public interface by setting the __all__ field (either in the __init__.py if it's a package module or, if it's a module file, at the top of the file directly below the import section). The public interface is all module members that are used outside of the module, except for tests.

Reasoning: To ensure clean encapsulation, any module should explicitly define what is part of its public interface. This makes it much easier to understand which part of the code is used where, and to keep a clean and intentional dependency structure. It also avoids other developers unintentionally adding dependencies on module internals. If you are using the recommended project structure, every public module will set the __all__ field in its __init__.py. Before AI tools, I would've enforced this only for public modules and skipped it for internal modules, but now it doesn't add much extra work. And for long internal modules, it is very handy when navigating code if every file explicitly states what it exposes to the rest of the codebase.

C6 - Never use wildcard imports

Rule: Never use wildcard imports.

Reasoning: Modules should only import code that they use. Wildcard imports make it unclear which names are present in the current namespace, confusing human readers and automated tools. They can also lead to unexpected name collisions.

Example:

from package import *   # NO
import package          # YES

C7 - Never use relative imports

Rule: Don't use relative imports, always specify the full module path.

Reasoning: Relative imports can make refactorings difficult. Explicit import paths can lead to longer import statements, but make it easier to find modules in the package. See also 2.3 Packages.

Example:

from .module import function    # NO
import project.module           # YES
from project import module      # Also YES

C8 - Do not import external modules' internal members

Rule: No code outside of a module should depend on that module's internal objects. In particular, this means that importing internal modules, functions or classes is not allowed. Exception: Unit tests are allowed to call internal functions of a module. Code outside of a module should only depend on its explicitly declared API.

Reasoning: Internal members are implementation details that can change without notice, so depending on them breaks encapsulation and makes subsequent changes harder. A module should always be free to change its own implementation details.

Example:

from module._internal import function   # NO
from module import _internal_function   # ALSO NO

C9 - Do not directly import module members

Rule: Do not import objects directly, import the parent module instead. Exemptions from this rule:

  • from typing import x, y, z is okay.
  • Similarly for collections.abc, typing_extensions.

Reasoning: While importing module members directly is often seen (and suggested by Copilot), it has many disadvantages: It clutters code because it leads to very long import lists, it makes it more difficult to distinguish between custom and imported code, and it makes refactoring harder. The cost of enforcing this rule is low since it can be checked automatically using pylint-google-style-guide-imports-enforcing as a pre-commit hook. See also 2.2 Imports.

Example:

Don't:

from package.module import function     # NO

function()

Do:

from package import module

module.function()

C10 - Avoid unconventional module aliasing

Rule: Use from package import module as new_name only in the following cases:

  • Two modules named module are to be imported.
  • module conflicts with a top-level name defined in the current module.
  • module conflicts with a common parameter name that is part of the public API (e.g., features).
  • module is an inconveniently long name.
  • module is too generic in the context of your code (e.g., from storage.file_system import options as fs_options).
  • new_name is any of these standard abbreviations:
    • import datetime as dt
    • import numpy as np
    • import pandas as pd
    • import matplotlib.pyplot as plt
    • import multiprocessing as mp
    • import seaborn as sns
    • import tensorflow as tf
    • from pyspark.sql import functions as F, from pyspark.sql import types as T
    • from jax import numpy as jnp
    • import sqlalchemy as sa

Reasoning: While module aliasing can be helpful -- especially when enforcing the preceding rule -- it can also be very confusing if the reader expects a different abbreviation. It should only be used when it increases clarity or solves a name clash. Use abbreviations of external libraries only sparingly, if in doubt assume that the reader will not be familiar with an abbreviation.

Example:

import package.module as module # NO
import package.module as mod    # YES

import pathlib as pl            # you might think this is safe...
import pytorch_lightning as pl  # ...but what now?

C11 - Use built-in decorators when applicable

Rule: Use at least these decorators where applicable: @abstractmethod, @property.

Reasoning: Built-in decorators are useful to convey the behaviour of functions and can help avoid writing boilerplate code (such as getters and setters).

C12 - Avoid static methods and class methods

Rule: Never use @staticmethod. Write a module-level function instead. In general, avoid the anti-pattern of using classes to collect related functions.

Reasoning: Static methods are often part of the god-class or class-as-namespace antipattern. Creating a function as a static method instead of a module-level function hinders testability and reuse, as any user of that method needs to explicitly construct an instance of the class.

Example:

# Don't:
class Text:
    @staticmethod
    def slug(s: str) -> str:
        return "-".join(s.lower().split())

# Do:
def slug(s: str) -> str:
    return "-".join(s.lower().split())

C13 - Never use mutable defaults

Rule: Never use mutable defaults or values computed at import time in ways that confuse callers (for example, time.time() as a default). An empty tuple default is acceptable; do not use a mutable Mapping literal such as {} as a default, even when type-annotated.

Reasoning: ... See also Google Style Guide: 2.12 Default Argument Values.

Example:

# This is bad:
def function(some_list: list = []):
    ...

# Do this instead:
def function(some_list: list | None = None):
    if some_list is None:
        some_list = []
    ...

C14 - Use exceptions instead of None for invalid input

Rule: If a function receives invalid input, do not return None. Raise exceptions instead.

Reasoning: A consumer of your function might not expect that it returns None and might miss handling this case correctly, leading to unexpected behaviour in the code or unanticipated bugs. It is often better if functions raise errors on edge cases instead of using fallbacks, so that the referencing code is forced to deal with these edge cases explicitly.

Example:

# Bad:
def real_square_root(x: float) -> float | None:
    if x >= 0:
        return math.sqrt(x)
    else:
        return None

# Better:
def real_square_root(x: float) -> float:
    if x >= 0:
        return math.sqrt(x)
    else:
        raise ValueError(f"{x} is not a positive number.")

C15 - Avoid returning long unnamed tuples

Rule: Don't return tuples with more than 3 elements. Return objects, or at least named tuples.

Reasoning: Positional indexing should always be avoided. It's also harder to use functions that return long tuples because it requires looking into the docstring (or worse, implementation) of the function to understand the return values, and it makes code harder to read.

Example:

import dataclasses
import pathlib

# DON'T:

def load_config(path: pathlib.Path) -> tuple[int, str, str, str, bool]:
    return 5432, "db.example.com", "app", "secret", True


# DO:

@dataclasses.dataclass
class Config:
    port: int
    host: str
    user: str
    password: str
    tls: bool


def load_config(path: pathlib.Path) -> Config:
    return Config(5432, "db.example.com", "app", "secret", True)

C16 - Do not use assert statements in productive code

Rule: Do not use assert statements in productive code, except for verifying contracts.

Reasoning: Do not use asserts to validate arguments, catch errors or exceptions. Assert statements should only be used in tests, to verify post-conditions for the contract of a function or class, or to assist type checkers such as mypy.

Example:

def split_to_words(string: str) -> list[str]:
    assert isinstance(string, str)  # No! Use 'raise TypeError' instead.
    words = string.split()
    for w in words:
        assert not " " in w     # Okay. Verifies the post-condition.
    return words

C17 - Always use type hints

Rule: Use type hints for all function arguments and outputs. Type hint all class and instance attributes using instance variable annotations.

Reasoning: Complete type hints are a major improvement to readability, since the reader doesn't have to infer input and output types from the context. They also enable static type checking which can catch bugs early and tremendously helps in debugging.

Example:

C18 - Avoid the use of typing.Any

Rule: Never use typing.Any to avoid defining the correct type. Only use it as a type hint if the variable can really take any type.

Reasoning: Using Any as a type hint destroys all of the advantages of proper type hints. The typing module allows to correctly type even very broad or complex types. Code where variables can truly take Any value should be very rare.

Example:

# Bad: Use a suitable union type instead to indicate what `user_ids` can be.
def fetch_users(user_ids: Any) -> list[User]:
    ...

# Valid use of 'Any': A decorator that wraps functions with arbitrary signatures and return types
def retry(func: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        ...
        return func(*args, **kwargs)
    return wrapper

C19 - Use instance variable annotations in the class body

Rule: Always use instance variable annotations in the class body PEP 526.

Reasoning: Without explicit annotation, inferring the type of an instance attribute used in a method requires jumping back to the constructor, and can be very tricky if the instantiation contains complex logic.

Example:

class Person:
    name: str
    age: int

    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age

C20 - Type hint class variables with ClassVar

Rule: Type hint class variables using ClassVar.

Reasoning: Class variables should be explicitly declared as such and also type-hinted accordingly.

Example:

class Widget:
    registry: ClassVar[list[str]] = []
    name: str   # instance attribute

    def __init__(self, name: str) -> None:
        self.name = name
        Widget.registry.append(name)

C21 - Do not repeat type-hint information in docstrings

Rule: Don't repeat information in the docstring that is expressed by type hints.

Reasoning: Since type information should be part of the type hints, this information doesn't need to be repeated in the docstrings, since it can only get out-of-sync, clutter the docstrings, and tends to make docstrings more descriptive (where an argument's docstring only explains its type, but not how it is used by the function).

Example:

# Bad: Type hint information is repeated in the docstrings.
def add(a: int, b: int) -> int:
    """Return the sum of a and b.

    Args:
        a (int): The first summand.
        b (int): The second summand.

    Returns:
        int: The sum of ``a`` and ``b``.
    """
    return a + b

C22 - Type-hint NumPy arrays with np.ndarray

Rule: Type hint numpy arrays with np.ndarray (np.array is a function, not a class). If you want to type hint the data type as well, use numpy.typing.NDArray with a NumPy scalar type, like numpy.typing.NDArray[np.int64] or numpy.typing.NDArray[np.float64].

Reasoning: This one is more of a personal note than a recommendation, but it took me some time to find the idiomatic way to type hint numpy arrays, which is why I wanted to document it here. Note that NDArray expects a NumPy scalar type (like np.int64), not a Python built-in type like int.

Example:

import numpy as np
import numpy.typing as npt


# basic type hinting with numpy arrays
def foo(arr: np.ndarary) -> np.ndarray:
    return arr


# type hinting the data type as well
def foo(arr: npt.NDArray[np.int64]) -> npt.NDArray[np.int64]:
    return arr

C23 - Give every function, class, and module a full docstring

Rule: Every function, class, and module must have a Google Style docstring. For nontrivial functions, the docstring must at least contain the "Args", "Returns", and "Raises" sections (if applicable). For classes, all public attributes except properties must be documented in the "Attributes" section.

Reasoning: By the guiding principle of encapsulation, the observable behaviour of a function or class should be documented so that it can be used having to read and understand the implementation. Docstrings should therefore fully describe the input-output behaviour of each function and class. While I earlier found it okay to skip docstrings for simple internal functions, nowadays AI coding assistants allow to semi-automate this task. To avoid the cognitive load of deciding whether a function is complex enough to deserve a docstring, I therefore simply require docstrings for everything.

C24 - Avoid inline comments

Rule: Avoid inline comments. Only use them to add context that cannot be inferred from reading the code.

Reasoning: Inline comments are a code smell and can often be avoided by using better variable names or separating logic into clearly named functions. Inline comments clutter the code, making it harder to navigate and become outdated quickly. Even if a line of code takes a bit of time to analyse (like a complicated join operation), simply restating the operation is not useful. Use inline comments only if it would not suffice to ask Copilot "what does this line do?", for example because

  • the code uses very obscure syntax or is a workaround against some limitation;
  • there is some additional context not obvious from the code;
  • it uses some property of the data that is not obvious from the surrounding code;
  • there is a more obvious way to do the same, but the current alternative implementation was chosen due to performance reasons, subtle behaviour, etc.

C25 - Strive for small single-purpose functions.

Rule: If a function exceeds 40 lines, consider breaking it up, if this can be done meaningfully.

Reasoning: While the Google Style Guide recommends splitting functions that exceed 40 lines, and other sources give the rule of thumb "split a function if it doesn't fit on the screen," I don't think that long functions are bad per se, and the decision to split a function should not be dictated by arbitrary line counts. It is often natural to try to implement a complete algorithm in a single function. You have everything in one place and don't have to recursively explore many auxiliary functions. That being said, a piece of code should be split off into a separate function if it is used more than once, or if it is so complex that it should be tested. Also, if you feel the need to add inline comments that explain what a complex piece of code does, I recommend turning that code into a function and putting the comment in the function's docstring.

C26 - Avoid non-obvious abbreviations

Rule: Introduce abbreviations to shorten overly long names, but only if they are common. Examples: properties -> props, attribute -> attr, variable -> var, calculate -> calc, compute -> comp. If abbreviations are used, they should be used consistently. You can also use abbreviations from the business domain (e.g., NPV for "net present value" if you are writing a financial application).

Reasoning: Long object names can make code harder to parse, but abbreviations can hide the meaning of a function and can be very hard to decode for readers who are missing some of the original context.

Example:

# Bad: "fmt_dt" could mean format date, datetime, or data
fmt_dt: str

# Bad: "mgr" might be manager, merger, or an internal role; with "cfg" the reader still guesses the pairing
mgr_cfg: dict[str, str]

# Good: Standard abbreviations
user_id: str
max_len: int
props: dict[str, str] 

C27 - Never use print logging

Rule: Never use print logging, instead use a dedicated logging library such as loguru. A print statement should only appear in code where it is part of the intended functionality, e.g., in a CLI application.

Reasoning: Logging should always be done using suitable logging functionalities or telemetry frameworks that allow different log levels and sophisticated tracing. The print statement should only be used if console output is a feature of the application, such as in a CLI.

Example:

# Bad: Using print for logging statements.
def process_file(path: pathlib.Path) -> None:
    print(f"Processing {path}") # Use loguru.logger.info instead.
    try:
        ...
    except OSError as exc:
        print(f"Failed to process {path}: {exc}")   # Use loguru.logger.exception instead

# Good: Using print in command line scripts.
if __name__ == "__main__":
    path = pathlib.Path(sys.argv[1])
    n_words = len(path.read_text(encoding="utf-8").split())
    print(f"{path.name}: {n_words} words")  # Okay: Print statement is used for deliberate user-facing behaviour.

C28 - Avoid mutable global state

Rule: Avoid mutable global state. Prefer encapsulating state instead of mutating module-level objects at runtime.

Reasoning: See § GPSG 2.5.

C29 - Use @property only when access behaves like an attribute

Rule: Use @property only when access is cheap, direct, and unsurprising.

Reasoning: See § GPSG 2.13.

C30 - Prefer built-in exception types and avoid bare except

Rule: Prefer built-in exception types, avoid bare except, keep try bodies small, and define custom errors as subclasses ending in Error.

Reasoning: See § GPSG 2.4.

C31 - Use comprehensions and generator expressions only for simple cases

Rule: Prefer comprehensions and generator expressions only for straightforward cases.

Reasoning: See § GPSG 2.7.

C32 - Prefer default iterators and operators

Rule: Use default iterators and operators for types that support them, such as lists, dictionaries, and files.

Reasoning: See § GPSG 2.8.

C33 - Use generators when they simplify stateful iteration

Rule: Generators are appropriate when they simplify stateful iteration.

Reasoning: See § GPSG 2.9.

C34 - Restrict lambda use to short one-liners

Rule: Use lambdas only for short one-liners.

Reasoning: See § GPSG 2.10.

C35 - Keep conditional expressions to one-line branches

Rule: Use conditional expressions (x if cond else y) only when each branch fits on one line.

Reasoning: See § GPSG 2.11.

C36 - Prefer implicit false evaluation when appropriate

Rule: Use implicit false evaluation where appropriate.

Reasoning: See § GPSG 2.14.

C37 - Use lexical scoping carefully

Rule: Lexical scoping is okay when used carefully.

Reasoning: See § GPSG 2.16.

C38 - Do not rely on atomicity of built-in types

Rule: Do not rely on the atomicity of built-in types.

Reasoning: See § GPSG 2.18.

C39 - Avoid power features

Rule: Avoid "power features".

Reasoning: See § GPSG 2.19.

C40 - Use from __future__ import ... where beneficial

Rule: Use from __future__ import ... when it lets a file adopt modern semantics on older runtimes.

Reasoning: See § GPSG 2.20.

C41 - Use correct string formatting

Rule: Use correct string formatting.

Reasoning: See § GPSG 3.10.

C42 - Keep error messages precise and greppable

Rule: Error messages need to precisely match the real error condition and be easy to grep.

Reasoning: See § GPSG 3.10.2.

C43 - Prefer with for closeable resources

Rule: Prefer with for files and similar resources; explicitly close files and sockets when done with them.

Reasoning: See § GPSG 3.11.

C44 - Use issue-linked TODO comments

Rule: Do not use person-based TODO comments. Always use # TODO: <issue URL> - <short explanation>.

Reasoning: See § GPSG 3.12.

C45 - Use accessors and mutators only when meaningful

Rule: Accessors and mutators should only be used when they provide a meaningful role or behaviour for getting or setting a variable's value.

Reasoning: See § GPSG 3.15.

Further Reading

Apart from the already cited Google Python Style Guide and the official PEP 8 documentation, I also recommend the Little Book of Python Anti-Patterns for a light read.