We all write functions when coding in Python. But do we necessarily write good functions? Well, let’s find out.
Functions in Python let you write modular code. When you have a task you need to perform at multiple places, you can wrap the logic of the task into a Python function. And you can call the function every time you need to perform that specific task. As simple as it seems to get started with Python functions, writing maintainable and performant functions is not so straightforward.
And that’s why we’ll explore a few practices that’ll help you write cleaner and easy-to-maintain Python functions. Let’s get started…
1. Write Functions That Do Only One Thing
When writing functions in Python, it’s often tempting to put all related tasks into a single function. While this can help you code things up quickly, it’ll only make your code a pain to maintain in the near future. Not only will this make understanding what a function does more difficult but also leads to other issues such as too many parameters (more on that later!).
As a good practice, you should always try to make your function do only one thing—one task—and do that well. But sometimes, for a single task, you may need to work through a series of subtasks. So how do you decide if and how the function should be refactored?
Depending on what the function is trying to do and how complex the task is, you can work out the separation of concerns between subtasks. And then identify a suitable level at which you can refactor the function into multiple functions—each focusing on a specific subtask.
Refactor functions | Image by Author
Here’s an example. Look at the function analyze_and_report_sales:
def analyze_and_report_sales(data, report_filename):
total_sales = sum(item[‘price’] * item[‘quantity’] for item in data)
average_sales = total_sales / len(data)
with open(report_filename, ‘w’) as report_file:
report_file.write(f”Total Sales: {total_sales}\n”)
report_file.write(f”Average Sales: {average_sales}\n”)
return total_sales, average_sales
It’s quite easy to see that it can be refactored into two functions: one calculating the sales metrics and another on writing the sales metrics to a file like so:
def calculate_sales_metrics(data):
total_sales = sum(item[‘price’] * item[‘quantity’] for item in data)
average_sales = total_sales / len(data)
return total_sales, average_sales
def write_sales_report(report_filename, total_sales, average_sales):
with open(report_filename, ‘w’) as report_file:
report_file.write(f”Total Sales: {total_sales}\n”)
report_file.write(f”Average Sales: {average_sales}\n”)
Now it’s easier to debug any concerns with the calculation of sales metrics and file operations separately. And here’s a sample function call:
total_sales, average_sales = calculate_sales_metrics(data)
write_sales_report(‘sales_report.txt’, total_sales, average_sales)
You should be able to see the ‘sales_report.txt’ file in your working directory with the sales metrics. This is a simple example to get started, but this is helpful especially when you’re working on more complex functions.
2. Add Type Hints to Improve Maintainability
Python is a dynamically typed language. So you do not need to declare types for the variables you create. But you can add type hints to specify the expected data type for variables. When you define the function, you can add the expected data types for the parameters and the return values.
Because Python does not enforce types at runtime, adding type hints has no effect at runtime. But there still are benefits to using type hints, especially on the maintainability front:
Adding type hints to Python functions serves as inline documentation and gives a better idea of what the function does and what values it consumes and returns.
When you add type hints to your functions, you can configure your IDE to leverage these type hints. So you’ll get helpful warnings if you try to pass an argument of invalid type in one or more function calls, implement functions whose return values do not match the expected type, and the like. So you can minimize errors upfront.
You can optionally use static type checkers like mypy to catch errors earlier rather than letting type mismatches introduce subtle bugs that are difficult to debug.
Here’s a function that processes order details:
def process_orders(orders):
total_quantity = sum(order[‘quantity’] for order in orders)
total_value = sum(order[‘quantity’] * order[‘price’] for order in orders)
return {
‘total_quantity’: total_quantity,
‘total_value’: total_value
}
Now let’s add type hints to the function like so:
from typing import List, Dict
def process_orders(orders: List[Dict[str, float | int]]) -> Dict[str, float | int]:
total_quantity = sum(order[‘quantity’] for order in orders)
total_value = sum(order[‘quantity’] * order[‘price’] for order in orders)
return {
‘total_quantity’: total_quantity,
‘total_value’: total_value
}
With the modified version, you get to know that the function takes in a list of dictionaries. The keys of the dictionary should all be strings and the values can either be integers or floating point values. The function also returns a dictionary. Let’s take a sample function call:
orders = [
{‘price’: 100.0, ‘quantity’: 2},
{‘price’: 50.0, ‘quantity’: 5},
{‘price’: 150.0, ‘quantity’: 1}
]
# Sample function call
result = process_orders(orders)
print(result)
Here’s the output:
In this example, type hints help us get a better idea of how the function works. Going forward, we’ll add type hints for all the better versions of Python functions we write.
3. Accept Only the Arguments You Actually Need
If you are a beginner or have just started your first dev role, it’s important to think about the different parameters when defining the function signature.
It’s quite common to introduce additional parameters in the function signature that the function never actually processes.
Ensuring that the function takes in only the arguments that are actually necessary keeps function calls cleaner and more maintainable in general. On a related note, too many parameters in the function signature also make it a pain to maintain. So how do you go about defining easy-to-maintain functions with the right number of parameters?
If you find yourself writing a function signature with a growing number of parameters, the first step is to remove all unused parameters from the signature. If there are too many parameters even after this step, go back to tip #1: break down the task into multiple subtasks and refactor the function into multiple smaller functions. This will help keep the number of parameters in check.
Keep num_params in check | Image by Author
It’s time for a simple example. Here the function definition to calculate student grades contains the instructor parameter that’s never used:
def process_student_grades(student_id, grades, course_name, instructor’):
average_grade = sum(grades) / len(grades)
return f”Student {student_id} achieved an average grade of {average_grade:.2f} in {course_name}.”
You can rewrite the function without the instructor parameter like so:
def process_student_grades(student_id: int, grades: list, course_name: str) -> str:
average_grade = sum(grades) / len(grades)
return f”Student {student_id} achieved an average grade of {average_grade:.2f} in {course_name}.”
# Usage
student_id = 12345
grades = [85, 90, 75, 88, 92]
course_name = “Mathematics”
result = process_student_grades(student_id, grades, course_name)
print(result)
Here’s the output of the function call:
4. Enforce Keyword-Only Arguments to Minimize Errors
In practice, most Python functions take in multiple arguments. You can pass in arguments to Python functions as positional arguments, keyword arguments, or a mix of both. Read Python Function Arguments: A Definitive Guide for a quick review of function arguments.
Some arguments are naturally positional. But sometimes having function calls containing only positional arguments can be confusing. This is especially true when the function takes in multiple arguments of the same data type, some required and some optional.
If you recall, with positional arguments, the arguments are passed to the parameters in the function signature in the same order in which they appear in the function call. So change in order of arguments can introduce subtle bugs type errors.
It’s often helpful to make optional arguments keyword-only. This also makes adding optional parameters much easier—without breaking existing calls.
Here’s an example. The process_payment function takes in an optional description string:
def process_payment(transaction_id: int, amount: float, currency: str, description: str = None):
print(f”Processing transaction {transaction_id}…”)
print(f”Amount: {amount} {currency}”)
if description:
print(f”Description: {description}”)
Say you want to make the optional description a keyword-only argument. Here’s how you can do it:
# make the optional `description` arg keyword-only
def process_payment(transaction_id: int, amount: float, currency: str, *, description: str = None):
print(f”Processing transaction {transaction_id}:”)
print(f”Amount: {amount} {currency}”)
if description:
print(f”Description: {description}”)
Let’s take a sample function call:
This outputs:
Amount: 100.0 USD
Description: Payment for services
Now try passing in all arguments as positional:
process_payment(5678, 150.0, ‘EUR’, ‘Invoice payment’)
You’ll get an error as shown:
File “/home/balapriya/better-fns/tip4.py”, line 9, in
process_payment(1234, 150.0, ‘EUR’, ‘Invoice payment’)
TypeError: process_payment() takes 3 positional arguments but 4 were given
5. Don’t Return Lists From Functions; Use Generators Instead
It’s quite common to write Python functions that generate sequences such as a list of values. But as much as possible, you should avoid returning lists from Python functions. Instead you can rewrite them as generator functions. Generators use lazy evaluation; so they yield elements of the sequence on demand rather than computing all the values ahead of time. Read Getting Started with Python Generators for an introduction to how generators work in Python.
As an example, take the following function that generates the Fibonacci sequence up to a certain upper limit:
def generate_fibonacci_numbers_list(limit):
fibonacci_numbers = [0, 1]
while fibonacci_numbers[-1] + fibonacci_numbers[-2]
It’s a recursive implementation that’s computationally expensive and populating the list and returning it seems more verbose than necessary. Here’s an improved version of the function that uses generators:
from typing import Generator
def generate_fibonacci_numbers(limit: int) -> Generator[int, None, None]:
a, b = 0, 1
while a
In this case, the function returns a generator object which you can then loop through to get the elements of the sequence:
fibonacci_numbers_generator = generate_fibonacci_numbers(limit)
for num in fibonacci_numbers_generator:
print(num)
Here’s the output:
1
1
2
3
5
8
13
21
34
55
89
As you can see, using generators can be much more efficient especially for large input sizes. Also, you can chain multiple generators together, so you can create efficient data processing pipelines with generators.
Wrapping Up
And that’s a wrap. You can find all the code on GitHub. Here’s a review of the different tips we went over:
Write functions that do only one thing
Add type hints to improve maintainability
Accept only the arguments you actually need
Enforce keyword-only arguments to minimize errors
Don’t return lists from functions; use generators instead
I hope you found them helpful! If you aren’t already, try out these practices when writing Python functions. Happy coding!
Bala Priya C is a developer and technical writer from India.
She enjoys working at the intersection of math, programming, data science, and content creation. Her areas of interest and expertise include DevOps, data science, and natural language processing. She loves reading, writing, coding, and coffee! Currently, she is focused on learning and sharing her knowledge with the developer community by creating tutorials, how-to guides, opinion pieces, and more. Bala also develops engaging resource overviews and coding tutorials.