Introduction
In my first Python post we covered variables, lists, dictionaries, and list comprehensions — the data and control flow that let you write a working script. In the OOP post we jumped all the way to classes. But there is an important stop in between, and that stop is functions.
Functions are how you stop writing the same thing twice. They are the reason a 500-line program does not become a 5,000-line program. They are also the first step toward thinking about code as something you design rather than something you just write. Once functions feel natural, classes make much more sense — a class is largely just a collection of functions that share some data.
We will keep our birds. They are patient, useful, and by now familiar.
What Is a Function?
A function is a named block of code you can call by name, pass data into, and get a result back from. In Python you define one with def:
def greet_bird(name):
print(f"Hello, {name}!")
greet_bird("Eagle")
greet_bird("Pigeon")
Hello, Eagle!
Hello, Pigeon!
That is the whole idea. Write the logic once inside def, then call it as many times as you need. Without functions we would have to repeat print(f"Hello, ...") every time — tedious, error-prone, and hard to change later.
Parameters and Return Values
Functions become genuinely useful when they take inputs and give something back. The return statement sends a value back to the caller:
def describe_bird(name, color, can_fly):
status = "can fly" if can_fly else "cannot fly"
return f"A {color} {name} that {status}."
print(describe_bird("Penguin", "black and white", False))
print(describe_bird("Eagle", "brown", True))
A black and white Penguin that cannot fly.
A brown Eagle that can fly.
The variables name, color, and can_fly are called parameters — placeholders that receive the values you pass in. The values you pass when calling the function ("Penguin", "black and white", False) are called arguments.
A function can return any Python object: a number, a string, a list, a dictionary, even another function. If you do not write a return statement, Python returns None silently.
Default Arguments
Sometimes a parameter has a sensible default value and you do not want to have to type it every time. Python lets you set defaults in the function signature:
def feed_bird(name, food="seeds", amount=100):
print(f"Feeding {name} with {amount}g of {food}.")
feed_bird("Pigeon")
feed_bird("Parrot", food="fruit")
feed_bird("Eagle", food="fish", amount=300)
Feeding Pigeon with 100g of seeds.
Feeding Parrot with 100g of fruit.
Feeding Eagle with 300g of fish.
Default arguments must come after non-default ones in the signature. This does not work:
# Wrong — default before non-default
def feed_bird(food="seeds", name): # SyntaxError
...
One important gotcha: never use a mutable object (a list, a dictionary) as a default argument. Python creates the default once, not on each call, so every call shares the same object and mutations accumulate in surprising ways. Use None and create the object inside the function instead:
# Wrong
def add_bird(bird, flock=[]):
flock.append(bird)
return flock
# Right
def add_bird(bird, flock=None):
if flock is None:
flock = []
flock.append(bird)
return flock
*args and **kwargs
Sometimes you want a function to accept a variable number of arguments. Two special syntaxes handle this.
*args collects any number of positional arguments into a tuple:
def count_birds(*birds):
print(f"You have {len(birds)} birds: {', '.join(birds)}.")
count_birds("Eagle", "Pigeon")
count_birds("Eagle", "Pigeon", "Stork", "Swan", "Penguin")
You have 2 birds: Eagle, Pigeon.
You have 5 birds: Eagle, Pigeon, Stork, Swan, Penguin.
**kwargs collects any number of keyword arguments into a dictionary:
def bird_profile(name, **attributes):
print(f"\n{name}:")
for key, value in attributes.items():
print(f" {key}: {value}")
bird_profile("Eagle", color="brown", wingspan=200, can_fly=True)
bird_profile("Penguin", color="black and white", can_swim=True, speed_kmh=3)
Eagle:
color: brown
wingspan: 200
can_fly: True
Penguin:
color: black and white
can_swim: True
speed_kmh: 3
You can combine all of these in one function signature. The order must be: regular parameters, then *args, then keyword-only parameters, then **kwargs:
def full_report(location, *birds, season="summer", **notes):
print(f"Location: {location}, Season: {season}")
print(f"Birds spotted: {', '.join(birds)}")
for key, value in notes.items():
print(f" Note — {key}: {value}")
full_report("Amsterdam", "Heron", "Coot", season="spring", weather="rainy")
Location: Amsterdam, Season: spring
Birds spotted: Heron, Coot
Note — weather: rainy
Type Hints
Python is dynamically typed, which means a function will happily accept the wrong type of argument and fail later in a confusing way. Type hints let you document what types a function expects and returns. They are optional and not enforced at runtime, but they make code much easier to read and they let tools like mypy or your IDE catch type errors before you run anything:
def calculate_flight_time(distance_km: float, speed_kmh: float) -> float:
"""Return flight time in hours for a given distance and speed."""
return distance_km / speed_kmh
hours = calculate_flight_time(200.0, 55.0)
print(f"Flight time: {hours:.2f} hours")
Flight time: 3.64 hours
The -> float after the parentheses declares the return type. The docstring inside triple quotes documents what the function does — a habit worth forming from the beginning, because future-you will thank present-you.
For more complex types you can import from typing (Python 3.8 and earlier) or use the built-in generics (Python 3.9+):
# Python 3.9+
def filter_flying_birds(birds: list[str], can_fly: dict[str, bool]) -> list[str]:
return [b for b in birds if can_fly.get(b, False)]
birds = ["Eagle", "Penguin", "Pigeon", "Ostrich"]
flight_map = {"Eagle": True, "Penguin": False, "Pigeon": True, "Ostrich": False}
print(filter_flying_birds(birds, flight_map))
['Eagle', 'Pigeon']
Lambda Functions
A lambda is a small anonymous function written in a single expression. It is useful when you need a short function as an argument to another function and do not want to define a full def for something so brief:
birds = [
{"name": "Eagle", "wingspan": 200},
{"name": "Pigeon", "wingspan": 50},
{"name": "Stork", "wingspan": 110},
{"name": "Swan", "wingspan": 240},
]
# Sort by wingspan using a lambda as the key
sorted_birds = sorted(birds, key=lambda b: b["wingspan"])
for bird in sorted_birds:
print(f"{bird['name']}: {bird['wingspan']} cm")
Pigeon: 50 cm
Stork: 110 cm
Eagle: 200 cm
Swan: 240 cm
Use lambda sparingly. When the logic is more than a single expression, a named def is always clearer. A well-named function is its own documentation; a complex lambda is just confusion waiting to happen.
Putting It Together: A Bird Feeding Station
Here is a slightly more realistic example that combines everything from this post — a simple bird feeding station manager:
from datetime import datetime
def record_feeding(
bird_name: str,
food: str = "seeds",
amount_g: float = 100.0,
*observers: str,
location: str = "garden",
**weather,
) -> dict:
"""
Record a bird feeding event and return a log entry.
Args:
bird_name: Name of the bird being fed.
food: Type of food provided.
amount_g: Amount of food in grams.
*observers: Names of people who witnessed the feeding.
location: Where the feeding took place.
**weather: Arbitrary weather conditions as key-value pairs.
Returns:
A dictionary representing the feeding log entry.
"""
return {
"timestamp": datetime.now().isoformat(),
"bird": bird_name,
"food": food,
"amount_g": amount_g,
"observers": list(observers),
"location": location,
"weather": weather,
}
entry = record_feeding(
"Pigeon",
"mixed grain",
150.0,
"Elena", "Anna",
location="park",
temperature_c=18,
wind="light breeze",
)
for key, value in entry.items():
print(f"{key}: {value}")
timestamp: 2026-05-10T09:14:02.341
bird: Pigeon
food: mixed grain
amount_g: 150.0
observers: ['Elena', 'Anna']
location: park
weather: {'temperature_c': 18, 'wind': 'light breeze'}
One function, fully documented, handles a wide range of inputs, and returns structured data ready to be stored, logged, or passed to the next function in your program.
Conclusion
Functions are one of those things that feel slightly unnecessary when you first learn them — your script works fine without them — and then indispensable once you have used them for a week. They are how you turn a collection of lines into a program you can actually maintain and build on.
In the next post in this series we will look at what happens when things go wrong: error handling, exceptions, and how to write Python that fails gracefully rather than crashing in the most confusing way possible. The birds will be involved, and some of them will misbehave.
Did you like this post? Please let me know if you have any comments or suggestions — always happy to hear from you!