DEV Community

Cover image for Python Functions, Arguments & Scope: The Details That Start to Matter
Mr Abdullah
Mr Abdullah

Posted on

Python Functions, Arguments & Scope: The Details That Start to Matter

Python's function syntax looks simple until you start writing reusable libraries, decorators, callbacks, configurable utilities, or APIs. Then small choices around arguments, unpacking, scope, and closures start affecting readability and behavior.

This part focuses on the pieces that sit underneath everyday function calls: *args, **kwargs, positional-only and keyword-only parameters, unpacking, LEGB scope, closures, lambda, and the functional tools map(), filter(), and reduce(). These features are closely related rather than isolated tricks.


Part 1 — Functions, Arguments & Scope

1. *args and **kwargs

What It Is

A Python function normally declares a fixed number of parameters:

def create_user(name, email):
    ...
Enter fullscreen mode Exit fullscreen mode

That is usually the right design. The function tells its caller exactly what information it expects.

Sometimes, however, the number of positional or keyword arguments is not fixed. That's where *args and **kwargs become useful.

*args collects additional positional arguments into a tuple.

**kwargs collects additional keyword arguments into a dictionary.

The important point is that these are not special argument types. They are syntax for packing multiple arguments into a parameter.

def inspect_arguments(*args, **kwargs):
    print(args)
    print(kwargs)
Enter fullscreen mode Exit fullscreen mode

Calling:

inspect_arguments(10, 20, 30, name="Abu", active=True)
Enter fullscreen mode Exit fullscreen mode

produces:

(10, 20, 30)
{'name': 'Abu', 'active': True}
Enter fullscreen mode Exit fullscreen mode

The positional arguments were packed into a tuple, while the keyword arguments were packed into a dictionary.

This becomes particularly useful when writing wrappers, callbacks, decorators, adapters, or functions whose API intentionally accepts an open-ended collection of arguments.


Syntax

def function_name(*args, **kwargs):
    ...
Enter fullscreen mode Exit fullscreen mode

args is simply a conventional name. Python does not require it.

This is equally valid:

def function_name(*values, **options):
    ...
Enter fullscreen mode Exit fullscreen mode

The * tells Python to collect remaining positional arguments.

The ** tells Python to collect remaining keyword arguments.


How It Works

Consider:

def log_values(*args):
    print(args)
Enter fullscreen mode Exit fullscreen mode

When you call:

log_values("error", 404, "/users")
Enter fullscreen mode Exit fullscreen mode

Python effectively packs those positional arguments into:

("error", 404, "/users")
Enter fullscreen mode Exit fullscreen mode

The function receives one local variable named args, and that variable refers to a tuple.

Likewise:

def configure(**kwargs):
    print(kwargs)
Enter fullscreen mode Exit fullscreen mode

Calling:

configure(host="localhost", port=8000, debug=True)
Enter fullscreen mode Exit fullscreen mode

gives:

{
    "host": "localhost",
    "port": 8000,
    "debug": True
}
Enter fullscreen mode Exit fullscreen mode

A useful mental model is:

multiple positional arguments
        ↓
      *args
        ↓
      tuple

multiple keyword arguments
        ↓
    **kwargs
        ↓
     dict
Enter fullscreen mode Exit fullscreen mode

The reverse operation is called unpacking, which becomes important when calling another function.


Real-World Example

Suppose you have a logging utility that adds contextual information to an event:

def log_event(event_name, *messages, **metadata):
    ...
Enter fullscreen mode Exit fullscreen mode

The caller can provide any number of message fragments and arbitrary metadata.

That can be useful for infrastructure utilities, adapters, or generic framework code where the function deliberately does not want to prescribe every possible value.


Handwritten Python Code

from datetime import datetime


def log_event(event_name, *messages, **metadata):
    timestamp = datetime.now().isoformat(timespec="seconds")

    print(f"[{timestamp}] {event_name}")

    for message in messages:
        print(f"  - {message}")

    for key, value in metadata.items():
        print(f"  {key}: {value}")


log_event(
    "user_login",
    "Authentication successful",
    "Session created",
    user_id=42,
    method="password",
    ip_address="192.168.1.10",
)
Enter fullscreen mode Exit fullscreen mode

Output

[2026-08-09T13:00:00] user_login
  - Authentication successful
  - Session created
  user_id: 42
  method: password
  ip_address: 192.168.1.10
Enter fullscreen mode Exit fullscreen mode

The exact timestamp will depend on when the program runs.


Common Mistakes

Mistake 1: Assuming args is a list

def process(*args):
    args.append("new")
Enter fullscreen mode Exit fullscreen mode

This fails because args is a tuple.

If you actually need a mutable collection:

def process(*args):
    values = list(args)
    values.append("new")
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Using *args when the API has a known shape

This:

def calculate_total(*values):
    ...
Enter fullscreen mode Exit fullscreen mode

may be appropriate for a mathematical aggregation.

But if your function always needs exactly:

price
quantity
tax
Enter fullscreen mode Exit fullscreen mode

then this is clearer:

def calculate_total(price, quantity, tax):
    ...
Enter fullscreen mode Exit fullscreen mode

A flexible function is not automatically a better function.

Mistake 3: Treating **kwargs as an excuse to skip API design

This:

def create_user(**kwargs):
    ...
Enter fullscreen mode Exit fullscreen mode

looks flexible, but it also hides the function's contract.

A caller can now pass:

create_user(
    nmae="Abu",
    emial="user@example.com",
)
Enter fullscreen mode Exit fullscreen mode

and the typo may survive until much later.

Explicit parameters provide better readability and often better tooling support.


When to Use It

Use *args when:

  • the number of positional values is intentionally variable
  • you're building wrappers or adapters
  • you're forwarding positional arguments
  • you're implementing APIs such as aggregation utilities

Use **kwargs when:

  • optional named settings are genuinely open-ended
  • you're forwarding keyword arguments
  • you're writing decorators or framework-level utilities
  • a function intentionally accepts arbitrary configuration

When NOT to Use It

Avoid *args or **kwargs when the function's expected inputs are known.

Prefer:

def send_email(recipient, subject, body):
    ...
Enter fullscreen mode Exit fullscreen mode

over:

def send_email(**kwargs):
    ...
Enter fullscreen mode Exit fullscreen mode

The second version pushes responsibility for discovering the API onto the caller.

That usually makes code harder to understand and validate.


Developer Notes

*args and **kwargs also matter when forwarding arguments:

def wrapper(*args, **kwargs):
    return target(*args, **kwargs)
Enter fullscreen mode Exit fullscreen mode

Here the first function receives arguments by packing them and then immediately unpacks them when calling target.

This pattern appears frequently in decorators.

Also remember that *args and **kwargs are just conventional names. The syntax is what matters:

def example(*values, **options):
    ...
Enter fullscreen mode Exit fullscreen mode

Short Takeaway

*args collects extra positional arguments into a tuple. **kwargs collects extra keyword arguments into a dictionary.

Use them when variable argument counts are part of the function's actual design. Don't use them merely to make a function look flexible.


2. Positional-Only / and Keyword-Only * Arguments

What It Is

Python allows a function to control how callers are allowed to pass arguments.

Consider:

def create_user(name, age):
    ...
Enter fullscreen mode Exit fullscreen mode

Both of these calls work:

create_user("Abu", 25)
create_user(name="Abu", age=25)
Enter fullscreen mode Exit fullscreen mode

Sometimes an API should not permit both forms.

Python provides two special markers:

/
Enter fullscreen mode Exit fullscreen mode

marks the end of positional-only parameters.

*
Enter fullscreen mode Exit fullscreen mode

marks the beginning of keyword-only parameters.

For example:

def connect(host, port, /, *, timeout=10):
    ...
Enter fullscreen mode Exit fullscreen mode

Here:

  • host must be positional
  • port must be positional
  • timeout must be passed by keyword

So this works:

connect("localhost", 8000, timeout=5)
Enter fullscreen mode Exit fullscreen mode

but this does not:

connect(host="localhost", port=8000, timeout=5)
Enter fullscreen mode Exit fullscreen mode

Syntax

Positional-only:

def function(value, /):
    ...
Enter fullscreen mode Exit fullscreen mode

Keyword-only:

def function(*, value):
    ...
Enter fullscreen mode Exit fullscreen mode

Both together:

def function(first, second, /, third, *, fourth, fifth=None):
    ...
Enter fullscreen mode Exit fullscreen mode

The categories become:

first, second
    ↓
positional-only

third
    ↓
positional or keyword

fourth, fifth
    ↓
keyword-only
Enter fullscreen mode Exit fullscreen mode

How It Works

The / and * markers do not create variables.

They define the calling convention of the function.

Consider:

def resize(width, height, /, *, quality=80):
    ...
Enter fullscreen mode Exit fullscreen mode

This is intentional API design.

The caller writes:

resize(1920, 1080, quality=90)
Enter fullscreen mode Exit fullscreen mode

The dimensions are positional, while quality is explicitly named.

That distinction can make calls easier to read:

resize(1920, 1080, quality=90)
Enter fullscreen mode Exit fullscreen mode

is clearer than:

resize(1920, 1080, 90)
Enter fullscreen mode Exit fullscreen mode

because the name quality tells the reader what 90 represents.


Real-World Example

Suppose you are designing a function for HTTP requests:

def request(method, url, /, *, timeout=10, retries=3):
    ...
Enter fullscreen mode Exit fullscreen mode

The request method and URL form the core identity of the operation.

The optional configuration values are named because their meaning matters at the call site.


Handwritten Python Code

def create_request(method, url, /, *, timeout=10, retries=3):
    return {
        "method": method.upper(),
        "url": url,
        "timeout": timeout,
        "retries": retries,
    }


request = create_request(
    "get",
    "https://example.com/users",
    timeout=5,
    retries=2,
)

print(request)
Enter fullscreen mode Exit fullscreen mode

Output

{'method': 'GET', 'url': 'https://example.com/users', 'timeout': 5, 'retries': 2}
Enter fullscreen mode Exit fullscreen mode

This API prevents ambiguous calls such as:

create_request("get", "https://example.com/users", 5, 2)
Enter fullscreen mode Exit fullscreen mode

The configuration must be named.


Common Mistakes

Mistake 1: Forgetting what / means

def add(a, b, /):
    return a + b
Enter fullscreen mode Exit fullscreen mode

This is invalid:

add(a=10, b=20)
Enter fullscreen mode Exit fullscreen mode

because both parameters are positional-only.

Mistake 2: Assuming * means variable arguments

Compare:

def example(*args):
    ...
Enter fullscreen mode Exit fullscreen mode

with:

def example(*, timeout):
    ...
Enter fullscreen mode Exit fullscreen mode

They have completely different meanings.

The first collects positional arguments.

The second makes following parameters keyword-only.

Mistake 3: Making everything keyword-only

You can technically write:

def process(*, name, email, age, country, phone):
    ...
Enter fullscreen mode Exit fullscreen mode

but that does not automatically make the API better.

For some APIs, positional arguments are natural and readable. Restrictions should have a reason.


When to Use It

Use positional-only parameters when:

  • parameter names are implementation details
  • you want freedom to rename parameters later
  • positional calling is the natural API
  • you're designing a library with a carefully controlled public interface

Use keyword-only parameters when:

  • an argument is optional configuration
  • the value's meaning is not obvious from the value alone
  • you want calls to be self-documenting
  • several optional arguments could otherwise be confused

When NOT to Use It

Don't add / and * just because advanced Python allows it.

For small internal functions, ordinary parameters are often sufficient:

def calculate_area(width, height):
    return width * height
Enter fullscreen mode Exit fullscreen mode

API restrictions are most useful when they communicate a real design decision.


Developer Notes

These markers become especially valuable in libraries.

If callers are allowed to use:

process(data, timeout=5)
Enter fullscreen mode Exit fullscreen mode

you can later rename data internally without necessarily breaking callers who never depended on its keyword name.

Likewise, keyword-only arguments can prevent accidental argument swaps:

send_email(
    recipient,
    subject,
    body,
    cc="manager@example.com",
    urgent=True,
)
Enter fullscreen mode Exit fullscreen mode

The named configuration is much easier to review.


Short Takeaway

/ controls positional-only parameters. * controls keyword-only parameters.

They are not syntax tricks. They are tools for designing a function's public calling convention.


3. Unpacking

What It Is

Unpacking is the opposite side of argument packing.

Python lets you take values from an iterable or mapping and distribute them into variables or function arguments.

For example:

numbers = [10, 20, 30]

first, second, third = numbers
Enter fullscreen mode Exit fullscreen mode

The iterable is unpacked into three variables.

The same idea works when calling functions:

values = [10, 20, 30]

print(*values)
Enter fullscreen mode Exit fullscreen mode

Here *values means:

Take the elements inside values and pass them as separate positional arguments.

For dictionaries:

options = {
    "sep": "-",
    "end": "!\n",
}

print("Python", "rocks", **options)
Enter fullscreen mode Exit fullscreen mode

**options passes dictionary entries as keyword arguments.


Syntax

Sequence unpacking:

first, second = values
Enter fullscreen mode Exit fullscreen mode

Positional argument unpacking:

function(*values)
Enter fullscreen mode Exit fullscreen mode

Keyword argument unpacking:

function(**mapping)
Enter fullscreen mode Exit fullscreen mode

Extended unpacking:

first, *middle, last = values
Enter fullscreen mode Exit fullscreen mode

How It Works

Consider:

def calculate_total(price, quantity, tax):
    return price * quantity * (1 + tax)


values = [100, 2, 0.10]

calculate_total(*values)
Enter fullscreen mode Exit fullscreen mode

The call behaves as though you had written:

calculate_total(100, 2, 0.10)
Enter fullscreen mode Exit fullscreen mode

The * does not change the function's parameters. It changes how the caller supplies the values.

For dictionaries:

def connect(host, port, timeout):
    ...


config = {
    "host": "localhost",
    "port": 8000,
    "timeout": 5,
}

connect(**config)
Enter fullscreen mode Exit fullscreen mode

This is equivalent to:

connect(
    host="localhost",
    port=8000,
    timeout=5,
)
Enter fullscreen mode Exit fullscreen mode

Real-World Example

Configuration-driven applications often have a dictionary containing options:

database_config = {
    "host": "localhost",
    "port": 5432,
    "timeout": 10,
}
Enter fullscreen mode Exit fullscreen mode

A function can receive those settings without manually repeating every key:

connect_database(**database_config)
Enter fullscreen mode Exit fullscreen mode

That is useful when the dictionary and function signature intentionally have matching keys.


Handwritten Python Code

def create_connection(host, port, *, timeout=10, ssl=True):
    return {
        "host": host,
        "port": port,
        "timeout": timeout,
        "ssl": ssl,
    }


connection_config = {
    "host": "db.internal",
    "port": 5432,
    "timeout": 5,
    "ssl": True,
}

connection = create_connection(**connection_config)

print(connection)
Enter fullscreen mode Exit fullscreen mode

Output

{'host': 'db.internal', 'port': 5432, 'timeout': 5, 'ssl': True}
Enter fullscreen mode Exit fullscreen mode

You can also unpack while constructing collections:

default_headers = {
    "Accept": "application/json",
}

auth_headers = {
    "Authorization": "Bearer token",
}

headers = {
    **default_headers,
    **auth_headers,
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

Mistake 1: Using * with a non-iterable

number = 10

print(*number)
Enter fullscreen mode Exit fullscreen mode

This raises:

TypeError
Enter fullscreen mode Exit fullscreen mode

because an integer is not iterable.

Mistake 2: Passing unexpected dictionary keys

def connect(host, port):
    ...


config = {
    "host": "localhost",
    "port": 8000,
    "debug": True,
}

connect(**config)
Enter fullscreen mode Exit fullscreen mode

This fails because connect() does not accept debug.

Unpacking does not magically adapt an incompatible mapping to a function signature.

Mistake 3: Confusing unpacking with copying

result = [*values]
Enter fullscreen mode Exit fullscreen mode

does create a new list containing the elements, but:

function(*values)
Enter fullscreen mode Exit fullscreen mode

is about supplying function arguments.

The same syntax has different consequences depending on context.


When to Use It

Unpacking is useful for:

  • forwarding arguments
  • passing configuration dictionaries
  • combining collections
  • assigning multiple values
  • handling structured return values
  • writing wrappers and adapters

When NOT to Use It

Avoid excessive unpacking when it hides where values are coming from.

This:

process(*get_values())
Enter fullscreen mode Exit fullscreen mode

can be less readable than:

values = get_values()
process(values[0], values[1], values[2])
Enter fullscreen mode Exit fullscreen mode

if the argument meanings matter and are not obvious.

Shorter syntax is not automatically clearer syntax.


Developer Notes

Extended unpacking is especially useful:

first, *remaining = values
Enter fullscreen mode Exit fullscreen mode

For example:

command, *arguments = ["git", "commit", "-m", "message"]
Enter fullscreen mode Exit fullscreen mode

produces:

command = "git"
arguments = ["commit", "-m", "message"]
Enter fullscreen mode Exit fullscreen mode

The starred target receives the remaining values as a list.

This is useful when parsing command structures, processing records, or separating a first element from the rest.


Short Takeaway

Unpacking lets you distribute values from collections into variables or function arguments.

* handles positional unpacking, while ** handles keyword unpacking. The feature is particularly useful when passing structured configuration or forwarding arguments.


4. Scope and LEGB

What It Is

Scope determines where a name can be found.

Consider:

name = "global"


def greet():
    name = "local"
    print(name)


greet()
Enter fullscreen mode Exit fullscreen mode

The function prints:

local
Enter fullscreen mode Exit fullscreen mode

The reason is not that Python randomly prefers one variable over another. Python follows a defined name-resolution process commonly described as LEGB:

L → Local
E → Enclosing
G → Global
B → Built-in
Enter fullscreen mode Exit fullscreen mode

When Python evaluates a name, it searches these namespaces in that order.

Understanding LEGB becomes essential once you work with nested functions, closures, decorators, comprehensions, modules, and callbacks.


Syntax

There is no special LEGB syntax.

The important constructs are:

global variable_name
Enter fullscreen mode Exit fullscreen mode

and:

nonlocal variable_name
Enter fullscreen mode Exit fullscreen mode

These change how assignments to names are interpreted.


How It Works

Consider:

message = "global"


def outer():
    message = "enclosing"

    def inner():
        message = "local"
        print(message)

    inner()


outer()
Enter fullscreen mode Exit fullscreen mode

The result is:

local
Enter fullscreen mode Exit fullscreen mode

Inside inner(), Python finds message immediately in the local namespace.

Remove the local variable:

message = "global"


def outer():
    message = "enclosing"

    def inner():
        print(message)

    inner()


outer()
Enter fullscreen mode Exit fullscreen mode

Now Python searches:

  1. Local namespace of inner
  2. Enclosing namespace of outer
  3. Global namespace
  4. Built-ins

It finds "enclosing".

If outer() did not define message, Python would continue to the module's global namespace.

If the global variable did not exist either, Python would finally search built-ins.

For example:

def example():
    print(len([1, 2, 3]))
Enter fullscreen mode Exit fullscreen mode

len is not local or enclosing or global. Python finds it in the built-in namespace.


global

Consider:

counter = 0


def increment():
    counter += 1
Enter fullscreen mode Exit fullscreen mode

This raises an error.

Why?

Because assignment makes counter local to increment() unless told otherwise.

Python treats:

counter += 1
Enter fullscreen mode Exit fullscreen mode

as an operation involving assignment.

If you genuinely want to modify the module-level variable:

counter = 0


def increment():
    global counter
    counter += 1
Enter fullscreen mode Exit fullscreen mode

Now Python knows that counter refers to the global name.


nonlocal

nonlocal is different.

It refers to a variable in an enclosing function scope.

def create_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment
Enter fullscreen mode Exit fullscreen mode

Here count belongs to create_counter(), not the module.

The inner function can modify that enclosing variable because of nonlocal.


Real-World Example

A useful application is a small stateful callback:

def create_request_counter():
    count = 0

    def record_request():
        nonlocal count
        count += 1
        return count

    return record_request
Enter fullscreen mode Exit fullscreen mode

The returned function remembers the state.


Handwritten Python Code

def create_request_counter():
    count = 0

    def record_request():
        nonlocal count
        count += 1
        return count

    return record_request


counter = create_request_counter()

print(counter())
print(counter())
print(counter())
Enter fullscreen mode Exit fullscreen mode

Output

1
2
3
Enter fullscreen mode Exit fullscreen mode

The variable count remains alive because the nested function retains access to its enclosing scope.

That leads directly to closures.


Common Mistakes

Mistake 1: Expecting assignment to modify an outer variable

def outer():
    value = 10

    def inner():
        value = 20

    inner()
    print(value)
Enter fullscreen mode Exit fullscreen mode

Output:

10
Enter fullscreen mode Exit fullscreen mode

The assignment created a new local value inside inner().

It did not modify the enclosing variable.

Mistake 2: Using global as a general state-management solution

Global mutable state can make dependencies difficult to track.

This:

global cache
Enter fullscreen mode Exit fullscreen mode

may solve an immediate problem while creating a larger maintenance problem.

Mistake 3: Forgetting that built-ins are names too

You can accidentally shadow built-ins:

list = [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

Now:

list("abc")
Enter fullscreen mode Exit fullscreen mode

does not call the built-in list() function.

The global name list is found first.


When to Use It

Understanding LEGB is useful whenever you work with:

  • nested functions
  • callbacks
  • closures
  • decorators
  • modules
  • configuration
  • comprehensions
  • variable shadowing

nonlocal can be appropriate for tightly encapsulated state inside a closure.

global has legitimate uses, but it should usually be a deliberate design decision rather than the default way to share state.


When NOT to Use It

Avoid relying on deeply nested scope chains to make code work.

If a function depends on five names from surrounding scopes, understanding the function may require reading several layers of code.

Often, explicit parameters are clearer:

def calculate_total(price, tax):
    return price * (1 + tax)
Enter fullscreen mode Exit fullscreen mode

rather than relying on:

tax = 0.10
Enter fullscreen mode Exit fullscreen mode

from some distant outer scope.


Developer Notes

A subtle but important point: scope and lifetime are related but not identical concepts.

A local variable normally disappears from direct access after its function returns. But an object referenced by a closure can remain reachable because another function still holds a reference to it.

That is why this works:

counter = create_request_counter()
Enter fullscreen mode Exit fullscreen mode

even though create_request_counter() has already returned.

The nested function keeps access to its enclosing environment.


Short Takeaway

LEGB describes Python's name lookup order: Local, Enclosing, Global, Built-in.

Most scope bugs become much easier to diagnose once you stop thinking of variables as floating around globally and start thinking in terms of namespaces and name resolution.


5. Closures

What It Is

A closure occurs when a nested function retains access to variables from its enclosing scope after the enclosing function has returned.

For example:

def make_multiplier(factor):
    def multiply(number):
        return number * factor

    return multiply
Enter fullscreen mode Exit fullscreen mode

The returned function still knows what factor was.

double = make_multiplier(2)

print(double(10))
Enter fullscreen mode Exit fullscreen mode

Output:

20
Enter fullscreen mode Exit fullscreen mode

make_multiplier() has already finished executing, but multiply() still has access to factor.

That retained environment is the important part of a closure.


Syntax

There is no special closure keyword.

The basic pattern is:

def outer(value):
    def inner():
        return value

    return inner
Enter fullscreen mode Exit fullscreen mode

The inner function references a variable from the enclosing function.


How It Works

Consider:

def make_prefix(prefix):
    def add_prefix(text):
        return prefix + text

    return add_prefix
Enter fullscreen mode Exit fullscreen mode

When:

add_log_prefix = make_prefix("[LOG] ")

print(add_log_prefix("Server started"))
Enter fullscreen mode Exit fullscreen mode

is executed, the inner function needs prefix.

Python therefore retains the necessary enclosing state so the function can access it later.

Conceptually:

make_prefix("[LOG] ")
        │
        ├── prefix = "[LOG] "
        │
        └── returns add_prefix
                    │
                    └── remembers prefix
Enter fullscreen mode Exit fullscreen mode

This is one of the mechanisms behind many decorators and factory functions.


Real-World Example

Suppose you need validators with different limits.

Instead of duplicating functions:

def validate_short_name(name):
    return len(name) <= 20


def validate_long_name(name):
    return len(name) <= 100
Enter fullscreen mode Exit fullscreen mode

you can create a validator factory:

def create_length_validator(max_length):
    def validate(value):
        return len(value) <= max_length

    return validate
Enter fullscreen mode Exit fullscreen mode

Now:

validate_username = create_length_validator(20)
validate_description = create_length_validator(500)
Enter fullscreen mode Exit fullscreen mode

Each returned function remembers its own limit.


Handwritten Python Code

def create_length_validator(max_length):
    def validate(value):
        if not isinstance(value, str):
            return False

        return len(value) <= max_length

    return validate


validate_username = create_length_validator(20)
validate_title = create_length_validator(80)

print(validate_username("developer"))
print(validate_username("a" * 25))
print(validate_title("Python Functions"))
Enter fullscreen mode Exit fullscreen mode

Output

True
False
True
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

Mistake 1: Confusing a closure with simply nesting a function

Nesting alone is not enough.

The inner function must retain and use information from its enclosing scope.

Mistake 2: Forgetting nonlocal when modifying captured state

This does not modify the outer variable:

def create_counter():
    count = 0

    def increment():
        count += 1
        return count

    return increment
Enter fullscreen mode Exit fullscreen mode

Use:

def create_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Creating closures inside loops without understanding late binding

Consider:

functions = []

for number in range(3):
    functions.append(lambda: number)

print([function() for function in functions])
Enter fullscreen mode Exit fullscreen mode

The result is:

[2, 2, 2]
Enter fullscreen mode Exit fullscreen mode

The lambdas don't capture three independent snapshots of number. They refer to the same loop variable, whose final value is 2.

One common solution is to bind the current value as a default argument:

functions = []

for number in range(3):
    functions.append(lambda number=number: number)
Enter fullscreen mode Exit fullscreen mode

Now:

[0, 1, 2]
Enter fullscreen mode Exit fullscreen mode

This distinction matters when creating callbacks dynamically.


When to Use It

Closures are useful for:

  • function factories
  • encapsulated state
  • decorators
  • callbacks
  • configurable behavior
  • small stateful utilities

When NOT to Use It

Don't create a closure when a simple class would make the state and behavior easier to understand.

For example, if an object needs ten pieces of mutable state and several methods, a class is generally clearer than a deeply nested closure.

Closures work particularly well for small, focused pieces of state.


Developer Notes

Closures are closely connected to decorators.

A decorator often looks like:

def decorator(function):
    def wrapper(*args, **kwargs):
        ...
        return function(*args, **kwargs)

    return wrapper
Enter fullscreen mode Exit fullscreen mode

wrapper retains access to function.

That is a closure.

So understanding closures makes decorators much less mysterious.


Short Takeaway

A closure is a function that retains access to variables from its enclosing scope.

It is useful for creating small, configurable functions and encapsulating state without introducing a full class.


6. lambda

What It Is

A lambda creates a small anonymous function expression.

For example:

square = lambda number: number * number
Enter fullscreen mode Exit fullscreen mode

is roughly equivalent to:

def square(number):
    return number * number
Enter fullscreen mode Exit fullscreen mode

The major difference is that a lambda is an expression, while def creates a named function using a statement.

Lambdas are most useful when a small function is needed temporarily, especially as an argument to another function.


Syntax

lambda parameter: expression
Enter fullscreen mode Exit fullscreen mode

Multiple parameters are allowed:

lambda x, y: x + y
Enter fullscreen mode Exit fullscreen mode

The result of the expression becomes the return value.

You cannot write ordinary statements such as:

lambda x:
    print(x)
    return x
Enter fullscreen mode Exit fullscreen mode

A lambda is intentionally limited to a single expression.


How It Works

Consider sorting records by a specific field:

users = [
    {"name": "Mina", "age": 31},
    {"name": "Abu", "age": 24},
    {"name": "Rafi", "age": 28},
]

users.sort(key=lambda user: user["age"])
Enter fullscreen mode Exit fullscreen mode

The lambda receives each user and returns the value used for sorting.

There is no reason to create a separate globally named function if the behavior is only needed at that point.


Real-World Example

Sorting API results by response time is a realistic example:

responses = [
    {"endpoint": "/users", "duration": 320},
    {"endpoint": "/orders", "duration": 120},
    {"endpoint": "/products", "duration": 210},
]
Enter fullscreen mode Exit fullscreen mode

You can sort them directly:

responses.sort(key=lambda response: response["duration"])
Enter fullscreen mode Exit fullscreen mode

The lambda expresses the sorting key right where it is used.


Handwritten Python Code

users = [
    {"name": "Mina", "age": 31},
    {"name": "Abu", "age": 24},
    {"name": "Rafi", "age": 28},
]

users.sort(key=lambda user: user["age"])

for user in users:
    print(user["name"], user["age"])
Enter fullscreen mode Exit fullscreen mode

Output

Abu 24
Rafi 28
Mina 31
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

Mistake 1: Writing complicated lambdas

This:

process = lambda user: (
    user["active"]
    and user["role"] in {"admin", "editor"}
    and user["age"] >= 18
)
Enter fullscreen mode Exit fullscreen mode

may technically work, but if the logic needs explanation, a named function is usually clearer:

def can_edit(user):
    return (
        user["active"]
        and user["role"] in {"admin", "editor"}
        and user["age"] >= 18
    )
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Using lambda just because it is shorter

Short code is not necessarily better code.

If naming the operation makes the code easier to understand, use def.

Mistake 3: Expecting multiple statements

A lambda cannot contain ordinary statements such as for, try, or return.

It is intended for small expressions.


When to Use It

Lambdas are useful for:

  • sorting keys
  • small callbacks
  • simple transformations
  • short predicates
  • APIs that expect a callable

When NOT to Use It

Use def when:

  • the function has meaningful business logic
  • the expression is difficult to read
  • you need multiple statements
  • the function deserves a descriptive name
  • you need documentation or type annotations that would be clearer on a named function

For example:

users.sort(key=lambda user: user["created_at"])
Enter fullscreen mode Exit fullscreen mode

is fine.

But:

users.sort(key=lambda user: calculate_complex_business_rule(user))
Enter fullscreen mode Exit fullscreen mode

may indicate that the logic belongs in a named function.


Developer Notes

A lambda is still a normal Python function object.

For example:

operation = lambda x: x * 2

print(operation(5))
print(type(operation))
Enter fullscreen mode Exit fullscreen mode

The output includes:

10
<class 'function'>
Enter fullscreen mode Exit fullscreen mode

The important distinction is mostly about syntax, naming, and how the function is used.


Short Takeaway

Use lambda for small, local expressions where a named function would add unnecessary ceremony.

Once the logic becomes difficult to understand at a glance, give it a name with def.


7. map(), filter(), and reduce()

What It Is

map(), filter(), and reduce() are functional programming tools that operate on sequences or other iterables.

Their basic ideas are:

map()
    transform each item

filter()
    keep items that satisfy a condition

reduce()
    combine multiple items into one result
Enter fullscreen mode Exit fullscreen mode

They are related, but they solve different problems.

A common beginner mistake is to use all three simply because they are available.

Python often has a clearer alternative in comprehensions or ordinary loops.

The important skill is choosing the clearest expression of the operation.


map()

map() applies a function to every item in an iterable.

map(function, iterable)
Enter fullscreen mode Exit fullscreen mode

For example:

numbers = [1, 2, 3, 4]

result = map(lambda number: number * 2, numbers)
Enter fullscreen mode Exit fullscreen mode

In modern Python, map() returns an iterator rather than a list.

To materialize the values:

list(result)
Enter fullscreen mode Exit fullscreen mode

filter()

filter() keeps items for which a predicate returns a truthy value.

filter(function, iterable)
Enter fullscreen mode Exit fullscreen mode

Example:

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

result = filter(lambda number: number % 2 == 0, numbers)
Enter fullscreen mode Exit fullscreen mode

Again, filter() returns an iterator.


reduce()

reduce() repeatedly combines items and eventually produces one value.

It lives in functools:

from functools import reduce
Enter fullscreen mode Exit fullscreen mode

For example:

numbers = [1, 2, 3, 4]

total = reduce(lambda left, right: left + right, numbers)
Enter fullscreen mode Exit fullscreen mode

The operation proceeds conceptually as:

1 + 2 → 3
3 + 3 → 6
6 + 4 → 10
Enter fullscreen mode Exit fullscreen mode

The final result is:

10
Enter fullscreen mode Exit fullscreen mode

How It Works

Consider:

numbers = [10, 20, 30]

doubled = map(lambda value: value * 2, numbers)
Enter fullscreen mode Exit fullscreen mode

The map object does not immediately contain a new list.

It produces values as iteration requests them.

That means:

for value in doubled:
    print(value)
Enter fullscreen mode Exit fullscreen mode

will retrieve the mapped values during iteration.

This lazy behavior can be useful when processing large streams of data because you don't necessarily need to construct an intermediate list.


map() vs Comprehension

These are both valid:

result = list(map(lambda x: x * 2, numbers))
Enter fullscreen mode Exit fullscreen mode

and:

result = [x * 2 for x in numbers]
Enter fullscreen mode Exit fullscreen mode

The comprehension is often easier to read because the transformation is directly visible.

Compare:

list(map(lambda user: user["email"], users))
Enter fullscreen mode Exit fullscreen mode

with:

[user["email"] for user in users]
Enter fullscreen mode Exit fullscreen mode

The second version is often the more idiomatic Python expression.


filter() vs Comprehension

Likewise:

active_users = list(
    filter(lambda user: user["active"], users)
)
Enter fullscreen mode Exit fullscreen mode

can often be written more clearly as:

active_users = [
    user for user in users
    if user["active"]
]
Enter fullscreen mode Exit fullscreen mode

The choice should be based on readability rather than blindly following one style.


Real-World Example

Suppose an API returns transaction records:

transactions = [
    {"amount": 120, "status": "completed"},
    {"amount": 50, "status": "failed"},
    {"amount": 200, "status": "completed"},
]
Enter fullscreen mode Exit fullscreen mode

You might want to:

  1. keep completed transactions
  2. extract their amounts
  3. calculate the total

This creates a natural relationship between filtering, mapping, and reduction.


Handwritten Python Code

from functools import reduce


transactions = [
    {"amount": 120, "status": "completed"},
    {"amount": 50, "status": "failed"},
    {"amount": 200, "status": "completed"},
]

completed = filter(
    lambda transaction: transaction["status"] == "completed",
    transactions,
)

amounts = map(
    lambda transaction: transaction["amount"],
    completed,
)

total = reduce(
    lambda left, right: left + right,
    amounts,
    0,
)

print(f"Completed transaction total: {total}")
Enter fullscreen mode Exit fullscreen mode

Output

Completed transaction total: 320
Enter fullscreen mode Exit fullscreen mode

Notice that the operations are lazy until the values are consumed by reduce().


A More Pythonic Alternative

The same task can often be expressed more directly:

transactions = [
    {"amount": 120, "status": "completed"},
    {"amount": 50, "status": "failed"},
    {"amount": 200, "status": "completed"},
]

total = sum(
    transaction["amount"]
    for transaction in transactions
    if transaction["status"] == "completed"
)

print(f"Completed transaction total: {total}")
Enter fullscreen mode Exit fullscreen mode

Output:

Completed transaction total: 320
Enter fullscreen mode Exit fullscreen mode

For this particular problem, sum() plus a generator expression communicates the intent more directly than filter()map()reduce().

That is an important Python lesson: having a functional tool available does not mean it is the best tool for every functional-looking problem.


Common Mistakes

Mistake 1: Assuming map() returns a list

result = map(lambda x: x * 2, [1, 2, 3])

print(result)
Enter fullscreen mode Exit fullscreen mode

You get a map object representation, not:

[2, 4, 6]
Enter fullscreen mode Exit fullscreen mode

If you need a list:

result = list(map(lambda x: x * 2, [1, 2, 3]))
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Consuming an iterator twice

values = map(lambda x: x * 2, [1, 2, 3])

print(list(values))
print(list(values))
Enter fullscreen mode Exit fullscreen mode

The second result is empty because the iterator has already been exhausted.

Output:

[2, 4, 6]
[]
Enter fullscreen mode Exit fullscreen mode

If the values need to be traversed repeatedly, materialize them into a collection.

Mistake 3: Using reduce() when a named built-in expresses the operation

For a total:

reduce(lambda a, b: a + b, numbers, 0)
Enter fullscreen mode Exit fullscreen mode

is usually less clear than:

sum(numbers)
Enter fullscreen mode Exit fullscreen mode

Likewise, don't reach for reduce() when max(), min(), sum(), or another specialized operation already expresses the intent.

Mistake 4: Building unreadable functional pipelines

This:

list(
    map(
        lambda x: transform(x),
        filter(
            lambda x: condition(x),
            values,
        ),
    )
)
Enter fullscreen mode Exit fullscreen mode

may be correct but difficult to maintain.

A comprehension or explicit loop may communicate the same logic better.


When to Use map()

map() can make sense when:

  • you already have a named transformation function
  • you want lazy transformation
  • multiple iterables need to be processed together
  • the functional form reads naturally

For example:

emails = map(normalize_email, raw_emails)
Enter fullscreen mode Exit fullscreen mode

can be perfectly readable.


When to Use filter()

filter() can be appropriate when:

  • you already have a named predicate
  • lazy filtering is useful
  • the operation reads naturally in functional form

For simple conditions, comprehensions are often clearer:

active = [user for user in users if user["active"]]
Enter fullscreen mode Exit fullscreen mode

When to Use reduce()

Use reduce() when the operation genuinely represents repeated pairwise combination and there isn't a clearer specialized function.

Examples can include certain aggregation or functional pipelines where the reduction operation is naturally expressed as a binary function.

But don't use it merely because you can.


When NOT to Use Them

Avoid functional constructs when they make straightforward Python harder to read.

Compare:

result = list(
    map(lambda x: x * 2, numbers)
)
Enter fullscreen mode Exit fullscreen mode

with:

result = [x * 2 for x in numbers]
Enter fullscreen mode Exit fullscreen mode

The comprehension is usually easier for a Python developer to scan.

Likewise, use:

sum(values)
Enter fullscreen mode Exit fullscreen mode

instead of:

reduce(lambda a, b: a + b, values, 0)
Enter fullscreen mode Exit fullscreen mode

when summation is all you need.


Developer Notes

There is an important connection between these tools and Python's iterator model.

map() and filter() are lazy iterators.

That means they can process values without immediately creating a complete output list.

For example:

large_dataset = get_large_dataset()

processed = map(normalize_record, large_dataset)
Enter fullscreen mode Exit fullscreen mode

If get_large_dataset() itself produces values incrementally, this can form a pipeline where records are processed one at a time.

That can reduce unnecessary intermediate storage.

But laziness is not automatically a performance win. If you immediately write:

list(map(normalize_record, large_dataset))
Enter fullscreen mode Exit fullscreen mode

you have deliberately materialized the result into memory.

The real advantage depends on how the resulting iterator is consumed.


Short Takeaway

map() transforms values, filter() selects values, and reduce() combines values into one result.

They are useful tools, but Python's comprehensions, generator expressions, sum(), any(), all(), and other built-ins often express the same intent more clearly. Good Python is not about using the most sophisticated-looking construct; it is about choosing the clearest one.


Part 1 — Connecting the Concepts

These features become much easier to understand when viewed as one system.

Function arguments can move through several stages:

function parameters
       ↓
positional / keyword arguments
       ↓
*args / **kwargs
       ↓
unpacking
       ↓
forwarding to another function
Enter fullscreen mode Exit fullscreen mode

Scope provides the environment in which those functions resolve names:

Local
  ↓
Enclosing
  ↓
Global
  ↓
Built-in
Enter fullscreen mode Exit fullscreen mode

Closures combine functions with that enclosing scope:

outer()
  │
  ├── local state
  │
  └── inner function
          ↓
     retains access
     to enclosing state
Enter fullscreen mode Exit fullscreen mode

Then lambda, map(), filter(), and reduce() provide different ways to treat functions as values and compose operations around iterables.

A practical progression looks like this:

functions
   ↓
arguments
   ↓
*args / **kwargs
   ↓
unpacking
   ↓
scope
   ↓
closures
   ↓
lambda
   ↓
map / filter / reduce
Enter fullscreen mode Exit fullscreen mode

The important shift is that these features stop being isolated pieces of syntax. They become tools for controlling data flow, function behavior, and state.

When you understand why a function receives its arguments the way it does, where Python looks for a name, why a nested function can remember a value, and when a functional pipeline is clearer than a loop, you're no longer just memorizing Python syntax. You're starting to reason about Python code the way you would when reviewing or designing it.

Top comments (0)