Arguments in Depth - Positional, Keyword, and Default
Context
Arguments can be supplied to a function by position or by name, and parameters can carry a default so a caller may leave them out. How Python matches what was passed to what was declared is a fixed set of rules worth knowing exactly.
Defaults are how one function serves many callers. A loading function that takes a separator, an encoding, and a flag for whether to skip a header row can be called with the file name alone in the common case, and with the extras named explicitly for the one file that is different. Naming arguments at the call is also how a line stays readable, because a call carrying three bare true-or-false values tells the reader nothing about which is which.
Position is where mistakes hide. Two parameters of the same type in the wrong order often raise no error at all, so a function asked for the ratio of correct to total quietly computes the reciprocal, and the metric is wrong by a factor nobody notices. A default that is shared rather than fresh causes a subtler version of the same silence. The previous lesson introduced passing values in, this lesson pins down exactly how they land.
Goal
- Call a function by position, passing arguments in the order the parameters are declared.
- Call a function by keyword, naming each parameter explicitly at the call site.
- Define parameters with default values so callers may omit them.
- Predict how a mix of positional and keyword arguments binds to parameters.
- Identify and avoid the broken-call patterns that raise
SyntaxErrororTypeError.
Prerequisites
Not required, and the lesson does not wait for them. Read one if you would rather see the shape before building it.
- YouTube - "Python Tutorial for Beginners 8: Functions" by Corey Schafer, the arguments portion. Watch for the picture of naming arguments at the call.
- Official docs - The Python Tutorial, "Default Argument Values". The canonical description of defaults, including the mutable-default warning.
Table of Contents
- Positional Argument
- Keyword Argument
- Default Parameter
- Argument Binding
Theory
Nothing needs installing for this lesson. Positional arguments, keyword arguments, and defaults are all built into Python's function syntax, with nothing to import.
Positional Argument
What. A positional argument is a value bound to a parameter purely by its position, the first argument fills the first parameter, the second fills the second, and so on. Why. It is the shortest call form, and for functions with one or two obvious inputs it reads cleanly with no extra words. It is also the default behavior, so understanding it is non-negotiable. When. Use positional arguments when the order of inputs is obvious and few enough that position alone is unambiguous.
The binding is strictly left to right. Python does not look at names, it lines up the arguments you wrote against the parameters in declaration order and pairs them off one by one.
# Parameters declared in the order: 'score', then 'weight'.
def weighted(score, weight): # position 1 = score, position 2 = weight
return score * weight
print(weighted(80, 0.5)) # 40.0, score is 80, weight is 0.5Nothing about the values decides the binding, only where they sit in the call.
Because position is everything, swapping the arguments silently changes the meaning. Python cannot warn you, since both calls are valid.
# Same function, arguments in the WRONG order.
def weighted(score, weight):
return score * weight
print(weighted(0.5, 80)) # 40.0 by luck, score is 0.5, weight is 80
# A function where the order changes the arithmetic.
def accuracy(correct, total):
return correct / total
print(accuracy(80, 100)) # 0.8
print(accuracy(100, 80)) # 1.25, an accuracy above 100%The weighted call still gives 40.0 by coincidence, because multiplication does not care which number came first. Division does: accuracy(100, 80) returns 1.25 without a word, and Python had no way to object to either call. This positional fragility is exactly what keyword arguments fix.
DON'T:
- DON'T rely on position for functions with many or interchangeable-looking parameters, one wrong order is a silent bug.
- DON'T assume Python matches by value or by name in a positional call, it matches strictly by order.
Keyword Argument
What. A keyword argument is a value bound to a parameter by naming that parameter explicitly at the call, written as name=value.
Why. Naming the parameter makes the call self-documenting and immune to order mistakes, weighted(weight=0.5, score=80) means the same thing no matter how you arrange it. It is the readable choice when a call has several inputs.
When. Use keyword arguments when there are several parameters, when some are easy to confuse, or when you want the call to read clearly on its own.
With keyword arguments, position no longer matters, because each value states which parameter it belongs to. This removes the ordering risk entirely.
# Same function as before.
def weighted(score, weight): # two parameters
return score * weight
# Name each parameter at the call: order is now irrelevant.
print(weighted(score=80, weight=0.5)) # 40.0
print(weighted(weight=0.5, score=80)) # 40.0 - reordered, same resultKeyword arguments also make an unfamiliar call readable at a glance, you do not need to remember what the third number means when it is labeled.
# A keyword call documents itself.
def train(epochs, learning_rate, verbose): # three parameters
return (epochs, learning_rate, verbose) # hand back as-is for inspection
# Without names, '50, 0.01, 1' is a guessing game, with names it is obvious.
print(train(epochs=50, learning_rate=0.01, verbose=1))The library functions you will lean on later expose dozens of keyword arguments with sensible defaults, and this is why their call signatures will make sense to you.
DON'T:
- DON'T misspell a keyword name,
weighted(scor=80, weight=0.5)raises aTypeErrorfor an unexpected keyword argument. - DON'T expect a keyword argument to invent a parameter, the name must already exist in the definition.
Default Parameter
What. A default parameter has a fallback value written in the definition, used automatically when the caller supplies no argument for it. Why. Defaults let one function serve both the common case and the rare case, callers ignore the parameter most of the time and override it only when they need to. This is how a function stays simple to call yet flexible. When. Give a parameter a default when there is a sensible value almost every caller would pick, while still allowing override.
A default is written with = in the signature. A parameter with a default may be omitted at the call, and a parameter without one must always be supplied.
# 'places' defaults to 2 when the caller omits it.
def round_to(value, places=2): # 'value' required, 'places' optional
return round(value, places) # 'round' is built in
print(round_to(3.14159)) # 3.14, 'places' defaults to 2
print(round_to(3.14159, 4)) # 3.1416, the caller overrides
print(round_to(3.14159, places=0)) # 3.0, overridden by keywordIn a parameter list like these, parameters with defaults must come after all parameters without them, otherwise the definition itself is an error.
# Required parameters first, defaulted ones after.
# two defaults at the end
def split(dataset_size, train_frac=0.8, shuffle=1):
return (dataset_size, train_frac, shuffle)
print(split(1000)) # uses both defaults
print(split(1000, 0.7)) # overrides train_frac only
# def bad(train_frac=0.8, dataset_size): ... # SyntaxError: parameter without a default follows parameter with a defaultThe reason the commented bad definition is rejected is that its default would be useless to a positional call: positional arguments fill parameters left to right, so any call that reaches dataset_size by position must pass train_frac first.
One fact about defaults is easy to miss and causes trouble later: the default expression is evaluated exactly once, when the def statement runs, not on each call. The value it produced is then reused by every call that omits the argument. For numbers, strings and booleans sharing that one value does no harm, because none of them can be changed in place. For a mutable value (one that can be changed in place after it is created) like a list, an ordered collection covered in the next section, it produces one object shared by every call that omits the argument, so a change one call makes is still there in the next, which is why a list must never be used as a default.
DON'T:
- DON'T place a defaulted parameter before a required one in a parameter list like these, it is a
SyntaxError. - DON'T pick a default that hides a missing input, if a caller almost always should think about a value, leave it required.
- DON'T use a list as a default, it is built once when the
defruns, so every call that leaves the argument out gets that same list, and whatever one call appends is still there in the next.
Argument Binding
What. Argument binding is the rule Python uses to match the arguments you supply to the parameters a function declares. Why. Mixed calls, some positional, some keyword, some defaulted, are everywhere in real code, and a wrong mental model produces calls that fail or, worse, silently bind to the wrong parameter. When. You apply binding rules every time you call a function, so the rules must be automatic, especially for mixed calls.
The order of resolution is fixed. Python first assigns positional arguments left to right, then assigns keyword arguments by name, then fills any still-empty parameters from their defaults. The one hard rule for mixed calls: every positional argument must come before any keyword argument.
# Mixed call: positional first, then keyword.
# one default at the end
def train(epochs, learning_rate, verbose=0):
return (epochs, learning_rate, verbose)
print(train(50, learning_rate=0.01)) # (50, 0.01, 0), verbose kept its default
print(train(50, 0.01, verbose=1)) # (50, 0.01, 1), two positional, one keywordBreaking the positional-before-keyword rule is a SyntaxError, and binding the same parameter twice raises a TypeError.
def train(epochs, learning_rate, verbose=0):
return (epochs, learning_rate, verbose)
# print(train(epochs=50, 0.01)) # SyntaxError: positional argument follows keyword argument
# print(train(50, epochs=50)) # TypeError: train() got multiple values for argument 'epochs'
print(train(50, 0.01)) # the correct call, it breaks neither ruleDON'T:
- DON'T write a positional argument after a keyword argument, it is a
SyntaxErrorevery time. - DON'T bind one parameter both positionally and by keyword, Python rejects the duplicate.
Lab Exercises
Lab Exercise 1: Position, Then Name
Define rate(distance, time) that returns distance / time. Call it positionally as rate(100, 4), then again with the arguments swapped as rate(4, 100), and print both. Add a comment stating which call gives the intended speed and why Python raised no error for the other.
Then define train(epochs, learning_rate, batch_size) that returns a tuple (values written in parentheses and separated by commas, handed back together as one value) of its three values. Call it twice using only keyword arguments, writing the two calls in a different keyword order, and print both. Confirm in a comment that the order changed nothing.
Lab Exercise 2: Defaults That Bend, and Mixed Calls
Define make_split(size, train_frac=0.8, shuffle=1) returning the three values as a tuple. Call it four times and print each result: once with only size, once overriding train_frac positionally, once overriding shuffle by keyword while leaving train_frac at its default, and once supplying all three positionally.
Then, without running them, write two more calls as comments that would each fail, one breaking the positional-before-keyword rule and one binding size twice. Name the error each would raise in the comment beside it.
Solution 1: Position, Then Name
# Position decides which value is distance and which is time.
def rate(distance, time): # position 1 = distance, position 2 = time
return distance / time
print(rate(100, 4)) # 25.0
print(rate(4, 100)) # 0.04
# Both calls are valid syntax, only the second is wrong, so Python cannot object.# Three parameters, named at every call.
# returns the three as a tuple
def train(epochs, learning_rate, batch_size):
return (epochs, learning_rate, batch_size)
# named in declared order
a = train(epochs=50, learning_rate=0.01, batch_size=32)
# named, deliberately shuffled
b = train(batch_size=32, epochs=50, learning_rate=0.01)
print(a) # (50, 0.01, 32)
print(b) # (50, 0.01, 32)
# Keyword order does not affect binding, the names do all the work.DON'T:
- DON'T trust position when a swap still runs without error, that bug is silent and survives any test that only checks the call runs.
- DON'T misspell a keyword,
epoch=50instead ofepochs=50raises aTypeErrorfor an unexpected keyword argument.
Solution 2: Defaults That Bend, and Mixed Calls
# Two defaulted parameters, both after the required one.
# 'size' required
def make_split(size, train_frac=0.8, shuffle=1):
return (size, train_frac, shuffle)
print(make_split(1000)) # (1000, 0.8, 1)
print(make_split(1000, 0.7)) # (1000, 0.7, 1)
print(make_split(1000, shuffle=0)) # (1000, 0.8, 0)
print(make_split(1000, 0.7, 0)) # (1000, 0.7, 0)
# print(make_split(size=1000, 0.7)) # SyntaxError: positional argument follows keyword argument
# print(make_split(1000, size=1000)) # TypeError: make_split() got multiple values for argument 'size'The third call is the one worth pausing on. Positionally there is no way to reach shuffle without also passing train_frac, because you cannot leave a hole in the sequence. Naming shuffle skips over the middle parameter and leaves its default untouched, which is the reason keywords and defaults are so often used together.
DON'T:
- DON'T pass a placeholder value for
train_fracjust to reachshuffle, name the parameter you actually want. - DON'T move a defaulted parameter in front of
size, that order is aSyntaxErrorin the definition itself.
Theory Assessment
Q1: Order That Matters and Order That Does Not
For def rate(distance, time): return distance / time, rate(100, 4) returns 25.0 while rate(4, 100) returns 0.04. Explain why. Then, for def weighted(score, weight): return score * weight, explain why weighted(weight=0.5, score=80) and weighted(score=80, weight=0.5) return the same thing despite the different order.
Hint: One call form matches by where a value sits, the other matches by what it is called. Only one of the two is sensitive to rearrangement.
Q2: How Many Arguments Are Legal, and How Do You Skip One
Given def make_split(size, train_frac=0.8, shuffle=1): ..., how many arguments is it legal to call make_split with, and which parameter is mandatory? Then explain how to keep train_frac at its default while setting shuffle to 0 in a single call.
Hint: A defaulted parameter is optional and an undefaulted one is not. For the second half, remember you cannot leave a hole in a sequence of positional arguments.
Q3: Two Calls That Fail
For def train(epochs, learning_rate, verbose=0): ..., explain why train(epochs=50, 0.01) fails and why train(50, epochs=50, learning_rate=0.01) also fails. Name the error in each case and say what makes the two failures different from each other.
Hint: One breaks a rule about the arrangement of the call. The other breaks a rule about how many values a single parameter may receive.
Q4: The Same Call, Two Answers
Read the following without running it, then say what each print shows.
threshold = 0.5
def classify(score, cutoff=threshold): # 'cutoff' defaults to 'threshold'
return score >= cutoff
threshold = 0.9
print(classify(0.7))
print(classify(0.7, threshold))The same function is asked about the same score twice and answers differently. Explain exactly why, and say at what moment cutoff's default was decided.
Hint: A default is an expression, and expressions are evaluated at a particular moment rather than continuously. Ask when the def line itself ran, and whether anything re-runs it later.
Answer 1:
rate binds positionally, so only order counts and the size of the values is irrelevant. In rate(100, 4), 100 fills distance and 4 fills time, giving 100 / 4 = 25.0. In rate(4, 100), 4 fills distance and 100 fills time, giving 4 / 100 = 0.04. The same two numbers, reversed, so the meaning flips and Python cannot object because both calls are valid.
weighted is called with keyword arguments, which bind by the name written rather than by position. weight=0.5 fills weight and score=80 fills score in either arrangement, so both calls return 40.0. Rearranging keyword arguments never changes the binding, and that is precisely the safety a positional call does not give you.
DON'T assume Python will catch a reversed positional call, and don't imagine keyword order matters, only the names do.
Answer 2:
It is legal to call make_split with one, two, or three arguments. size is mandatory because it has no default, while train_frac and shuffle are optional because each has one.
To keep train_frac and set shuffle, name it: make_split(1000, shuffle=0). The positional 1000 fills size, train_frac is never mentioned so it keeps 0.8, and shuffle=0 overrides only shuffle. Positionally this is impossible, because arguments fill parameters left to right with no way to leave a gap, so reaching the third parameter positionally forces you to supply the second as well.
DON'T call make_split() with no arguments, size has no default and must always be supplied.
Answer 3:
train(epochs=50, 0.01) is a SyntaxError, because the positional argument 0.01 appears after the keyword argument epochs=50. Every positional argument must come before any keyword argument, and this one is rejected before the function is ever called.
train(50, epochs=50, learning_rate=0.01) is a TypeError, because epochs is bound twice, once by the positional 50 and again by the keyword epochs=50. A parameter may receive exactly one value per call.
The difference is when and why each is caught. The first is malformed as text, so Python rejects it while reading the file and no call is attempted. The second is perfectly well-formed text, and only fails once Python tries to match the arguments to the parameters at call time.
DON'T treat these as the same mistake, one is about the arrangement of the call and the other is about a parameter receiving two values.
Answer 4:
The first prints True, the second prints False.
The default expression cutoff=threshold is evaluated exactly once, at the moment the def statement runs. At that moment threshold was 0.5, so cutoff's default was fixed as the value 0.5, not as a live link to the name threshold. Reassigning threshold = 0.9 afterwards changes the variable but cannot reach back into a default that was already decided. So classify(0.7) compares 0.7 >= 0.5 and returns True.
The second call passes threshold explicitly, and that argument is evaluated at call time, when threshold is 0.9. So it compares 0.7 >= 0.9 and returns False.
The lesson underneath is that a default is a value captured once, not an expression re-run per call. With numbers this only surprises you. With a mutable value it does real damage, because every call that omits the argument shares the one object created back at definition time.
DON'T write a default that reads a variable you intend to change later, the default keeps the value the variable held when the def ran and silently ignores every reassignment after that.
Assignment
Assignment: An Accumulator That Survives Every Call Style
Build the following in one file, in this order, and make it demonstrate the failure before it fixes it.
First write add_metric(name, log=[]) that appends name to log and returns log. [] is an empty list, and log.append(name) adds name to the end of log, changing that same list in place. Call it three times with no second argument and print each result. Do not correct it yet, the output is the point.
Then write collect(item, bucket=None) using the None-default idiom: when bucket is omitted, build a fresh list inside the body, otherwise append to the list the caller supplied. Call it three times with no bucket and print each result, so the contrast with the first version is visible.
Then prove sharing still works when it is asked for. Create a list of your own outside the function, pass it to collect twice, and print both results.
Finally, exercise the binding rules against collect: call it once fully positionally, once with bucket named by keyword, and once with both arguments named in the reverse of their declared order. Print all three and add a comment beside each stating which parameter got which value.
Hints:
- The broken version's default list is created once when the
defline runs, so every call that omitslogwrites into that same object, useif bucket is None: bucket = []to get a new one per call instead. - For the last part, remember a keyword argument binds by name, so naming both parameters lets you write them in any order, while a positional argument after a keyword one is rejected outright.
Solution:
# BROKEN: 'log=[]' is evaluated ONCE, when this 'def' line runs.
def add_metric(name, log=[]): # one list, created at definition, shared forever
log.append(name) # mutates the ONE shared list
return log
print(add_metric("loss")) # ['loss']
print(add_metric("acc")) # ['loss', 'acc']
print(add_metric("f1")) # ['loss', 'acc', 'f1']Nothing here is a bug in the body. add_metric("acc") was called with one argument and returned two entries, because the default was decided once and every defaulted call has been writing into that same list ever since.
# FIXED: default to None, then build a fresh list inside on each call.
def collect(item, bucket=None): # 'None' is immutable, so sharing it is harmless
if bucket is None: # the caller supplied nothing
bucket = [] # a new list, created on THIS call
bucket.append(item)
return bucket
print(collect("a")) # ['a']
print(collect("b")) # ['b']
print(collect("c")) # ['c']
# Sharing on purpose: pass a list we own and watch it grow.
shared = [] # a list, an ordered collection, that we control
print(collect("x", shared)) # ['x']
print(collect("y", shared)) # ['x', 'y']
# The binding rules, exercised against the same signature.
print(collect("p", shared)) # positional: item='p', bucket=shared
print(collect("q", bucket=shared)) # keyword: item='q', bucket=shared
print(collect(bucket=shared, item="r")) # both named, reversed: item='r', bucket=sharedNone is chosen as the sentinel (a marker value meaning the caller passed nothing) because it is immutable, so the one shared copy can do no harm, and because no ordinary caller would pass it, which makes bucket is None a reliable signal that the argument was omitted. Test it with is rather than ==, since is asks whether it is that exact object rather than whether it merely compares equal.
The last three calls bind item and bucket three different ways. The final one names both parameters and writes them in the reverse of their declared order, which is legal precisely because keyword arguments carry their own destination. Rewriting it as collect(shared, "r") would bind shared to item and "r" to bucket, and the call would fail inside the function with AttributeError: 'str' object has no attribute 'append', an error reported at bucket.append(item), inside the function, while the swapped call that caused it shows only as the frame above it.
DON'T write def collect(item, bucket=[]), and don't reach for bucket == None in place of bucket is None, the identity check is what the idiom depends on.
Additional Resources
Optional. Nothing later in the module depends on either, they are here if the binding rules interested you.
- YouTube - "Learn Python - Full Course for Beginners [Tutorial]" by freeCodeCamp.org. The "Functions" chapter, for a second pass over defining a function and passing it values by position.
- Official docs - Python Language Reference, "Function definitions", the paragraphs describing the
/and*markers in a parameter list. How a parameter is made positional-only or keyword-only, which is the author's side of the choice this lesson made from the caller's.
Summary
This lesson took the single idea of "passing a value to a function" and split it into the three forms you will use for the rest of your programming life. Positional arguments are the terse default, binding by order alone, fast to write and perfectly clear for one or two obvious inputs, but silently wrong the instant the order slips. Keyword arguments trade brevity for safety and readability, labeling each value so a call documents itself and never depends on order. Defaults let a single function serve both the common path and the exceptional one, so callers say only what differs from the usual.
Underneath all three sits one binding model: positional arguments first, left to right, then keyword arguments by name, then defaults for whatever is still empty, with the unbreakable rule that positional always precedes keyword. Most of the TypeError and SyntaxError messages you will meet around calls are this model refusing a call that breaks one of its rules, such as a positional after a keyword, a parameter bound twice or a required parameter left empty. Reading those errors as "which binding rule did this call break" turns them from mysteries into quick fixes.
The mutable-default trap is worth the early warning because it bites quietly. A default is built once, when the function is defined, so a shared list silently keeps whatever earlier calls put in it, and the None-default idiom is the fix you will see in professional code everywhere. When you reach the libraries at the heart of this program, their call signatures will be long lists of keyword arguments with carefully chosen defaults, and you will read them fluently because of exactly this lesson.
Cheat Sheet
| Form | Syntax | Binds by | When to Use |
|---|---|---|---|
| Positional | f(80, 0.5) |
Order | Few, obviously ordered inputs |
| Keyword | f(score=80, weight=0.5) |
Name | Many or confusable inputs |
| Default | def f(x, w=0.5): |
Fallback if omitted | A sensible common value |
| Mixed | f(80, weight=0.5) |
Position then name | Positional first, always |
| Skip middle | f(80, shuffle=0) |
Name the one you want | Keeping earlier defaults |
| Bad: pos after kw | f(x=1, 2) |
Nothing, it never parses | Never, SyntaxError |
# one required, two defaults
def train(epochs, learning_rate=0.01, verbose=0):
return (epochs, learning_rate, verbose)
train(50) # positional, both defaults used
train(50, 0.05) # override learning_rate positionally
train(50, verbose=1) # skip middle via keyword
train(epochs=50, learning_rate=0.02) # keyword, order-free
def safe(item, bucket=None): # None-default idiom
if bucket is None: # avoid shared mutable default
bucket = [] # fresh list per call
bucket.append(item)
return bucket