CARVIEW |
The Art of Writing Readable Python Functions
If your functions need comments to be understood, it’s probably time for a rewrite. Learn the key habits that make Python functions readable by design.

Image by Author | Ideogram
If you’re reading this article, you’ve probably been coding in Python for a while. Let's talk about something that can level up your coding game: writing readable functions.
Think about it: we spend about 10x more time reading code than writing it. So every time you create a function that's clear and intuitive, you're saving both time and frustration for yourself and your team.
In this article, I'll walk you through seven practical techniques to transform confusing code into clear, maintainable functions. We'll look at before-and-after examples, with explanations of why the changes matter. Let’s get started
1. Use Descriptive Function and Parameter Names
Function names should be verbs that clearly describe the action being performed. And parameter names should be descriptive as well.
Bad Example
Looking at this function, can you tell what it does?
def process(d, t):
return d * (1 + t/100)
The name "process" is vague, and the single-letter parameters "d" and "t" give no indication of their purpose. Is this calculating a discount? Applying interest? It's impossible to know without looking at how the function is used elsewhere in the code.
Good Example
This version is immediately clear: we're applying a tax rate to a price.
def apply_tax_to_price(price, tax_rate):
return price * (1 + tax_rate/100)
The function name tells us exactly what action is being performed, and the parameter names clearly indicate what each value represents. Even someone unfamiliar with the code can understand this function at a glance.
2. Limit the Number of Parameters
Functions with many parameters are hard to understand and use correctly. When you need to pass multiple related values, group them logically.
Bad Example
This function has nine parameters:
def send_notification(user_id, email, phone, message, subject,
priority, send_email, send_sms, attachment):
# code goes here...
When calling this function, you'd need to remember the correct order of all these parameters, which is error-prone. It's also not clear which parameters are required and which are optional.
When you see a function call like send_notification(42, "user@example.com", "+1234567890", "Hello", "Greeting", 2, True, False, None)
, it's hard to understand what each value represents without looking up the function definition.
Good Example
This version reduces the parameter count by grouping related parameters into logical objects:
def send_notification(user, notification_config, message_content):
"""
Send a notification to a user based on configuration settings.
Parameters:
- user: User object with contact information
- notification_config: NotificationConfig with delivery preferences
- message_content: MessageContent with subject, body, and attachments
"""
# code goes here...
Now when you call send_notification(user, config, message)
, it's much clearer what each argument represents. This approach also makes the function more flexible.
If you need to add new notification options in the future, you can add them to the NotificationConfig
class without changing the function signature.
3. Write Clear and Helpful Docstrings
A good docstring explains what the function does, its inputs, outputs, and any side effects. Don't just repeat the function name!
Bad Example
This docstring is essentially useless:
def validate_email(email):
"""This function validates email."""
# code goes here...
It just repeats what the function name already tells us without providing any additional information.
We don't know what "validates" means exactly: does it check the format? Verify the domain exists? Contact the mail server? We also don't know what the function returns or if it might raise exceptions.
Good Example
This docstring provides clear, useful information:
def validate_email(email: str) -> bool:
"""
Check if an email address has valid format.
Parameters:
- email: String containing the email address to validate
Returns:
- True if the email is valid, else False
Note:
- This validation checks format only, not if the address actually exists
"""
# code goes here...
Specifically, it:
- Specifies what kind of validation is performed: format checking
- Documents what the function expects as input: a string
- Explains what the function returns: a boolean
- Clarifies limitations: doesn't verify the address exists
The type annotations also add clarity, showing that the function takes a string and returns a boolean.
4. Stick to One Purpose per Function
Functions should do one thing well. If you find yourself using "and" to describe what a function does, it's probably doing too much.
Bad Example
I think you’d agree. This function is indeed trying to do too many things at once:
def process_order(order):
# Validate order
# Update inventory
# Charge customer
# Send confirmation email
# Update analytics
It's handling validation, inventory management, payment processing, communication, and analytics. That’s five separate concerns in one function!
This makes the function:
- Harder to test because you need to mock many dependencies
- Harder to maintain as changes to any one aspect affect the whole function
- Less reusable; well, you can't use just the validation logic elsewhere without bringing everything else along
Good Example
Break it down into single-purpose functions:
def process_order(order):
"""Process a customer order from validation through confirmation."""
validated_order = validate_order(order)
update_inventory(validated_order)
payment_result = charge_customer(validated_order)
if payment_result.is_successful:
send_confirmation_email(validated_order, payment_result)
update_order_analytics(validated_order)
return OrderResult(validated_order, payment_result)
This version still handles the entire order processing flow, but it delegates each specific task to a dedicated function. The benefits are:
- Each single-purpose function can be tested in isolation
- Changes to email logic, for example, only require changes to
send_confirmation_email()
The main function reads almost like pseudocode, making the overall flow easy to understand.
5. Add Type Annotations for Clarity
Python's type hints make code more self-documenting and help catch errors before runtime.
Bad Example
This function works, but it lacks clarity in several ways:
def calculate_final_price(price, discount):
return price * (1 - discount / 100)
What units is discount in? Percentage points or a decimal fraction? Could it be negative? What does the function return exactly?
Without type annotations, a new developer might pass incorrect values or misuse the return value.
Good Example
The type annotations make it clear that both inputs and the output are floating-point numbers:
def calculate_final_price(price: float, discount_percentage: float) -> float:
"""
Calculate final price after applying the discount.
Parameters:
- price: Original price of the item
- discount_percentage: Percentage discount to apply (0-100)
Returns:
- Discounted price
"""
return price * (1 - discount_percentage / 100)
The parameter name discount_percentage
(rather than just discount
) clarifies that the value should be provided as a percentage (like 20 for 20%), not a decimal fraction (like 0.2). The docstring further clarifies the expected range (0-100).
6. Use Default Arguments and Keyword Arguments Wisely
Default arguments make functions more flexible, but they come with responsibilities.
Bad Example
This function has several issues:
def create_report(data, include_charts=True, format='pdf', output_path='report.pdf'):
# code goes here...
The parameter format shadows a built-in Python function. The hardcoded output_path
means reports will always overwrite each other by default.
And since all parameters can be passed positionally, calls like create_report(customer_data, False, 'xlsx')
are allowed but unclear. What does that False
refer to?
Good Example
Here’s a much better version:
def create_report(
data: List[Dict[str, Any]],
*, # Force keyword arguments for clarity
include_charts: bool = True,
format_type: Literal['pdf', 'html', 'xlsx'] = 'pdf',
output_path: Optional[str] = None
) -> str:
"""
Generate a report from the provided data.
Parameters:
- data: List of records to include in the report
- include_charts: Whether to generate charts from the data
- format_type: Output format of the report
- output_path: Where to save the report (if None, uses a default location)
Returns:
- Path to the generated report
"""
if output_path is None:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
output_path = f"reports/report_{timestamp}.{format_type}"
# code goes here...
return output_path
This improved version has several advantages:
- The
*
after the first parameter forces keyword arguments for the optional parameters, making calls likecreate_report(data, include_charts=False)
more readable - Renamed
format
toformat_type
to avoid shadowing the built-in - Using
None
as the default foroutput_path
with dynamic generation prevents accidental overwrites - The type annotation
Literal['pdf', 'html', 'xlsx']
documents the allowed values forformat_type
7. Use Guard Clauses for Early Returns
Use guard clauses to handle edge cases early rather than nesting conditionals deeply.
Bad Example
This function uses deeply nested conditionals, which creates the "pyramid of doom" or "arrow code" pattern:
def process_payment(payment):
if payment.is_valid:
if payment.amount > 0:
if not payment.is_duplicate:
# Actual payment processing logic (buried in conditionals)
return success_result
else:
return DuplicatePaymentError()
else:
return InvalidAmountError()
else:
return InvalidPaymentError()
The actual business logic (the happy path) is buried deep in the nesting, making it hard to identify at a glance. Each new condition we might need to check would create another level of nesting, making the code increasingly unreadable.
Good Example
This version uses guard clauses to handle all the error cases up front, then proceeds with the main business logic without any nesting.
def process_payment(payment: Payment) -> PaymentResult:
"""
Process a payment transaction.
Returns a PaymentResult or raises appropriate exceptions.
"""
# Guard clauses for validation
if not payment.is_valid:
raise InvalidPaymentError("Payment validation failed")
if payment.amount <= 0:
raise InvalidAmountError(f"Invalid payment amount: {payment.amount}")
if payment.is_duplicate:
raise DuplicatePaymentError(f"Duplicate payment ID: {payment.id}")
# Main logic - now at the top level with no nesting
transaction_id = submit_to_payment_processor(payment)
update_payment_records(payment, transaction_id)
notify_payment_success(payment)
return PaymentResult(
success=True,
transaction_id=transaction_id,
processed_at=datetime.now()
)
Each validation is a separate, clear check that fails fast if something is wrong. The path for successful payment processing is now at the top level of the function, making it much easier to follow.
This approach is also easier to extend: adding new validations just means adding new guard clauses, not increasing the nesting depth.
Wrapping Up
By spending time in writing clean, readable functions, you're creating code that:
- Has fewer bugs
- Is easier to test
- Can be maintained by other developers (or yourself in 6 months)
- Serves as its own documentation
- Is more likely to be reused rather than rewritten
Remember, code is read far more often than it is written. And I really hope you found a few key takeaways from this article.
Bala Priya C is a developer and technical writer from India. She likes 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 enjoys reading, writing, coding, and coffee! Currently, she's working on learning and sharing her knowledge with the developer community by authoring tutorials, how-to guides, opinion pieces, and more. Bala also creates engaging resource overviews and coding tutorials.
- 5 Tips for Writing Better Python Functions
- Functional Programming in Python: Leveraging Lambda Functions and…
- Mastering the Art of Data Cleaning in Python
- Mastering Python: 7 Strategies for Writing Clear, Organized, and…
- Tips for Writing Better Unit Tests for Your Python Code
- Stop Writing Messy Python: A Clean Code Crash Course
Latest Posts
- We Benchmarked DuckDB, SQLite, and Pandas on 1M Rows: Here’s What Happened
- Prompt Engineering Templates That Work: 7 Copy-Paste Recipes for LLMs
- A Complete Guide to Seaborn
- 10 Command-Line Tools Every Data Scientist Should Know
- How I Actually Use Statistics as a Data Scientist
- The Lazy Data Scientist’s Guide to Exploratory Data Analysis
Top Posts |
---|
- How I Actually Use Statistics as a Data Scientist
- The Lazy Data Scientist’s Guide to Exploratory Data Analysis
- We Benchmarked DuckDB, SQLite, and Pandas on 1M Rows: Here’s What Happened
- Prompt Engineering Templates That Work: 7 Copy-Paste Recipes for LLMs
- 10 Command-Line Tools Every Data Scientist Should Know
- A Gentle Introduction to TypeScript for Python Programmers
- 5 Fun AI Agent Projects for Absolute Beginners
- A Complete Guide to Seaborn
- From Excel to Python: 7 Steps Analysts Can Take Today
- A Gentle Introduction to MCP Servers and Clients