Skip to main content

Python Functional Programming: map, filter, reduce, lambda, and Closures

Python Functional Programming: map, filter, reduce, lambda, and Closures

🗓️  Jun 21, 2026

Post #5 quietly did something worth returning to. The unit converter’s dispatch dictionary stored actual function names — miles_to_km, km_to_miles — as dictionary values, then retrieved and called them dynamically based on user input. That worked because of a fact about Python that has been used implicitly ever since without being named directly: functions are first-class objects. They can be stored in variables, passed as arguments to other functions, stored in data structures, and returned from other functions — treated exactly like any integer, string, or list.

This single fact is the foundation of functional programming, a style Python supports well without requiring you to abandon everything else covered so far. This post makes explicit what Post #5 used implicitly, covers lambda for small anonymous functions, map/filter/reduce as the classic functional trio, and closures — functions that remember the environment they were created in, used to solve a real problem left open since Post #10.


The Mental Model: Functions Are Values

Every function defined so far in this series has been called by writing its name followed by parentheses: miles_to_km(10). But the name miles_to_km on its own, without the parentheses, is not “calling” anything — it refers to the function object itself, the same way x = 5 makes x refer to the integer 5.

def miles_to_km(miles):
    return miles * 1.60934

converter = miles_to_km   # no parentheses — assigning the function itself, not calling it
print(converter)           # <function miles_to_km at 0x...>
print(converter(10))       # 16.0934 — now calling it, through the new name

This is precisely what made Post #5’s CONVERSIONS dictionary work:

CONVERSIONS = {
    "1": ("Miles to Kilometers", miles_to_km),  # storing the function object as a value
}

self._miles_to_km was retrieved from the dictionary and called with func(value) — no different, structurally, than retrieving any other stored value and using it. Once functions are understood as ordinary values, an entire category of flexible, reusable patterns opens up.


Higher-Order Functions: Functions That Take or Return Functions

A higher-order function is any function that accepts another function as an argument, returns a function as its result, or both.

def apply_twice(func, value):
    """Apply a function to a value, then apply it again to the result."""
    return func(func(value))

def add_ten(x):
    return x + 10

print(apply_twice(add_ten, 5))  # 25 — 5 + 10 = 15, then 15 + 10 = 25

apply_twice does not know or care what func actually does — it just knows it can be called with one argument. This is the essential flexibility functional programming provides: apply_twice works identically whether func is add_ten, miles_to_km, or any other single-argument function you supply.


lambda: Small, Anonymous Functions

For a short function used only once, defining it with def and a name can feel like unnecessary ceremony. lambda creates a small, unnamed function inline:

add_ten = lambda x: x + 10
print(add_ten(5))  # 15

# Equivalent to:
def add_ten(x):
    return x + 10
square = lambda x: x ** 2
add = lambda x, y: x + y
is_even = lambda x: x % 2 == 0

A lambda can only contain a single expression — no if/else statements, no loops, no multiple lines. This is a deliberate constraint, not a limitation to work around: lambda is meant for small, simple operations passed inline; anything requiring real logic belongs in a properly named def function instead, where it can also have a docstring, be tested independently (Post #11), and be reused by name elsewhere.

# Appropriate — short, simple, used inline
numbers.sort(key=lambda x: abs(x))

# Inappropriate — cramming real logic into a lambda hurts readability
result = (lambda x: x * 2 if x > 0 else x * -3 if x < -10 else 0)(value)  # avoid this

map(): Transform Every Item

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

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

# The equivalent list comprehension (Post #3) — often preferred for readability
squared = [x ** 2 for x in numbers]

map(function, iterable) applies function to every item and returns an iterator of the results — list() is needed to see them as an actual list, since map itself produces a lazy iterator rather than computing every result immediately (closely related to the generators covered fully in Post #15). Most experienced Python developers reach for a list comprehension over map with a lambda specifically because the comprehension reads more directly as plain English — but map remains genuinely useful when you already have a named function to apply, without needing a lambda wrapper at all:

temperatures_f = [32, 68, 98.6, 212]
temperatures_c = list(map(fahrenheit_to_celsius, temperatures_f))

filter(): Keep Only What Matches

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4, 6, 8, 10]

# The equivalent list comprehension
evens = [x for x in numbers if x % 2 == 0]

filter(function, iterable) keeps only the items for which function returns something truthy (Post #2’s truthiness rules apply directly here). The same readability preference generally applies — a comprehension with an if clause is usually clearer than filter with a lambda — though filter with an existing named function reads cleanly:

def is_valid_email(email: str) -> bool:
    return "@" in email and "." in email.split("@")[-1]

emails = ["alex@example.com", "not-an-email", "sam@test.org"]
valid_emails = list(filter(is_valid_email, emails))

functools.reduce(): Combine Everything Into One Value

reduce has no direct comprehension equivalent — it repeatedly applies a function to pairs of values, carrying an accumulated result forward, until the entire iterable has been collapsed into a single value.

from functools import reduce

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

total = reduce(lambda acc, x: acc + x, numbers)
print(total)  # 15 — equivalent to sum(numbers), but sum() is the better tool for this specific case

product = reduce(lambda acc, x: acc * x, numbers)
print(product)  # 120 — 1*2*3*4*5, no built-in shortcut for this one

Walking through product step by step: reduce starts with the first two items (1, 2), computes 1 * 2 = 2, then combines that result with the next item (2 * 3 = 6), then the next (6 * 4 = 24), then the next (24 * 5 = 120) — each step’s output becomes the next step’s accumulated input, until nothing is left.

# Finding the longest string in a list
words = ["cat", "elephant", "dog", "hippopotamus"]
longest = reduce(lambda longest, word: word if len(word) > len(longest) else longest, words)
print(longest)  # hippopotamus

reduce genuinely shines for exactly this kind of “combine everything down to one answer” logic that does not fit neatly into sum(), max(), or a comprehension — though for the most common cases (sum, product via math.prod, max, min), Python’s built-in functions are more readable and should be preferred when they apply directly.


Closures: Functions That Remember

A closure is a function that “remembers” variables from the scope it was created in, even after that outer scope has finished executing.

def make_multiplier(factor: float):
    def multiplier(x: float) -> float:
        return x * factor  # factor comes from the enclosing scope, not a parameter
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))   # 10
print(triple(5))    # 15

multiplier is defined inside make_multiplier, and it references factor — a variable from its enclosing function’s scope, not one of its own parameters. Even after make_multiplier(2) finishes running and returns, the resulting double function still has access to factor = 2, permanently attached to it. This is genuinely different from the global/local scope distinction covered in Post #4 — it is a third category, the enclosing function’s scope, remembered specifically by the inner function.

⚠️ The Classic Late-Binding Closure Bug

This catches experienced developers in every language with closures, not just Python:

# BUG — all three functions end up returning the same final value
functions = []
for i in range(3):
    functions.append(lambda: i)

results = [f() for f in functions]
print(results)  # [2, 2, 2] — not [0, 1, 2] as intuition might suggest!

Each lambda does not capture the value of i at the moment it was created — it captures a reference to the variable i itself. By the time any of the lambdas are actually called, the loop has already finished, and i holds its final value, 2, which every single lambda now sees.

# Fixed — the default argument is evaluated immediately, capturing the CURRENT value
functions = []
for i in range(3):
    functions.append(lambda i=i: i)

results = [f() for f in functions]
print(results)  # [0, 1, 2] — correct

Using i=i as a default parameter forces Python to evaluate i’s current value at the moment the lambda is defined (default argument values are evaluated once, at definition time — the same underlying mechanism behind Post #4’s mutable default argument trap) rather than looking it up freshly every time the lambda is eventually called.


A Real Closure: Caching the Currency Converter

Post #10’s final exercise asked you to design, in comments, a caching strategy for get_exchange_rate() so repeated lookups for the same currency pair would not require a fresh API call every time. Closures are exactly the right tool to implement that design for real:

def make_cached_rate_lookup():
    """Returns a function that caches exchange rate lookups, avoiding repeated API calls."""
    cache: dict[tuple[str, str], float] = {}

    def lookup(from_currency: str, to_currency: str) -> float:
        key = (from_currency.upper(), to_currency.upper())
        if key not in cache:
            print(f"Fetching fresh rate for {key[0]}{key[1]}...")
            cache[key] = get_exchange_rate(*key)
        else:
            print(f"Using cached rate for {key[0]}{key[1]}")
        return cache[key]

    return lookup


get_cached_rate = make_cached_rate_lookup()

rate1 = get_cached_rate("USD", "EUR")   # Fetching fresh rate...
rate2 = get_cached_rate("USD", "EUR")   # Using cached rate — no API call, instant
rate3 = get_cached_rate("USD", "GBP")   # different pair — Fetching fresh rate...

cache lives inside make_cached_rate_lookup’s scope, and lookup — the function actually returned and used — closes over it, keeping it alive and private across every subsequent call to get_cached_rate(...), without needing a class, without needing a global variable, and without any code outside this function having any way to accidentally interfere with the cache directly. This is closures solving a genuine, previously-open problem from earlier in this series — not a contrived teaching example.


Real-World Use Cases

Configuration and callbacks: Passing a function as an argument — a “callback” to run under specific conditions — is foundational to event-driven programming, UI frameworks, and the key= argument accepted by sorted(), .sort(), max(), and min(), all of which take a function telling them how to compare items.

Dispatch tables: Post #5’s CONVERSIONS dictionary is a genuine, common real-world pattern — mapping identifiers to the functions that handle them — used constantly in web frameworks (routing a URL to the function that handles it) and command-line tools (routing a subcommand to its handler).

Data pipelines: Chaining filter and map operations (or their comprehension equivalents) to transform raw data step by step is one of the most common shapes real data-processing code takes.

Encapsulating private state without a full class: The cached rate lookup above achieves genuine, protected internal state — exactly what a class’s instance attributes provide — using a much lighter-weight mechanism, appropriate when a full class feels like more ceremony than the problem actually needs.


Common Mistakes and Gotchas

⚠️ Mistake 1: Cramming complex logic into a lambda Covered above — a lambda should stay to a single, simple expression. Anything requiring multiple steps, branching logic, or a docstring belongs in a properly named def function.

⚠️ Mistake 2: The late-binding closure bug Covered in depth above — a lambda (or nested def) created inside a loop captures variables by reference, not by value at creation time. The i=i default-argument fix is the standard, idiomatic solution.

⚠️ Mistake 3: Reaching for map/filter with a lambda when a comprehension would be clearer

# Less readable
result = list(map(lambda x: x * 2, filter(lambda x: x > 0, numbers)))

# More readable — most experienced Python developers prefer this
result = [x * 2 for x in numbers if x > 0]

This is a genuine, ongoing style preference in the Python community — comprehensions are generally favored for simple transform-and-filter operations; map/filter remain valuable specifically when you already have a named function to pass directly, without wrapping it in an unnecessary lambda.

⚠️ Mistake 4: Forgetting that map and filter return lazy iterators, not lists

result = map(lambda x: x * 2, [1, 2, 3])
print(result)          # <map object at 0x...> — not the values themselves!
print(list(result))     # [2, 4, 6] — now you see them

Both map() and filter() produce lazy iterators — nothing is actually computed until you iterate over the result (with a for loop, list(), or similar) — a preview of exactly the generator behavior covered fully in Post #15.

⚠️ Mistake 5: Overusing closures where a simple class or plain function would be clearer to a reader Closures are powerful, but a closure with several nested functions and multiple captured variables can become genuinely harder to follow than the equivalent class from Post #6. Use closures when they solve the problem more directly than the alternatives — not as a demonstration of cleverness.


Performance Note

map and filter’s lazy evaluation means they do not build an entire intermediate list in memory before you need it — for very large datasets processed once, this can be a genuine memory advantage over an eagerly-built list comprehension. For typical, moderately-sized data, list comprehensions are usually just as fast and considerably more readable, which is why they remain the more common default choice in idiomatic Python. Post #15’s full treatment of generators covers exactly when lazy evaluation’s memory advantage genuinely matters versus when it is a premature concern.


Quick Reference

# Functions as values
def my_func(x): return x + 1
alias = my_func           # no parentheses — refers to the function itself
alias(5)                    # calls it — 6

# lambda
square = lambda x: x ** 2
add = lambda x, y: x + y

# map — transform every item (lazy — wrap in list() to see results)
list(map(lambda x: x * 2, [1, 2, 3]))          # [2, 4, 6]

# filter — keep matching items (lazy)
list(filter(lambda x: x > 0, [-1, 0, 1, 2]))    # [1, 2]

# reduce — combine into one value
from functools import reduce
reduce(lambda acc, x: acc + x, [1, 2, 3, 4])     # 10

# Closures
def outer(value):
    def inner():
        return value  # captured from outer's scope
    return inner

# The late-binding fix
[lambda i=i: i for i in range(3)]  # captures current value, not the variable itself

Exercises

Exercise 1 — Direct application Using filter and map (or the equivalent comprehensions — write both versions and compare), take a list of dictionaries representing employees (from Post #6’s shape: {"name": ..., "salary": ...}) and produce a list of names for employees earning over $80,000.

Exercise 2 — Slight variation Write a closure make_counter() that returns a function which, each time it is called, returns the next integer starting from 0 (first call returns 0, second call returns 1, and so on) — with the count itself stored privately inside the closure, not as a global variable.

Exercise 3 — Real-world combination Using functools.reduce, write a function that takes a list of BankAccount objects (Post #6/#7) and returns the combined total balance across all of them, without using a for loop or sum() directly.

Exercise 4 — Open-ended challenge The make_cached_rate_lookup() closure in this post never expires its cached rates — a rate fetched this morning stays cached forever, even though real exchange rates change throughout the day. Sketch (in comments, or working code if you want to push further) how you would add a time-based expiration, so a cached rate older than, say, 60 seconds triggers a fresh API call instead of returning stale data.


FAQ

Q: Is Python a “functional programming language” like Haskell or Clojure? A: No — Python is a multi-paradigm language that supports functional programming techniques (first-class functions, map/filter/reduce, closures) alongside the object-oriented and procedural styles used throughout the rest of this series. Python does not enforce immutability or pure functions the way dedicated functional languages do; it simply makes functional patterns available as one more tool.

Q: Should I always prefer list comprehensions over map/filter? A: For simple, single-step transformations and filters, yes — comprehensions are more commonly considered more readable by the Python community. map/filter remain genuinely useful specifically when you already have a named function ready to pass directly, without needing a lambda wrapper, or when working with very large iterables where the laziness matters.

Q: What’s actually different between a closure and a regular nested function? A: Every nested function technically has access to its enclosing scope’s variables while that outer function is running. What makes it a closure specifically is that the inner function continues to have access to those variables even after the outer function has finished executing and returned — which is exactly what makes make_multiplier and make_cached_rate_lookup work as shown in this post.

Q: Why does reduce need to be imported from functools while map and filter don’t need any import at all? A: This reflects a real historical design decision — map and filter are built-in functions, while reduce was deliberately moved out of the built-ins and into the functools module in Python 3, based on the view that most everyday uses of reduce are more clearly expressed with a simple loop, a comprehension, or a specific built-in like sum() or max().


Summary and Next Steps

You now understand that functions in Python are ordinary values — storable, passable, returnable — exactly the mechanism that made Post #5’s dispatch dictionary work. You can write small lambda functions appropriately, use map/filter/reduce and know when a comprehension would be clearer instead, and build closures that maintain private state across calls without a full class — including a working, cached currency rate lookup that directly resolves an exercise left open since Post #10.

Your next step: Complete Exercise 2 — the make_counter() closure — since building something this small and self-contained is the fastest way to genuinely internalize how a closure keeps state alive between calls, before applying the pattern to something more complex like the cached rate lookup.

The next post builds directly on everything here: decorators, which are themselves higher-order functions that take a function and return a modified version of it — the exact mechanism behind @pytest.fixture and @pytest.mark.parametrize, both used constantly since Post #11 without ever explaining how they actually work.


Code tested with Python 3.13. Last updated: June 2026.

Share This Post

Enjoyed this article?

Get notified when we publish new guides and tutorials. No spam, unsubscribe anytime.

📬 Newsletter coming soon — stay tuned!

The information contained on this blog is for academic and educational purposes only. Unauthorized use and/or duplication of this material without express and written permission from this site’s author and/or owner is strictly prohibited. The materials (images, logos, content) contained in this web site are protected by applicable copyright and trademark law.