In my previous article, I highlighted the importance of effective project management in Python development. Now, let’s shift our focus to the code itself and explore how to write clean, maintainable code — an essential practice in professional and collaborative environments.
- Readability & Maintainability: Well-structured code is easier to read, understand, and modify. Other developers — or even your future self — can quickly grasp the logic without struggling to decipher messy code.
- Debugging & Troubleshooting: Organized code with clear variable names and structured functions makes it easier to identify and fix bugs efficiently.
- Scalability & Reusability: Modular, well-organized code can be reused across different projects, allowing for seamless scaling without disrupting existing functionality.
So, as you work on your next Python project, remember:
Half of good code is Clean Code.
Introduction
Python is one of the most popular and versatile Programming languages, appreciated for its simplicity, comprehensibility and large community. Whether web development, data analysis, artificial intelligence or automation of tasks — Python offers powerful and flexible tools that are suitable for a wide range of areas.
However, the efficiency and maintainability of a Python project depends heavily on the practices used by the developers. Poor structuring of the code, a lack of conventions or even a lack of documentation can quickly turn a promising project into a maintenance and development-intensive puzzle. It is precisely this point that makes the difference between student code and professional code.
This article is intended to present the most important best practices for writing high-quality Python code. By following these recommendations, developers can create scripts and applications that are not only functional, but also readable, performant and easily maintainable by third parties.
Adopting these best practices right from the start of a project not only ensures better collaboration within teams, but also prepares your code to evolve with future needs. Whether you’re a beginner or an experienced developer, this guide is designed to support you in all your Python developments.
The code structuration
Good code structuring in Python is essential. There are two main project layouts: flat layout and src layout.
The flat layout places the source code directly in the project root without an additional folder. This approach simplifies the structure and is well-suited for small scripts, quick prototypes, and projects that do not require complex packaging. However, it may lead to unintended import issues when running tests or scripts.
📂 my_project/
├── 📂 my_project/ # Directly in the root
│ ├── 🐍 __init__.py
│ ├── 🐍 main.py # Main entry point (if needed)
│ ├── 🐍 module1.py # Example module
│ └── 🐍 utils.py
├── 📂 tests/ # Unit tests
│ ├── 🐍 test_module1.py
│ ├── 🐍 test_utils.py
│ └── ...
├── 📄 .gitignore # Git ignored files
├── 📄 pyproject.toml # Project configuration (Poetry, setuptools)
├── 📄 uv.lock # UV file
├── 📄 README.md # Main project documentation
├── 📄 LICENSE # Project license
├── 📄 Makefile # Automates common tasks
├── 📄 DockerFile # Automates common tasks
├── 📂 .github/ # GitHub Actions workflows (CI/CD)
│ ├── 📂 actions/
│ └── 📂 workflows/
On the other hand, the src layout (src is the contraction of source) organizes the source code inside a dedicated src/
directory, preventing accidental imports from the working directory and ensuring a clear separation between source files and other project components like tests or configuration files. This layout is ideal for large projects, libraries, and production-ready applications as it enforces proper package installation and avoids import conflicts.
📂 my-project/
├── 📂 src/ # Main source code
│ ├── 📂 my_project/ # Main package
│ │ ├── 🐍 __init__.py # Makes the folder a package
│ │ ├── 🐍 main.py # Main entry point (if needed)
│ │ ├── 🐍 module1.py # Example module
│ │ └── ...
│ │ ├── 📂 utils/ # Utility functions
│ │ │ ├── 🐍 __init__.py
│ │ │ ├── 🐍 data_utils.py # data functions
│ │ │ ├── 🐍 io_utils.py # Input/output functions
│ │ │ └── ...
├── 📂 tests/ # Unit tests
│ ├── 🐍 test_module1.py
│ ├── 🐍 test_module2.py
│ ├── 🐍 conftest.py # Pytest configurations
│ └── ...
├── 📂 docs/ # Documentation
│ ├── 📄 index.md
│ ├── 📄 architecture.md
│ ├── 📄 installation.md
│ └── ...
├── 📂 notebooks/ # Jupyter Notebooks for exploration
│ ├── 📄 exploration.ipynb
│ └── ...
├── 📂 scripts/ # Standalone scripts (ETL, data processing)
│ ├── 🐍 run_pipeline.py
│ ├── 🐍 clean_data.py
│ └── ...
├── 📂 data/ # Raw or processed data (if applicable)
│ ├── 📂 raw/
│ ├── 📂 processed/
│ └── ....
├── 📄 .gitignore # Git ignored files
├── 📄 pyproject.toml # Project configuration (Poetry, setuptools)
├── 📄 uv.lock # UV file
├── 📄 README.md # Main project documentation
├── 🐍 setup.py # Installation script (if applicable)
├── 📄 LICENSE # Project license
├── 📄 Makefile # Automates common tasks
├── 📄 DockerFile # To create Docker image
├── 📂 .github/ # GitHub Actions workflows (CI/CD)
│ ├── 📂 actions/
│ └── 📂 workflows/
Choosing between these layouts depends on the project’s complexity and long-term goals. For production-quality code, the src/
layout is often recommended, whereas the flat layout works well for simple or short-lived projects.
You can imagine different templates that are better adapted to your use case. It is important that you maintain the modularity of your project. Do not hesitate to create subdirectories and to group together scripts with similar functionalities and separate those with different uses. A good code structure ensures readability, maintainability, scalability and reusability and helps to identify and correct errors efficiently.
Cookiecutter is an open-source tool for generating preconfigured project structures from templates. It is particularly useful for ensuring the coherence and organization of projects, especially in Python, by applying good practices from the outset. The flat layout and src layout can be initiate using a UV tool.
The SOLID principles
SOLID programming is an essential approach to software development based on five basic principles for improving code quality, maintainability and scalability. These principles provide a clear framework for developing robust, flexible systems. By following the Solid Principles, you reduce the risk of complex dependencies, make testing easier and ensure that applications can evolve more easily in the face of change. Whether you are working on a single project or a large-scale application, mastering SOLID is an important step towards adopting object-oriented programming best practices.
S — Single Responsibility Principle (SRP)
The principle of single responsibility means that a class/function can only manage one thing. This means that it only has one reason to change. This makes the code more maintainable and easier to read. A class/function with multiple responsibilities is difficult to understand and often a source of errors.
Example:
# Violates SRP
class MLPipeline:
def __init__(self, df: pd.DataFrame, target_column: str):
self.df = df
self.target_column = target_column
self.scaler = StandardScaler()
self.model = RandomForestClassifier()
def preprocess_data(self):
self.df.fillna(self.df.mean(), inplace=True) # Handle missing values
X = self.df.drop(columns=[self.target_column])
y = self.df[self.target_column]
X_scaled = self.scaler.fit_transform(X) # Feature scaling
return X_scaled, y
def train_model(self):
X, y = self.preprocess_data() # Data preprocessing inside model training
self.model.fit(X, y)
print("Model training complete.")
Here, the Report class has two responsibilities: Generate content and save the file.
# Follows SRP
class DataPreprocessor:
def __init__(self):
self.scaler = StandardScaler()
def preprocess(self, df: pd.DataFrame, target_column: str):
df = df.copy()
df.fillna(df.mean(), inplace=True) # Handle missing values
X = df.drop(columns=[target_column])
y = df[target_column]
X_scaled = self.scaler.fit_transform(X) # Feature scaling
return X_scaled, y
class ModelTrainer:
def __init__(self, model):
self.model = model
def train(self, X, y):
self.model.fit(X, y)
print("Model training complete.")
O — Open/Closed Principle (OCP)
The open/close principle means that a class/function must be open to extension, but closed to modification. This makes it possible to add functionality without the risk of breaking existing code.
It is not easy to develop with this principle in mind, but a good indicator for the main developer is to see more and more additions (+) and fewer and fewer removals (-) in the merge requests during project development.
L — Liskov Substitution Principle (LSP)
The Liskov substitution principle states that a subordinate class can replace its parent class without changing the behavior of the program, ensuring that the subordinate class meets the expectations defined by the base class. It limits the risk of unexpected errors.
Example :
# Violates LSP
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
# Changing the width of a square violates the idea of a square.
To respect the LSP, it is better to avoid this hierarchy and use independent classes:
class Shape:
def area(self):
raise NotImplementedError
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
I — Interface Segregation Principle (ISP)
The principle of interface separation states that several small classes should be built instead of one with methods that cannot be used in certain cases. This reduces unnecessary dependencies.
Example:
# Violates ISP
class Animal:
def fly(self):
raise NotImplementedError
def swim(self):
raise NotImplementedError
It is better to split the class Animal
into several classes:
# Follows ISP
class CanFly:
def fly(self):
raise NotImplementedError
class CanSwim:
def swim(self):
raise NotImplementedError
class Bird(CanFly):
def fly(self):
print("Flying")
class Fish(CanSwim):
def swim(self):
print("Swimming")
D — Dependency Inversion Principle (DIP)
The Dependency Inversion Principle means that a class must depend on an abstract class and not on a concrete class. This reduces the connections between the classes and makes the code more modular.
Example:
# Violates DIP
class Database:
def connect(self):
print("Connecting to database")
class UserService:
def __init__(self):
self.db = Database()
def get_users(self):
self.db.connect()
print("Getting users")
Here, the attribute db of UserService depends on the class Database. To respect the DIP, db has to depend on an abstract class.
# Follows DIP
class DatabaseInterface:
def connect(self):
raise NotImplementedError
class MySQLDatabase(DatabaseInterface):
def connect(self):
print("Connecting to MySQL database")
class UserService:
def __init__(self, db: DatabaseInterface):
self.db = db
def get_users(self):
self.db.connect()
print("Getting users")
# We can easily change the used database.
db = MySQLDatabase()
service = UserService(db)
service.get_users()
PEP standards
PEPs (Python Enhancement Proposals) are technical and informative documents that describe new features, language improvements or guidelines for the Python community. Among them, PEP 8, which defines style conventions for Python code, plays a fundamental role in promoting readability and consistency in projects.
Adopting the PEP standards, especially PEP 8, not only ensures that the code is understandable to other developers, but also that it conforms to the standards set by the community. This facilitates collaboration, re-reads and long-term maintenance.
In this article, I present the most important aspects of the PEP standards, including:
- Style Conventions (PEP 8): Indentations, variable names and import organization.
- Best practices for documenting code (PEP 257).
- Recommendations for writing typed, maintainable code (PEP 484 and PEP 563).
Understanding and applying these standards is essential to take full advantage of the Python ecosystem and contribute to professional quality projects.
PEP 8
This documentation is about coding conventions to standardize the code, and there exists a lot of documentation about the PEP 8. I will not show all recommendation in this posts, only those that I judge essential when I review a code
Naming conventions
Variable, function and module names should be in lower case, and use underscore to separate words. This typographical convention is called snake_case.
my_variable
my_new_function()
my_module
Constances are written in capital letters and set at the beginning of the script (after the imports):
LIGHT_SPEED
MY_CONSTANT
Finally, class names and exceptions use the CamelCase format (a capital letter at the beginning of each word). Exceptions must contain an Error at the end.
MyGreatClass
MyGreatError
Remember to give your variables names that make sense! Don’t use variable names like v1, v2, func1, i, toto…
Single-character variable names are permitted for loops and indexes:
my_list = [1, 3, 5, 7, 9, 11]
for i in range(len(my_liste)):
print(my_list[i])
A more “pythonic” way of writing, to be preferred to the previous example, gets rid of the i index:
my_list = [1, 3, 5, 7, 9, 11]
for element in my_list:
print(element )
Spaces management
It is recommended surrounding operators (+, -, *, /, //, %, ==, !=, >, not, in, and, or, …) with a space before AND after:
# recommended code:
my_variable = 3 + 7
my_text = "mouse"
my_text == my_variable
# not recommended code:
my_variable=3+7
my_text="mouse"
my_text== ma_variable
You can’t add several spaces around an operator. On the other hand, there are no spaces inside square brackets, braces or parentheses:
# recommended code:
my_list[1]
my_dict{"key"}
my_function(argument)
# not recommended code:
my_list[ 1 ]
my_dict{ "key" }
my_function( argument )
A space is recommended after the characters “:” and “,”, but not before:
# recommended code:
my_list= [1, 2, 3]
my_dict= {"key1": "value1", "key2": "value2"}
my_function(argument1, argument2)
# not recommended code:
my_list= [1 , 2 , 3]
my_dict= {"key1":"value1", "key2":"value2"}
my_function(argument1 , argument2)
However, when indexing lists, we don’t put a space after the “:”:
my_list= [1, 3, 5, 7, 9, 1]
# recommended code:
my_list[1:3]
my_list[1:4:2]
my_list[::2]
# not recommended code:
my_list[1 : 3]
my_list[1: 4:2 ]
my_list[ : :2]
Line length
For the sake of readability, we recommend writing lines of code no longer than 80 characters long. However, in certain circumstances this rule can be broken, especially if you are working on a Dash project, it may be complicated to respect this recommendation
The \
character can be used to cut lines that are too long.
For example:
my_variable = 3
if my_variable > 1 and my_variable < 10 \
and my_variable % 2 == 1 and my_variable % 3 == 0:
print(f"My variable is equal to {my_variable }")
Within a parenthesis, you can return to the line without using the \ character. This can be useful for specifying the arguments of a function or method when defining or using it:
def my_function(argument_1, argument_2,
argument_3, argument_4):
return argument_1 + argument_2
It is also possible to create multi-line lists or dictionaries by skipping a line after a comma:
my_list = [1, 2, 3,
4, 5, 6,
7, 8, 9]
my_dict = {"key1": 13,
"key2": 42,
"key2": -10}
Blank lines
In a script, blank lines are useful for visually separating different parts of the code. It is recommended to leave two blank lines before the definition of a function or class, and to leave a single blank line before the definition of a method (in a class). You can also leave a blank line in the body of a function to separate the logical sections of the function, but this should be used sparingly.
Comments
Comments always begin with the # symbol followed by a space. They give clear explanations of the purpose of the code and must be synchronized with the code, i.e. if the code is modified, the comments must be too (if applicable). They are on the same indentation level as the code they comment on. Comments are complete sentences, with a capital letter at the beginning (unless the first word is a variable, which is written without a capital letter) and a period at the end.I strongly recommend writing comments in English and it is important to be consistent between the language used for comments and the language used to name variables. Finally, Comments that follow the code on the same line should be avoided wherever possible, and should be separated from the code by at least two spaces.
Tool to help you
Ruff is a linter (code analysis tool) and formatter for Python code written in Rust. It combines the advantages of the flake8 linter and black and isort formatting while being faster.
Ruff has an extension on the VS Code editor.
To check your code you can type:
ruff check my_modul.py
But, it is also possible to correct it with the following command:
ruff format my_modul.py
PEP 20
PEP 20: The Zen of Python is a set of 19 principles written in poetic form. They are more a way of coding than actual guidelines.
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren’t special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one– and preferably only one –obvious way to do it.
Although that way may not be obvious at first unless you’re Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it’s a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea — let’s do more of those!
PEP 257
The aim of PEP 257 is to standardize the use of docstrings.
What is a docstring?
A docstring is a string that appears as the first instruction after the definition of a function, class or method. A docstring becomes the output of the __doc__
special attribute of this object.
def my_function():
"""This is a doctring."""
pass
And we have:
>>> my_function.__doc__
>>> 'This is a doctring.'
We always write a docstring between triple double quote """
.
Docstring on a line
Used for simple functions or methods, it must fit on a single line, with no blank line at the beginning or end. The closing quotes are on the same line as opening quotes and there are no blank lines before or after the docstring.
def add(a, b):
"""Return the sum of a and b."""
return a + b
Single-line docstring MUST NOT reintegrate function/method parameters. Do not do:
def my_function(a, b):
""" my_function(a, b) -> list"""
Docstring on several lines
The first line should be a summary of the object being documented. An empty line follows, followed by more detailed explanations or clarifications of the arguments.
def divide(a, b):
"""Divide a byb.
Returns the result of the division. Raises a ValueError if b equals 0.
"""
if b == 0:
raise ValueError("Only Chuck Norris can divide by 0") return a / b
Complete Docstring
A complete docstring is made up of several parts (in this case, based on the numpydoc standard).
- Short description: Summarizes the main functionality.
- Parameters: Describes the arguments with their type, name and role.
- Returns: Specifies the type and role of the returned value.
- Raises: Documents exceptions raised by the function.
- Notes (optional): Provides additional explanations.
- Examples (optional): Contains illustrated usage examples with expected results or exceptions.
def calculate_mean(numbers: list[float]) -> float:
"""
Calculate the mean of a list of numbers.
Parameters
----------
numbers : list of float
A list of numerical values for which the mean is to be calculated.
Returns
-------
float
The mean of the input numbers.
Raises
------
ValueError
If the input list is empty.
Notes
-----
The mean is calculated as the sum of all elements divided by the number of elements.
Examples
--------
Calculate the mean of a list of numbers:
>>> calculate_mean([1.0, 2.0, 3.0, 4.0])
2.5
Tool to help you
VsCode’s autoDocstring extension lets you automatically create a docstring template.
PEP 484
In some programming languages, typing is mandatory when declaring a variable. In Python, typing is optional, but strongly recommended. PEP 484 introduces a typing system for Python, annotating the types of variables, function arguments and return values. This PEP provides a basis for improving code readability, facilitating static analysis and reducing errors.
What is typing?
Typing consists in explicitly declaring the type (float, string, etc.) of a variable. The typing module provides standard tools for defining generic types, such as Sequence, List, Union, Any, etc.
To type function attributes, we use “:” for function arguments and “->” for the type of what is returned.
Here a list of none typing functions:
def show_message(message):
print(f"Message : {message}")
def addition(a, b):
return a + b
def is_even(n):
return n % 2 == 0
def list_square(numbers):
return [x**2 for x in numbers]
def reverse_dictionary(d):
return {v: k for k, v in d.items()}
def add_element(ensemble, element):
ensemble.add(element)
return ensemble
Now here’s how they should look:
from typing import List, Tuple, Dict, Set, Any
def show _message(message: str) -> None:
print(f"Message : {message}")
def addition(a: int, b: int) -> int:
return a + b
def is_even(n: int) -> bool:
return n % 2 == 0
def list_square (numbers: List[int]) -> List[int]:
return [x**2 for x in numbers]
def reverse_dictionary (d: Dict[str, int]) -> Dict[int, str]:
return {v: k for k, v in d.items()}
def add_element(ensemble: Set[int], element: int) -> Set[int]:
ensemble.add(element)
return ensemble
Tool to help you
The MyPy extension automatically checks whether the use of a variable corresponds to the declared type. For example, for the following function:
def my_function(x: float) -> float:
return x.mean()
The editor will point out that a float has no “mean” attribute.
The benefit is twofold: you’ll know whether the declared type is the right one and whether the use of this variable corresponds to its type.
In the above example, x must be of a type that has a mean() method (e.g. np.array).
Conclusion
In this article, we have looked at the most important principles for creating clean Python production code. A solid architecture, adherence to SOLID principles, and compliance with PEP recommendations (at least the four discussed here) are essential for ensuring code quality. The desire for beautiful code is not (just) coquetry. It standardizes development practices and makes teamwork and maintenance much easier. There’s nothing more frustrating than spending hours (or even days) reverse-engineering a program, deciphering poorly written code before you’re finally able to fix the bugs. By applying these best practices, you ensure that your code remains clear, scalable, and easy for any developer to work with in the future.
References
1. src layout vs flat layout
2. SOLID principles
3. Python Enhancement Proposals index