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

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'],)

__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

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

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