Python Programming Series  ·  Article 7

Types of Functions in Python: A Complete Guide

Writing your own functions is one skill. Knowing the handful of built-in tools that work with functions, and the different shapes a function itself can take, is a different one. enumerate, zip, filter, map, and reduce all follow a similar pattern: take an iterable, apply some logic to it, and hand back a new result. Lambda functions, recursive functions, and higher-order functions describe different shapes a function can be written in, not new tools exactly, but new ways of thinking about what a function is allowed to do.


This guide covers each one in detail, verified by running the actual code, with real examples from Airbnb, Spotify, Amazon, X (formerly Twitter), Reddit, and AWS.


enumerate(): Numbering an Iterable

Looping over a list normally only gives you the values, not their positions:

python
numbers = [10, 20, 45, 8, 23, 32, 90, 11]

for value in numbers:
    print(value)

If you also need to know where each value sits in the list, enumerate() wraps the iterable and hands back a running counter alongside each item:

python
for counter, value in enumerate(numbers):
    print(counter, value)
0 10
1 20
2 45
3 8
4 23
5 32
6 90
7 11

The counter defaults to starting at 0, but you can tell it to start anywhere:

python
for counter, value in enumerate(numbers, 5):
    print(counter, value)
5 10
6 20
7 45
8 8
9 23
10 32
11 90
12 11

That second argument to enumerate() is genuinely useful anytime you need "position 1" to mean the first item instead of the zeroth, like displaying a numbered list to a user.


zip(): Pairing Up Multiple Sequences

zip() takes two or more iterables and pairs up their items position by position, stopping as soon as the shortest one runs out:

python
names = ["Vinay", "Ronit", "Sachin", "Surya", "Virat"]
passwords = (123, 234, 345, 456, 567)
age = {21, 22, 23, 24, 25}

zipped_data = list(zip(names, passwords, age))
[('Vinay', 123, 21), ('Ronit', 234, 22), ('Sachin', 345, 23), ('Surya', 456, 24), ('Virat', 567, 25)]

This particular example mixes three different container types, a list, a tuple, and a set, and it happens to line up cleanly here. Worth knowing why "happens to" is doing real work in that sentence: a set never guarantees any particular order. The reason {21, 22, 23, 24, 25} comes out sorted above is a detail of how CPython currently hashes small integers, not a promise the language makes. Swap in a set of strings and the illusion breaks immediately:

python
sample = {'twenty-one', 'twenty-two', 'twenty-three', 'twenty-four', 'twenty-five'}
list(sample)
['twenty-one', 'twenty-two', 'twenty-five', 'twenty-three', 'twenty-four']

Whenever the position of an item matters, which is the entire point of zip(), stick to ordered containers (lists or tuples) for every sequence involved. A set is the right tool when you only care about membership and uniqueness, not the tool for anything where "3rd item" needs to mean something reliable.


filter(), map(), and reduce(): The Big Three

These three show up constantly once you start writing more compact code, and they're easiest to understand side by side, since they all take a function and an iterable but do three genuinely different things with them.

filter() map() reduce() [1,2,3,4,5,6] keep evens? [2, 4, 6] shorter list survivors only [1,2,3,4] square each [1, 4, 9, 16] same length every item transformed [1,2,3,4] 1+2=3, 3+3=6, 6+4=10 10 one final value rolled up pairwise All three take a function and an iterable. The difference is entirely in the shape of what comes back: a shorter list, an equal-length list, or a single collapsed value.
filter, map, and reduce at a glance

filter()

python
def find_positive(num):
    if num > 0:
        return num

list1 = list(range(-10, 11))
positive_values = list(filter(find_positive, list1))
positive_values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

This gets the right answer, but it's worth noticing exactly why: find_positive never actually returns True or False. For a positive number it returns the number itself; for anything else, since there's no else, it falls through and implicitly returns None. filter() doesn't require an actual boolean, just something Python considers truthy or falsy, and both a positive number and None fit that bill correctly here. It works, but a function written to explicitly return num > 0 says the same thing more clearly and doesn't rely on a reader knowing Python's truthiness rules. The Airbnb example below does exactly that.

Airbnb: Filtering Search Results. A search returns thousands of listings; the site only needs the ones under a price cutoff.

python
listings = [
    {"title": "Cozy Studio", "price": 95},
    {"title": "Luxury Villa", "price": 450},
    {"title": "Downtown Loft", "price": 130},
    {"title": "Beachfront Condo", "price": 200}
]

def is_affordable(listing):
    return listing["price"] < 150

affordable_places = list(filter(is_affordable, listings))

for place in affordable_places:
    print(f"Found: {place['title']} - ${place['price']}/night")
Found: Cozy Studio - $95/night
Found: Downtown Loft - $130/night

map()

python
def square(num):
    return num ** 2

list2 = [2, 65, 32, 11, 7, 8]
mapped_values = list(map(square, list2))
mapped_values = [4, 4225, 1024, 121, 49, 64]

Spotify: Currency Conversion for Royalties. Every value in an earnings list needs the same conversion applied to it, which is exactly what map() is for.

python
usd_earnings = [100.50, 250.00, 45.75, 800.20]
exchange_rate = 0.92  # 1 USD = 0.92 EUR

def convert_to_euros(amount):
    return round(amount * exchange_rate, 2)

eur_earnings = list(map(convert_to_euros, usd_earnings))

print(f"Original USD: {usd_earnings}")
print(f"Converted EUR: {eur_earnings}")
Original USD: [100.5, 250.0, 45.75, 800.2]
Converted EUR: [92.46, 230.0, 42.09, 736.18]

reduce()

Unlike filter and map, reduce isn't a built-in you can call directly; it lives in the functools module. Here's the plain loop it replaces, side by side with the function itself:

python
# The manual version
product = 1
lst = [1, 2, 3, 4]
for num in lst:
    product *= num
print(product)   # 24
python
from functools import reduce

def multiply(x, y):
    return x * y

list3 = [1, 2, 3, 4, 5]
product = reduce(multiply, list3)   # 120

reduce calls multiply on the first two items, then calls it again on that result and the third item, and so on, carrying a running total forward until only one value is left.

Amazon Logistics: Total Freight Weight. A cargo plane's total load weight is exactly this kind of rolling sum.

python
from functools import reduce

package_weights = [12.5, 4.0, 8.2, 1.5, 20.0]

def add_weights(weight1, weight2):
    return weight1 + weight2

total_cargo_weight = reduce(add_weights, package_weights)

print(f"Individual packages: {package_weights}")
print(f"Total takeoff weight: {total_cargo_weight} kg")
Individual packages: [12.5, 4.0, 8.2, 1.5, 20.0]
Total takeoff weight: 46.2 kg

Anonymous Functions: lambda

A lambda is a function without a name, written on a single line. The rules are a little different from a normal def:

Syntax: lambda parameters: expression

A normal function and its lambda equivalent, side by side:

python
def addition(num1, num2):
    return num1 + num2
python
lambda num1, num2: num1 + num2

A slightly more involved one, including a conditional expression inside the lambda:

python
def maximum(num1, num2):
    if num1 > num2:
        return num1
    else:
        return num2
python
maximum = lambda num1, num2: num1 if num1 > num2 else num2

maximum(23, 45)   # 45

Lambdas are used constantly alongside filter, map, and reduce, specifically because writing a full def for a one-line throwaway function is more ceremony than the logic deserves:

python
lst = [1, 2, 3, 4, 5]

even_lst = list(filter(lambda num: num % 2 == 0, lst))
print(even_lst)                                          # [2, 4]

new_lst = list(map(lambda x: x ** 2, lst))
print(new_lst)                                            # [1, 4, 9, 16, 25]

from functools import reduce
product_lst = reduce(lambda x, y: x * y, lst)
print(product_lst)                                         # 120

X (Twitter): Sorting Trending Topics. sorted() accepts a key function that tells it what to compare, and a lambda is the natural fit for "just look at this one field."

python
trending_topics = [
    {"hashtag": "#Python", "volume": 15000},
    {"hashtag": "#TechNews", "volume": 8500},
    {"hashtag": "#Coding", "volume": 22000},
    {"hashtag": "#AI", "volume": 45000}
]

sorted_trends = sorted(trending_topics, key=lambda topic: topic["volume"], reverse=True)

for rank, trend in enumerate(sorted_trends[:3], start=1):
    print(f"{rank}. {trend['hashtag']} ({trend['volume']} tweets)")
1. #AI (45000 tweets)
2. #Coding (22000 tweets)
3. #Python (15000 tweets)

Without the lambda, sorting by volume would need a separate named function defined purely to extract one dictionary key, used exactly once and never again.


Recursive Functions

A recursive function is a function that calls itself, working on a smaller version of the same problem each time, until it reaches a case simple enough to answer directly.

python
def factorial(num):
    if num == 0 or num == 1:
        return 1
    else:
        return num * factorial(num - 1)

num = 7
print(f"Factorial of {num} is {factorial(num)}")
Factorial of 7 is 5040

Two parts make a recursive function work: a base case (num == 0 or num == 1, where the function stops calling itself and just returns an answer) and a recursive case (where it calls itself with a smaller input, moving toward that base case). Skip the base case entirely and the function calls itself forever, until Python gives up with a RecursionError.

Tracing factorial(4) shows the two-phase shape every recursive function has: it keeps calling itself downward until it hits the base case, then those calls resolve upward, each one multiplying by the number it was waiting on.

CALLING DOWN RETURNING UP factorial(4) factorial(3) factorial(2) factorial(1) → 1 base case, stops here 2 × 1 = 2 3 × 2 = 6 4 × 6 = 24 factorial(4) = 24 Each call waits for the one below it to finish before it can multiply and return. Nothing actually multiplies until factorial(1) hits the base case and the unwinding begins.
Tracing factorial(4): down to the base case, then back up

Reddit: Counting Nested Comment Threads. A comment can have replies, and those replies can have their own replies, arbitrarily deep. There's no way to know the depth in advance, which is exactly the situation recursion is built for.

python
reddit_thread = {
    "text": "Great tutorial!",
    "replies": [
        {
            "text": "I agree, very helpful.",
            "replies": [
                {"text": "Helped me pass my test.", "replies": []}
            ]
        },
        {
            "text": "Could you do one on OOP next?",
            "replies": []
        }
    ]
}

def count_total_comments(comment_node):
    total = 1  # count the current comment itself
    for reply in comment_node["replies"]:
        total += count_total_comments(reply)  # recurse into each reply
    return total

total_count = count_total_comments(reddit_thread)
print(f"Total comments in this thread: {total_count}")
Total comments in this thread: 4

Here the base case is implicit: a comment with an empty "replies" list simply doesn't loop at all, and returns 1 on its own.


Higher-Order Functions

A higher-order function is any function that takes another function as an argument, returns a function, or both. filter, map, and reduce are all higher-order functions themselves, since every one of them accepts a function as its first argument.

python
def apply_to_each(func, iterable):
    return [func(x) for x in iterable]

def square(x):
    return x * x

numbers = [1, 2, 3, 4, 5]
squared_numbers = apply_to_each(square, numbers)
print(squared_numbers)
[1, 4, 9, 16, 25]

The other direction, a function that returns a function, is called a closure, and it's worth slowing down on:

python
def create_adder(x):
    def adder(y):
        return x + y
    return adder

add_15 = create_adder(15)
print(add_15(10))
25

create_adder(15) doesn't add anything by itself; it builds and returns a brand-new adder function that has permanently remembered x = 15. add_15 is that returned function, and every time it's called it still has access to the 15 from the call that created it, even though create_adder itself finished running long ago. This is the mechanism behind a lot of customizable, reusable code: instead of one rigid add15() function, create_adder can stamp out an add_5, an add_100, or any other adder, on demand.

create_adder(15) runs once, builds a new adder() function then finishes and exits returns add_15 def adder(y): return x + y 🎒 remembers: x = 15 add_15(10) 15 + 10 = 25 add_15 keeps its own private copy of x = 15, permanently, even though create_adder already finished running.
A closure: add_15 permanently remembers x = 15

AWS: Execution Timers for Billing. Cloud billing platforms need to measure how long arbitrary customer code takes to run, without knowing in advance what that code does. A higher-order "wrapper" function solves this: it accepts the customer's function as an argument, times it, and reports the result.

python
import time

def execution_timer(customer_function, data):
    print("AWS Cloud: Starting execution timer...")
    start_time = time.time()

    customer_function(data)  # the customer's own function, passed in as an argument

    end_time = time.time()
    duration = end_time - start_time
    print(f"AWS Cloud: Execution finished. Billed for {duration:.5f} seconds.\n")

def process_data(data_list):
    print("   Customer Code: Processing data...")
    time.sleep(0.5)
    print(f"   Customer Code: Processed {len(data_list)} items.")

user_data = [1, 2, 3, 4, 5]
execution_timer(process_data, user_data)

execution_timer never needs to know what process_data actually does; it just needs to know it's a function it can call. Any other customer function could be passed into the exact same execution_timer without changing a single line of it.


Quick Reference Summary

Tool Takes Returns Use it when
enumerate(iterable) one iterable pairs of (index, value) you need each item's position while looping
zip(a, b, ...) two or more iterables tuples pairing up matching positions combining parallel sequences (use lists/tuples, not sets)
filter(func, iterable) a function + an iterable only the items where func is truthy narrowing a list down by a condition
map(func, iterable) a function + an iterable every item transformed by func applying the same operation to every item
reduce(func, iterable) a function + an iterable (from functools) a single collapsed value rolling totals, products, or combining everything into one result
lambda params: expr any number of parameters the expression's value a small, throwaway function, often passed straight into filter/map/sorted
Recursive function itself, on a smaller input eventually, a base-case value problems with unknown or arbitrary depth (nested data, tree structures)
Higher-order function and/or returns another function varies building flexible, reusable wrappers around other functions

filter, map, and reduce all answer the same underlying question, "what do I do with every item in this collection?", just with three different endings: keep some, transform all, or combine everything into one. Lambda, recursion, and higher-order functions aren't separate tools so much as three different answers to "what is a function allowed to look like?" Once those two ideas are separate in your head, reading unfamiliar functional-style code gets a lot less intimidating.

Where this leaves you: filter, map, and reduce all answer the same question, what do I do with every item in this collection, just with three different endings. Lambda, recursion, and higher-order functions are three different answers to what a function is allowed to look like. Once those two ideas are separate in your head, functional-style code reads a lot less like magic.