Filter concepts by levelShowing all levels.

Python · Fundamentals

Functions

Defining one, the four ways arguments reach it, and what a function actually is

  • def builds, call runsthe body is not checked until called
  • Mutable defaults are sharedbuilt once, at def time — use None instead
  • *args / **kwargsvariadic collection, both directions
  • LEGBLocal, Enclosing, Global, Built-in — first match wins

1.Defining functions

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.

Key features

  • def builds a function object and binds a name to it — nothing runs yet
  • The indented block is not checked for correctness until the function is called
  • A string as the first line becomes __doc__, not part of what runs
  • Calling the same function twice reuses one function object both times
  • A default argument value is computed once, at def time, and shared by every call

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
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

Command

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'

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.

2.Keyword arguments

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.

Key features

  • name=value in a call matches by name, not by position
  • Positional and keyword arguments can mix in one call, positional first
  • An unrecognized keyword: TypeError naming the bad keyword
  • A keyword that duplicates an already-filled positional: TypeError
  • Keyword arguments can appear in any order relative to each other

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'
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

Command

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

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.

3.Default arguments

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.

Key features

  • name=value in the def line makes that parameter optional
  • The default is computed once, at def time — never re-evaluated per call
  • Passing a value at the call site always overrides the default
  • A mutable default (list, dict, set) is one object shared across every call that keeps it
  • The safe pattern: default to None, build the mutable value inside the body

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
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

Command

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

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.

4.*args

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.

Key features

  • *args collects every extra positional argument into a tuple named args
  • A call with zero extra positional arguments gives args an empty tuple, not an error
  • Any name works after the * — args is convention, not a keyword
  • A parameter after *args in the def line becomes keyword-only automatically
  • *seq at a CALL site does the opposite: unpacks a sequence into separate arguments

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
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

Command

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=()

*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.

5.**kwargs

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.

Key features

  • **kwargs collects every extra keyword argument into a dict named kwargs
  • A call with zero extra keyword arguments gives kwargs an empty dict, not an error
  • Any name works after ** — kwargs is convention, not a keyword
  • Iterate a dict of items with kwargs.items() to get (name, value) pairs
  • **mapping at a CALL site does the opposite: unpacks a dict into separate keyword arguments

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
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

Command

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={}

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

6.First-class functions

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.

Key features

  • A function is an object, the same category of thing as an int, str, or list
  • Assigning it to a new name (loud = shout) does not copy it; both names point at one object
  • A function can be stored in a list, a dict value, or any other container
  • A function can be passed as an argument to another function, unevaluated
  • A bare function name (no parentheses) refers to the object; name() calls it

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")
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

Command

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

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

7.Closures

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.

Key features

  • A closure is a nested function plus the enclosing variables it references
  • Those variables stay alive as long as the closure exists, past the enclosing call
  • nonlocal is required inside the nested function to REASSIGN a captured variable
  • Each call to the outer function creates a separate, independent closure
  • func.__closure__ holds the captured cells — inspectable, rarely used directly

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
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

Command

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

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.

8.Scope and LEGB

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.

Key features

  • L — Local: names assigned anywhere inside the current function
  • E — Enclosing: names in any function this one is nested inside (what closures.js captures)
  • G — Global: names assigned at module level, outside every function
  • B — Built-in: len, print, str, and the rest, always available unless shadowed
  • Assigning to a name ANYWHERE in a function makes it local for the WHOLE function body

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
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

Command

python
y = "global y"

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

show_y()

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.

9.Positional arguments

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.

Key features

  • The first argument fills the first parameter, in written order
  • Too few positional arguments: TypeError naming the missing parameter
  • Too many positional arguments: TypeError naming how many were expected
  • Swapping two same-typed arguments is not an error — just the wrong answer
  • Any positional argument can also be passed by name — see keyword arguments

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

Command

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

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

10.Keyword-only arguments

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.

Key features

  • A bare * in the parameter list marks every parameter after it keyword-only
  • Passing a keyword-only parameter positionally: TypeError naming the accepted count
  • Omitting a required keyword-only parameter: TypeError naming which one is missing
  • Keyword-only parameters can still have defaults, exactly like any other parameter
  • Forces a clear call site for parameters whose meaning is not obvious from a bare value

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

Command

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

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

11.Positional-only arguments

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.

Key features

  • A bare / in a def line has no name — it is a separator, not a parameter
  • Every parameter before / can only be filled by position, never by name
  • Passing a positional-only parameter by keyword: TypeError naming it
  • Lets a function change a parameter name later without breaking callers
  • Many builtins (len, pow) are positional-only in exactly this way

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

Command

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

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.

12.Higher-order functions

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.

Key features

  • Takes a function as an argument, returns one, or both — either is enough to qualify
  • map(f, iterable) applies f to every item, lazily, without a manual loop
  • filter(f, iterable) keeps only the items where f(item) is truthy
  • sorted(items, key=f) sorts by f(item) instead of the items themselves
  • A function that returns a function (a factory) is higher-order in the other direction

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

Command

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

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.

Remember

  • A default value is computed once, at def time — never per call. Default to None for anything mutable.
  • Everything after a bare * in a def line is keyword-only; everything before a bare / is positional-only.
  • *args collects extra positional arguments into a tuple; **kwargs collects extra keyword ones into a dict.
  • Assigning to a name anywhere in a function makes it local for the WHOLE function — global/nonlocal override that.

Pro tip

  • A same-typed positional swap runs silently wrong — keyword arguments remove the ambiguity.
  • nonlocal reaches one enclosing function; global reaches the module. Never confuse the two.
  • A function is first-class: assign it, store it, pass it, return it — nothing about def is special-cased.
  • Function annotations are never enforced by the interpreter — only a type checker or your own code acts on them.

Next up

  • Built-ins worth knowing well
  • Modules and imports
  • Iterators, generators and decorators

Sources

  • Function definitions — docs.python.org/3/reference/compound_stmts.html#function-definitions
  • Naming and binding — docs.python.org/3/reference/executionmodel.html#naming-and-binding
  • Every example executed on Python 3.14.3 · checked 2026-08-20

Spotted a mistake on this page? Tell us.