RizTech Academy logo
RizTech Academy
FunctionsLesson 2 of 725 min

Arguments: positional, keyword and default

Python gives you several ways to pass values into a function. Most of it is convenience, but one feature — the mutable default — is a genuine trap that catches people who have been writing Python for years.

Positional arguments

The default: values match parameters by position.

def describe(name, age, city):
    print(f"{name}, {age}, from {city}")

describe("Priya", 28, "Pune")

Simple, and it depends entirely on getting the order right. With three parameters of different types a mistake usually crashes. With three strings, it quietly prints nonsense.

Keyword arguments

Name them at the call site and order stops mattering:

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

More typing, and much clearer at the point somebody reads it. Compare:

create_user("Priya", "priya@example.com", True, False, True)

with:

create_user(
    name="Priya",
    email="priya@example.com",
    is_active=True,
    is_admin=False,
    send_welcome=True,
)

The first requires opening the definition to understand. Whenever an argument is a bare True, False or a number whose meaning is not obvious, name it.

You can mix, but positional arguments must come first:

describe("Priya", city="Pune", age=28)      # fine
describe(name="Priya", 28, "Pune")          # SyntaxError

Default values

Give a parameter a default and callers may omit it:

def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("Priya")
greet("Priya", "Good morning")
greet("Priya", greeting="Namaste")
Hello, Priya!
Good morning, Priya!
Namaste, Priya!

Parameters with defaults must come after those without:

def greet(greeting="Hello", name):    # SyntaxError

Otherwise Python could not tell which value a single argument was meant for.

Defaults are excellent for widening a function without breaking existing callers — add a parameter with a sensible default and every existing call keeps working.

The mutable default trap

Here is the one to remember.

def add_item(item, basket=[]):
    basket.append(item)
    return basket

print(add_item("apple"))
print(add_item("banana"))

You would expect ['apple'] then ['banana']. You get:

['apple']
['apple', 'banana']

The default value is created once, when the function is defined — not each time it is called. There is one list, shared by every call that does not supply its own. It accumulates for the lifetime of the program.

This is the aliasing lesson again: one list, several uses, each seeing the others' changes.

The fix is always the same shape:

def add_item(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

None is immutable and safe as a default. A fresh list is created on each call that needs one.

Rule: never use a list, dictionary or set as a default value. Use None and create it inside. Strings, numbers, True, False and tuples are immutable and perfectly safe.

This is also exactly why is None rather than if not basket — an empty list passed deliberately is falsy, and if not basket would replace it with a different empty list, quietly losing the connection to the caller's one.

Arguments are passed by reference

A closely related point:

def add_tax(prices):
    for i in range(len(prices)):
        prices[i] = prices[i] * 1.18

my_prices = [100, 200]
add_tax(my_prices)
print(my_prices)
[118.0, 236.0]

The function changed the caller's list. It did not receive a copy — it received another label on the same list.

For immutable values, nothing of the sort happens:

def increment(n):
    n += 1

count = 5
increment(count)
print(count)        # 5 — unchanged

n += 1 rebound the local name to a new number. The caller's count was never touched, because numbers cannot be modified in place.

So: a function can modify a list or dictionary you pass it, and cannot modify a number or string. Which behaviour you want should be a decision, not a surprise. When in doubt, return a new value rather than modifying the argument:

def with_tax(prices):
    return [p * 1.18 for p in prices]

Callers of that cannot be surprised.

Forcing keyword arguments

A bare * in the parameter list means everything after it must be named:

def create_user(name, *, is_admin=False, send_email=True):
    ...

create_user("Priya", is_admin=True)     # fine
create_user("Priya", True)              # TypeError

Worth using when a function takes several flags. It stops create_user("Priya", True, False) at the door rather than letting it become a bug.

There is a matching / that forces the arguments before it to be positional. You will see it in library code; you will rarely need it.

Unpacking into a call

* and ** spread a collection into arguments:

def describe(name, age, city):
    print(f"{name}, {age}, from {city}")

values = ["Priya", 28, "Pune"]
describe(*values)

details = {"name": "Priya", "age": 28, "city": "Pune"}
describe(**details)

Both print the same line. * spreads a sequence into positional arguments; ** spreads a dictionary into keyword arguments, matching keys to parameter names.

Useful when data arrives as a dictionary — from JSON, say — and lines up with a function's parameters. Be careful: a key that does not match a parameter gives TypeError: got an unexpected keyword argument.

Check your work

Calling in the wrong order positionally does not crash when the types happen to fit — it prints nonsense. That is why naming arguments at the call site is worth the typing whenever the meaning is not obvious.

A default before a non-default is a SyntaxError, because Python could not tell which parameter a single argument was meant for.

The mutable default.

def add_item(item, basket=[]):
    basket.append(item)
    return basket

add_item("apple")      # ['apple']
add_item("banana")     # ['apple', 'banana']   ← one shared list

The default is created once, when the function is defined, not on each call. There is one list, shared by every call that does not supply its own.

def add_item(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

Note it is is None, not if not basket — an empty list passed deliberately is falsy, and the sloppy check would silently replace the caller's list with a different one.

Modifying versus returning.

def add_tax(prices):           # changes the caller's list
    for i in range(len(prices)):
        prices[i] *= 1.18

def with_tax(prices):          # returns a new one
    return [p * 1.18 for p in prices]

The first surprises callers; the second cannot.

Why increment(n) cannot change the caller's number. n += 1 rebinds the local name to a new number. Numbers cannot be modified in place, so there is nothing to change. A list can be, which is why question 5 behaves differently.

Keyword-only. create_user("Priya", True) with a bare * in the signature gives TypeError: takes 1 positional argument but 2 were given.

Practice

  1. Write describe(name, age, city) and call it positionally, with keywords, and with a mix. Then call it in the wrong order positionally and notice it does not crash.
  2. Add a default country="India" and call it both with and without.
  3. Try putting a defaulted parameter before a non-defaulted one. Read the error.
  4. Run the add_item trap exactly as written. Call it three times. Explain in one sentence why the list grows, then fix it with None.
  5. Write add_tax(prices) that modifies the caller's list, and with_tax(prices) that returns a new one. Show the difference by printing the original after each.
  6. Write increment(n) that tries to change a number, and show it cannot. Explain why this differs from question 5.
  7. Write create_user(name, *, is_admin=False) and prove the * stops a positional second argument.
  8. Build a dictionary of arguments and call a function with **. Then add an extra key and read the error.

Next: return values in more depth, including returning several things at once.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship