Filter concepts by levelShowing all levels.

Python · Python Fundamentals

Functions

Concepts
17
Python overview

Defining and calling

Building a function, and the two default ways an argument reaches it.

Defining functions

corebeginner

def builds a function object out of an indented block and binds a name to it. The block does not run, and its contents are not even checked for correctness, until the function is actually called.

Think of it as

def is a recipe card, not a meal. Writing the card does not cook anything — it just gives a name to a set of steps you can hand to someone later. The steps only run, and only get checked for whether the ingredients exist, when someone actually follows the card.

python
def calculate_total(cents):    # def builds the object, binds calculate_total to it
    """Convert integer cents to dollars."""
    return cents / 100

calculate_total(420)           # only now does the body run — 4.2
calculate_total.__name__       # 'calculate_total'

What we're doing: Confirm a function body does not run at def time, and that names inside it are not even checked until the call that actually needs them.

defining.pypython
def format_price(cents):
    """Convert integer cents to a display string like "$4.20"."""
    dollars = cents / 100
    return f"${dollars:.2f}"


print(format_price.__name__, format_price.__doc__)
print(format_price(420))
print(format_price(100))


def call_helper():
    return helper()          # helper is not defined yet — fine, checked only when called


try:
    call_helper()
except NameError as e:
    print(e)


def helper():
    return "ready"


print(call_helper())
1–4
def builds the function object right here. The division and the f-string inside have not run yet.
2
The string right after def becomes __doc__ — data attached to the object, not a step it runs.
7
Name and docstring are already available, before format_price has been called even once.
8–9
One function object, called twice with different arguments — nothing about it is rebuilt.
13
helper does not exist anywhere above this line. Defining call_helper does not check that — it only stores the body.
17
Calling call_helper() now is what actually looks helper up, and fails because it still does not exist.
22
helper is defined after call_helper — file order does not matter, only order by the time of the call.
26
The exact same call_helper() now succeeds, because helper exists by the time this line runs.
Output
format_price Convert integer cents to a display string like "$4.20".
$4.20
$1.00
name 'helper' is not defined
ready

Why this works: def does exactly two things: build a function object from the indented block, and bind a name to it. The block is not executed and its names are not resolved at that point — which is why call_helper can reference helper before helper exists anywhere in the file. Python only looks a name up inside a function body when that line actually runs, and by the time call_helper() is called a second time, helper has been defined. Calling format_price twice reuses the one object def built; nothing about defining it happens again.

Naming the function instead of calling it

Wrong

python
def calculate_total(cents):
    return cents / 100


order_total = calculate_total
print(f"Total: ${order_total}")

Better

python
def calculate_total(cents):
    return cents / 100


order_total = calculate_total(420)
print(f"Total: ${order_total}")

What you see: The wrong version prints something like "Total: $<function calculate_total at 0x...>" — the function object itself, not a number. No error is raised anywhere.

Why: calculate_total, with no parentheses, is just the name — it refers to the function object, the same way a variable name refers to any other object. Only calculate_total(420) runs the body and produces the value the body computes. Leaving off the parentheses is not a typo Python can catch, because a bare function name is perfectly valid syntax; it just is not the call that was intended.

def builds an object; calling it runs the body

def runs

builds a function object, binds a name

the name

points at that object — nothing inside has executed

name(...) runs

only now does the body execute, and get checked

  1. def runs — builds a function object, binds a name
  2. the name — points at that object — nothing inside has executed
  3. name(...) runs — only now does the body execute, and get checked

What every function object carries

What every function object carries
AttributeHolds
func.__name__the name it was defined with, as a string
func.__doc__the docstring, or None if there is not one
func.__defaults__a tuple of the default argument values
func(*args)calling it — the only line that runs the body

Together

python
def greet(name, greeting="Hello"):
    """Return a greeting for name."""
    return f"{greeting}, {name}!"

greet.__name__       # 'greet'
greet.__doc__        # 'Return a greeting for name.'
greet.__defaults__   # ('Hello',)

Remember: def only builds a function object and binds a name to it — the body runs, and its names get checked, only when the function is actually called.

See also: names and references · none · lists

Positional arguments

standardbeginner

A positional argument is matched to a parameter by where it sits in the call, not by name — the first value fills the first, the second fills the second. Get the order wrong and Python only complains about the count, never the meaning.

Think of it as

A queue at one till. Whoever is first in line gets served by the first open register, second by the second — position decides everything, and nothing about standing in line says who anyone actually is.

python
def create_user(username, email, role):
    ...

create_user("nova", "nova@example.com", "admin")   # position: username, email, role, in order
create_user("nova@example.com", "nova", "admin")   # runs — but username and email are now swapped

What we're doing: Fill three parameters by position, trigger both arity errors, and watch a same-typed swap run without complaint.

create_user.pypython
def create_user(username, email, role):
    return f"{username} <{email}> as {role}"


print(create_user("nova", "nova@example.com", "admin"))

try:
    create_user("nova", "nova@example.com")
except TypeError as e:
    print(e)

try:
    create_user("nova", "nova@example.com", "admin", "extra")
except TypeError as e:
    print(e)

print(create_user("nova@example.com", "nova", "admin"))
5
Three values, three parameters, matched purely by position: username first, email second, role third.
8
One argument short. Python names exactly which parameter never got filled.
13
One argument too many. Python names how many it expected against how many arrived.
17
username and email are swapped in the call. Both are strings, so this runs — wrong, not broken.
Output
nova <nova@example.com> as admin
create_user() missing 1 required positional argument: 'role'
create_user() takes 3 positional arguments but 4 were given
nova@example.com <nova> as admin

Why this works: Python matches positional arguments to parameters purely by their order in the call, so a count mismatch is a shape Python can check and report by name — it knows exactly which parameter needed a fourth value, or which argument had nowhere to go. A swap between two arguments of the same type is not a shape mismatch at all: three strings arrive for three string parameters, the call succeeds, and the result is simply wrong. Only the caller can catch that, which is exactly the gap keyword arguments close.

Two same-typed arguments, swapped at the call site

Wrong

python
def schedule_retry(delay_seconds, max_attempts):
    return f"waiting {delay_seconds}s, up to {max_attempts} attempts"


print(schedule_retry(30, 5))

Better

python
def schedule_retry(delay_seconds, max_attempts):
    return f"waiting {delay_seconds}s, up to {max_attempts} attempts"


print(schedule_retry(delay_seconds=5, max_attempts=30))

What you see: The wrong version prints "waiting 30s, up to 5 attempts" — a 30-second delay with only 5 attempts, the opposite of what was intended. No error, no warning.

Why: delay_seconds and max_attempts are both plain ints, so schedule_retry(30, 5) is a perfectly well-formed call — Python has no way to know that 30 was meant for the second parameter. Passing the same two values by name instead removes the ambiguity: the call reads correctly regardless of the order the values are written in, which is the entire reason keyword arguments exist alongside positional ones.

Calling send_email(to, subject, body)

Calling send_email(to, subject, body)
CallResult
send_email("a@x.com", "Hi", "text")runs — to, subject, body filled in order
send_email("a@x.com", "Hi")TypeError: missing 1 required positional argument: 'body'
send_email("a@x.com", "Hi", "text", "extra")TypeError: takes 3 positional arguments but 4 were given
send_email("Hi", "a@x.com", "text")runs — no error, but to and subject are swapped

Together

python
def send_email(to, subject, body):
    ...

send_email("a@x.com", "Hi", "text")   # fine
send_email("a@x.com", "Hi")           # TypeError: missing 1 required positional argument: 'body'
send_email("Hi", "a@x.com", "text")   # runs — wrong, and Python cannot tell

Remember: Position is all Python checks for a positional argument — never the meaning. Two same-typed values in the wrong order still run, silently wrong.

See also: defining functions · is vs equals

Keyword arguments

corebeginner

A keyword argument is matched to a parameter by name, written as name=value in the call. Order stops mattering, and a name Python cannot match — misspelled, or already filled — is a TypeError, not a silent wrong value.

Think of it as

A labelled parcel instead of a queue position. It does not matter which shelf you place it on — the label is what routes it to the right person, and a label nobody recognizes gets bounced back immediately instead of delivered to the wrong desk.

python
def create_user(username, email, role):
    ...

create_user(role="admin", username="nova", email="nova@example.com")   # any order, named
create_user("nova", role="admin", email="nova@example.com")            # positional then keyword

What we're doing: Call the same function three ways — all keyword, mixed, and two ways that raise — and compare the two TypeError messages a mismatched keyword can produce.

keyword_args.pypython
def create_user(username, email, role):
    return f"{username} <{email}> as {role}"


print(create_user(role="admin", username="nova", email="nova@example.com"))
print(create_user("nova", email="nova@example.com", role="admin"))

try:
    create_user(username="nova", email="nova@example.com", role="admin", team="core")
except TypeError as e:
    print(e)

try:
    create_user("nova", "nova@example.com", username="dup")
except TypeError as e:
    print(e)
5
All three arguments are named, written in a completely different order than the parameter list — the names do the matching, not position.
6
username is positional, email and role are named — positional arguments must still come first.
9
team matches no parameter of create_user. Python reports the exact keyword it could not place.
14
username is already filled positionally by "nova"; naming it again as username="dup" is the same parameter filled twice, and Python refuses instead of picking one.
Output
nova <nova@example.com> as admin
nova <nova@example.com> as admin
create_user() got an unexpected keyword argument 'team'
create_user() got multiple values for argument 'username'

Why this works: Keyword arguments are matched by looking up the name against the function's parameter list, so their order in the call is irrelevant — the two working calls above pass the same three values in two different orders and produce identical results. Because matching goes through the parameter list, a keyword that is not in it is a shape Python can detect and name directly, unlike the silent swap positional-only arguments allow. A keyword that repeats a parameter already filled positionally is the same detectable shape: two values competing for one slot, refused rather than resolved by picking either one.

A misspelled keyword read as a typo, not an error

Wrong

python
def schedule_retry(delay_seconds, max_attempts=3):
    return f"waiting {delay_seconds}s, up to {max_attempts} attempts"


print(schedule_retry(delay_seconds=30, max_attemps=5))

Better

python
def schedule_retry(delay_seconds, max_attempts=3):
    return f"waiting {delay_seconds}s, up to {max_attempts} attempts"


print(schedule_retry(delay_seconds=30, max_attempts=5))

What you see: The wrong version raises TypeError: schedule_retry() got an unexpected keyword argument 'max_attemps'. Did you mean 'max_attempts'? — it never silently falls back to the default.

Why: max_attempts has a default, but that only means the argument is optional — it does not make an unrecognized keyword acceptable. Python still checks every keyword in the call against the actual parameter list, so a one-letter misspelling is caught immediately as a bad keyword rather than quietly using the default and hiding the mistake.

Names route the values, not position

create_user(role="admin", username="nova", email="nova@example.com")

role="admin"

keyword argument — matched to the role parameter by name, regardless of position

username="nova"

keyword argument — matched to username by name — written first in the call, third in the def

email="nova@example.com"

keyword argument — matched to email by name — order among keywords never matters

  • Whole: create_user(role="admin", username="nova", email="nova@example.com")
  • role="admin" — keyword argument: matched to the role parameter by name, regardless of position
  • username="nova" — keyword argument: matched to username by name — written first in the call, third in the def
  • email="nova@example.com" — keyword argument: matched to email by name — order among keywords never matters

Calling send_email(to, subject, body)

Calling send_email(to, subject, body)
CallResult
send_email(to="a@x.com", subject="Hi", body="text")runs — order does not matter
send_email("a@x.com", subject="Hi", body="text")runs — mixing positional then keyword is fine
send_email(to="a@x.com", subject="Hi", body="t", extra="x")TypeError: got an unexpected keyword argument 'extra'
send_email("a@x.com", "Hi", to="dup")TypeError: got multiple values for argument 'to'

Together

python
def send_email(to, subject, body):
    ...

send_email(subject="Hi", to="a@x.com", body="text")   # order irrelevant, all named
send_email("a@x.com", subject="Hi", body="text")      # to is positional, the rest named
send_email("a@x.com", "Hi", to="dup")                 # TypeError: multiple values for 'to'

Remember: A keyword argument is matched by name, so a bad name or one reused after a positional fill is always a TypeError — never a silent wrong value.

See also: positional arguments · default arguments · defining functions

Advertisement

Shaping the call site

Making an argument optional, or restricting how it may be passed.

Default arguments

corebeginner

A parameter written as name=value in the def line becomes optional — callers who omit it get value. That value is computed once, when def runs, and the exact same object is reused for every call that does not override it.

Think of it as

A pre-filled form with one box already checked. Everyone who doesn't cross it out gets the same pre-filled answer — and if that box is something shared, like a physical clipboard rather than a fresh photocopy, every person who writes on it without replacing it first is writing on the same page as everyone before them.

python
def add_item(cart, item, quantity=1):    # quantity is optional, defaults to 1
    cart.append((item, quantity))
    return cart

add_item([], "apple")             # quantity uses the default: 1
add_item([], "apple", 3)          # quantity overridden positionally: 3
add_item([], "apple", quantity=5) # quantity overridden by keyword: 5

What we're doing: Confirm a default is optional and overridable both ways, then reproduce the mutable-default bug live — the same function, called three times, silently accumulating state.

default_args.pypython
def add_item(cart, item, quantity=1):
    cart.append((item, quantity))
    return cart


print(add_item([], "apple"))
print(add_item([], "apple", 3))
print(add_item([], "apple", quantity=5))


def append_bad(item, target=[]):
    target.append(item)
    return target


print(append_bad("a"))
print(append_bad("b"))
print(append_bad("c"))
6
quantity is omitted, so the default, 1, fills it — a fresh empty list is still passed explicitly for cart.
7
quantity overridden positionally: 3.
8
quantity overridden by keyword: 5 — either way of overriding works identically.
11
target=[] runs exactly once, when append_bad is defined — this one list object becomes the default forever, not a fresh empty list per call.
17
No target passed, so the shared default list is used — starts empty, gets "a" appended.
18
No target passed again — this is the SAME list from line 17, now holding "a", and "b" gets appended to it.
19
Same list a third time — now holds all of "a", "b", "c", though each call only ever appended one item.
Output
[('apple', 1)]
[('apple', 3)]
[('apple', 5)]
['a']
['a', 'b']
['a', 'b', 'c']

Why this works: def evaluates every default value exactly once, at the moment the def statement runs, and stores the resulting object on the function for every future call to reuse — this is true for add_item's int default and append_bad's list default alike. An int default is harmless because ints are immutable: nothing about using it can change the object itself. A list default is the same one object every time, and because lists are mutable, append_bad's target.append(item) call permanently changes that shared object — so each call that relies on the default starts from whatever the previous one left behind, not from an empty list.

A mutable default silently accumulating state

Wrong

python
def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags


print(add_tag("urgent"))
print(add_tag("bug"))

Better

python
def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags


print(add_tag("urgent"))
print(add_tag("bug"))

What you see: The wrong version prints ['urgent'] then ['urgent', 'bug'] — the second call's result contains a tag from the first call, even though neither call passed a tags argument at all.

Why: tags=[] builds one list when add_tag is defined, and every call that omits tags mutates that same object — there is no per-call reset, because the default was never re-evaluated after def time. Defaulting to None and building the list inside the body instead means every call that relies on the default gets a genuinely new, empty list, because that line runs fresh on every call rather than once at definition time.

A default is built once, then reused every call

def runs once

target=[] builds one list object

the default

points at that same object, always

every skipped call

mutates that one shared object, not a fresh one

  1. def runs once — target=[] builds one list object
  2. the default — points at that same object, always
  3. every skipped call — mutates that one shared object, not a fresh one

What a default value actually is

What a default value actually is
Default kindBehavior across calls
quantity=1 (int)immutable — every call that omits it gets the same value, unshared
role="viewer" (str)immutable — same, strings cannot be mutated in place
target=[] (list)one list object, built once — appending to it in the body leaks into the next call
target=None, then target = target or []the safe pattern — a fresh list is built inside the body each call

Together

python
def append_bad(item, target=[]):      # one list, built once, at def time
    target.append(item)
    return target

append_bad("a")   # ['a']
append_bad("b")   # ['a', 'b']  -- leftover from the previous call
append_bad("c")   # ['a', 'b', 'c']

Remember: A default is computed once, at def time, never per call. Default to None and build a mutable value inside the body — never write mutable=[] in a def line.

See also: keyword arguments · mutable vs immutable · defining functions

Keyword-only arguments

standardintermediate

A bare * in a parameter list marks everything after it as keyword-only — those parameters can only be filled by name, never by position, even though a caller could always name any parameter before now.

Think of it as

A counter with a rope after the first few registers: the first parameters are open queue positions, but past the rope, staff only take orders called out by name — standing in line past that point does not get you served.

python
def resize(image, *, width, height):    # width and height must always be named
    ...

resize("photo.jpg", width=800, height=600)   # required — resize(image, 800, 600) is a TypeError

What we're doing: Call a keyword-only function correctly two ways, then trigger the two failure shapes: passing one positionally, and omitting a required one entirely.

keyword_only.pypython
def connect(host, *, timeout=30, retries=3):
    return f"{host} timeout={timeout} retries={retries}"


print(connect("db.local"))
print(connect("db.local", timeout=5))
print(connect("db.local", retries=1, timeout=2))

try:
    connect("db.local", 5)
except TypeError as e:
    print(e)


def resize(image, *, width, height):
    return f"{width}x{height}"


try:
    resize("photo.jpg")
except TypeError as e:
    print(e)
5
host fills positionally; timeout and retries are both omitted and fall back to their defaults.
6
timeout named explicitly; retries still defaults. Both are past the *, so both must be named to override.
7
Both keyword-only parameters named, in the opposite order from how they were defined — order between them is free.
9
timeout sits after the * in the def line, so the second positional value here has nowhere valid to go.
21
width and height have no default and no positional route in — omitting both leaves Python unable to fill either.
Output
db.local timeout=30 retries=3
db.local timeout=5 retries=3
db.local timeout=2 retries=1
connect() takes 1 positional argument but 2 were given
resize() missing 2 required keyword-only arguments: 'width' and 'height'

Why this works: The bare * in each def line does not itself take a parameter slot — it is a marker that closes off positional filling for everything written after it. host stays fillable by position because it comes before the *; timeout, retries, width and height can only ever be reached by name, whether or not they have a default. Python enforces this the same way it enforces an arity mismatch: the count of positional arguments connect("db.local", 5) supplies (two) exceeds what the signature allows to be positional (one), and a required keyword-only parameter left unfilled is reported by name exactly like a missing required positional one would be.

Assuming any parameter can be filled positionally

Wrong

python
def transfer_funds(amount, *, from_account, to_account):
    return f"moved {amount} from {from_account} to {to_account}"


print(transfer_funds(500, "checking", "savings"))

Better

python
def transfer_funds(amount, *, from_account, to_account):
    return f"moved {amount} from {from_account} to {to_account}"


print(transfer_funds(500, from_account="checking", to_account="savings"))

What you see: The wrong version raises TypeError: transfer_funds() takes 1 positional argument but 3 were given — it never runs far enough to move anything, let alone mix up which account is which.

Why: from_account and to_account sit after the * in the definition, which is exactly what keyword-only means: no amount of correct ordering makes them fillable by position. This is the point of the feature, not an edge case — a call like transfer_funds(500, "checking", "savings") is exactly the kind of same-typed positional swap that plain positional arguments cannot catch, so marking these two keyword-only forces every caller to write from_account= and to_account= explicitly and removes the ambiguity at the call site itself.

Calling connect(host, *, timeout=30, retries=3)

Calling connect(host, *, timeout=30, retries=3)
CallResult
connect("db.local")runs — host positional, timeout and retries use their defaults
connect("db.local", timeout=5)runs — timeout named, retries defaults to 3
connect("db.local", 5)TypeError: takes 1 positional argument but 2 were given
connect("db.local", retries=1, timeout=2)runs — both keyword-only args, any order

Together

python
def connect(host, *, timeout=30, retries=3):
    ...

connect("db.local", timeout=5)     # fine — timeout is named
connect("db.local", 5)             # TypeError: takes 1 positional argument but 2 were given
connect("db.local", retries=1, timeout=2)   # fine — order between keyword-only args is free

Remember: A bare * closes off positional filling for everything after it — those parameters must always be named, default or not.

See also: keyword arguments · positional only arguments · default arguments

Positional-only arguments

standardintermediate

A bare / in a def line marks every parameter before it as positional-only — never fillable by name, only by position. It mirrors the keyword-only *, letting a def line say exactly which parameters callers may name.

Think of it as

Package slots with no label printed on them at all — only their order on the shelf tells you what they are. You cannot ask for "the second one" by a name that was never written down; you can only take it by where it sits.

python
def to_json(obj, /, *, indent=2):   # obj: positional-only · indent: keyword-only
    ...

to_json({"a": 1}, indent=4)         # fine
to_json(obj={"a": 1})               # TypeError — obj cannot be named

What we're doing: Call a positional-only parameter correctly, then trigger the TypeError from naming it — and see the same rule combined with keyword-only in one realistic signature.

positional_only.pypython
def power(base, exp, /):
    return base ** exp


print(power(2, 10))

try:
    power(base=2, exp=10)
except TypeError as e:
    print(e)


def to_json(obj, /, *, indent=2):
    import json
    return json.dumps(obj, indent=indent)


print(to_json({"a": 1}, indent=4))

try:
    to_json(obj={"a": 1})
except TypeError as e:
    print(e)
1
The bare / marks both base and exp — every parameter before it — as positional-only.
5
Filled by position: fine, exactly like any ordinary call.
8
Naming base and exp is refused outright, even though those are their real parameter names inside the function.
14
obj is positional-only (before /); indent is keyword-only (after *) — one def line pins down both ends of how each parameter may be called.
21
obj cannot be named, mirroring the first error — the / rule applies here exactly as it did for power.
Output
1024
power() got some positional-only arguments passed as keyword arguments: 'base, exp'
{
    "a": 1
}
to_json() got some positional-only arguments passed as keyword arguments: 'obj'

Why this works: The bare / does not consume an argument — it draws a line in the parameter list, and everything before that line loses the "or by name" half of the usual positional-or-keyword rule. Python still knows the parameter is named base internally, which is why the error message can name it — the name still exists for documentation and introspection, it simply is not part of the call-site contract any more.

Assuming positional-only means the parameter's name doesn't matter anywhere

Wrong

python
def area(width, height, /):
    return width * height


print(area(width=4, height=5))

Better

python
def area(width, height, /):
    return width * height


print(area(4, 5))

What you see: TypeError: area() got some positional-only arguments passed as keyword arguments: 'width, height' — the names width and height are visible right there in the def line, which makes calling them by name look like it should obviously work.

Why: Positional-only is a restriction on the CALL site, not a statement that the parameter is nameless — width and height still have those names inside the function body and in error messages. The / after them specifically forbids using those names when calling; only their position in the argument list is honored there.

Calling power(base, exp, /)

Calling power(base, exp, /)
CallResult
power(2, 10)runs — 1024, both filled by position
power(base=2, exp=10)TypeError: got some positional-only arguments passed as keyword arguments: 'base, exp'
power(2, exp=10)TypeError: same — exp alone is still positional-only

Together

python
def power(base, exp, /):
    return base ** exp

power(2, 10)              # 1024
power(base=2, exp=10)     # TypeError — base and exp cannot be named

Remember: A bare / marks every parameter before it as positional-only — reachable by position alone, even though the parameter still has a name inside the function.

See also: keyword only arguments · positional arguments · keyword arguments

Advertisement

Variadic arguments

Accepting — and passing on — an unknown number of arguments.

*args

coreintermediate

A parameter written as *args collects any number of extra positional arguments into a tuple named args. The same * can also unpack a sequence back into separate arguments at a call site.

Think of it as

A basket at the end of the till, not one more numbered slot. Every item a customer puts down that has no assigned register just lands in the basket together, in the order it arrived — the basket does not care how many items show up, zero or twenty.

python
def log(label, *values):        # label: normal · values: every remaining positional argument
    ...

log("scores", 90, 85, 100)      # label="scores", values=(90, 85, 100)
log("empty")                    # label="empty", values=()

What we're doing: Call *args with zero, one, and several arguments, confirm it always produces a tuple, and unpack an existing list into a call with the mirrored * syntax.

variadic.pypython
def total(*amounts):
    return sum(amounts)


print(total())
print(total(10))
print(total(10, 20, 30))


def log(label, *values):
    return f"{label}: {values}"


print(log("scores", 90, 85, 100))
print(log("empty"))


nums = [1, 2, 3, 4]
print(total(*nums))
5
Zero extra arguments — amounts is an empty tuple, and sum of nothing is 0, not an error.
6
One extra argument still produces a tuple: (10,), the one-element form.
7
Three extra arguments, collected in the order they were passed.
14
label takes the first positional argument as normal; every argument after it falls into values.
15
No values passed at all — values is (), and the f-string shows it plainly.
19
The * here is at the CALL site, on an existing list — it unpacks nums into four separate positional arguments, the reverse of what *amounts does inside total.
Output
0
10
60
scores: (90, 85, 100)
empty: ()
10

Why this works: *args in a def line tells Python to keep collecting positional arguments past the named parameters and pack every one of them into a single tuple, however many arrive — zero, one, or a hundred. total(*nums) uses the identical * symbol the opposite way: instead of gathering values into a sequence, it spreads an existing sequence out into individual positional arguments, exactly as if each element of nums had been typed into the call by hand. Both directions exist because a function that receives a variable number of arguments often also needs to be called with a variable number of arguments already sitting in a list.

Forgetting a parameter after *args becomes keyword-only

Wrong

python
def make_path(*parts, separator):
    return separator.join(parts)


print(make_path("usr", "local", "bin", "/"))

Better

python
def make_path(*parts, separator="/"):
    return separator.join(parts)


print(make_path("usr", "local", "bin", separator="/"))

What you see: TypeError: make_path() missing 1 required keyword-only argument: 'separator' — the fourth value, "/", was absorbed into parts instead of filling separator, because *args has no upper bound on how many positional arguments it will take.

Why: *args does not stop collecting until the positional arguments run out, so there is no way for a later positional value to "skip past" it and reach separator — every parameter written after *args in the def line is keyword-only automatically, whether or not that was the intent. Calling with separator="/" explicitly, or giving separator a default as shown, is the only way to fill it once a function also takes *args.

Every extra positional argument lands in one tuple

caller passes N values

total(10, 20, 30)

*args collects them

all of them, in order, as one tuple

args inside the body

(10, 20, 30) — a plain tuple, iterate or index it

  1. caller passes N values — total(10, 20, 30)
  2. *args collects them — all of them, in order, as one tuple
  3. args inside the body — (10, 20, 30) — a plain tuple, iterate or index it

Calling total(*amounts) with different argument counts

Calling total(*amounts) with different argument counts
Callamounts inside the function
total()() — empty tuple, sum is 0
total(10)(10,)
total(10, 20, 30)(10, 20, 30)
total(*[1, 2, 3, 4])(1, 2, 3, 4) — a list unpacked into separate arguments first

Together

python
def total(*amounts):
    return sum(amounts)

total()              # 0 — amounts is ()
total(10, 20, 30)     # 60 — amounts is (10, 20, 30)

nums = [1, 2, 3, 4]
total(*nums)          # 10 — nums unpacked into four separate arguments

Remember: *args collects every remaining positional argument into a tuple, even zero of them — and any parameter written after it in the def line becomes keyword-only.

See also: positional arguments · kwargs · extended iterable unpacking

**kwargs

coreintermediate

A parameter written as **kwargs collects any number of extra keyword arguments into a dict named kwargs, keyed by the name each was passed with. The same ** unpacks an existing dict back into separate keyword arguments at the call site.

Think of it as

A suggestion box with a label on every slip, instead of a numbered ticket. Anyone can drop in a labelled note the front desk didn't explicitly plan for, and the box just keeps every label paired with what was written on it — nothing about the box limits how many notes, or what the labels say.

python
def describe(name, **details):        # name: normal · details: every remaining keyword argument
    ...

describe("nova", role="admin", active=True)   # name="nova", details={'role': 'admin', 'active': True}
describe("nova")                              # name="nova", details={}

What we're doing: Call **kwargs with zero and several keyword arguments, confirm it always produces a dict, combine it with *args in one signature, and unpack an existing dict into a call with the mirrored ** syntax.

kwargs_demo.pypython
def build_query(**filters):
    return filters


print(build_query())
print(build_query(status="active", role="admin"))


def describe(name, **details):
    parts = [f"{k}={v}" for k, v in details.items()]
    return f"{name}: " + ", ".join(parts)


print(describe("nova", role="admin", active=True))


opts = {"status": "active", "role": "admin"}
print(build_query(**opts))


def combo(*args, **kwargs):
    return args, kwargs


print(combo(1, 2, x=3, y=4))
5
Zero keyword arguments — filters is an empty dict, not an error.
6
Two keyword arguments become two dict entries, in the order they were passed.
10
.items() walks a dict as (key, value) pairs — the standard way to consume **kwargs inside a function body.
18
The ** here is at the CALL site, on an existing dict — it unpacks opts into separate keyword arguments, the reverse of what **filters does inside build_query.
22
One signature can take both: *args collects the positional 1 and 2, **kwargs collects x=3 and y=4, completely independently.
Output
{}
{'status': 'active', 'role': 'admin'}
nova: role=admin, active=True
{'status': 'active', 'role': 'admin'}
((1, 2), {'x': 3, 'y': 4})

Why this works: **kwargs tells Python to keep collecting keyword arguments past the named parameters and pack every name=value pair into a single dict, using the argument name as the key — this works for zero pairs exactly as well as for many. build_query(**opts) uses the identical ** symbol the opposite way: instead of gathering pairs into a dict, it spreads an existing dict out into individual keyword arguments, one per key, as if each key=value had been typed into the call by hand. *args and **kwargs together in one signature do not interfere — Python routes positional arguments to *args and named ones to **kwargs independently, which is exactly what combo(1, 2, x=3, y=4) shows.

Passing a dict directly instead of unpacking it

Wrong

python
def build_query(**filters):
    return filters


opts = {"status": "active"}
print(build_query(opts))

Better

python
def build_query(**filters):
    return filters


opts = {"status": "active"}
print(build_query(**opts))

What you see: TypeError: build_query() takes 0 positional arguments but 1 was given — opts arrived as a single positional value, and build_query has no positional parameters at all to receive it.

Why: Without **, opts is just one ordinary value being passed positionally — build_query never sees its contents as separate keyword arguments, because nothing told Python to unpack it. ** at the call site is what spreads a dict's keys and values out into individual name=value arguments; leaving it off passes the dict itself as a single object instead, which **filters (a keyword-collecting parameter, not a positional one) cannot accept at all.

Every extra keyword argument lands in one dict

caller passes name=value pairs

build_query(status="active", role="admin")

**kwargs collects them

each name becomes a key, each value the key's value

kwargs inside the body

{'status': 'active', 'role': 'admin'} — a plain dict

  1. caller passes name=value pairs — build_query(status="active", role="admin")
  2. **kwargs collects them — each name becomes a key, each value the key's value
  3. kwargs inside the body — {'status': 'active', 'role': 'admin'} — a plain dict

Calling build_query(**filters) with different keyword arguments

Calling build_query(**filters) with different keyword arguments
Callfilters inside the function
build_query(){} — empty dict
build_query(status="active"){'status': 'active'}
build_query(status="active", role="admin"){'status': 'active', 'role': 'admin'}
build_query(**{"status": "active", "role": "admin"}){'status': 'active', 'role': 'admin'} — a dict unpacked into separate keyword arguments first

Together

python
def build_query(**filters):
    return filters

build_query()                          # {}
build_query(status="active")           # {'status': 'active'}

opts = {"status": "active", "role": "admin"}
build_query(**opts)                    # {'status': 'active', 'role': 'admin'}

Remember: **kwargs collects every remaining keyword argument into a dict, even zero — ** at a call site does the reverse, spreading a dict into arguments.

See also: keyword arguments · args · dictionaries

Advertisement

Functions as values

What being first-class actually unlocks.

First-class functions

coreintermediate

A first-class function is treated as an ordinary value: assignable to a name, storable in a list or dict, passable as an argument, returnable from another function — the same things an int or a string can do.

Think of it as

A recipe card that behaves like any other card in the box — you can copy it, hand it to someone, file it under a different label, or put it inside another folder. Nothing about it being a recipe (rather than, say, an index card with a phone number) changes what a card is allowed to do.

python
def shout(text):
    return text.upper() + "!"

loud = shout             # assign the function object to a second name
funcs = [shout, str.lower]  # store it in a list, alongside a builtin method

What we're doing: Assign a function to a second name, store several functions in a list and a dict, and pass one as an ordinary argument.

first_class.pypython
def shout(text):
    return text.upper() + "!"


loud = shout
print(loud("hello"))
print(loud is shout)

funcs = [shout, str.lower, str.title]
for f in funcs:
    print(f("Hello World"))


def apply_twice(f, value):
    return f(f(value))


print(apply_twice(shout, "hi"))
5
shout, with no parentheses, is the function object itself. loud now names that same object.
6
Calling loud runs the identical code shout would — there is only ever one function object here.
7
is checks object identity: loud and shout are two names for the same object, so this is True, not just equal.
9
A list holding three callables — one written with def, two Python builtins — with nothing distinguishing shout from the others.
15
apply_twice takes f as an ordinary parameter, exactly like value — a function is not passed any differently than an int would be.
19
shout is passed unevaluated (no parentheses at the call site) — apply_twice is the one that calls it, twice.
Output
HELLO!
True
HELLO WORLD!
hello world
Hello World
HI!!

Why this works: def builds a function object and binds one name to it, but nothing prevents a second assignment, loud = shout, from binding a second name to that exact same object — the way a list already lets two names refer to one list. Because a function is just an object, it fits anywhere any other object fits: an element of funcs, a value in a dict, or an argument to apply_twice. apply_twice(shout, "hi") passes the function itself, not shout("hi")'s result, precisely because there are no parentheses after shout at that call site — the distinction between "the function" and "calling the function" is exactly what makes passing callables around possible at all.

Calling the function instead of passing it

Wrong

python
def shout(text):
    return text.upper() + "!"


def apply_twice(f, value):
    return f(f(value))


print(apply_twice(shout("hi"), "hi"))

Better

python
def shout(text):
    return text.upper() + "!"


def apply_twice(f, value):
    return f(f(value))


print(apply_twice(shout, "hi"))

What you see: TypeError: 'str' object is not callable — apply_twice tries to call f(...) inside its body, but f turned out to be the string "HI!", not a function.

Why: shout("hi") with parentheses calls the function immediately and passes its RESULT, "HI!", into apply_twice — by the time apply_twice tries f(value), f is a string, and strings are not callable. Passing shout with no parentheses passes the function object itself, leaving the actual call for apply_twice's body to perform, which is the entire reason apply_twice can call it twice instead of receiving an already-computed answer once.

A function object, reachable by more than one name

def shout(...)

builds one function object

shout AND loud

two names, the same object — loud = shout

either name(...) works

calling loud runs the exact same code as calling shout

  1. def shout(...) — builds one function object
  2. shout AND loud — two names, the same object — loud = shout
  3. either name(...) works — calling loud runs the exact same code as calling shout

What "first-class" allows

What "first-class" allows
ActionExample
Assign to a nameloud = shout
Store in a containerfuncs = [shout, str.lower, str.title]
Store as a dict valueregistry = {"shout": shout}
Pass as an argumentapply_twice(shout, "hi")

Together

python
def shout(text):
    return text.upper() + "!"

loud = shout            # a second name for the same object, not a copy
loud is shout            # True

registry = {"shout": shout}
registry["shout"]("hi")  # 'HI!' — looked up by key, then called

Remember: A function is an object like any other — assignable, storable, passable around. A bare name refers to it; name() calls it, never interchangeably.

See also: defining functions · higher order functions · names and references

Higher-order functions

standardintermediate

A higher-order function either takes a function as an argument, returns a function, or both. map, filter, sorted(key=...), and a factory that returns a new function are all higher-order — only possible because functions are first-class.

Think of it as

A machine that builds or operates other machines, rather than a product. map is a conveyor belt that runs whatever machine you hand it over every item; make_multiplier is a machine-building machine — feed it a factor, and it hands back a brand-new, ready-to-use multiplying machine.

python
def make_multiplier(factor):     # a factory: returns a new function
    def multiply(x):
        return x * factor
    return multiply

double = make_multiplier(2)      # double is now a function
double(5)                        # 10

What we're doing: Build a function-returning factory, then use three standard-library higher-order functions — filter, map, and sorted with a key — on the same data.

higher_order.pypython
def make_multiplier(factor):
    def multiply(x):
        return x * factor
    return multiply


double = make_multiplier(2)
print(double(5))

nums = [1, -2, 3, -4, 5]
print(list(filter(lambda n: n > 0, nums)))
print(list(map(double, nums)))
print(sorted(["banana", "kiwi", "apple"], key=len))
1
make_multiplier takes a plain value, factor — nothing higher-order about the input yet.
4
It returns multiply, a function — this return is what makes make_multiplier higher-order.
7
double is now a real, callable function — the one make_multiplier built and handed back.
10
filter takes a function as its first argument and keeps only the items it approves of.
11
map takes a function too, applying double to every item instead of writing a loop.
12
sorted takes key, a function it calls on each item to decide sort order — sorting by length here, not the strings themselves.
Output
10
[1, 3, 5]
[2, -4, 6, -8, 10]
['kiwi', 'apple', 'banana']

Why this works: make_multiplier is higher-order because it returns a function — multiply, built fresh each call — rather than a plain value; filter, map, and sorted(key=...) are higher-order the other way, each accepting a function as one of their own arguments. None of this needs a special syntax: a function argument is passed exactly like any other argument, and a returned function is returned exactly like any other value, because first-class-functions is the property that makes both directions ordinary rather than exceptional.

Calling the function argument instead of passing it

Wrong

python
nums = [1, -2, 3, -4, 5]


def is_positive(n):
    return n > 0


print(list(filter(is_positive(), nums)))

Better

python
nums = [1, -2, 3, -4, 5]


def is_positive(n):
    return n > 0


print(list(filter(is_positive, nums)))

What you see: TypeError: is_positive() missing 1 required positional argument: 'n' — filter never even runs; the call fails while building its own argument.

Why: is_positive() with parentheses calls the function immediately, before filter ever gets involved, and it fails right there because n was never supplied. filter needs the FUNCTION ITSELF, not the result of calling it, so it can call is_positive(item) once per item as it goes — passing the bare name (first-class-functions again) hands filter something it can call repeatedly, one item at a time.

Higher-order functions from the standard library

Higher-order functions from the standard library
CallResult
list(filter(lambda n: n > 0, [1, -2, 3, -4, 5]))[1, 3, 5] — keeps items where the function is True
list(map(double, [1, -2, 3]))[2, -4, 6] — applies double to every item
sorted(["banana", "kiwi", "apple"], key=len)['kiwi', 'apple', 'banana'] — sorted by length, not alphabetically
reduce(lambda acc, n: acc + n, nums, 0)one accumulated value, folding the sequence down to a single result

Together

python
nums = [1, -2, 3, -4, 5]
list(filter(lambda n: n > 0, nums))   # [1, 3, 5]
sorted(["banana", "kiwi", "apple"], key=len)   # ['kiwi', 'apple', 'banana']

Remember: A higher-order function takes a function as an argument, returns one, or both — map, filter, sorted(key=...) and any factory returning a function qualify.

See also: first class functions · closures · lambda functions

Closures

coreadvanced

A closure is a nested function that keeps access to a variable from its enclosing function, even after that call has returned. increment still reads and updates count long after make_counter itself finished running.

Think of it as

A backpack the inner function carries away with it. When make_counter() returns increment, increment does not leave count behind in the room that built it — it packs count into its own backpack and keeps that backpack every time it is called again, completely separate from any other backpack a second call to make_counter would pack.

python
def make_multiplier(factor):     # factor is the enclosing variable
    def multiply(x):
        return x * factor        # multiply closes over factor
    return multiply

times3 = make_multiplier(3)      # factor=3 is captured, alive inside times3
times3(7)                        # 21 — long after make_multiplier(3) returned

What we're doing: Build two independent counters from the same factory, confirm their state never mixes, and inspect the captured cell directly.

closures.pypython
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment


counter_a = make_counter()
counter_b = make_counter()
print(counter_a())
print(counter_a())
print(counter_a())
print(counter_b())
1
make_counter defines count and increment, then returns increment — count would normally vanish here.
4
nonlocal count tells Python that count inside increment refers to the ENCLOSING count, not a new local variable — required to reassign it, not just read it.
10
counter_a = make_counter() runs the factory once, capturing its own count starting at 0.
11
A second, completely separate call — counter_b closes over a different count, also starting at 0.
12
counter_a() reads and updates ITS count — 1.
15
counter_b() has never been called before now — its count is still fresh, so this prints 1, not 4.
Output
1
2
3
1

Why this works: increment references count, a variable from its enclosing scope, so Python keeps that variable alive as part of increment's closure rather than discarding it when make_counter returns — that is the entire mechanism a closure names. Each call to make_counter() runs count = 0 and def increment fresh, building a brand-new cell for count and a brand-new increment function bound to it, so counter_a and counter_b never share state even though they were built from identical code. nonlocal is what lets increment's count += 1 modify the captured variable in place, rather than silently creating a new local variable named count that shadows it.

A closure over a loop variable, evaluated too late

Wrong

python
funcs = []
for i in range(3):
    funcs.append(lambda: i)

print([f() for f in funcs])

Better

python
funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)

print([f() for f in funcs])

What you see: The wrong version prints [2, 2, 2] — every lambda returns the same final value of i, not the value it seemed to capture on its own iteration of the loop.

Why: lambda: i closes over the variable i itself, not the value i held at the moment the lambda was created — there is only ever one i, reused by every iteration of the for loop, and by the time any lambda is actually called, the loop has finished and i is 2. lambda i=i: i works because a default argument value IS evaluated immediately, at the point the lambda is defined (default-arguments.js) — so each lambda gets its own i, frozen at the value it had on that specific iteration, as a genuinely separate parameter rather than a shared closure variable.

The inner function keeps the outer variable alive

make_counter() runs

count = 0, then increment is defined and returned

make_counter() returns

its local scope would normally be gone — but increment still references count

counter_a() called later

reads and updates the SAME count, still alive inside the closure

  1. make_counter() runs — count = 0, then increment is defined and returned
  2. make_counter() returns — its local scope would normally be gone — but increment still references count
  3. counter_a() called later — reads and updates the SAME count, still alive inside the closure

Two calls to make_counter() — independent closures

Two calls to make_counter() — independent closures
CallWhat it captures
counter_a = make_counter()its own count, starting at 0
counter_b = make_counter()a SEPARATE count, starting at 0 — unrelated to counter_a's
counter_a(); counter_a(); counter_a()1, then 2, then 3 — counter_a's count only
counter_b()1 — counter_b's count, untouched by any counter_a call

Together

python
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

counter_a = make_counter()
counter_b = make_counter()
counter_a()   # 1
counter_a()   # 2
counter_b()   # 1 — its own count, not counter_a's

Remember: A closure captures the VARIABLE, not a snapshot of its value — closing over a loop variable needs a default argument to freeze it per iteration.

See also: higher order functions · lambda functions · nonlocal

Lambda functions

standardintermediate

lambda args: expression creates a nameless function limited to one expression, whose value it returns automatically. Most useful as a short throwaway passed straight into something like sorted(key=...).

Think of it as

A sticky note instead of a filed recipe card. Perfectly good for one quick instruction handed directly to someone right now — but it has no filing name, and it is not built to hold more than a single line, so anything with real steps belongs on a proper card (def) instead.

python
square = lambda x: x * x       # equivalent to def square(x): return x * x
square(5)                      # 25

people = [("Nova", 34), ("Kip", 22)]
sorted(people, key=lambda p: p[1])   # sort by the second element of each tuple

What we're doing: Assign a lambda to a name and call it directly, then use one inline as sorted's key — the pattern lambdas exist for.

lambdas.pypython
square = lambda x: x * x
print(square(5))
print(square.__name__)

greet = lambda name, greeting="Hello": f"{greeting}, {name}!"
print(greet("Nova"))
print(greet("Nova", "Hi"))

people = [("Nova", 34), ("Kip", 22), ("Ash", 29)]
print(sorted(people, key=lambda p: p[1]))
1
No def, no name in the syntax itself, no return — x * x is the expression, and its value is what square(...) produces.
3
A lambda still reports a __name__, but it is always the literal string "<lambda>", since none was ever given.
5
A lambda takes parameters exactly like def — including a default value, greeting="Hello".
10
The lambda here is never assigned to a name at all — it is built and passed directly as key, used once by sorted and then discarded.
Output
25
<lambda>
Hello, Nova!
Hi, Nova!
[('Kip', 22), ('Ash', 29), ('Nova', 34)]

Why this works: lambda builds a function object the same way def does — the difference is entirely syntactic: one expression instead of an indented block, an implicit return instead of an explicit one, and no binding to a name unless the caller adds one with =. sorted(people, key=lambda p: p[1]) is the shape lambda earns its place for: a function needed exactly once, right where it is used, small enough that giving it a def and a name elsewhere in the file would only make the sort call harder to read at the point that matters.

Trying to fit a statement inside a lambda

Wrong

python
describe = lambda x: label = "big" if x > 10 else "small"

Better

python
describe = lambda x: "big" if x > 10 else "small"

What you see: SyntaxError: cannot assign to lambda — the line fails to parse at all, before anything runs.

Why: A lambda body must be exactly one EXPRESSION — something that evaluates to a value — never a statement like an assignment. label = ... is a statement, so Python rejects it outright as invalid syntax inside a lambda. Python's own conditional expression ("big" if x > 10 else "small", conditional-expressions.js) is itself an expression, which is exactly why it fits inside a lambda and an ordinary if/else assignment does not — reaching for a full def is the answer the moment real logic, not just a single value-producing expression, is needed.

lambda compared to an equivalent def

lambda compared to an equivalent def
lambdaEquivalent def
lambda x: x * xdef square(x): return x * x
lambda a, b: a + bdef add(a, b): return a + b
lambda name, greeting="Hello": f"{greeting}, {name}!"def greet(name, greeting="Hello"): return f"{greeting}, {name}!"

Together

python
people = [("Nova", 34), ("Kip", 22), ("Ash", 29)]
sorted(people, key=lambda p: p[1])
# [('Kip', 22), ('Ash', 29), ('Nova', 34)] — sorted by age, the lambda's only job

Remember: lambda builds a nameless, one-expression function — reach for it only when short and used once, right where passed; anything more needs def.

See also: higher order functions · conditional expressions · defining functions

Advertisement

Function metadata

What else a function carries besides the code that runs.

Function annotations

standardintermediate

A function annotation is an optional expression written after a parameter (name: expr) or after -> for the return value, stored on the function for tools to read. Python parses and stores it, but never checks a call against it.

Think of it as

A label on a shipping box that says "fragile — books" without anyone at the warehouse actually checking the contents before it ships. The label is genuinely useful information for whoever reads it later, but nothing at the loading dock stops a box labelled "books" from actually containing bricks.

python
def greet(name: str, times: int = 1) -> str:   # annotate a parameter, a defaulted parameter, and the return
    return (name + "! ") * times

greet.__annotations__   # {'name': str, 'times': int, 'return': str}

What we're doing: Read the annotations dict off a function object, then prove Python never checks a call against them.

annotations.pypython
def greet(name: str, times: int = 1) -> str:
    return (name + "! ") * times


print(greet("Nova"))
print(greet.__annotations__)


def add(a: int, b: int) -> int:
    return a + b


print(add("x", "y"))
print(add.__annotations__)
1
name: str and times: int annotate the parameters; -> str annotates the return value — a default value can coexist with an annotation.
6
The annotations live in a dict on the function object, one entry per annotated name plus "return".
9
Both parameters and the return are annotated int here — the natural expectation is that this only accepts numbers.
13
Two strings are passed anyway. Nothing stops the call — a + b runs Python's ordinary + on two strings, which concatenates them.
Output
Nova! 
{'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
xy
{'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

Why this works: An annotation is evaluated once, at def time, exactly like a default value, and the result is stored in __annotations__ — nothing about that process asks the interpreter to remember "check future calls against this." Every argument still reaches the function body exactly as it was passed; add("x", "y") runs a + b on two strings and str.__add__ happily concatenates them, because + itself has no idea a or b were ever annotated int. The annotation is real, readable data — genuinely useful to a type checker or an IDE's autocomplete — but the interpreter that runs the function never consults it.

Treating an annotation as a runtime guarantee

Wrong

python
def divide(a: int, b: int) -> float:
    return a / b


print(divide(10, "2"))

Better

python
def divide(a: int, b: int) -> float:
    if not isinstance(b, int):
        raise TypeError(f"b must be int, got {type(b).__name__}")
    return a / b


print(divide(10, "2"))

What you see: The wrong version raises TypeError: unsupported operand type(s) for /: 'int' and 'str' — but only because / itself rejects a str operand, not because the b: int annotation was ever checked. A different bad type that / happens to tolerate would pass through silently.

Why: divide's b: int annotation looks like a guarantee but is purely informational — the actual error above comes from the division operator refusing a str, which is incidental, not enforcement of the annotation. A function that genuinely needs to reject the wrong type at runtime has to check for it explicitly, with isinstance or similar, exactly like any other runtime validation — annotations alone catch nothing until a separate tool (a type checker, or code written by hand) reads and acts on them.

What an annotation does and does not do

What an annotation does and does not do
ActionWhat actually happens
def greet(name: str) -> str:stores {'name': str, 'return': str} on greet.__annotations__
greet(123)runs — Python never checked that 123 is a str
add(a: int, b: int) called as add("x", "y")runs, returns "xy" — the annotation is not enforced
mypy / pyright on the same filethe ONLY layer that would actually flag add("x", "y") as wrong

Together

python
def add(a: int, b: int) -> int:
    return a + b

add("x", "y")          # runs — 'xy', no error, despite the int annotations
add.__annotations__    # {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

Remember: An annotation (name: expr, -> expr) is stored for tools to read — Python never checks a call against one. Only a type checker, or your own code, enforces it.

See also: defining functions · default arguments · function objects

Function objects

referenceadvanced

A function built with def has type function and, like most objects, its own __dict__ — arbitrary attributes can be attached directly (greet.call_count = 0), alongside its built-in ones: __name__, __doc__, __defaults__, __code__.

Think of it as

A filing folder, not just the pages inside it. The pages are the code that runs — but the folder itself can carry a sticky note stuck to the outside (a custom attribute) that has nothing to do with what the pages say, and several printed labels (__name__, __doc__) that came with the folder when it was made.

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

type(greet)          # <class 'function'>
callable(greet)       # True
greet.call_count = 0  # legal — a function's __dict__ accepts any attribute name

What we're doing: Inspect a function's built-in attributes, then attach and update a custom one directly on the function object.

function_objects.pypython
def greet(name, greeting="Hello"):
    """Return a greeting for name."""
    return f"{greeting}, {name}!"


print(greet.__name__)
print(greet.__doc__)
print(greet.__defaults__)
print(type(greet))
print(callable(greet))


greet.call_count = 0
print(greet.__dict__)
greet.call_count += 1
print(greet.call_count)
6
The name def bound — stored on the function object itself, independent of whatever name currently refers to it.
7
The string right after def, stored as data on the object — not executed as code.
8
A tuple of the default values — just 'Hello', since name has no default.
9
Confirms greet's type directly: it is an instance of the type function, exactly like [] is an instance of list.
13
call_count did not exist a moment ago — this line creates it, directly on the function object, exactly like setting an attribute on any other object.
14
greet's __dict__ now holds the custom attribute, alongside the built-in ones that live elsewhere on the object.
15
The custom attribute behaves like any other — readable, writable, incrementable — because it is an ordinary attribute.
Output
greet
Return a greeting for name.
('Hello',)
<class 'function'>
True
{'call_count': 0}
1

Why this works: def builds an instance of Python's built-in function type, and like almost every Python object, that instance carries a __dict__ for its own attributes — the same mechanism a plain class instance uses. The introspection attributes (__name__, __doc__, __defaults__, __code__) are populated by def itself and live outside __dict__, but nothing stops additional attributes from being set the ordinary way, with dot-assignment, and greet.__dict__ only ever shows the ones actually added this way — a caching pattern (memoizing results as function attributes) relies on exactly this.

Expecting two functions with identical code to compare equal

Wrong

python
def add_a(x, y):
    return x + y


def add_b(x, y):
    return x + y


print(add_a == add_b)

Better

python
def add_a(x, y):
    return x + y


add_b = add_a


print(add_a == add_b)

What you see: The wrong version prints False, even though add_a and add_b have byte-for-byte identical bodies — they look like they should be "the same function."

Why: Function objects have no structural equality — == on two functions falls back to identity, the same as is, because there is no meaningful way to compare "sameness" of arbitrary code. add_a and add_b in the wrong version are two separate def statements, so they built two separate function objects that happen to share source code; add_b = add_a in the fixed version makes add_b a second name for the SAME object, which is what makes == (and is) return True.

Attributes every function object carries

Attributes every function object carries
AttributeHolds
func.__name__the name it was defined with, as a string
func.__doc__the docstring, or None if there is not one
func.__defaults__a tuple of default values for the trailing parameters that have them
func.__code__.co_varnamesthe parameter and local variable names, as a tuple
func.__dict__custom attributes attached directly to the function — {} until something sets one

Together

python
def greet(name, greeting="Hello"):
    """Return a greeting for name."""
    return f"{greeting}, {name}!"

greet.__name__       # 'greet'
greet.__defaults__   # ('Hello',)
greet.call_count = 0 # a custom attribute — functions accept arbitrary ones
greet.__dict__       # {'call_count': 0}

Remember: A function is an ordinary object with its own __dict__ — attributes attach directly. Two functions compare equal only by identity, never matching code.

See also: defining functions · first class functions · is vs equals

Advertisement

Scope

Where a name resolves, and the two keywords that override the default.

Scope and LEGB

coreadvanced

A name lookup inside a function checks four scopes in order — Local, Enclosing, Global, Built-in — and uses the first that defines it. LEGB is why a name reused at several levels resolves to the innermost.

Think of it as

Checking your own pockets, then your bag, then the house, then the corner shop — in that order, every single time. The moment something turns up in your pocket, you stop looking in the bag, even if the bag has a different version of the same thing.

python
y = "global y"

def show_y():
    print(y)     # no local y, so this reaches Global — prints 'global y'

show_y()

What we're doing: Show a name resolving at each of the four levels — local, enclosing, global, and (for a name never assigned in the program) built-in.

legb.pypython
x = "global"


def outer():
    x = "enclosing"
    def inner():
        x = "local"
        print(x)
    inner()
    print(x)


outer()
print(x)

print(len([1, 2, 3]))
1
x at module level — this is the Global scope entry for x.
5
outer's own x shadows the global one for anything inside outer — this is the Enclosing scope, from inner's point of view.
7
inner's own x shadows BOTH outer's and the global one — Local wins, the search never even looks further out.
10
Back in outer's own body, after inner returns — this x is outer's, unaffected by inner's local x.
14
At module level again — this is the original global x, untouched by either function.
16
len is never assigned anywhere in this file — Local, Enclosing and Global all miss, so Python falls through to Built-in and finds it there.
Output
local
enclosing
global
3

Why this works: Every name lookup inside a function checks Local first, and inner's own x = "local" assignment is enough to make x local to inner for its entire body — the search stops there without ever consulting outer's x or the module's. outer's print(x), running after inner() has already returned, was never affected by inner's local x in the first place — different functions get entirely separate Local scopes, even when nested. len reaching the Built-in scope shows the chain does not stop at Global; it is the last resort, checked only when nothing closer defines the name.

Assigning to a name makes it local for the WHOLE function

Wrong

python
count = 0


def increment():
    print(count)
    count = count + 1


increment()

Better

python
count = 0


def increment():
    global count
    print(count)
    count = count + 1


increment()

What you see: UnboundLocalError: cannot access local variable 'count' where it is not associated with a value — raised on the print(count) line, even though count clearly exists at module level and is only assigned AFTER the print.

Why: Python decides which scope a name belongs to by scanning the WHOLE function body before running any of it — count = count + 1 anywhere in increment makes count local to increment for its ENTIRE body, including the print(count) line that runs before that assignment. This is not about order — it is about function-wide scope, decided at parse time, and it is exactly the situation global.js's global keyword exists to override.

LEGB — checked in this order, top to bottom

Local

names assigned inside the current function

Enclosing

names in any function this one is nested inside

Global

names assigned at module level

Built-in

len, print, str — always there unless shadowed

  1. Local — names assigned inside the current function
  2. Enclosing — names in any function this one is nested inside
  3. Global — names assigned at module level
  4. Built-in — len, print, str — always there unless shadowed

Where x resolves, by nesting level

Where x resolves, by nesting level
Scope that finds x firstResult
x assigned inside inner()local — inner's own x, innermost scope wins
x assigned in outer() but not inner()enclosing — inner sees outer's x
x assigned only at module levelglobal — every function sees the module-level x
x never assigned anywhere in the programbuilt-in, or NameError if it isn't a builtin either

Together

python
x = "global"

def outer():
    x = "enclosing"
    def inner():
        x = "local"
        print(x)          # 'local' — inner's own x wins
    inner()
    print(x)               # 'enclosing' — outer's own x

outer()
print(x)                    # 'global' — module-level x, untouched by either function

Remember: Local, Enclosing, Global, Built-in — checked in order, first match wins. Assigning to a name anywhere in a function makes it local for the whole body.

See also: closures · global · nonlocal

global

standardadvanced

global name, written inside a function, tells Python that name refers to the module-level variable for the rest of the function — assignments update it directly instead of creating a new local. Reading a global already works without it.

Think of it as

A note pinned to the door before you walk in a room, saying "the desk I use in here is the one out in the hallway, not a new one." Without that note, walking in and using a desk labelled the same name gets you a brand-new desk that happens to share a label — global is what makes you actually walk back out and use the hallway one.

python
total = 0

def add(n):
    global total       # without this line, total = total + n raises UnboundLocalError
    total = total + n

add(5)
add(10)
total   # 15

What we're doing: Reassign a module-level variable both without and with global, and confirm reading a global never needed the declaration in the first place.

global_demo.pypython
total = 0


def add_bad(n):
    total = total + n


try:
    add_bad(5)
except UnboundLocalError as e:
    print(e)


def add_good(n):
    global total
    total = total + n


add_good(5)
add_good(10)
print(total)


count = 0


def reader():
    print(count)


reader()
5
total = total + n assigns to total anywhere in add_bad, which makes Python treat it as local for the whole function — including the read on this same line.
9
Reading that local total, before it has ever been assigned, is exactly what UnboundLocalError reports.
16
global total, declared first, tells Python this function's total IS the module-level one — no separate local is created.
17
Now total = total + n reads and updates the module-level variable directly.
21
The change is visible outside the function too — total really was the module-level variable, not a local copy.
30
reader() only READS count — no global needed, because assignment is the only thing that triggers LEGB's local-by-default rule.
Output
cannot access local variable 'total' where it is not associated with a value
15
0

Why this works: Python decides whether a name is local by scanning a function's entire body before running any of it — any assignment to that name anywhere inside makes it local for the whole function, which is exactly what scope-and-legb.js's LEGB order predicts. add_bad's total = total + n is an assignment, so total is local there, and the read on the right-hand side of that same line finds nothing yet — that is the UnboundLocalError. global total in add_good removes total from that local-by-default treatment entirely, so both the read and the write on the following line go straight to the module-level variable, which is why the change is still visible after add_good returns. reader() never assigns count, so it never needed the declaration — reading always reaches outward through LEGB on its own.

Reaching for global to fix a read that was never broken

Wrong

python
settings = {"debug": False}


def show_debug():
    global settings
    print(settings["debug"])


show_debug()

Better

python
settings = {"debug": False}


def show_debug():
    print(settings["debug"])


show_debug()

What you see: Both versions print False — the global declaration in the first version changes nothing observable, and exists only as unnecessary noise at the top of a function that never assigns to settings.

Why: global is only needed to change which scope an ASSIGNMENT targets — reading a name, including settings["debug"] which reads settings and then indexes it, never triggers the local-by-default rule in the first place, so it already reaches the module-level settings through ordinary LEGB lookup. Declaring global for a function that only reads is not wrong exactly, but it signals a reassignment that never happens, which is misleading to anyone reading the function later.

With and without global, reassigning a module-level name

With and without global, reassigning a module-level name
Function bodyEffect
count += 1 (no global)UnboundLocalError — count is treated as local, and reading it before assigning it fails
global count count += 1updates the module-level count directly
print(count) (no global)works fine — reading does not require the declaration

Together

python
count = 0

def increment():
    global count
    count += 1

increment()
increment()
count   # 2 — the module-level variable was actually updated

Remember: global name lets a function reassign a module-level variable instead of a local one — only needed for assignment; reading already works without it.

See also: scope and legb · nonlocal · closures

nonlocal

standardadvanced

nonlocal name, inside a nested function, declares that name refers to the nearest ENCLOSING function scope — not the module (global's job), not a new local. It lets a closure reassign a captured variable, not just read it.

Think of it as

global's note said "use the hallway desk." nonlocal's note says "use the desk in the room I was built inside" — one door in, not all the way out to the building lobby. If there is no such room (the function is not nested in anything), the note makes no sense, and Python refuses it outright.

python
def outer():
    x = "enclosing x"
    def inner():
        nonlocal x           # without this, x = "..." below creates a new local x
        x = "changed by inner"
    inner()
    print(x)                 # 'changed by inner' — the enclosing x really was reassigned

outer()

What we're doing: Reassign an enclosing variable both without and with nonlocal, then trigger the SyntaxError from declaring nonlocal with no enclosing scope to bind to.

nonlocal_demo.pypython
def make_counter_bad():
    count = 0
    def increment():
        count += 1
        return count
    return increment


c2 = make_counter_bad()
try:
    c2()
except UnboundLocalError as e:
    print(e)


def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment


c = make_counter()
print(c())
print(c())
4
count += 1 assigns to count inside increment, which makes it LOCAL to increment — the enclosing count is never reached.
11
That local count is read before it's ever assigned (the += reads first) — exactly the UnboundLocalError shape scope-and-legb.js already covers.
19
nonlocal count, declared first, tells Python this increment's count IS make_counter's count — no new local is created.
20
count += 1 now reads and updates the ENCLOSING count directly.
25
Calling c() again shows the update persisted — the same closure cell, mutated in place, across separate calls.
Output
cannot access local variable 'count' where it is not associated with a value
1
2

Why this works: Without nonlocal, increment's count += 1 is an assignment, and LEGB's local-by-default rule (scope-and-legb.js) makes count local to increment for its entire body — the read half of += finds nothing, hence UnboundLocalError. nonlocal count removes count from that default, pointing it at make_counter's count instead — the exact cell closures.js's make_counter example already relies on. The distinction from global is which scope gets targeted: nonlocal reaches exactly one level out, to the nearest enclosing function, and stops there — it never reaches all the way to the module, even if a module-level variable of the same name exists.

nonlocal with no enclosing scope to bind to

Wrong

python
def process():
    nonlocal total
    total += 1

Better

python
total = 0


def process():
    global total
    total += 1

What you see: SyntaxError: no binding for nonlocal 'total' found — raised immediately, before process is ever called, because there is no enclosing function at all here.

Why: nonlocal specifically means "the nearest ENCLOSING FUNCTION scope" — process is defined at module level, not nested inside another function, so there is no enclosing function scope for total to belong to, and Python catches this as a syntax error rather than waiting to fail at runtime. The fix depends on what was actually meant: global (as shown) if total should be a module-level variable, or genuinely nesting process inside another function if an enclosing function scope was intended.

With and without nonlocal, reassigning an enclosing variable

With and without nonlocal, reassigning an enclosing variable
Nested function bodyEffect
count += 1 (no nonlocal)UnboundLocalError — count is treated as local to the nested function
nonlocal count count += 1updates the enclosing function's count directly
nonlocal y (no enclosing y exists)SyntaxError: no binding for nonlocal 'y' found

Together

python
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

c = make_counter()
c()   # 1
c()   # 2 — the enclosing count really was updated

Remember: nonlocal name lets a nested function reassign a variable from its nearest enclosing function — one level out, unlike global. No such scope means a SyntaxError.

See also: closures · global · scope and legb

Advertisement