DEV Community

Cover image for Python’s First-Class Functions: a friendly deep dive
Anik Sikder
Anik Sikder

Posted on

Python’s First-Class Functions: a friendly deep dive

If you’ve ever handed someone a tool instead of doing the job yourself, you already get first-class functions. In Python, functions are values: you can store them in variables, pass them to other functions, return them, put them in lists and dicts, the whole VIP treatment. Once this clicks, a lot of Python suddenly feels smoother and more powerful.

Below is a practical, human-sized tour. We’ll keep the words simple, the examples real, and the pace lively.


What “first-class” really means

def greet(name): 
    return f"Hello, {name}!"

say_hello = greet              # assign
toolbox = {"hi": greet}        # store in dict

def loud(func, name):          # pass as argument
    return func(name).upper()

def greeter(prefix):           # return a function
    def inner(name):
        return f"{prefix} {name}"
    return inner

print(say_hello("Anik"))               # Hello, Anik!
print(loud(greet, "Anik"))             # HELLO, ANIK!
print(greeter("Welcome")("Anik"))      # Welcome Anik
Enter fullscreen mode Exit fullscreen mode

That’s it. Everything else in this article builds on this idea.


Docstrings & annotations: tiny notes, huge payoffs

Docstrings explain why and how. Type annotations hint what goes in and out. They don’t change runtime behavior, but they help your future self (and your team, editors, linters, and tests).

from typing import Iterable, Callable, TypeVar, List

T = TypeVar("T")
U = TypeVar("U")

def transform(items: Iterable[T], fn: Callable[[T], U]) -> List[U]:
    """
    Apply a function to each item and return a list.

    Args:
        items: Any iterable of values (list, tuple, generator…)
        fn: A function that takes a T and returns a U.

    Returns:
        A list of transformed values.
    """
    return [fn(x) for x in items]
Enter fullscreen mode Exit fullscreen mode

A good rule: if a function has non-obvious behavior, add a docstring. If a parameter type isn’t obvious, annotate it.

Quick real-life example:
You’re processing user input from a CSV. Annotate your converter functions so teammates know what shape to expect.


Lambda expressions: tiny functions on the fly

A lambda is a small, one-expression function you define inline.

square = lambda x: x * x
print(square(5))  # 25
Enter fullscreen mode Exit fullscreen mode

Use lambdas for short glue code, especially with sorting and functional helpers.

When to avoid: if the logic needs a name, multiple steps, or a docstring use def.

Real-life example: remove currency symbols and convert to float.

prices = ["$12.99", "$5.50", "$100.00"]
to_number = lambda s: float(s.replace("$", ""))
clean = [to_number(p) for p in prices]   # [12.99, 5.5, 100.0]
Enter fullscreen mode Exit fullscreen mode

Lambdas and sorting: turning chaos into order

Python’s sorted loves a key function. Lambdas shine here.

products = [
    {"name": "Keyboard", "price": 59.99},
    {"name": "Mouse", "price": 25.00},
    {"name": "Monitor", "price": 199.00},
]

# Sort by price ascending
by_price = sorted(products, key=lambda p: p["price"])

# Sort by name length, then alphabetically
by_len_then_name = sorted(
    products, 
    key=lambda p: (len(p["name"]), p["name"])
)
Enter fullscreen mode Exit fullscreen mode

Tip: For speed and readability when sorting dicts/objects, operator.itemgetter and operator.attrgetter (later section) are great.


A playful challenge: “randomize” an iterable using sorted (!!)

“Wait, sorted to shuffle?” Yes, as a trick:

import random
data = [1, 2, 3, 4, 5]
randomized = sorted(data, key=lambda _: random.random())
Enter fullscreen mode Exit fullscreen mode

Each item gets a random key; sorting by those keys yields a random order.

Honest note: this is a cute hack. Prefer random.shuffle(data) for in-place shuffling, it’s faster and clearer. But sometimes you need a non-destructive shuffle (keep the original list unchanged); the sorted(..., key=...) trick works nicely.


Function introspection: peeking under the hood

Python lets you ask functions about themselves.

import inspect

def price_with_tax(price: float, rate: float = 0.15) -> float:
    """Return price plus tax."""
    return round(price * (1 + rate), 2)

print(price_with_tax.__name__)         # 'price_with_tax'
print(price_with_tax.__doc__)          # docstring
print(price_with_tax.__annotations__)  # {'price': float, 'rate': float, 'return': float}
print(price_with_tax.__defaults__)     # (0.15,)

sig = inspect.signature(price_with_tax)
print(sig)                             # (price: float, rate: float=0.15) -> float
for name, param in sig.parameters.items():
    print(name, param.default, param.annotation)
Enter fullscreen mode Exit fullscreen mode

Real-life use: Build a small CLI or web router that reads annotations and default values to generate help text or form fields automatically.


Callables: not just functions

Anything that can be “called” with () is a callable. Functions, methods, lambdas, and objects with a __call__ method.

class RateLimiter:
    def __init__(self, limit_per_minute: int):
        self.limit = limit_per_minute
        self.calls = 0
    def __call__(self) -> bool:
        # toy example: allow first N calls then block
        self.calls += 1
        return self.calls <= self.limit

allow = RateLimiter(3)
print(allow())  # True
print(allow())  # True
print(allow())  # True
print(allow())  # False
Enter fullscreen mode Exit fullscreen mode

You can check callability:

callable(len)          # True
callable(RateLimiter)  # True (classes are callable—they construct objects)
callable(allow)        # True
Enter fullscreen mode Exit fullscreen mode

Why it’s cool: You can pass around behavior with state. Great for caching, throttling, validators, and formatters.


Map, filter, zip & list comprehensions

These are Python’s little assembly line tools.

map(fn, iterable)

Apply a function to each item.

names = ["anik", "sara", "LEE"]
proper = list(map(str.title, names))  # ['Anik', 'Sara', 'Lee']
Enter fullscreen mode Exit fullscreen mode

filter(fn, iterable)

Keep items where fn(item) is truthy.

scores = [95, 50, 77, 88]
passed = list(filter(lambda s: s >= 60, scores))  # [95, 77, 88]
Enter fullscreen mode Exit fullscreen mode

zip(a, b, ...)

Pair items by position.

students = ["Anik", "Sara", "Lee"]
grades = [95, 88, 77]
paired = list(zip(students, grades))  # [('Anik', 95), ('Sara', 88), ('Lee', 77)]
Enter fullscreen mode Exit fullscreen mode

Unzip with the star operator:

names2, marks2 = zip(*paired)
Enter fullscreen mode Exit fullscreen mode

List comprehensions

Often clearer (and Pythonic) than map/filter:

proper = [n.title() for n in names]
passed = [s for s in scores if s >= 60]
Enter fullscreen mode Exit fullscreen mode

Real-life example: Clean CSV rows.

rows = [
    {"name": "  anik ", "age": "23"},
    {"name": "SARA", "age": " 19 "},
]

clean_rows = [
    {"name": r["name"].strip().title(), "age": int(r["age"])}
    for r in rows
]
Enter fullscreen mode Exit fullscreen mode

Reducing functions: folding a sequence into one value

functools.reduce applies a function cumulatively. You can write many things with reduce, but in Python we often prefer built-ins (sum, min, max, any, all) because they’re clearer and faster.

from functools import reduce
from operator import mul

nums = [2, 3, 4]
product = reduce(mul, nums, 1)   # 24
Enter fullscreen mode Exit fullscreen mode

Running totals (folding):

from functools import reduce

transactions = [100, -20, -5, 50]
balance = reduce(lambda acc, t: acc + t, transactions, 0)  # 125
Enter fullscreen mode Exit fullscreen mode

Better alternatives when they exist:

sum(transactions)                      # 125
any(x < 0 for x in transactions)       # True
max(prices, default=0.0)               # simple and clear
Enter fullscreen mode Exit fullscreen mode

Real-life example: count word frequency (but see note!).

from functools import reduce

words = "to be or not to be".split()
freq = reduce(
    lambda acc, w: (acc.update({w: acc.get(w, 0) + 1}) or acc),
    words,
    {}
)
# {'to': 2, 'be': 2, 'or': 1, 'not': 1}
Enter fullscreen mode Exit fullscreen mode

Note: That trick is educational, but in real projects use collections.Counter clearer and quicker.


Partial functions: pre-filling arguments for later

functools.partial creates a new function with some arguments fixed. It’s like setting a default once, then reusing it everywhere.

from functools import partial

def apply_tax(price: float, rate: float) -> float:
    return round(price * (1 + rate), 2)

vat_bd = partial(apply_tax, rate=0.15)    # Bangladesh example VAT ~15%
print(vat_bd(100))  # 115.0
Enter fullscreen mode Exit fullscreen mode

Real-life examples:

  • Formatting and parsing:
  int_base2 = partial(int, base=2)
  print(int_base2("1011"))  # 11
Enter fullscreen mode Exit fullscreen mode
  • Custom rounding:
  round2 = partial(round, ndigits=2)
  print(round2(3.14159))  # 3.14
Enter fullscreen mode Exit fullscreen mode
  • Preconfigured validators, loggers, or HTTP calls (e.g., get_json = partial(requests.get, timeout=5)), keeping your code DRY.

The operator module: tiny, fast, readable helpers

operator provides function versions of Python’s operators and common access patterns. They’re fast and pair beautifully with sorted, map, and reduce.

from operator import itemgetter, attrgetter, methodcaller, add, mul
Enter fullscreen mode Exit fullscreen mode

itemgetter for dicts/tuples

rows = [
    {"city": "Dhaka", "pop": 21_000_000},
    {"city": "Chattogram", "pop": 2_600_000},
]
top = max(rows, key=itemgetter("pop"))
# {'city': 'Dhaka', 'pop': 21000000}
Enter fullscreen mode Exit fullscreen mode

attrgetter for objects

class User:
    def __init__(self, name, age): self.name, self.age = name, age

users = [User("Anik", 23), User("Sara", 21)]
youngest = min(users, key=attrgetter("age"))
Enter fullscreen mode Exit fullscreen mode

methodcaller call a named method

caps = list(map(methodcaller("upper"), ["hi", "there"]))  # ['HI', 'THERE']
Enter fullscreen mode Exit fullscreen mode

Arithmetic operators

from functools import reduce
total = reduce(add, [1, 2, 3, 4], 0)    # 10
area_scale = reduce(mul, [2, 3, 4], 1)  # 24
Enter fullscreen mode Exit fullscreen mode

Why use these? They read like English, avoid tiny lambdas, and can be a tad faster.


Patterns you’ll reuse a lot

  • Strategy pattern (choose behavior at runtime):
  strategies = {
      "percent": lambda price, v: price * (1 - v),
      "flat":    lambda price, v: price - v,
  }
  def apply_discount(kind, price, value):
      return strategies[kind](price, value)
Enter fullscreen mode Exit fullscreen mode
  • Pipelines:
  def pipe(x, *funcs):
      for f in funcs: x = f(x)
      return x

  result = pipe(
      "  hello  ",
      str.strip,
      str.title,
      lambda s: f"{s}!"
  )
  # 'Hello!'
Enter fullscreen mode Exit fullscreen mode
  • Callbacks for events / hooks: pass functions to be called later (GUI events, web hooks, retries).

Tiny “try it” moments (pick a couple!)

  1. Sort by multiple keys: sort a list of student dicts by score desc, then name asc. Use itemgetter and a negative score or key=lambda s: (-s['score'], s['name']).
  2. Callable object: make a Slugify class that remembers allowed characters and converts titles to URL slugs via __call__.
  3. Partial in the wild: create round_bd = partial(round, ndigits=0) and use it inside a map to tidy numbers from a sensor.

Wrap-up

First-class functions are not a fancy trick; they’re the heart of “Pythonic” code:

  • They let you compose behavior like Lego bricks.
  • They keep your code dry, testable, and flexible.
  • They play nicely with the standard library: functools, operator, itertools, and friends.

What’s a small, elegant function you reach for again and again maybe a tiny formatter, a filter you’re proud of, or a callable class that cleaned up a messy workflow? The best patterns often start small and travel far.

Top comments (0)