Skip to main content

Python Higher-Order Functions Practice Problems & Exercises

Practice: Higher-Order Functions

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Map with a Named FunctionEasy
mapnamed-functionbasics

Use map() with math.sqrt to compute the square root of each number in the list. Print the result as a list.

Python
import math

numbers = [1, 4, 9, 16, 25]
roots = list(map(math.sqrt, numbers))
print(roots)
Solution
import math

numbers = [1, 4, 9, 16, 25]
roots = list(map(math.sqrt, numbers))
print(roots)

Output:

[1.0, 2.0, 3.0, 4.0, 5.0]

How it works: map(math.sqrt, numbers) applies math.sqrt to each element in numbers and returns a lazy map object. Wrapping it in list() materializes all the results. Since math.sqrt is already a named function, there is no need for a lambda wrapper — map(math.sqrt, numbers) is cleaner than map(lambda x: math.sqrt(x), numbers).

Key insight: When you already have a named function that matches the signature you need, pass it directly to map. This is the scenario where map is more readable than a list comprehension.

import math

numbers = [1, 4, 9, 16, 25]

# Use map() with math.sqrt to compute square roots
# Print the result as a list
Expected Output
[1.0, 2.0, 3.0, 4.0, 5.0]
Hints

Hint 1: map(func, iterable) applies func to each element and returns a lazy iterator.

Hint 2: You need to wrap the result in list() to materialize it.

Hint 3: math.sqrt is already a named function — pass it directly to map without a lambda.

#2Filter with a PredicateEasy
filterpredicatelambda

Use filter() to keep only valid-looking email addresses from the list. An address is valid if it contains "@" and has a "." somewhere after the "@".

Python
emails = [
    "[email protected]",
    "not-an-email",
    "[email protected]",
    "",
    "[email protected]",
    "missing-at-sign.com",
]

valid = list(filter(lambda e: "@" in e and "." in e.split("@")[-1], emails))
print(valid)
Solution
emails = [
"not-an-email",
"",
"missing-at-sign.com",
]

valid = list(filter(lambda e: "@" in e and "." in e.split("@")[-1], emails))
print(valid)

Output:

How it works: filter() applies the lambda to each element and keeps only those for which it returns True. The lambda checks two conditions with short-circuit and: first that "@" exists in the string, then that "." appears in the domain part (everything after the last "@").

Key insight: filter() returns a lazy iterator just like map(). If you only need to iterate once (e.g., in a for loop), you do not need list(). Converting to a list is only necessary when you need indexed access or want to print the full result.

emails = [
  "[email protected]",
  "not-an-email",
  "[email protected]",
  "",
  "[email protected]",
  "missing-at-sign.com",
]

# Use filter() to keep only strings that contain "@"
# and have a "." after the "@"
# Print the result as a list
Expected Output
Hints

Hint 1: filter(func, iterable) keeps elements where func returns True.

Hint 2: A basic email check: the string must contain "@" and have a "." after the "@".

Hint 3: You can use a lambda or define a named function.

#3Reduce to Compute a ProductEasy
reduceaccumulatorfunctools

Use functools.reduce() to compute the product of all numbers in the list. Provide an initial value of 1.

Python
from functools import reduce

numbers = [1, 2, 3, 4, 5]
product = reduce(lambda acc, x: acc * x, numbers, 1)
print(product)
Solution
from functools import reduce

numbers = [1, 2, 3, 4, 5]
product = reduce(lambda acc, x: acc * x, numbers, 1)
print(product)

Output:

120

How it works: reduce(func, iterable, initial) starts with acc = 1 (the initial value), then repeatedly applies func(acc, x) for each element. The accumulation proceeds as: 1 * 1 = 1, 1 * 2 = 2, 2 * 3 = 6, 6 * 4 = 24, 24 * 5 = 120.

Why provide an initial value? Without initial, reduce uses the first element as the starting accumulator. That works here, but if the list were empty, it would raise TypeError. Always provide an initial value for safety — 1 for multiplication, 0 for addition, [] for list concatenation.

from functools import reduce

numbers = [1, 2, 3, 4, 5]

# Use reduce() to compute the product of all numbers
# Provide an initial value of 1
# Print the result
Expected Output
120
Hints

Hint 1: reduce is in the functools module — import it with: from functools import reduce.

Hint 2: The function takes two arguments: the accumulator and the current element.

Hint 3: For multiplication, the initial value should be 1 (the multiplicative identity).

#4Sorted with a Key FunctionEasy
sortedkey-functionlambda

Use sorted() with a key function to sort the list of words by length (shortest first).

Python
words = ["banana", "apple", "cherry", "kiwi", "grape"]
by_length = sorted(words, key=len)
print(by_length)
Solution
words = ["banana", "apple", "cherry", "kiwi", "grape"]
by_length = sorted(words, key=len)
print(by_length)

Output:

['kiwi', 'apple', 'grape', 'banana', 'cherry']

How it works: sorted(words, key=len) applies len to each word before comparing. The comparison values are [6, 5, 6, 4, 5], so "kiwi" (4) comes first, then "apple" and "grape" (5), then "banana" and "cherry" (6). Words with equal length retain their original relative order because Python's sort is stable.

Key insight: sorted(), min(), and max() are all higher-order functions because they accept a key= argument that is a function. When the key function already exists as a built-in (like len, str.lower, abs), pass it directly — no lambda needed.

words = ["banana", "apple", "cherry", "kiwi", "grape"]

# Sort the words by their length (shortest first)
# Print the sorted list
Expected Output
['kiwi', 'apple', 'grape', 'banana', 'cherry']
Hints

Hint 1: sorted() accepts a key= argument — a function applied to each element before comparison.

Hint 2: Use len as the key function to sort by string length.

Hint 3: sorted() returns a new list and does not modify the original.


Medium

#5Chaining Map and Filter LazilyMedium
mapfilterlazy-pipelinechaining

Chain filter() and map() to first keep only languages with more than 4 characters, then convert them to uppercase. Do not create any intermediate lists — use lazy chaining.

Python
languages = ["go", "python", "c", "javascript", "rust", "typescript", "r"]

long_langs = filter(lambda s: len(s) > 4, languages)
uppered = map(str.upper, long_langs)
print(list(uppered))
Solution
languages = ["go", "python", "c", "javascript", "rust", "typescript", "r"]

long_langs = filter(lambda s: len(s) > 4, languages)
uppered = map(str.upper, long_langs)
print(list(uppered))

Output:

['PYTHON', 'JAVASCRIPT', 'TYPESCRIPT']

How it works: filter() returns a lazy iterator that yields only elements where len(s) > 4. map() wraps that iterator and applies str.upper to each yielded element. No intermediate list is created — at any point, only one string is in memory per pipeline stage. The entire chain only executes when list() consumes the final iterator.

Key insight: Lazy chaining with filter and map is memory-efficient for large datasets. Each element flows through the full pipeline before the next element is processed. This is the same principle behind Unix pipes and Spark transformations.

languages = ["go", "python", "c", "javascript", "rust", "typescript", "r"]

# Step 1: filter() to keep only languages with more than 4 characters
# Step 2: map() to convert the filtered results to uppercase
# Step 3: materialize with list() and print
Expected Output
['PYTHON', 'JAVASCRIPT', 'TYPESCRIPT']
Hints

Hint 1: Chain filter() then map() — both return lazy iterators.

Hint 2: filter keeps elements where the predicate is True. Use len to check length.

Hint 3: map applies a transformation — str.upper converts a string to uppercase.

#6functools.partial for Specialized FunctionsMedium
partialfunctoolsfunction-factory

Use functools.partial() to create two specialized JSON serializers from json.dumps:

  1. pretty_json — indented with 2 spaces, keys sorted
  2. compact_json — no extra whitespace (separators (",", ":"))
Python
import json
from functools import partial

data = {"name": "Alice", "role": "engineer"}

pretty_json = partial(json.dumps, indent=2, sort_keys=True)
compact_json = partial(json.dumps, separators=(",", ":"))

print(pretty_json(data))
print(compact_json(data))
Solution
import json
from functools import partial

data = {"name": "Alice", "role": "engineer"}

pretty_json = partial(json.dumps, indent=2, sort_keys=True)
compact_json = partial(json.dumps, separators=(",", ":"))

print(pretty_json(data))
print(compact_json(data))

Output:

{
"name": "Alice",
"role": "engineer"
}
{"name":"Alice","role":"engineer"}

How it works: partial(json.dumps, indent=2, sort_keys=True) creates a new callable that remembers indent=2 and sort_keys=True. When you call pretty_json(data), it effectively calls json.dumps(data, indent=2, sort_keys=True). The stored keyword arguments are merged with any new arguments provided at call time.

Why partial over lambda? partial objects are picklable (lambdas are not), which matters for multiprocessing. You can also inspect the original function and pre-filled arguments via pretty_json.func, pretty_json.args, and pretty_json.keywords.

import json
from functools import partial

data = {"name": "Alice", "role": "engineer"}

# Create pretty_json: partial of json.dumps with indent=2, sort_keys=True
# Create compact_json: partial of json.dumps with separators=(",", ":")
# Print pretty_json(data)
# Print compact_json(data)
Expected Output
{"name": "Alice", "role": "engineer"}
{"name":"Alice","role":"engineer"}
Hints

Hint 1: functools.partial(func, *args, **kwargs) returns a new callable with some arguments pre-filled.

Hint 2: json.dumps accepts indent and sort_keys keyword arguments.

Hint 3: Create two partial functions: one for pretty printing and one for compact output.

#7Functions Returning Functions: Multiplier FactoryMedium
closurefunction-factoryreturning-functions

Write a function make_multiplier(factor) that returns a new function. The returned function should take a single number and return that number multiplied by factor. Then create triple and quadruple from it.

Python
def make_multiplier(factor):
    def multiply(n):
        return n * factor
    return multiply

triple = make_multiplier(3)
quadruple = make_multiplier(4)

print(triple(5))
print(quadruple(7))
print(make_multiplier(10)(5))
Solution
def make_multiplier(factor):
def multiply(n):
return n * factor
return multiply

triple = make_multiplier(3)
quadruple = make_multiplier(4)

print(triple(5)) # 15
print(quadruple(7)) # 28
print(make_multiplier(10)(5)) # 50

Output:

15
28
50

How it works: make_multiplier is a higher-order function because it returns a function. The inner function multiply captures factor from the enclosing scope — this is a closure. Each call to make_multiplier creates a new closure with its own factor value. triple permanently holds factor=3, and quadruple holds factor=4.

Key insight: This is category B of higher-order functions — functions that return functions. The returned function carries state (the factor) without using a class or global variable. This pattern is the foundation of decorators and functools.partial.

# Define make_multiplier(factor) that returns a function
# The returned function should take a number and return number * factor

# Create triple = make_multiplier(3)
# Create quadruple = make_multiplier(4)

# Print triple(5)
# Print quadruple(7)
# Print make_multiplier(10)(5)
Expected Output
15
28
50
Hints

Hint 1: A higher-order function can return a new function.

Hint 2: The inner function captures the factor variable from the enclosing scope (closure).

Hint 3: Each call to make_multiplier creates a separate closure with its own factor.

#8Building a Pipeline FunctionMedium
pipelinecomposefunction-chaining

Implement a pipeline() function that takes any number of functions and returns a new function that applies them left-to-right. Use it to build a name-cleaning pipeline that strips, lowercases, replaces spaces with underscores, and adds a "user_" prefix.

Python
def pipeline(*funcs):
    def run(value):
        for f in funcs:
            value = f(value)
        return value
    return run

clean_name = pipeline(
    str.strip,
    str.lower,
    lambda s: s.replace(" ", "_"),
    lambda s: "user_" + s,
)

print(clean_name("  Alice Smith  "))
Solution
def pipeline(*funcs):
def run(value):
for f in funcs:
value = f(value)
return value
return run

clean_name = pipeline(
str.strip,
str.lower,
lambda s: s.replace(" ", "_"),
lambda s: "user_" + s,
)

print(clean_name(" Alice Smith "))

Output:

user_alice_smith

How it works: pipeline() returns a closure (run) that iterates over the stored functions in order, feeding each result into the next function. The data flows left-to-right: " Alice Smith " is stripped to "Alice Smith", lowered to "alice smith", spaces become underscores "alice_smith", and finally the prefix is added: "user_alice_smith".

Pipeline vs compose: pipeline(f, g, h)(x) gives h(g(f(x))) — left-to-right order matches how you read the code. compose(f, g, h)(x) gives f(g(h(x))) — right-to-left, matching mathematical notation. Most engineers prefer pipeline because the execution order matches the reading order.

def pipeline(*funcs):
  # Return a function that applies funcs left-to-right
  pass

# Create a name cleaner that:
# 1. Strips whitespace
# 2. Converts to lowercase
# 3. Replaces spaces with underscores
# 4. Adds "user_" prefix

# Test with: "  Alice Smith  "
Expected Output
user_alice_smith
Hints

Hint 1: A pipeline applies functions left-to-right: pipeline(f, g, h)(x) means h(g(f(x))).

Hint 2: Use a loop inside the inner function: for each func, apply it to the current value.

Hint 3: The pipeline should accept any number of functions via *funcs.


Hard

#9Reduce to Build a Frequency DictionaryHard
reduceaccumulatordict-building

Combine map() and reduce() to count the frequency of log levels. First use map to extract the log level from each entry (the word before the colon, lowercased), then use reduce to accumulate them into a frequency dictionary.

Python
from functools import reduce

logs = [
    "ERROR: connection timeout",
    "WARNING: disk space low",
    "ERROR: null pointer",
    "INFO: server started",
    "WARNING: memory high",
    "ERROR: auth failed",
]

levels = map(lambda entry: entry.split(":")[0].strip().lower(), logs)

freq = reduce(
    lambda acc, level: dict(list(acc.items()) + [(level, acc.get(level, 0) + 1)]),
    levels,
    {}
)
print(freq)
Solution
from functools import reduce

logs = [
"ERROR: connection timeout",
"WARNING: disk space low",
"ERROR: null pointer",
"INFO: server started",
"WARNING: memory high",
"ERROR: auth failed",
]

levels = map(lambda entry: entry.split(":")[0].strip().lower(), logs)

freq = reduce(
lambda acc, level: dict(list(acc.items()) + [(level, acc.get(level, 0) + 1)]),
levels,
{}
)
print(freq)

Output:

{'error': 3, 'warning': 2, 'info': 1}

How it works: The map step extracts and normalizes log levels: "ERROR: connection timeout" becomes "error". The reduce step accumulates a dictionary. On each step, it creates a new dict with the updated count for the current level. acc.get(level, 0) + 1 safely handles the first occurrence of any level.

Alternative (more readable) approach using a named function:

def count_level(acc, level):
updated = dict(acc)
updated[level] = updated.get(level, 0) + 1
return updated

freq = reduce(count_level, levels, {})

When to use this pattern vs collections.Counter: In production, use Counter(levels) — it is faster and clearer. The reduce approach is valuable when your accumulation logic is more complex than simple counting, or when you need to build a custom data structure incrementally.

from functools import reduce

logs = [
  "ERROR: connection timeout",
  "WARNING: disk space low",
  "ERROR: null pointer",
  "INFO: server started",
  "WARNING: memory high",
  "ERROR: auth failed",
]

# Step 1: Use map() to extract the log level (word before the colon)
#         and convert it to lowercase
# Step 2: Use reduce() to build a frequency dict from the log levels
#         Initial value should be an empty dict
# Print the frequency dict
Expected Output
{'error': 3, 'warning': 2, 'info': 1}
Hints

Hint 1: Use reduce with an initial value of an empty dict.

Hint 2: The accumulator function receives the dict so far and the current log level.

Hint 3: Use dict.get(key, 0) to safely handle keys that do not exist yet.

#10Compose with ReduceHard
composereducehigher-order

Implement both compose() and pipeline() using reduce. Then demonstrate that compose(f, g, h) and pipeline(h, g, f) produce the same result for any input.

Python
from functools import reduce
import math

def compose(*funcs):
    def composed(value):
        return reduce(lambda acc, f: f(acc), reversed(funcs), value)
    return composed

def pipeline(*funcs):
    def run(value):
        return reduce(lambda acc, f: f(acc), funcs, value)
    return run

transform = compose(math.sqrt, abs, lambda x: x * x - 100)
print(transform(6))

clean_c = compose(
    lambda s: "user_" + s,
    lambda s: s.replace(" ", "_"),
    str.lower,
    str.strip,
)
clean_p = pipeline(
    str.strip,
    str.lower,
    lambda s: s.replace(" ", "_"),
    lambda s: "user_" + s,
)
print("result of compose:", clean_c("  Hello World  "))
print("result of pipeline:", clean_p("  Hello World  "))
Solution
from functools import reduce
import math

def compose(*funcs):
"""compose(f, g, h)(x) == f(g(h(x))) — right-to-left."""
def composed(value):
return reduce(lambda acc, f: f(acc), reversed(funcs), value)
return composed

def pipeline(*funcs):
"""pipeline(f, g, h)(x) == h(g(f(x))) — left-to-right."""
def run(value):
return reduce(lambda acc, f: f(acc), funcs, value)
return run

# Test compose: x=6 -> 6*6-100 = -64 -> abs(-64) = 64 -> sqrt(64) = 8.0
transform = compose(math.sqrt, abs, lambda x: x * x - 100)
print(transform(6))

# Both produce the same result — just reversed argument order
clean_c = compose(
lambda s: "user_" + s,
lambda s: s.replace(" ", "_"),
str.lower,
str.strip,
)
clean_p = pipeline(
str.strip,
str.lower,
lambda s: s.replace(" ", "_"),
lambda s: "user_" + s,
)
print("result of compose:", clean_c(" Hello World "))
print("result of pipeline:", clean_p(" Hello World "))

Output:

8.0
result of compose: user_hello_world
result of pipeline: user_hello_world

How it works: Both compose and pipeline use reduce to chain function applications. The only difference is compose reverses the function list so the rightmost function runs first (mathematical convention: f(g(h(x)))), while pipeline applies them in the order given (engineering convention: data flows left-to-right).

The key line: reduce(lambda acc, f: f(acc), funcs, value) starts with value as the initial accumulator, then for each function f in the sequence, it replaces the accumulator with f(acc). This is a fold over functions rather than over data — a powerful pattern.

When to use which: pipeline is more intuitive for data processing (ETL, feature engineering) because the execution order matches reading order. compose is conventional in mathematics and in libraries that follow Haskell-style functional programming.

from functools import reduce

def compose(*funcs):
  # compose(f, g, h)(x) == f(g(h(x)))
  # Use reduce and reversed to apply right-to-left
  pass

def pipeline(*funcs):
  # pipeline(f, g, h)(x) == h(g(f(x)))
  # Use a for loop to apply left-to-right
  pass

# Test compose: sqrt of (1 + double(x)) where x = 3
import math
transform = compose(math.sqrt, lambda x: x + 1, lambda x: x * 2)
# x=3 -> double: 6 -> add 1: 7 -> wait, let's recalculate...
# Actually: compose applies right-to-left
# x=3 -> rightmost first: 3*2=6 -> 6+1=7 -> sqrt(7)?
# Hmm, let's pick: compose(math.sqrt, lambda x: x*x, lambda x: x+1)
# x=3 -> 3+1=4 -> 4*4=16 -> sqrt(16)=4.0...
# Let's just use: compose(math.sqrt, lambda x: x**2, abs)
# x=-3 -> abs: 3 -> square: 9 -> sqrt: 3.0... boring
# Better: sqrt(abs(x*x - 100)) for x=6 -> 36-100=-64 -> abs: 64 -> sqrt: 8

transform = compose(math.sqrt, abs, lambda x: x * x - 100)
print(transform(6))

# Test both compose and pipeline give same result with same order
clean_c = compose(
  lambda s: "user_" + s,
  lambda s: s.replace(" ", "_"),
  str.lower,
  str.strip,
)
clean_p = pipeline(
  str.strip,
  str.lower,
  lambda s: s.replace(" ", "_"),
  lambda s: "user_" + s,
)
print("result of compose:", clean_c("  Hello World  "))
print("result of pipeline:", clean_p("  Hello World  "))
Expected Output
8.0
result of compose: user_hello_world
result of pipeline: user_hello_world
Hints

Hint 1: compose(f, g, h)(x) means f(g(h(x))) — rightmost function runs first.

Hint 2: Use reduce with reversed(funcs) to apply right-to-left.

Hint 3: For pipeline, apply funcs left-to-right (no reversal needed).

#11Build a Retry Decorator as a Higher-Order FunctionHard
decoratorhigher-orderretryreal-world

Implement a retry(max_attempts) higher-order function that returns a decorator. The decorator should wrap any function with retry logic: if the function raises an exception, retry up to max_attempts times. Print a message on each failure and on success. If all attempts fail, re-raise the last exception.

Python
from functools import wraps

def retry(max_attempts=3):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, max_attempts + 1):
                try:
                    result = func(*args, **kwargs)
                    print(f"Attempt {attempt} for {func.__name__}: success")
                    return result
                except Exception as e:
                    last_error = e
                    print(f"Attempt {attempt} for {func.__name__} failed: {e}")
            raise last_error
        return wrapper
    return decorator

# Test 1: function that succeeds on attempt 3
call_count = 0

@retry(max_attempts=3)
def fetch_data():
    global call_count
    call_count += 1
    if call_count < 3:
        raise ConnectionError("Connection error")
    return "data from API"

result = fetch_data()
print(f"Got result: {result}")

# Test 2: function that always fails
@retry(max_attempts=3)
def always_fails():
    raise RuntimeError("Service unavailable")

try:
    always_fails()
except RuntimeError as e:
    print(f"Caught expected error: {e}")
Solution
from functools import wraps

def retry(max_attempts=3):
"""Higher-order function: returns a decorator with retry logic."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, max_attempts + 1):
try:
result = func(*args, **kwargs)
print(f"Attempt {attempt} for {func.__name__}: success")
return result
except Exception as e:
last_error = e
print(f"Attempt {attempt} for {func.__name__} failed: {e}")
raise last_error
return wrapper
return decorator

call_count = 0

@retry(max_attempts=3)
def fetch_data():
global call_count
call_count += 1
if call_count < 3:
raise ConnectionError("Connection error")
return "data from API"

result = fetch_data()
print(f"Got result: {result}")

@retry(max_attempts=3)
def always_fails():
raise RuntimeError("Service unavailable")

try:
always_fails()
except RuntimeError as e:
print(f"Caught expected error: {e}")

Output:

Attempt 1 for fetch_data failed: Connection error
Attempt 2 for fetch_data failed: Connection error
Attempt 3 for fetch_data: success
Got result: data from API
Attempt 1 for always_fails failed: Service unavailable
Attempt 2 for always_fails failed: Service unavailable
Attempt 3 for always_fails failed: Service unavailable
Caught expected error: Service unavailable

How it works: retry(max_attempts=3) is a higher-order function that returns decorator. decorator takes func and returns wrapper. This is the "decorator factory" pattern — category C of higher-order functions (takes a function and returns a function). The @wraps(func) preserves the original function's __name__ and __doc__, which is why the print statements correctly show fetch_data and always_fails.

Three levels of functions:

  1. retry(max_attempts) — the factory, configures retry behavior
  2. decorator(func) — the decorator, receives the target function
  3. wrapper(*args, **kwargs) — the replacement function that actually runs with retry logic

Production improvements: In real code, you would add exponential backoff (delay * 2 ** attempt), configurable exception types (only retry on network errors, not on ValueError), and logging instead of print statements. Libraries like tenacity provide battle-tested implementations of this pattern.

from functools import wraps

def retry(max_attempts=3):
  # Return a decorator that wraps func with retry logic
  # On each failed attempt, print which attempt failed
  # After all attempts exhausted, re-raise the last exception
  pass

# Test 1: function that succeeds on attempt 3
call_count = 0

@retry(max_attempts=3)
def fetch_data():
  # Simulates a flaky API
  pass

# Test 2: function that always fails
@retry(max_attempts=3)
def always_fails():
  raise RuntimeError("Service unavailable")
Expected Output
Attempt 1 for fetch_data failed: Connection error
Attempt 2 for fetch_data failed: Connection error
Attempt 3 for fetch_data: success
Got result: data from API
All 3 attempts for always_fails failed
Caught expected error: Service unavailable
Hints

Hint 1: retry(max_attempts) is a higher-order function that returns a decorator.

Hint 2: The decorator takes a function and returns a wrapper that catches exceptions and retries.

Hint 3: Use functools.wraps to preserve the original function name and docstring.

© 2026 EngineersOfAI. All rights reserved.