All 57 lessons

Lesson 18 of 57 · Functions

Python Function Arguments: Defaults, Keywords, *args and **kwargs

By Skillancy Editorial Team

Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).

In the last lesson every function took a fixed list of inputs in a fixed order. Real functions need more flexibility. A report function might take a required data set, an optional city filter, and a number of formatting options. A logging helper might accept any number of messages. Python gives you a rich set of tools for this: default values, keyword arguments, and the special *args and **kwargs collectors.

Knowing these tools also makes you better at reading other people's code and library documentation. When you see pd.read_csv(path, sep=",", parse_dates=["order_date"]), you are looking at positional and keyword arguments working together. By the end of this lesson, signatures like that will be second nature, and you will know about one famous bug (the mutable default argument) that catches many experienced developers.

Worked examples

Example 1: Positional and keyword arguments

Question: Call the same function in two different styles.

def delivery_fee(distance_km, order_value):
    if order_value >= 499:
        return 0
    return 15 + 5 * distance_km

print(delivery_fee(3, 250))
print(delivery_fee(order_value=250, distance_km=3))
print(delivery_fee(3, order_value=250))

Output

30
30
30

How it works: With positional arguments the order matters. With keyword arguments you name each parameter, so the order does not matter and the call documents itself. You can mix the styles, but positional ones must come first.

Reading the result: All three calls gave the same fee of ₹30. Keyword arguments make calls with many numbers much easier to read: delivery_fee(order_value=250, distance_km=3) leaves no doubt about which number is which.

Example 2: Default values

Question: Make the GST rate optional, defaulting to 5%.

def with_gst(price, rate=5):
    return round(price * (1 + rate / 100), 2)

print(with_gst(200))
print(with_gst(200, 12))
print(with_gst(200, rate=18))

Output

210.0
224.0
236.0

How it works: A parameter with = value in the definition is optional. If the caller omits it, the default is used. Parameters with defaults must come after those without.

Reading the result: The default 5% rate gives 210.0, while 12% gives 224.0 and 18% gives 236.0. Choose defaults that are right for the most common case.

Example 3: A realistic function with several options

Question: Write a labelling function with optional prefix, suffix and width.

def label(name, value, prefix="", suffix="", width=18):
    text = f"{name}:"
    return f"{text:<{width}}{prefix}{value}{suffix}"

print(label("Orders", 6000))
print(label("Revenue", "35.3", prefix="₹", suffix=" L"))
print(label("Avg delivery", 28.4, suffix=" min", width=24))

Output

Orders:           6000
Revenue:          ₹35.3 L
Avg delivery:           28.4 min

How it works: Callers override only the options they care about, using keywords. This is how most library functions are designed: required inputs first, many optional settings after.

Reading the result: Each line used a different mix of options, for example Revenue got a rupee prefix and an L suffix. Because options are passed by keyword, adding a new option later does not break existing calls.

Example 4: Keyword-only and positional-only parameters

Question: How can a function force callers to name an argument?

def fee(distance_km, order_value, *, express=False):
    base = 0 if order_value >= 499 else 15 + 5 * distance_km
    return base + (30 if express else 0)

print(fee(2, 300))
print(fee(2, 300, express=True))

try:
    fee(2, 300, True)
except TypeError as e:
    print("TypeError:", e)

Output

25
55
TypeError: fee() takes 2 positional arguments but 3 were given

How it works: A bare * in the parameter list means everything after it must be passed by keyword. That stops unclear calls like fee(2, 300, True), where nobody can tell what True means. There is a matching / marker for positional-only parameters, used by some built-ins.

Reading the result: The standard fee is ₹25 and the express fee is ₹55. The unnamed True is rejected with a clear error.

Example 5: *args collects extra positional arguments

Question: Write an average function that accepts any number of values.

def average(*numbers):
    print(type(numbers), numbers)
    return sum(numbers) / len(numbers)

print(average(10, 20))
print(average(68, 45, 96, 62))

Output

<class 'tuple'> (10, 20)
15.0
<class 'tuple'> (68, 45, 96, 62)
67.75

How it works: *numbers gathers all the positional arguments into a tuple. The name args is only a convention; *numbers is clearer here. Inside the function you use it like any tuple.

Reading the result: The two-value call gave 15.0 and the four-value call gave 67.75. The same function handled both because of the star.

Example 6: kwargs collects extra keyword arguments

Question: Accept any number of named options and display them.

def make_filter(**conditions):
    print(type(conditions), conditions)
    return " AND ".join(f"{k} = {v!r}" for k, v in conditions.items())

print(make_filter(city="Pune", status="Delivered"))
print(make_filter(is_prime=True))

Output

<class 'dict'> {'city': 'Pune', 'status': 'Delivered'}
city = 'Pune' AND status = 'Delivered'
<class 'dict'> {'is_prime': True}
is_prime = True

How it works: **conditions gathers all keyword arguments that were not matched by named parameters into a dictionary. This is useful when you want to forward options to another function or build something dynamic, such as a SQL-like condition.

Reading the result: The first call produced city = 'Pune' AND status = 'Delivered'. The full signature order is: normal parameters, *args, keyword-only parameters, then **kwargs.

Example 7: Unpacking into a call

Question: Call a function using values stored in a list and a dictionary.

def delivery_fee(distance_km, order_value):
    if order_value >= 499:
        return 0
    return 15 + 5 * distance_km

args = (4, 350)
kwargs = {"distance_km": 4, "order_value": 650}

print(delivery_fee(*args))
print(delivery_fee(**kwargs))

nums = [3, 7, 5]
print(max(*nums), min(*nums))

Output

35
0
7 3

How it works: At the call site, *sequence spreads the items out as positional arguments and **mapping spreads the pairs out as keyword arguments. It is the mirror image of *args and **kwargs in a definition. Unpacking is helpful when arguments come from a file or from user settings.

Reading the result: The list produced a fee of ₹35 and the dictionary produced ₹0, because the second order qualified for free delivery.

Example 8: Forwarding arguments

Question: Write a wrapper that adds timing information to any function.

import time

def timed(func, *args, **kwargs):
    start = time.perf_counter()
    result = func(*args, **kwargs)
    elapsed = time.perf_counter() - start
    return result, elapsed

result, secs = timed(sum, range(1_000_000))
print(result)
print(secs < 5)

result2, _ = timed(sorted, [3, 1, 2], reverse=True)
print(result2)

Output

499999500000
True
[3, 2, 1]

How it works: timed accepts a function and whatever arguments that function needs, then passes them along with *args, **kwargs. This forwarding pattern is the foundation of decorators, which you will meet in the advanced module.

Reading the result: The sum was 499999500000 and the sorted list came back as [3, 2, 1]. Functions are values, so you can pass them as arguments just like numbers.

Example 9: Type hints

Question: How do you tell readers what types a function expects?

def apply_discount(price: float, pct: float = 10.0) -> float:
    """Return price after a percentage discount."""
    return round(price * (1 - pct / 100), 2)

print(apply_discount(250))
print(apply_discount.__annotations__)

Output

225.0
{'price': <class 'float'>, 'pct': <class 'float'>, 'return': <class 'float'>}

How it works: price: float and -> float are type hints. Python does not enforce them while running, but editors and checkers like mypy use them to catch mistakes, and they act as built-in documentation. They are optional, though most professional code uses them.

Reading the result: A 10% discount on ₹250 gives 225.0. The hints are stored in __annotations__, but nothing stops you from passing the wrong type at runtime.

Example 10: The mutable default trap

Question: Why is def add(item, bucket=[]) dangerous?

def add_bad(item, bucket=[]):
    bucket.append(item)
    return bucket

print(add_bad("milk"))
print(add_bad("eggs"))

def add_good(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

print(add_good("milk"))
print(add_good("eggs"))

Output

['milk']
['milk', 'eggs']
['milk']
['eggs']

How it works: Default values are created once, when the function is defined, not on every call. So the same default list is shared by all calls that omit bucket. The fix is to use None as the default and create a fresh list inside the function.

Reading the result: The bad version remembered milk and returned ['milk', 'eggs', 'bread'] on the third call, while the good version gave ['bread'] every time. Never use a list, dictionary or set as a default value.

Common mistakes

1. Non-default parameter after a default one.

def f(rate=5, price):
    return price

Output

  File "example.py", line 1
      def f(rate=5, price):
                    ^^^^^
  SyntaxError: parameter without a default follows parameter with a default

2. Passing the same argument twice.

def delivery_fee(distance_km, order_value):
    return 0
delivery_fee(3, distance_km=3)

Output

Traceback (most recent call last):
  File "example.py", line 3, in <module>
    delivery_fee(3, distance_km=3)
    ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
TypeError: delivery_fee() got multiple values for argument 'distance_km'

3. Using a mutable default such as [] or {}.

4. Forgetting the star when forwarding. func(args) passes one tuple; func(*args) passes its contents.

*5. Using `args` when a list parameter would be clearer.** If callers already have a list, make the parameter a list.

Practice questions

Practice 1 (Easy): Greeting with a default

Write greet(name, greeting="Hello") that returns something like Hello, Aarav!. Call it with and without the second argument.

Show solution
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(greet("Aarav"))
print(greet("Diya", greeting="Namaste"))

Output

Hello, Aarav!
Namaste, Diya!

The default saves callers from typing the common case: Hello, Aarav!.

Practice 2 (Medium): A flexible total

Write total(*prices, tax=0) that adds all the prices and then adds a percentage tax. Call it as total(100, 50, 25) and total(100, 50, 25, tax=10).

Show solution
def total(*prices, tax=0):
    return round(sum(prices) * (1 + tax / 100), 2)

print(total(100, 50, 25))
print(total(100, 50, 25, tax=10))

Output

175.0
192.5

Anything after *prices is keyword-only, so tax must be named. The two answers are 175.0 and 192.5.

Practice 3 (Hard): A safe filter builder

Write describe_order(order_id, **details) that returns a string like O1 | city=Pune | amount=450 with details sorted alphabetically by key. Then call it using a dictionary through **.

Show solution
def describe_order(order_id, **details):
    parts = [f"{k}={v}" for k, v in sorted(details.items())]
    return " | ".join([order_id] + parts)

info = {"city": "Pune", "amount": 450}
print(describe_order("O1", **info))
print(describe_order("O2"))

Output

O1 | amount=450 | city=Pune
O2

Sorting makes the output predictable regardless of the order the caller used. The first line is O1 | amount=450 | city=Pune, and with no details the result is just O2.

Key takeaways

  • Positional arguments go by order; keyword arguments go by name and read better.
  • Defaults make parameters optional; put them after the required ones.
  • A bare * forces the following parameters to be keyword-only.
  • *args collects extras into a tuple; **kwargs collects named extras into a dictionary.
  • *seq and **mapping in a call spread values into arguments.
  • Type hints document intent but are not enforced.
  • Never use a mutable default; use None and create the object inside.

Frequently asked questions

What is the difference between * in a definition and * in a call?

In a definition, *args collects extras into a tuple. In a call, *seq spreads a sequence into separate arguments. They are opposite operations with the same symbol.

Do I have to call the variables args and kwargs?

No, only the stars matter. The names args and kwargs are a strong convention, but a descriptive name such as *prices is often better.

When should I use keyword-only parameters?

When a parameter is a flag or option whose meaning would be unclear as a bare value, like True or 0. Forcing the name makes calls readable.

Are type hints required?

No. They are optional documentation checked by external tools. They are very valuable in larger projects, though.

Why does Python evaluate defaults only once?

The default is part of the function object, created when def runs. It is a design choice that is efficient but surprises beginners with mutable values.

Can I change the arguments I receive?

You can rebind a local name freely, but changing a mutable object (like a list) passed in modifies the caller's object too. Copy it first if you need to keep the original untouched.

Newsletter

Keep learning Python

One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.

Ready to practise? Try the Python interview questions