| layout | default |
|---|---|
| title | Functional Python |
| parent | Lessons |
| nav_order | 4 |
| permalink | /lessons/functional-python/ |
| course_lesson | true |
| course_index | 04 |
| previous_page | /lessons/descriptors-metaclasses/ |
| previous_title | Descriptors and Metaclasses |
| next_page | /lessons/generators-coroutines/ |
| next_title | Generators and Coroutines |
Functional style builds programs from small transformations, treats functions as values, and limits hidden mutation. Python is multi-paradigm: use functional ideas where they clarify data flow, not as a rule that forbids every loop or object.
Imagine a line of stations cleaning, checking, and packing fruit. Each station receives something and produces the next result. If a station secretly changes fruit in another basket, the line becomes difficult to trust. Functional pipelines try to make every transformation visible.
raw rows -> clean -> validate -> transform -> group -> report
A function can be stored, passed, and returned:
def double(number):
return number * 2
def apply_to_all(function, values):
return [function(value) for value in values]
operation = double
print(apply_to_all(operation, [1, 2, 3]))A function that accepts or returns another function is a higher-order function. Use a parameter such as function or a business name such as pricing_rule; do not hide what callers must provide.
A pure function returns a result based only on its inputs and does not change outside state. Printing, file access, network access, database updates, clock reads, and random numbers are side effects.
def calculate_tax(amount, rate):
return amount * ratePure functions are easy to test and combine. Real applications still need side effects. Keep them at clear boundaries:
raw_text = path.read_text(encoding="utf-8") # side effect
report = build_report(raw_text) # pure transformation
output_path.write_text(report, encoding="utf-8") # side effectdef add_total(record):
updated = dict(record)
updated["total"] = updated["price"] * updated["quantity"]
return updatedThe function returns a new top-level dictionary. This is a shallow copy: nested mutable objects are still shared. Use immutable records or intentional deeper copying when nested ownership matters; blindly deep-copying large graphs can be expensive and may copy objects that should remain shared.
These are equivalent for a simple transformation:
names = [" asha ", " BEN "]
cleaned = [name.strip().title() for name in names]
cleaned_with_map = list(map(str.title, map(str.strip, names)))The comprehension is easier to read here. map() can be useful when an existing named function directly represents the operation. A normal loop is clearer when work needs several conditions, error handling, or intermediate names.
A reduction combines many values into one:
from functools import reduce
from operator import mul
product = reduce(mul, [2, 3, 4], 1)
print(product)The initializer 1 makes the empty product defined. Prefer specialized built-ins such as sum, min, max, any, and all when they express the operation directly.
functools.partial creates a callable with some arguments already selected:
from functools import partial
def format_money(amount, *, currency, decimals):
return f"{currency} {amount:.{decimals}f}"
format_rupees = partial(format_money, currency="INR", decimals=2)
print(format_rupees(125.5))The returned partial object stores references to the original function and pre-filled arguments. If a pre-filled mutable argument later changes, calls observe that change.
def minimum_length_rule(minimum):
def is_valid(text):
return len(text) >= minimum
return is_valid
is_valid_username = minimum_length_rule(4)The inner function closes over the name minimum. Closures capture variables through cells, not frozen textual values. This matters in loops where late binding can make every function use the final loop value.
def run_pipeline(value, *stages):
result = value
for stage in stages:
result = stage(result)
return result
def strip_rows(rows):
return [row.strip() for row in rows]
def remove_empty(rows):
return [row for row in rows if row]
result = run_pipeline(
[" apple ", "", " banana "],
strip_rows,
remove_empty,
)
print(result)The stages use lists, so this pipeline is eager. For very large or infinite input, return iterators and ensure no stage accidentally calls list().
match is structural pattern matching, not a replacement for every if:
def handle(command):
match command:
case {"type": "add", "item": str(item)}:
return f"adding {item}"
case {"type": "remove", "id": int(item_id)}:
return f"removing {item_id}"
case _:
raise ValueError("unsupported command")A pattern can both test and bind names. Document accepted shapes and keep validation errors understandable.
Every Python function is an object containing code, defaults, annotations, globals, and possibly closure cells. Calling it creates an execution frame with local state. Many tiny Python-level calls can cost more than a built-in loop implemented in C, so a highly fragmented functional pipeline may be slower even when logically elegant.
Immutability in Python is normally by convention and type choice, not automatic deep immutability. A frozen dataclass prevents normal field assignment, but a mutable list inside it can still change.
def normalize(records):
for record in records:
record["name"] = record["name"].strip()
return recordschecks = [lambda value: value > limit for limit in [10, 20, 30]]product = reduce(mul, values)Show Bug Hunter fixes
- Return new records or clearly document in-place mutation.
- Bind each current value, for example
lambda value, limit=limit: value > limit, or use a named factory. - Supply the identity initializer
1when an empty input should produce a valid product.
- Write a higher-order function that transforms a sequence.
- Compare a comprehension,
map, and normal loop for cleaning names. - Separate file I/O from a pure report calculation.
- Reduce numbers to a product with defined empty behavior.
- Create a reusable pipeline runner.
- Use
partialto create two currency formatters. - Write a closure that validates a configurable numeric range.
- Rewrite a function so it does not mutate its input records.
- Use
matchfor three documented command shapes. - Test a pipeline with empty, invalid, and very large lazy input.
Show hints
- Accept a callable and iterable. 2. Prefer the clearest version, not the shortest. 3. Pass plain data into the calculation. 4. Use
1as identity. 5. Feed each result to the next stage. 6. Fill keyword-only arguments. 7. Return the inner function. 8. Copy at the required ownership depth. 9. End with a rejecting wildcard. 10. Ensure stages do not materialize the stream.
Show solution ideas
- Return a comprehension applying the callable. 2. Use a comprehension unless named functions make
mapclearer. 3. Read once, calculate purely, write once. 4.reduce(mul, values, 1). 5. A small loop is more readable than nested calls. 6. Build INR and USD callables from one formatter. 7. Capture minimum and maximum in a factory. 8. Construct new dictionaries or immutable records. 9. Return or dispatch only after the shape matches. 10. Test with a generator that records how far it was consumed.
Build a report pipeline that cleans, validates, groups, and summarizes records without modifying the original input. Provide both an eager version and a lazy version, then explain their memory and reuse trade-offs.
Explain purity, side effects, shallow immutability, closure binding, and why a readable normal loop can be more Pythonic than a chain of higher-order functions.