How to Use Python Functions with Optional Arguments

You write Python functions with optional arguments by giving a parameter a default value with = in the def line. Callers can leave that argument out, and Python uses the default instead:

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

greet("Megan")          # 'Hello, Megan!'
greet("Megan", "Hi")    # 'Hi, Megan!'

Tested on Python 3.12.5: the outputs below are real Command Prompt results, not hand-typed guesses.

In this tutorial, we’ll cover default values, the mutable default trap and its None fix, keyword-only options, *args and **kwargs for extras, type hints, and how to tell whether a caller passed an argument at all.

How do default values make an argument optional in Python?

A parameter with a default is optional; one without a default is required. Here’s a shipping calculator where only the weight is required, and the rate and express flag fall back to their defaults:

def shipping_cost(weight_lb, rate=4.99, express=False):
    cost = weight_lb * rate
    if express:
        cost += 12.50
    return round(cost, 2)

print(shipping_cost(3))                          # both defaults used
print(shipping_cost(3, 5.75))                    # rate passed by position
print(shipping_cost(3, express=True))            # skip rate, set express
print(shipping_cost(weight_lb=2, rate=3.25))     # everything by keyword

Output:

14.97
17.25
27.47
6.5
Command Prompt output of a Python shipping cost function called with and without its optional arguments
The same function returns four different costs depending on which optional arguments each call passes.

Notice the third call. It skips rate and sets express by name, which is the main reason to pass optional arguments as keywords. You don’t have to fill in every argument before the one you care about.

If the def line itself is new to you, start with how to define a function in Python.

Rules for ordering required and optional parameters

Python checks two things. A call must supply every required argument, and in the definition every required parameter must come before the first one with a default. Break either rule and you get an error:

def shipping_cost(weight_lb, rate=4.99):
    return round(weight_lb * rate, 2)

try:
    shipping_cost()
except TypeError as err:
    print("TypeError:", err)

code = "def book_room(nights=1, guest):\n    pass\n"
try:
    compile(code, "hotel.py", "exec")
except SyntaxError as err:
    print("SyntaxError:", err.msg)

Output:

TypeError: shipping_cost() missing 1 required positional argument: 'weight_lb'
SyntaxError: parameter without a default follows parameter with a default

The TypeError happens when the call runs. The SyntaxError is caught earlier, when Python compiles the file, so book_room(nights=1, guest) never runs at all. The fix is to move guest first.

Positional arguments fill parameters left to right, and keyword arguments match by name. For a deeper look at how the two kinds of arguments differ, see the difference between *args and **kwargs in Python.

The mutable default argument trap with lists and dicts

This is the bug I see most often with optional arguments. A default that’s a list or dict is created once, when the function is defined, and every call that relies on it shares that same object:

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

print(add_item("coffee"))
print(add_item("bagel"))          # expected ['bagel'], got both items
print(add_item("tea", ["milk"]))  # passing a list works fine
print("stored default:", add_item.__defaults__)

Output:

['coffee']
['coffee', 'bagel']
['milk', 'tea']
stored default: (['coffee', 'bagel'],)
Command Prompt output showing a Python mutable default list keeping items from earlier calls
The second call returns both items because the default list remembered the first call; __defaults__ shows the shared list.

The __defaults__ attribute holds the stored default values, and you can see both items sitting inside it. Passing your own list, as in the third call, is unaffected because it replaces the default.

Lists and dicts are affected because they can change in place; numbers, strings and tuples can’t. Mutable vs immutable types in Python explains the difference.

Fix mutable defaults with a None default

The standard fix is to make None the default and build the real object inside the function. A new list is created on each call that doesn’t pass one:

def add_item(item, cart=None):
    if cart is None:
        cart = []                 # a fresh list on every call
    cart.append(item)
    return cart

print(add_item("coffee"))
print(add_item("bagel"))
print(add_item("tea", ["milk"]))
print("stored default:", add_item.__defaults__)

Output:

['coffee']
['bagel']
['milk', 'tea']
stored default: (None,)

Now each call starts empty, and __defaults__ stores only None. Use is None rather than == None or a plain truth test, so an empty list the caller passes on purpose isn’t swapped for a new one.

There’s more on that comparison in checking if a variable is None in Python.

When does Python evaluate default argument values?

Defaults are evaluated once, at the moment the def statement runs, not on each call. A function call used as a default shows this clearly:

def default_rate():
    print("  computing default_rate()")
    return 0.0825

def sales_tax(amount, rate=default_rate()):   # runs ONCE, at def time
    return round(amount * rate, 2)

print("calling sales_tax three times:")
print(sales_tax(100.00))
print(sales_tax(59.99))
print(sales_tax(20.00, rate=0.06))

Output:

  computing default_rate()
calling sales_tax three times:
8.25
4.95
1.2

The message prints before any call to sales_tax(), and only once. That’s harmless for a fixed tax rate, but a default like datetime.now() would freeze the time the module loaded. Use None and compute the value inside the function instead.

Some code uses this once-only behavior on purpose to keep a cache between calls. I prefer the clearer options in using static variables in Python functions.

How to check if an optional argument was passed

A None default can’t tell “not passed” apart from “passed None“. When that difference matters, use a private sentinel object, a unique value no caller will ever pass by accident:

_MISSING = object()

def update_profile(name, phone=_MISSING):
    if phone is _MISSING:
        return f"{name}: phone unchanged"
    if phone is None:
        return f"{name}: phone removed"
    return f"{name}: phone set to {phone}"

print(update_profile("Megan Bowen"))
print(update_profile("Megan Bowen", None))
print(update_profile("Megan Bowen", "(206) 555-0142"))

Output:

Megan Bowen: phone unchanged
Megan Bowen: phone removed
Megan Bowen: phone set to (206) 555-0142
Command Prompt output of a Python function that uses a sentinel default to detect whether an optional argument was passed
Leaving the phone out, passing None, and passing a number each lead to a different result.

object() creates a new object that’s only equal to itself, so phone is _MISSING is true only when the caller left the argument out. That’s how a profile update can mean “don’t touch the phone” or “clear it”.

Another way is to accept options through **kwargs and test the key with in:

def book_table(party, time="7:00 PM", **extras):
    seat = extras.get("seat", "any table")
    note = "notes added" if "notes" in extras else "no notes"
    return f"{party} guests at {time}, {seat}, {note}"

print(book_table(4))
print(book_table(2, "8:30 PM", seat="patio"))
print(book_table(6, notes="birthday"))

Output:

4 guests at 7:00 PM, any table, no notes
2 guests at 8:30 PM, patio, no notes
6 guests at 7:00 PM, any table, notes added

Here "notes" in extras is true only when the caller actually wrote notes=. It’s handy for a few rare options, though explicit parameters are easier to read in your editor’s hints.

Keyword-only optional arguments with a bare *

Boolean flags passed by position are hard to read: export_report(sales, "xlsx", True) says nothing about what True means. A bare * in the signature makes every parameter after it keyword-only:

def export_report(rows, *, fmt="csv", overwrite=False):
    mode = "overwrite" if overwrite else "keep existing"
    return f"{len(rows)} rows -> {fmt} ({mode})"

sales = [("Seattle", 1250.00), ("Austin", 980.50), ("Denver", 1430.25)]
print(export_report(sales))
print(export_report(sales, fmt="xlsx", overwrite=True))
try:
    export_report(sales, "xlsx")
except TypeError as err:
    print("TypeError:", err)

Output:

3 rows -> csv (keep existing)
3 rows -> xlsx (overwrite)
TypeError: export_report() takes 1 positional argument but 2 were given
Command Prompt output of a Python function with keyword-only optional arguments and the TypeError for a positional call
Keyword calls work, while passing the format by position raises a TypeError about positional arguments.

I use this for any optional setting that’s a flag or a mode. Callers must write fmt= and overwrite=, so the call reads like a sentence, and you can reorder those parameters later without breaking anyone.

The same idea works on a script’s entry point; using the Python main function with arguments shows options passed in from the command line.

Accept optional extras with *args and **kwargs

When the number of optional values isn’t fixed, *args gathers extra positional arguments into a tuple and **kwargs gathers extra keyword arguments into a dict. Either one can be empty, so both are optional:

def send_invoice(customer, amount, *notes, currency="USD", **options):
    print(f"Invoice for {customer}: {amount:,.2f} {currency}")
    for note in notes:
        print("  note:", note)
    for key, value in options.items():
        print(f"  {key} = {value}")

send_invoice("Alex Wilber", 1499.00)
send_invoice("Adele Vance", 325.50, "Net 30", "Thank you!",
             due="2026-11-15", cc="billing@example.com")

Output:

Invoice for Alex Wilber: 1,499.00 USD
Invoice for Adele Vance: 325.50 USD
  note: Net 30
  note: Thank you!
  due = 2026-11-15
  cc = billing@example.com

The first call passes nothing extra, so