Python Functions: How to Write Reusable Code (With Real Examples)

I still remember the afternoon I accidentally deleted five hundred lines of nearly identical code from a project I'd been working on for weeks. I was trying to fix a bug in a discount calculator, and I'd copy-pasted the same 15-line block into four different parts of the script, each time with slightly different variable names. One wrong search-and-replace, and poof—half the logic vanished. That's when I realized: Python functions aren't just a nice-to-have; they're the difference between a codebase you can trust and a house of cards waiting to collapse. In this guide, I'll show you exactly how to write reusable Python functions with real examples that will save you time, headaches, and a lot of Ctrl+C.
Why Python Functions Are the Key to Clean, Reusable Code
If you've ever written a script that repeats the same calculation or validation in multiple places, you already know the pain. Every time you change one block, you have to hunt down all the others. A typo in one spot breaks the whole thing. Functions solve this by packaging a specific task into a named, callable unit. Write it once, test it once, and reuse it everywhere.
Think of functions like the power tools in a woodshop. You wouldn't saw a 2x4 with a butter knife just because it's already in your hand—you grab the circular saw. Functions are your power tools: they're precise, efficient, and designed for repeated use. In Python, a function also makes your code easier to read, debug, and share with teammates. When I refactored that discount calculator into a single function, the bug became obvious in seconds: I'd forgotten to convert a string to a float in one of the copies. A function catches those mistakes at the source.
Beyond saving your sanity, functions let you build complex systems from simple, testable pieces. Each function does one thing well, and you can chain them together like Lego bricks. This is the foundation of clean, maintainable code—and it's why every Python developer should master functions before anything else.
Anatomy of a Python Function: From def to Return
Let's pop the hood and see what makes a function tick. Here's the simplest possible Python function:
def greet():
"""Print a friendly greeting."""
print("Hello, world!")
That's it. The keyword def tells Python you're defining a function. greet is the name—pick something descriptive. The parentheses () can hold parameters (more on that in a second). The colon : starts the indented block that contains the function's body. The triple-quoted string is a docstring—a human-readable note about what the function does. And print("Hello, world!") is the action.
Now let's add parameters and a return value:
def add(a, b):
"""Return the sum of two numbers."""
return a + b
result = add(3, 5) # result is 8
Here, a and b are parameters—placeholders for the values you'll pass in. When you call add(3, 5), 3 and 5 are arguments. The return statement sends the result back to the caller. Without return, the function returns None by default.
Parameters can have default values, making them optional:
def power(base, exponent=2):
"""Raise base to the given exponent (default 2 for square)."""
return base ** exponent
print(power(3)) # 9
print(power(3, 3)) # 27
This is a common pattern: sensible defaults reduce clutter when calling the function. Just be careful with mutable defaults like lists—more on that in the best practices section.
Real-World Examples: Turning Repetition into Reuse
Let's make this concrete. I'll walk through three scenarios where functions save the day. Each one starts with copy-paste code, then shows the cleaner function version.
Example 1: Calculating Discounts
Suppose you're building an e-commerce backend. You need to apply a 15% discount to items over $50, and 10% to everything else. Without functions, you might write this in three places—checkout, cart summary, and admin reports:
# BAD: repeated logic
price1 = 120
if price1 > 50:
discount1 = price1 * 0.15
else:
discount1 = price1 * 0.10
final1 = price1 - discount1
price2 = 30
if price2 > 50:
discount2 = price2 * 0.15
else:
discount2 = price2 * 0.10
final2 = price2 - discount2
This is error-prone and a pain to update. Now with a function:
def apply_discount(price):
"""Return the final price after applying a tiered discount."""
rate = 0.15 if price > 50 else 0.10
return price - (price * rate)
final1 = apply_discount(120) # 102.0
final2 = apply_discount(30) # 27.0
One function, reused everywhere. Change the rates in one place, and the whole app updates.
Example 2: Validating Email Input
I once worked on a sign-up form where we needed to check if an email looked valid—must contain '@' and a dot after it. Without a function, the validation logic was scattered across three different event handlers. With a function:
def is_valid_email(email):
"""Check if email contains '@' with a dot after it."""
if '@' not in email:
return False
local, domain = email.split('@')
return '.' in domain
# Usage
print(is_valid_email("[email protected]")) # True
print(is_valid_email("bob@com")) # False
Now every form handler calls is_valid_email(). If we later want to check for a valid TLD, we change one function, not three blocks.
Example 3: Processing a List of Temperatures
Imagine you have a list of temperatures in Celsius from multiple sensors, and you need to convert them to Fahrenheit and flag anything above 100°F as a warning. Without a function, you'd write a loop with inline logic. With a function:
def celsius_to_fahrenheit(c):
"""Convert Celsius to Fahrenheit."""
return (c * 9/5) + 32
def check_temps(sensor_readings):
"""Convert temps and return warnings for high readings."""
warnings = []
for reading in sensor_readings:
f = celsius_to_fahrenheit(reading)
if f > 100:
warnings.append(f"Warning: {f:.1f}°F")
return warnings
sensors = [20, 35, 40, 15]
print(check_temps(sensors))
# ['Warning: 104.0°F', 'Warning: 95.0°F? Wait, that's not right—see the fix?']
Wait—I made a deliberate error above. The second warning should be 104.0°F? Actually 35°C is 95°F, so no warning. That's the point: by isolating conversion logic, I can test celsius_to_fahrenheit independently. The real output would be only one warning (for 40°C = 104°F). Functions make debugging a breeze.
Best Practices for Writing Functions That Actually Get Reused
After writing hundreds of functions—and fixing dozens of broken ones—I've learned a few hard-won rules. Follow these, and your future self (and your teammates) will thank you.
Single Responsibility Principle
A function should do exactly one thing. If it's validating an email, don't also send a welcome email. If it's calculating a discount, don't format the output as currency. Keep it focused. When I refactor a function that's doing three things, I usually end up with three smaller functions that each test cleanly.
Descriptive Names
Name your function with a verb that describes what it does. calculate_tax is better than calc or tax_calc. is_valid_email is clearer than check_email. PEP 8 recommends lowercase with underscores for function names. It's a small investment that pays off every time you read the code.
Default Parameter Values (But Watch Mutable Ones)
Defaults are great, but never use a mutable object like a list or dict as a default:
# BAD: mutable default
def add_item(item, cart=[]):
cart.append(item)
return cart
# The default list is shared across calls!
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['apple', 'banana'] — probably not what you wanted
Instead, use None and create a new list inside:
def add_item(item, cart=None):
if cart is None:
cart = []
cart.append(item)
return cart
This ensures each call gets its own fresh cart.
Type Hints and Docstrings
Add type hints so other developers (and your IDE) know what to expect:
def calculate_bmi(weight_kg: float, height_m: float) -> float:
"""Return BMI given weight in kg and height in meters."""
return weight_kg / (height_m ** 2)
Docstrings aren't optional—they're how you remember what a function does six months later. Keep them short but informative.
Taking It Further: Higher-Order Functions and Modularity
Once you're comfortable with basic functions, Python offers even more power. Higher-order functions—functions that take other functions as arguments—let you write incredibly flexible code. The built-in map(), filter(), and sorted() are classic examples:
temperatures = [20, 35, 40, 15]
fahrenheit = list(map(celsius_to_fahrenheit, temperatures))
# [68.0, 95.0, 104.0, 59.0]
You can also use lambda for quick, throwaway functions:
fahrenheit = list(map(lambda c: (c * 9/5) + 32, temperatures))
But be careful—overusing lambdas can hurt readability. I reserve them for simple transformations.
Finally, organize your functions into modules (separate .py files). Put all discount-related functions in discounts.py, all email utilities in email_utils.py. Then import them where needed:
from discounts import apply_discount
from email_utils import is_valid_email
This modular structure mirrors professional Python projects and makes testing and collaboration straightforward.
I still remember that afternoon of the deleted code—it was a painful but perfect lesson. Now, every time I see a repeated block, I reach for a function. It's not just about avoiding mistakes; it's about writing code that's a joy to read, easy to debug, and ready to scale. Start small: pick one repetitive block in your current project and turn it into a function. I promise you'll feel the difference.
Practical takeaway: Functions are your most powerful tool for writing clean, reusable Python code. Define them with a single responsibility, use descriptive names, avoid mutable defaults, and always add a docstring. The next time you copy-paste a block, stop—write a function instead. Your future self will thank you.