RizTech Academy logo
RizTech Academy
FunctionsLesson 5 of 720 min

*args and **kwargs

print() takes as many values as you like. max() does too. You can write functions that do the same, and you will meet the syntax constantly in other people's code, so it is worth being able to read even before you need to write it.

*args

A parameter prefixed with * collects every extra positional argument into a tuple:

def total(*numbers):
    return sum(numbers)

print(total(1, 2))
print(total(1, 2, 3, 4, 5))
print(total())
3
15
0

Inside the function, numbers is a tuple — (1, 2), then (1, 2, 3, 4, 5), then (). It is always a tuple, even with one argument or none, so you can safely loop over it or call len() without checking.

The * is the syntax; args is just a conventional name. *numbers is better here because it says what the values are. Use *args only when the values genuinely have no more specific name.

Ordinary parameters can come first:

def announce(prefix, *names):
    for name in names:
        print(f"{prefix}: {name}")

announce("Attending", "Priya", "Arjun", "Sneha")
Attending: Priya
Attending: Arjun
Attending: Sneha

prefix takes the first argument; *names collects the rest. Only one * parameter is allowed, and everything after it must be keyword-only — which is the same mechanism as the bare * from the arguments lesson.

**kwargs

Two asterisks collect every extra keyword argument into a dictionary:

def describe(**details):
    for key, value in details.items():
        print(f"{key}: {value}")

describe(name="Priya", age=28, city="Pune")
name: Priya
age: 28
city: Pune

details is a dictionary. Again, kwargs is convention, not syntax.

Both together

The full form, which you will recognise in library code:

def log(level, *messages, **context):
    print(f"[{level}]", *messages)
    for key, value in context.items():
        print(f"  {key}={value}")

log("ERROR", "Database unreachable", host="db01", retries=3)
[ERROR] Database unreachable
  host=db01
  retries=3

The order in a definition is always: normal parameters, *args, keyword-only parameters, **kwargs. You cannot rearrange it.

Note print(f"[{level}]", *messages) — that is * at a call site, spreading the tuple back into separate arguments. Which brings us to the symmetry.

The two meanings of * and **

This is the part that makes it click.

In a definition, they collect:

def f(*args, **kwargs):
    ...

At a call, they spread:

values = [1, 2, 3]
f(*values)                      # f(1, 2, 3)

options = {"a": 1, "b": 2}
f(**options)                    # f(a=1, b=2)

Same symbols, opposite directions. Collecting on the way in, spreading on the way out.

That symmetry gives you the most common real use — passing arguments straight through:

def log_call(func, *args, **kwargs):
    print(f"Calling {func.__name__}")
    return func(*args, **kwargs)

print(log_call(max, 3, 7, 2))
Calling max
7

log_call collects whatever it is given and hands it on untouched, without knowing or caring what func expects. This is how wrappers and decorators work, and why *args, **kwargs appears in so much framework code.

Note also that functions are values here: max was passed without brackets, stored in func, and called later. A function with brackets runs; without, it is a thing you can pass around. That is the same distinction as module 1, finally paying off.

lambda

Since functions are values, you sometimes want one where writing a whole def would be more ceremony than it is worth. lambda makes a small unnamed function:

double = lambda n: n * 2
print(double(5))        # 10

The form is lambda parameters: expression. There is no return — the expression is the return value — and it must be a single expression, so no if statements, no loops, no multiple lines.

That example is also bad practice. If you are assigning a lambda to a name, use def, which gives you a docstring, a proper name in tracebacks, and room to grow:

def double(n: int) -> int:
    return n * 2

Where lambda genuinely earns its place is as a throwaway argument to another function — which is the key promised back in the list-methods lesson:

people = [("Priya", 28), ("Arjun", 34), ("Sneha", 25)]

print(sorted(people, key=lambda person: person[1]))
[('Sneha', 25), ('Priya', 28), ('Arjun', 34)]

key calls that lambda on each item and sorts by what comes back — here, the age. Writing a named function for something used once, on one line, would be worse.

More of the same:

words = ["banana", "fig", "cherry"]
print(sorted(words, key=lambda w: len(w)))

students = [{"name": "Priya", "mark": 78}, {"name": "Arjun", "mark": 92}]
print(sorted(students, key=lambda s: s["mark"], reverse=True))
print(max(students, key=lambda s: s["mark"])["name"])

That last line finds the top student in one expression.

Use lambda only as an argument, and only when it fits on the line. The moment it needs a condition or a second thought, write a def and pass its name — key=get_mark reads better than any lambda ever will.

When to use them

Less often than beginners expect.

Good reasons:

  • The number of values genuinely varies — total(*numbers).
  • You are wrapping another function and must pass everything through.
  • You are accepting optional extras to forward elsewhere.

Bad reasons:

def create_user(**kwargs):
    name = kwargs["name"]
    email = kwargs["email"]

That takes a function with two clear required parameters and makes it accept anything, fail at runtime instead of at the call, and tell the reader nothing. Worse, an editor can no longer autocomplete or check the call.

Named parameters are documentation. Write them out whenever you know what they are:

def create_user(name, email, is_admin=False):
    ...

Use *args and **kwargs when you genuinely do not know, not to avoid deciding.

Reading them in the wild

Given the above, this is now readable:

def retry(func, *args, attempts=3, **kwargs):
    for attempt in range(attempts):
        result = func(*args, **kwargs)
        if result is not None:
            return result
    return None

Takes a function, any positional arguments for it, a keyword-only attempts with a default, and any keyword arguments for it. Calls it up to attempts times, passing everything through, and returns the first non-None result.

Note attempts sits after *args, making it keyword-only — so it cannot be swallowed by *args as one more argument for func. That placement is deliberate, and recognising why is a sign this has landed.

Practice

  1. Write total(*numbers) and call it with zero, one and five arguments. Print type(numbers) inside to confirm it is always a tuple.
  2. Write average(*numbers) that returns 0 for no arguments rather than crashing on division by zero.
  3. Write announce(prefix, *names) and call it with several names.
  4. Write describe(**details) printing each key and value. Call it with three keyword arguments, then with none.
  5. Build a list and a dictionary, and use * and ** to call an ordinary three-parameter function with them.
  6. Write log_call(func, *args, **kwargs) as above, and use it to call max, min and sorted. Notice it needs no changes between them.
  7. Rewrite def create_user(**kwargs) with named parameters. Say what improved.
  8. Explain in one sentence why attempts in the retry example must come after *args.
  9. Sort a list of (name, age) tuples by age using lambda. Then sort a list of dictionaries by a key, descending.
  10. Assign a lambda to a name, then rewrite it as a def. Say which you would rather see in a code review.

Next: docstrings and type hints — making a function explain itself.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship