Handbooks / Python / Chapter 1

Functions & Arguments

46 pages · ~73 min✓ Reviewed

Start here. Next up: Data Structures.

Part 1 · Introduction

Writing Functions You Can Trust

Almost every useful Python program is built from functions: small, named pieces of work that take some input, do one job and hand back a result. A function lets you write an idea once and use it everywhere, give it a name that explains itself, and test it on its own. Most bugs that beginners meet, such as a function that returns None when a value was expected, a list that mysteriously keeps growing between calls, or a variable that changes in the wrong place, come from a handful of details about how functions really behave.

This chapter walks through those details in order. You start with def and return, then see how arguments reach a function by position, by keyword, by default value and through *args and **kwargs. After that you look at where names live, using the LEGB rule for scope, and at how a function can remember its surroundings through a closure. The later parts treat functions as ordinary objects that you can pass around, wrap in a lambda, describe with type hints and document with docstrings.

By the end you will be able to design a function signature on purpose instead of by trial and error, predict what a call will do before you run it, and spot the mutable-default trap and other common mistakes in code review. You will also be able to read other people's function-heavy code, such as decorators, callbacks and key functions, without guessing.

Before you start

You need Python 3.11 or newer and any place to run code, such as a terminal with python or a notebook. Every example uses only the standard library. You should already be comfortable with variables, if statements, loops, and lists and dictionaries. Type each example yourself and change a value to see what happens, because that teaches more than reading does.

Part 2 · Defining and Calling Functions

What def Really Does

A function is a named block of code you can run whenever you want. You write it with a def statement: the keyword, a name, a list of parameters in parentheses, a colon, and an indented body. The key idea is that def is not a declaration. It is an ordinary statement that runs when Python reaches it, builds a function object from the body, and binds that object to the name.

python
def greet(name):
    return 'Hi ' + name

print(greet('Sam'))

def creates greet; the call greet('Sam') runs the body

output
Hi Sam

Creating the function and running it are two separate events. When def executes, the body is only compiled and stored. Nothing inside it runs until somebody calls the function with parentheses.

Life of a function
  1. 1def runsPython reaches the statement
  2. 2Function object createdbody stored, not executed
  3. 3Name boundgreet now points to it
  4. 4Call greet(...)the body finally executes

The next example makes the gap visible. The body contains a print, but it stays silent until the call, so the line printed after the def comes first.

python
def shout(text):
    print('running body')
    return text.upper()

print('after def')
print(shout('hey'))

Defining prints nothing; calling runs the body

output
after def
running body
HEY
Remember

def name(params): body creates a function object and binds it to name at run time. Defining runs nothing; the body executes only on a call.

Parameters, Arguments and Frames

Two words that are often mixed up have precise meanings. Parameters are the names listed in the def line. Arguments are the actual values you pass at the call site. Inside the body, each parameter becomes a local name bound to the matching argument.

ParameterArgument
Where it appearsIn the def lineAt the call site
What it isA nameA value
Exampleprice, rate100, 0.5
When it existsWhenever the def has runOnly for that one call
python
def add_tax(price, rate):
    total = price * (1 + rate)
    return total

print(add_tax(100, 0.5))
print(add_tax(40, 0.25))

price and rate are parameters; 100 and 0.5 are arguments

output
150.0
50.0

Every call gets its own fresh local namespace and a new stack frame. Local names such as total are created when the call starts and thrown away when it ends, so one call can never see another call's locals. The counter below shows it: if locals survived between calls, the second result would be 2.

python
def counter():
    count = 0
    count += 1
    return count

print(counter())
print(counter())

Each call starts with a brand-new count

output
1
1

The name versus the call

A function's name is just a variable holding the function object. Writing greet evaluates to that object. Only greet() with parentheses performs the call and evaluates to the returned value.

python
f = greet
print(type(f).__name__)
print(type(greet('Ana')).__name__)

greet is a function; greet('Ana') is a str

output
function
str
Common mistake: forgetting the parentheses

Writing result = greet instead of result = greet('Ana') does not raise an error. You silently get the function object, not its result, and the bug shows up later when you try to use it as a string or number.

Order, Placeholders and Interview Points

Because def is a statement that runs, the name does not exist until Python has executed it. Calling a function on a line above its def raises a NameError. Once the def has run, the call works normally.

python
try:
    hello()
except NameError as err:
    print('NameError:', err)

def hello():
    return 'hello'

print(hello())

The first call happens before def has executed

output
NameError: name 'hello' is not defined
hello

The rule applies to when the call happens, not to where the text sits in the file. A function body may mention names that are defined later. Those names are only looked up when the body runs, so they just have to exist by call time.

python
def report():
    return helper() + '!'

def helper():
    return 'done'

print(report())

helper is defined after report, but before report is called

output
done!

A body needs at least one statement. When you want to sketch a function and fill it in later, use pass, a statement that does nothing.

python
def todo():
    pass

print(todo())

A valid placeholder; calling it returns None

output
None
Common mistake: calling before the def has run

Calling a function at the top of a script while its def sits further down gives NameError. Put the def first, or put the call inside another function or an if __name__ == '__main__': block that runs after all the definitions.

Interview Point

What is the difference between a parameter and an argument?
  • A parameter is the name in the def line, such as name in def greet(name).
  • An argument is the value supplied at the call site, such as 'Sam' in greet('Sam').
  • Each call binds its arguments to the parameters in a fresh local namespace.
Is def executed at import time or call time?
  • The def statement itself runs at import time, or whenever execution reaches it. That creates the function object and binds the name.
  • The body runs only at call time, each time the function is called.
  • So a print at module level shows on import, but a print inside the body does not.
What does a function evaluate to when you write its name without ()?
  • The function object itself, not a result.
  • Nothing is run, so you can store it, pass it around, or call it later.
  • Add parentheses, as in greet('Sam'), to run the body and get the returned value.
f = greet
print(f('Sam'))

Part 3 · Return Values and None

What return Does

A function hands a result back to whoever called it with the return statement. The moment Python reaches return, the function stops right there. Any lines below it in that call never run, and the value after the word return becomes the value of the call expression.

What happens as a function body runs

The loop below shows the early exit. It checks numbers one at a time and returns the first negative one it meets, so the later numbers are never examined.

python
def first_negative(xs):
    for x in xs:
        print("checking", x)
        if x < 0:
            return x
    return None

print(first_negative([3, 5, -2, 8, -9]))

return leaves the loop and the function at once

output
checking 3
checking 5
checking -2
-2

Notice that 8 and -9 were never checked. Every function call produces a value, even when you did not ask for one. If the body ends without a return, or uses a bare return with nothing after it, Python hands back None, the built-in value that means "nothing here".

FormWhat the caller gets
return xThe value of x
return (bare)None, and the function stops there
No return at allNone, after the last line runs
return a, bThe tuple (a, b)

The last row deserves a closer look. Python has no special mechanism for sending back several values. return a, b builds a tuple (the comma makes it, not the parentheses), and the caller can unpack that tuple into separate names.

python
def greet(name):
    print("Hello,", name)

def stop_early(n):
    if n < 0:
        return
    print("not negative")

def bounds(xs):
    return min(xs), max(xs)

print(greet("Asha"))
print(stop_early(-1))
pair = bounds([4, 9, 1, 7])
print(pair, type(pair).__name__)
lo, hi = pair
print(lo, hi)

no return, bare return, and a tuple return

output
Hello, Asha
None
None
(1, 9) tuple
1 9

Both greet and stop_early give back None, so printing their calls shows None. In practice you usually write lo, hi = bounds(xs) in one step, and the tuple is unpacked straight into two names.

print vs return, and Guard Clauses

Beginners often treat print and return as the same thing, because both make a number appear while you are testing in a console. They are very different. print only writes text to the screen and the function's result is still None. return gives the caller a real value it can store, compare or calculate with.

python
def add_print(a, b):
    print(a + b)

def add_return(a, b):
    return a + b

x = add_print(2, 3)
y = add_return(2, 3)
print(x, y)
print(y * 10)

only the returned value can be reused

output
5
None 5
50

The first 5 comes from the print inside add_print. After that, x holds None, while y holds 5 and works in further arithmetic. The table sums up the difference.

printreturn
PurposeShow text to a personGive a value to the calling code
Caller receivesNoneThe returned value
Ends the functionNoYes
Reusable resultNoYes
Common mistake: print where you meant return

If total = add_print(2, 3) leaves total as None, the function printed its answer instead of returning it. Functions that compute something should return it and let the caller decide whether to print.

Because return ends the function, it is also a handy way to deal with special cases first. A guard clause is an early return placed at the top that handles the odd situation (an empty list, a missing value) and leaves the rest of the body for the normal case. This avoids stacking if/else blocks deeper and deeper.

Guard clauses peel off special cases
python
def shipping(order):
    if not order["items"]:
        return 0
    if order["total"] >= 50:
        return 0
    return 5

print(shipping({"items": [], "total": 0}))
print(shipping({"items": ["pen"], "total": 80}))
print(shipping({"items": ["pen"], "total": 20}))

each guard returns early, so the main case stays flat

output
0
0
5

The final return 5 sits at the normal indentation level with no else wrapped around it. A reader can see the special cases first and the ordinary result last.

Traps: None, Unreachable Code, Mixed Types

Methods that change an object in place, such as list.sort(), list.append(), list.extend(), list.reverse() and dict.update(), deliberately return None. This tells you the object itself was modified and there is no new object to hand back. The result is a classic bug: xs = xs.sort() sorts the list and then overwrites the name xs with None, so your data is gone.

Changes the object, returns NoneBuilds and returns a new object
xs.sort()sorted(xs)
xs.reverse()reversed(xs) or xs[::-1]
xs.append(v)xs + [v]
d.update(other){**d, **other}
python
xs = [3, 1, 2]
xs = xs.sort()
print(xs)

nums = [3, 1, 2]
nums.sort()
print(nums)
print(sorted(nums, reverse=True))

words = ["b", "a"]
print(words.append("c"))
print(words)

sort() and append() change the list and return None

output
None
[1, 2, 3]
[3, 2, 1]
None
['b', 'a', 'c']
Common mistake: assigning the result of an in-place method

Write xs.sort() on its own line, or use ordered = sorted(xs) when you want a new list. Never write xs = xs.sort() or xs = xs.append(1).

Two more traps involve how a function ends. First, unreachable code: any statement placed after return in the same block can never run. If you write return total and then print("done") on the next line, the print is dead code, and Python will not warn you. Put logging or cleanup before the return, not after it. Second, mixed return types. If a function returns an int on some paths and falls off the end (None) on others, every caller must remember to check for None, and the ones who forget crash far from the real cause.

python
ages = {"Asha": 31, "Ravi": 27}

def find_age(name):
    if name in ages:
        return ages[name]

print(find_age("Asha"))
print(find_age("Zed"))
try:
    print(find_age("Zed") + 1)
except TypeError as e:
    print("Error:", e)

the missing branch silently returns None

output
31
None
Error: unsupported operand type(s) for +: 'NoneType' and 'int'
Common mistake: a path that forgets to return

Make every path return the same kind of thing. Either return a sensible default (return 0), raise an exception for the missing case, or write an explicit return None so the choice is visible and documented.

Interview Point

What does a function with no return statement return?
  • It returns None, the single built-in value of type NoneType.
  • The same happens with a bare return, or when the body simply runs to its end.
  • So result = print("hi") sets result to None.
def f():
    pass
print(f())  # None
How does Python return multiple values?
  • It doesn't, strictly: return a, b packs the values into one tuple.
  • The caller usually unpacks it: lo, hi = bounds(xs).
  • If the caller doesn't unpack, they just hold the whole tuple.
def bounds(xs):
    return min(xs), max(xs)
lo, hi = bounds([4, 9, 1])
Why is x = my_list.sort() a bug?
  • sort() sorts the list in place and returns None.
  • So x becomes None, and if you reused the name my_list, the list is lost.
  • Call my_list.sort() alone, or use x = sorted(my_list) for a new sorted list.
my_list = [3, 1, 2]
x = my_list.sort()
print(x)  # None
Remember

return ends the function and hands back one object. No return means None. Several values mean one tuple. In-place methods return None, so never assign their result.

Part 4 · Positional and Keyword Arguments

Two ways to hand over a value

When you call a function, Python has to decide which value goes into which parameter. You can steer that decision in two ways. A positional argument is matched by where it sits in the call: the first value goes to the first parameter, the second to the second, and so on. A keyword argument is matched by name, written as name=value, so its place in the call no longer matters.

PositionalKeyword
How it is matchedBy position, left to rightBy the parameter name
Call looks likearea(3, 5)area(width=3, height=5)
Does order matter?Yes, swapping values changes the resultNo, any order works
Reads well whenThe meaning is obvious from the function nameThe call would otherwise be a row of unexplained values

The next example defines two functions. area is symmetric, so you cannot see the difference, but divide is not: swapping the positional values changes the answer, while swapping keyword arguments does not.

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

def divide(top, bottom):
    return top / bottom

print(area(3, 5))
print(area(width=3, height=5))
print(area(height=5, width=3))
print(divide(10, 4))
print(divide(4, 10))
output
15
15
15
2.5
0.4

You can mix the two styles in a single call, with one rule: every positional argument must come before the first keyword argument. Python fills the leading parameters by position and then looks up the rest by name. Putting a positional value after a keyword one is not a runtime error but a SyntaxError, caught before the call is ever tried. The snippet below asks Python to compile the bad call so we can read its message safely.

python
try:
    compile("area(width=3, 5)", "<call>", "eval")
except SyntaxError as error:
    print(error.msg)

print(area(3, height=5))

Positional first, then keywords: fine. The reverse never runs.

output
positional argument follows keyword argument
15
How Python binds one call
  1. 1Positional valuesfill parameters from the left
  2. 2Keyword valueseach lands on the parameter with that name
  3. 3Check the resultevery parameter filled exactly once

When a call does not fit

After the binding step, Python checks that every parameter received exactly one value and that every name you used actually exists. Any mismatch raises a TypeError before the function body starts. The messages are specific, so they are worth reading carefully rather than guessing. The most surprising case is giving the same parameter two values: area(3, width=4) fills width by position with 3 and then again by name with 4.

python
try:
    area(3, width=4)
except TypeError as error:
    print(error)

try:
    area(3)
except TypeError as error:
    print(error)

try:
    area(3, 5, depth=2)
except TypeError as error:
    print(error)

try:
    area(3, 5, 7)
except TypeError as error:
    print(error)

Uses the area function defined on the previous example.

output
area() got multiple values for argument 'width'
area() missing 1 required positional argument: 'height'
area() got an unexpected keyword argument 'depth'
area() takes 2 positional arguments but 3 were given
CallWhat went wrongError word to look for
area(3, width=4)width was filled twicegot multiple values
area(3)height never got a valuemissing 1 required positional argument
area(3, 5, depth=2)No parameter is named depthunexpected keyword argument
area(3, 5, 7)More values than parameterstakes 2 positional arguments but 3 were given
Common mistake: mixing styles for one parameter

Passing a value by position and then repeating the same parameter by name is easy to do when you edit a call and forget what the first values were. The fix is to count how many positional values come first and make sure no keyword names one of those parameters.

Forcing the style with * and /

By default a parameter accepts either style. A function author can narrow that with two markers in the signature. A bare * means that everything after it is keyword-only: callers must write the name. A / means that everything before it is positional-only: callers must not write the name. Here is the keyword-only side first.

python
def join_words(a, b, *, sep=","):
    return a + sep + b

print(join_words("x", "y"))
print(join_words("x", "y", sep="-"))

try:
    join_words("x", "y", "-")
except TypeError as error:
    print(error)
output
x,y
x-y
join_words() takes 2 positional arguments but 3 were given

The third call fails because sep sits after the *, so a third positional value has nowhere to go. Now the positional-only side: the / in double(x, /) locks x to its position.

python
def double(x, /):
    return x * 2

print(double(4))

try:
    double(x=4)
except TypeError as error:
    print(error)
output
8
double() got some positional-only arguments passed as keyword arguments: 'x'

Both markers can appear in one signature, and the parameters between them accept either style. Read a signature left to right and it splits into three zones.

The built-ins show both styles. len(x) takes its one value positionally only, while the sep option of print can only be given by name, which is why a stray third string is printed as text rather than used as a separator.

python
print("a", "b", sep="-")
print("a", "b", "-")

try:
    len(obj=[1, 2])
except TypeError as error:
    print(error)
output
a-b
a b -
len() takes no keyword arguments
len(x)print(sep='-')
StylePositional-onlyKeyword-only
Workslen([1, 2])print("a", "b", sep="-")
Failslen(obj=[1, 2])Passing the separator as a third positional value

Interview Point

When would you make a parameter keyword-only?
  • When the value is an option or flag whose meaning is invisible at the call site, such as sep, verbose or timeout.
  • When you may add or reorder options later: callers who must write the name cannot be broken by a change in position.
  • When the function takes several values of the same type, so a call like copy(src, dst, True) would be a guessing game.
  • Put a bare * before those parameters, for example def f(a, *, sep=',').
def join_words(a, b, *, sep=","):
    return a + sep + b
What do / and * mean in a signature?
  • / ends the positional-only parameters: everything before it must be passed by position, never by name.
  • * starts the keyword-only parameters: everything after it must be passed by name.
  • Parameters between the two accept either style.
  • Neither marker is a parameter itself, and neither takes a value.
def f(a, b, /, c, *, d):
    ...
Can a positional argument follow a keyword argument in a call?
  • No. area(width=3, 5) is a SyntaxError: positional argument follows keyword argument.
  • Python reads positional values first and then matches the keywords, so the reverse order is the only legal one: area(3, height=5).
  • Keyword arguments among themselves may come in any order.
Remember

Position decides for plain values, names decide for keywords, and a mismatch in either is a TypeError. Use * to make callers spell out options and / to stop callers depending on a parameter's name.

Part 5 · Default Values and the Mutable-Default Trap

Giving a parameter a default

A default value makes a parameter optional. Write name=value in the def line, and callers may leave that argument out. When they do, Python fills in the default for them.

python
def power(x, n=2):
    return x ** n

print(power(5))
print(power(5, 3))
print(power.__defaults__)

n is optional; x is required

output
25
125
(2,)

The last line shows where the default lives. Python stores all defaults in a tuple on the function object itself, called __defaults__. Keep that in mind, because it explains the trap later in this section.

Defaults go after required parameters

Parameters without a default must come first, and parameters with a default come after them. Python rejects the reverse order when it reads the def, before any call happens. It would otherwise have no way to know which argument a positional value was meant for.

SignatureResult
def f(x, n=10)Valid: x is required, n is optional
def f(x, y, n=10)Valid: required parameters first
def f(n=1, x)SyntaxError at definition time

When is the default evaluated?

This is the key idea of the section. The default expression is evaluated once, at the moment the def statement runs. It is not re-evaluated on each call. Python keeps the resulting object in f.__defaults__ and hands you that same object every time you skip the argument.

Life of a default value
  1. 1def runsthe default expression is evaluated once
  2. 2Object storedkept in f.defaults
  3. 3Call without the argumentPython reads the stored object
  4. 4Same object every timeno fresh copy is made

You can prove it by making the default expression announce itself. The message appears once, before any call, and never again.

python
def make_default():
    print("default evaluated")
    return 10

def f(x, n=make_default()):
    return x + n

print("def has run")
print(f(1))
print(f(2))
output
default evaluated
def has run
11
12

With an integer this is harmless, because a number can never change. The danger begins when the stored default is an object that can be modified.

The mutable-default trap

Suppose a function collects items into a list, and you give the list parameter an empty list as its default. It looks natural, and it is one of the best-known bugs in Python.

python
def add(item, bucket=[]):
    bucket.append(item)
    return bucket

print(add(1))
print(add(2))
print(add(3))
print(add.__defaults__)

every call without bucket shares one list

output
[1]
[1, 2]
[1, 2, 3]
([1, 2, 3],)

The empty list was created once, when def ran. Each call that omits bucket receives that same list, appends to it, and returns it. The list grows across calls, so add(2) returns [1, 2] rather than [2]. The last line shows the damage: the stored default itself has changed.

Lists are not special here. Any mutable object used as a default behaves the same way, including {}, set(), and a dict default of any contents.

python
def tally(word, counts={}):
    counts[word] = counts.get(word, 0) + 1
    return counts

print(tally("a"))
print(tally("b"))
print(tally("a"))

a dict default is shared too

output
{'a': 1}
{'a': 1, 'b': 1}
{'a': 2, 'b': 1}

Unrelated callers end up sharing counts, and the bug is hard to spot because the first call always looks correct.

DefaultSafe?Why
0, 3.5, TrueYesNumbers and bools cannot change
"hi"YesStrings are immutable
(1, 2)YesTuples cannot change (if their contents are immutable)
NoneYesA single fixed value, perfect as a marker
[]NoShared list grows across calls
{}NoShared dict collects entries across calls
set()NoShared set collects members across calls
Common mistake

Writing bucket=[], counts={} or seen=set() in a def line and expecting a fresh container on every call. The container is built once and shared by every call that omits the argument.

The None sentinel fix

The fix is to make the default something immutable, almost always None, and create the real container inside the function body. The body runs on every call, so each call gets its own fresh object. In this role None is called a sentinel: a marker meaning "the caller gave me nothing."

python
def add(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

print(add(1))
print(add(2))
mine = []
add(7, mine)
print(mine)
print(add.__defaults__)

each call without bucket builds its own list

output
[1]
[2]
[7]
(None,)

Now add(2) returns [2], because the list was created during that call. A caller who passes their own list still gets it back with the item appended. The stored default stays None and can never accumulate state.

What the fixed function does on each call

Use is None, not a truthiness test

It is tempting to write if not bucket: because it reads shorter. But an empty list is falsy, so that test also fires when the caller deliberately passed an empty list of their own. Your function then quietly swaps in a different list, and the caller's list never receives the item.

python
def add_bad(item, bucket=None):
    if not bucket:
        bucket = []
    bucket.append(item)
    return bucket

a, b = [], []
add_bad("x", a)
add("x", b)
print(a)
print(b)

a is ignored, b is honored

output
[]
['x']

The is None check asks one precise question: did the caller omit the argument? An empty list, 0 or an empty string passed on purpose all count as real values and are respected.

Common mistake

Writing if not bucket: as the sentinel check. An empty list the caller passed in is falsy, so it gets replaced and the caller's own list is never updated. Test bucket is None instead.

Remember

Defaults are evaluated once, when def runs, and stored in __defaults__. Immutable defaults are safe. For a list, dict or set, default to None and build the container inside the function with an is None check.

Interview Point

Why does a mutable default persist across calls?
  • The default object is created once and stored on the function, in f.__defaults__.
  • Every call that omits the argument receives that same object, so changes made to it stay visible to later calls.
When is a default evaluated?
  • Once, when the def statement executes, not each time the function is called.
  • Immutable defaults hide this, because nothing can change in them.
def f(x, n=make_default()):
    return x + n
How do you fix it?
  • Use None as the default and create the container inside the body.
  • The body runs on every call, so each call gets a fresh object.
def add(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket
Why is is None preferred to a truthiness test?
  • if not bucket is also true for an empty list the caller passed in, so the function would replace the caller's list.
  • is None detects only a missing argument, so every value the caller passes, even an empty one, is honored.

Part 6 · *args and **kwargs

Collecting extra arguments

Sometimes a function cannot know in advance how many arguments it will receive. A logger might get a message plus any number of values, and a wrapper must accept whatever the function it wraps accepts. Python handles this with two special parameters. A parameter written with one star, *args, collects every extra positional argument into a tuple. A parameter written with two stars, **kwargs, collects every extra keyword argument into a dict.

The names args and kwargs are only convention, and you could call them *values and **options. The stars are what matter. Everything else in the signature is matched first, and the leftovers land in these two containers.

python
def log(msg, *args, **kwargs):
    print(msg)
    print(args)
    print(kwargs)

log("start", 1, 2, level="info", tag="x")

msg takes the first value; 1 and 2 are extra positionals; level and tag are extra keywords

output
start
(1, 2)
{'level': 'info', 'tag': 'x'}

If the caller passes nothing extra, the containers are not missing. They are simply empty, so your code never has to check for None.

python
log("only")
output
only
()
{}
Common mistake: forgetting the stars

def log(msg, args, kwargs) has three ordinary parameters, so log("a", 1, 2) fails with a TypeError. Without the * and ** nothing is collected.

Parameter order and unpacking

When a signature mixes several kinds of parameters, Python insists on a fixed order. Ordinary positional parameters come first, then *args, then keyword-only parameters, and **kwargs comes last. Any parameter placed after *args can only be given by name, which is why it is called keyword-only.

Order inside a def
  1. 1positionaltitle
  2. 2*argsextra positionals
  3. 3keyword-onlysep=", "
  4. 4**kwargsextra keywords
python
def report(title, *items, sep=", ", **extra):
    print(title + ": " + sep.join(items), extra)

report("Fruit", "apple", "pear", sep=" | ", color="red")
report("Fruit", "apple", "pear")

sep sits after *items, so it can only be passed as sep=...

output
Fruit: apple | pear {'color': 'red'}
Fruit: apple, pear {}

The stars also work in the opposite direction. In a call, * unpacks an iterable into separate positional arguments, and ** unpacks a dict into keyword arguments. So f(*[1, 2], **{'sep': '-'}) means exactly f(1, 2, sep='-').

python
def show(*items, sep=" "):
    print(sep.join(str(i) for i in items))

show(*[1, 2, 3], **{"sep": "-"})
output
1-2-3

You can unpack more than one dict in a single call, as in f(**a, **b). If both dicts contain the same key, Python cannot decide which value wins, so it refuses and raises a TypeError. To merge deliberately, build one dict first and unpack that. In {**a, **b} the later dict wins.

python
a = {"sep": "-"}
b = {"sep": "+"}

try:
    show(1, 2, **a, **b)
except TypeError as e:
    print(type(e).__name__)

show(1, 2, **{**a, **b})

the same key arrives twice in the first call; the merged dict has one value

output
TypeError
1+2
Common mistake: wrong order in the def

def f(**kwargs, *args) is a SyntaxError, because **kwargs must be the last parameter. A normal parameter written after **kwargs is also a SyntaxError.

Where you use them, and what they cost

The most common use is a forwarding wrapper. A decorator does not know what the wrapped function takes, so it accepts everything and passes everything along with return fn(*args, **kwargs). Because the wrapper simply repeats whatever arrived, it works for any signature.

python
def timed(fn):
    def wrapper(*args, **kwargs):
        print("calling", fn.__name__)
        return fn(*args, **kwargs)
    return wrapper

@timed
def add(a, b=0):
    return a + b

print(add(2, b=3))
output
calling add
5

The second use is a variadic helper, a function whose natural input is a list of values of any length. sum_all(*nums) lets callers write sum_all(1, 2, 3) instead of building a list first. The third use is optional configuration, where a function accepts a bag of named settings and reads the ones it understands.

python
def sum_all(*nums):
    return sum(nums)

print(sum_all(1, 2, 3), sum_all())
output
6 0

Flexibility has a price. An explicit signature documents itself, and Python checks every call against it. With **kwargs, any keyword is accepted, including one you misspelled. The function then quietly falls back to its default.

python
def connect(host, **options):
    timeout = options.get("timeout", 30)
    return host, timeout

def connect_strict(host, *, timeout=30):
    return host, timeout

print(connect("db", timout=5))
try:
    connect_strict("db", timout=5)
except TypeError as e:
    print(e)

timout is a typo in both calls

output
('db', 30)
connect_strict() got an unexpected keyword argument 'timout'
Explicit signature**kwargs
ReadabilityNames and defaults visible in the defCaller must read the body or docs
Typo in a keywordTypeError right awayPasses silently, default is used
Tools and editorsCan autocomplete and check callsCannot know the valid names
Best forNormal functions with known optionsWrappers, forwarding, open-ended config
Rule of thumb

Use explicit parameters whenever you know the names. Reach for *args and **kwargs when you are forwarding to something else or the set of options is truly open.

Interview point

What type is args and what type is kwargs?
  • args is a tuple of the extra positional arguments, in the order they were passed.
  • kwargs is a dict mapping each extra keyword name to its value.
  • Both are empty, not None, when nothing extra is passed.
def f(*args, **kwargs):
    print(type(args).__name__, type(kwargs).__name__)

f(1, a=2)   # prints: tuple dict
What does the * mean in a call versus a def?
  • In a def, * collects extra positional arguments into a tuple, and ** collects extra keywords into a dict.
  • In a call, * unpacks an iterable into separate positional arguments, and ** unpacks a dict into keyword arguments.
  • So the same symbol packs on the way in and unpacks on the way out.
In a defIn a call
*Packs extras into a tupleUnpacks an iterable into positionals
**Packs extras into a dictUnpacks a dict into keyword arguments
Why can **kwargs hide misspelled argument names?
  • **kwargs accepts any keyword, so Python has no list of valid names to check against.
  • A misspelled name like timout just becomes one more dict entry that nobody reads.
  • The function then uses its default, so there is no error and the bug is silent.
  • An explicit or keyword-only parameter makes Python raise a TypeError instead.
Remember

The stars do the work: one star means a tuple of positionals, two stars mean a dict of keywords. Keep **kwargs last in a def, unpack carefully in calls, and prefer a named parameter whenever the name is known.

Part 7 · Scope and the LEGB Rule

How Python Finds a Name

Every time your code uses a name, Python has to decide which variable that name refers to. It does this by searching a fixed series of places, called scopes, in a fixed order. The first place that holds the name wins, and the search stops there. The order is remembered as LEGB.

ScopeWhat lives thereExample
LocalNames assigned inside the current function, including its parameterstotal = 0 inside def f():
EnclosingLocal names of any function that wraps the current onea variable of an outer def
GlobalNames assigned at the top level of the modulelimit = 10 in the file body
Built-inNames Python provides everywherelen, print, range, list

The example below defines the same name x in three places. Each print finds the nearest one. The call to len is not defined anywhere in the file, so the search runs all the way to the built-in scope.

python
x = 'global'

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

outer()
print(x)
print(len('abc'))
output
local
enclosing
global
3

Reading a name from an outer scope works without any special syntax. A function that only reads limit finds it in the global scope because the local scope does not have it.

python
limit = 10

def check(n):
    return n < limit

print(check(5))
print(check(50))
output
True
False
Remember the direction

The search only moves outward, from the inside of a function toward the built-ins. The global scope never looks into a function's local names.

Assignment Makes a Name Local

There is one rule that surprises almost everyone. Python decides whether a name is local by looking at the whole function body before running it. If the name is assigned anywhere in that function, including with +=, for, import or def, it is local for the entire function, even on the lines above the assignment.

That is why the classic counter fails. The statement count += 1 means count = count + 1, which assigns to count. So count is local, and the right-hand side tries to read a local that has no value yet.

python
count = 0

def inc():
    count += 1

try:
    inc()
except UnboundLocalError as e:
    print(type(e).__name__)
    print(e)
output
UnboundLocalError
cannot access local variable 'count' where it is not associated with a value

The same thing happens when the assignment comes later than the read. Here the first line of show looks like it should print the global total, but the assignment on the next line has already made total local.

python
total = 5

def show():
    print(total)
    total = 1

try:
    show()
except UnboundLocalError:
    print('total is local, so it has no value yet')
output
total is local, so it has no value yet

The usual fix is to stop depending on an outer variable. Pass the value in as a parameter and return the new value. Python also has the keywords global and nonlocal for changing outer names, which the section on closures covers.

Common mistake

Writing count += 1 in a function and expecting it to update a module-level count. It raises UnboundLocalError because the assignment makes count local to the function.

Common mistake

Thinking the error only happens when the assignment runs first. The assignment does not need to execute. Its mere presence anywhere in the function body makes the name local.

Shadowing, Loops and Comprehensions

Because the built-in scope is searched last, any name you create in an earlier scope shadows the built-in of the same name. Writing list = [1, 2] or sum = 0 hides the real list or sum for the rest of that scope. The next call to list('abc') then tries to call your list instead. Deleting your name makes the built-in visible again.

python
list = [1, 2]

try:
    list('abc')
except TypeError as e:
    print(e)

del list
print(list('abc'))
output
'list' object is not callable
['a', 'b', 'c']

Loop variables behave differently depending on the kind of loop. A comprehension runs in its own scope, so its variable disappears when it finishes. A regular for loop has no scope of its own, so its variable stays behind in the enclosing scope with the last value it was given.

python
squares = [n * n for n in range(3)]

try:
    print(n)
except NameError:
    print('n is gone')

for i in range(3):
    pass
print(i)
output
n is gone
2
Comprehensionfor loop
Own scopeYesNo
Variable after it endsGone, raises NameErrorStill defined, holds the last value
Empty sequenceVariable never createdVariable never assigned, so NameError
Common mistake

Naming a variable list, sum, max, str or id. Nothing complains at the assignment, and the failure shows up much later as object is not callable. Use names like items or total instead.

Interview: Spell out LEGB in order. Why does count += 1 raise UnboundLocalError? Does a for-loop variable remain visible after the loop?
  • LEGB is Local, Enclosing, Global, Built-in. Python searches in that order and stops at the first match.
  • count += 1 assigns to count, so Python treats it as local for the whole function. Reading it before it has a value raises UnboundLocalError.
  • Yes. A for-loop variable leaks into the enclosing scope and keeps its last value, unless the loop never ran. A comprehension variable does not leak.
count = 0
def inc():
    count += 1   # UnboundLocalError

Part 8 · global, nonlocal and Closures

Rebinding a Name Versus Mutating an Object

Inside a function, assigning to a name normally creates a local variable. That is usually what you want, but sometimes a function needs to change a name that lives outside it. Python gives you two keywords for this. global x makes the function rebind a module-level name. nonlocal x makes it rebind a name in the nearest enclosing function, never the module.

There is one rule that removes most confusion: only rebinding needs a keyword. Rebinding means =, += or any other assignment that points the name at a new object. Calling a method that changes an object in place, such as xs.append(...), does not rebind anything, so it needs neither keyword.

python
count = 0
log = []

def bump():
    global count
    count += 1
    log.append(count)

bump()
bump()
print(count, log)

count is rebound, so it needs global. log is only mutated, so it does not.

output
2 [1, 2]

If you delete the global count line, count += 1 makes count local for the whole function body. Python then finds a read of a local that has no value yet and raises UnboundLocalError. The log.append(count) line works either way, because it never assigns to log.

nonlocal follows the same idea, but it targets an enclosing function. In the next example the module also has an x, yet nonlocal x skips past it and binds to the x inside outer.

python
x = 'global'

def outer():
    x = 'outer'
    def inner():
        nonlocal x
        x = 'changed by inner'
    inner()
    print(x)

outer()
print(x)
output
changed by inner
global
global xnonlocal x
Rebinds a name inThe module (global) scopeThe nearest enclosing function
Name must already exist?No, it can be created by the assignmentYes, an enclosing function must define it
Can reach the module scope?YesNever
Needed for x.append(...)?NoNo
Which keyword does this function need?
Common mistake

Writing count += 1 in a function without global count or nonlocal count. Python treats count as local, so you get UnboundLocalError instead of the update you expected.

Closures: Functions That Remember

A closure is an inner function that keeps access to variables from its enclosing function, even after that outer function has returned. This is where nonlocal is most useful. The classic example is a counter. The outer function creates n, the inner function updates it, and returning the inner function hands the caller something that carries its own private state.

python
def make_counter():
    n = 0
    def step():
        nonlocal n
        n += 1
        return n
    return step

a = make_counter()
b = make_counter()
print(a(), a(), a())
print(b())
print(a.__code__.co_freevars)
print(a.__closure__[0].cell_contents)

a and b come from separate calls, so each has its own n.

output
1 2 3
1
('n',)
3

Every call to make_counter() runs the function body again and creates a fresh n, so a and b never interfere. The last two lines answer where the remembered value lives. The inner function's code object lists n as a free variable in co_freevars. The value itself sits in a cell object, reachable through the function's __closure__ tuple. The cell stays alive for as long as the function that refers to it does.

How a closure keeps its variable alive
  1. 1make_counter() runsn = 0 is created in a cell
  2. 2step is definedit refers to n, so n is a free variable
  3. 3step is returnedmake_counter ends, but the cell survives
  4. 4a() is callednonlocal n updates the cell in place
Remember

A closure is not a copy of the value. It holds a reference to the variable's cell, which is why nonlocal can change it and why each call to the outer function gets its own.

Late Binding and Choosing Where State Lives

Closures look up a variable when the inner function is called, not when it is defined. That causes a well-known surprise in loops. In the code below, three functions are defined in a for loop and stored in a list. All three refer to the same loop variable i. By the time you call them, the loop has finished and i is 2.

python
funcs = []
for i in range(3):
    def show():
        return i
    funcs.append(show)

print([f() for f in funcs])
output
[2, 2, 2]

The fix is to capture the current value at definition time. A default argument is evaluated once, when def runs, so def show(i=i) stores each loop value in its own function.

python
funcs = []
for i in range(3):
    def show(i=i):
        return i
    funcs.append(show)

print([f() for f in funcs])
output
[0, 1, 2]

The same trap appears with lambdas, which are small unnamed functions written as a single expression (a later section covers them properly). Here lambda: i behaves like def returning i, and the same default-argument fix applies.

python
late = [lambda: i for i in range(3)]
fixed = [lambda i=i: i for i in range(3)]
print([f() for f in late])
print([f() for f in fixed])
output
[2, 2, 2]
[0, 1, 2]
Common mistake

Building callbacks in a loop and expecting each one to remember its own loop value. Without i=i, every callback sees the final value.

The last question is where to keep state that changes between calls. A global variable works, but any function can change it at any time. A closure or an ordinary parameter makes the dependency visible.

Global stateClosureParameter
Where the data livesModule level, visible to everythingPrivate to the returned functionPassed in by the caller on each call
Who can change itAny code that uses globalOnly the inner functionThe caller, explicitly
TestingHard: reset it before every testEasy: make a fresh closureEasiest: pass any value
Hidden couplingHighLowNone
Interview: what is the difference between global and nonlocal?
  • global x lets a function rebind a module-level name.
  • nonlocal x lets an inner function rebind a name in the nearest enclosing function, and it can never reach the module scope.
  • Neither is needed to mutate an object such as xs.append(1). Only rebinding needs a keyword.
Interview: what is a closure, and where is its captured variable stored?
  • A closure is an inner function that remembers variables from its enclosing scope after the outer function has returned.
  • The variable lives in a cell object. The function's __closure__ holds the cells and __code__.co_freevars holds their names.
  • Each call to the outer function creates new cells, so each closure has independent state.
def make_counter():
    n = 0
    def step():
        nonlocal n
        n += 1
        return n
    return step
Interview: why do functions created in a loop all return the last value?
  • They share one variable, and it is looked up when the function is called, not when it is defined (late binding).
  • After the loop ends that variable holds its last value.
  • Bind the current value with a default argument such as def show(i=i) or lambda i=i: i.

Part 9 · Functions as First-Class Objects

Functions are objects

In Python a function is a value, just like a number or a string. When you write def add(a, b), Python builds a function object and binds the name add to it. Because it is an ordinary object, you can give it a second name, put it in a list or dict, hand it to another function, or return it from one. This is what people mean by first-class functions.

The first example shows the three simplest moves. f = len copies a reference to the built-in function, without calling it. The dict stores two functions as values, and the last lines look one up by key and call it right away.

python
def add(a, b):
    return a + b

def sub(a, b):
    return a - b

f = len
print(f('hello'))

ops = {'add': add, 'sub': sub}
print(ops['add'](2, 3))
print(ops['sub'](2, 3))

No parentheses after len means the function itself, not a call

output
5
5
-1

A function object also carries data about itself. __name__ holds the name it was defined with, __doc__ holds its docstring, and __defaults__ holds a tuple of its default argument values. A second name for the same function is not a copy, so is reports True.

python
def greet(name, punct='!'):
    """Say hello."""
    return 'Hello, ' + name + punct

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

shout = greet
print(shout is greet)
print(shout('Ana'))
output
greet
Say hello.
('!',)
True
Hello, Ana!

Passing and returning functions works the same way. apply_twice below receives a function as an argument. make_adder builds a new function inside itself and returns it, so the caller gets a function back.

python
def make_adder(n):
    def adder(x):
        return x + n
    return adder

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

add5 = make_adder(5)
print(add5(1))
print(apply_twice(add5, 1))
print(apply_twice(str.upper, 'hi'))
output
6
11
HI
You can do this with a functionExample
Give it another namef = len
Store it in a list or dict{'add': add, 'sub': sub}
Pass it as an argumentapply_twice(add5, 1)
Return it from a functionreturn adder
Read its attributesgreet.__name__, greet.__defaults__
Common mistake: parentheses decide everything

f = len stores the function. f = len('hello') calls it and stores the number 5. Adding or dropping () changes a function object into its result.

Higher-order functions

A higher-order function is one that takes a function as an argument, returns a function, or both. Python ships several. sorted takes a key function and calls it once per item to decide the order. map applies a function to every item. filter keeps the items for which a function returns a true value.

python
words = ['pear', 'fig', 'banana', 'kiwi']
print(sorted(words, key=len))
print(list(map(str.upper, words)))

items = ['12', 'ab', '7', 'x9']
print(list(filter(str.isdigit, items)))

len, str.upper and str.isdigit are passed as objects, never called

output
['fig', 'pear', 'kiwi', 'banana']
['PEAR', 'FIG', 'BANANA', 'KIWI']
['12', '7']

Here is what sorted(words, key=len) does under the hood. It receives the function object, calls it on each word to get a sort value, and then orders the words by those values.

sorted(words, key=len)
  1. 1Receive lenthe function object itself
  2. 2Call len(word) for each word4, 3, 6, 4
  3. 3Order by those numbers3, 4, 4, 6
  4. 4Return the wordsfig, pear, kiwi, banana

The most common slip is writing key=len(words). Python evaluates arguments before the call, so len(words) runs immediately, finds that the list has 4 items, and passes the integer 4 as the key. sorted then tries to call that integer on each word and fails. The rule is to pass the function itself, with no parentheses.

python
try:
    sorted(words, key=len(words))
except TypeError as e:
    print(e)

Wrong on purpose: len(words) is already the int 4

output
'int' object is not callable
key=lenkey=len(words)
What is passedThe function lenThe int 4
When len runsOnce per item, inside sortedOnce, before sorted starts
ResultSorted listTypeError
Common mistake: passing the result

If key= receives something that is not callable, you get TypeError: 'int' object is not callable. Check for stray parentheses after the function name.

Dispatch dicts, callables and decorators

Because functions can be dict values, a long if/elif chain that picks an action by name can become a lookup. This is called a dispatch dict. The key is the command, the value is the handler function, and .get() lets you handle an unknown command without a KeyError.

python
def do_start():
    return 'starting'

def do_stop():
    return 'stopping'

handlers = {'start': do_start, 'stop': do_stop}

for cmd in ['start', 'stop', 'pause']:
    handler = handlers.get(cmd)
    print(handler() if handler else 'unknown: ' + cmd)
output
starting
stopping
unknown: pause

Adding a new command now means adding one dict entry instead of another elif branch.

Functions are not the only things you can call. A callable is any object that has a __call__ method, and the built-in callable(x) tells you whether x qualifies. A class can make its instances callable by defining __call__. The Counter below is an object that remembers state between calls.

python
class Counter:
    def __init__(self):
        self.n = 0

    def __call__(self):
        self.n += 1
        return self.n

c = Counter()
print(c(), c())
print(callable(c), callable(len), callable(42), callable('abc'))
output
1 2
True True False False

Finally, a decorator is just a higher-order function that takes a function and returns a replacement. Writing hello = shout(hello) rebinds the name to a wrapper. The @shout line above a def is shorthand for exactly that rebinding.

python
def shout(func):
    def wrapper(*args):
        return func(*args).upper()
    return wrapper

def hello(name):
    return 'hello ' + name

hello = shout(hello)
print(hello('ana'))

@shout
def bye(name):
    return 'bye ' + name

print(bye('ben'))
output
HELLO ANA
BYE BEN
Remember

Functions are values. Pass the function without parentheses, call it with parentheses, and anything with __call__ counts as callable.

Interview Point: what does first-class mean?

What does 'functions are first-class' mean?
  • Functions are ordinary objects, so they can be assigned to names, stored in lists and dicts, passed as arguments and returned from other functions.
  • They have attributes such as __name__, __doc__ and __defaults__.
  • This is what makes higher-order functions like sorted, map and filter, dispatch dicts and decorators possible.
Write sorted() with a custom key.
  • Define a function that takes one item and returns the value to sort by, then pass it as key= without parentheses.
  • sorted calls it once per item and orders by the results.
  • Add reverse=True for descending order. Ties keep their original order.
def last_char(word):
    return word[-1]

print(sorted(words, key=last_char))
print(sorted(words, key=len, reverse=True))
output
['banana', 'fig', 'kiwi', 'pear']
['banana', 'pear', 'kiwi', 'fig']
Why does key=len(words) fail?
  • Python evaluates len(words) first, so it calls len right away and gets the int 4.
  • sorted receives 4 as its key and tries to call it on each item.
  • An int is not callable, so you get TypeError: 'int' object is not callable. The fix is key=len.

Part 10 · Lambdas and Higher-Order Helpers

What a lambda is

A lambda is a small function written as a single expression, with no name of its own. The form is lambda parameters: expression. Whatever the expression evaluates to is returned automatically, so there is no return keyword. Because a lambda is an ordinary function object, you can call it, pass it to other functions, or store it in a list.

The parameter list works like a def parameter list: you can use several parameters, defaults and *args. The one thing you cannot add is an annotation, because the lambda syntax has no place for one. The example below calls each lambda right where it is defined, so none of them needs a name.

python
print((lambda x: x * x)(7))
print((lambda a, b=10: a + b)(5))
print((lambda *args: len(args))(1, 2, 3))

Three anonymous functions, each called immediately

output
49
15
3

The body must be exactly one expression. A statement such as return, if as a block, for, while, import, assert or an assignment with = is a syntax error inside a lambda. A conditional expression like a if cond else b is fine, because it is an expression.

lambdadef
NameNone; it shows up as <lambda>Has its own name
BodyOne expression, value returned for youAny number of statements, explicit return
DocstringNot possibleSupported
AnnotationsNot possible on parametersSupported
TracebackShows <lambda>, which is hard to traceShows the function name
Where it livesInline, inside another expressionIts own statement
Common mistake: statements in a lambda

lambda x: y = x + 1 and lambda x: return x are both syntax errors. If you need an assignment, a loop or several steps, write a def.

Where lambdas fit: short throwaway keys

The best use of a lambda is a tiny key function that you hand to sorted, min, max or list.sort. These functions call your key once per item and compare the results. The logic is so small, and used in only one place, that giving it a name would add noise.

python
rows = [{'name': 'Asha', 'age': 31}, {'name': 'Ben', 'age': 24}, {'name': 'Chen', 'age': 28}]
print([r['name'] for r in sorted(rows, key=lambda r: r['age'])])

pairs = [('a', 3), ('b', 9), ('c', 5)]
print(max(pairs, key=lambda p: p[1]))

Sort rows by age; pick the pair with the biggest second item

output
['Ben', 'Chen', 'Asha']
('b', 9)

Both calls read naturally: "sort the rows by their age" and "take the maximum pair by its second element". The lambda sits exactly where the idea is used.

The trade-off against def is about naming and readability. A named function appears by name in tracebacks, can carry a docstring, can be tested on its own and has room for several lines. The lambda's name is always <lambda>, as the next example shows.

python
add_one = lambda x: x + 1

def add_two(x):
    return x + 2

print(add_one.__name__)
print(add_two.__name__)

What an error message or a log line would show

output
<lambda>
add_two
Lambda or def?

When not to use a lambda

Binding a lambda to a name, as in f = lambda x: x + 1, throws away the one thing a lambda is good for, which is being anonymous and inline. You get a function that still says <lambda> in every traceback, with no docstring. PEP 8 therefore recommends a def statement whenever the function needs a name. The example above with add_one is exactly the pattern to avoid.

Lambdas are also often used with map and filter, but a comprehension says the same thing with less ceremony. It reads left to right as "double x for each x that is positive", and it needs no nested lambdas.

python
xs = [-2, 3, 0, 5]
print(list(map(lambda x: x * 2, filter(lambda x: x > 0, xs))))
print([x * 2 for x in xs if x > 0])

Same result, very different readability

output
[6, 10]
[6, 10]

Another case where a lambda is unnecessary is when you only want to fix some arguments of an existing function. functools.partial does this directly. partial(pow, 2) is a new callable that already has 2 as its first argument, so calling it with 5 computes pow(2, 5). You can also fix keyword arguments.

python
from functools import partial

two_to = partial(pow, 2)
print(two_to(5), two_to(10))

from_binary = partial(int, base=2)
print(from_binary('101'))

partial fixes arguments without writing a lambda

output
32 1024
5
Common mistake: naming a lambda

square = lambda x: x * x works, but linters flag it. Write def square(x): return x * x instead, and keep lambdas for arguments passed straight into another call.

Rule of thumb

Use a lambda for a short key or callback that you write in place, and nowhere else. Use a comprehension to transform or filter, partial to pre-fill arguments, and def for everything with a name, a docstring or more than one step.

Interview Point

Can a lambda contain multiple statements?
  • No. The body is a single expression, and statements such as assignments, return, for, while and import are syntax errors there.
  • You can still put a lot into one expression, for example with a conditional expression or a nested call, but readability drops quickly.
  • If you need more than one step, use def.
lambda x: 'big' if x > 10 else 'small'   # fine: one expression
# lambda x: y = x + 1                    # SyntaxError: assignment is a statement
When would you choose def over lambda?
  • When the function needs a name, so tracebacks and logs are readable.
  • When it needs a docstring, type annotations or several lines.
  • When it is reused in more than one place, or you want to test it on its own.
  • In short: lambda for a throwaway inline key, def for anything you would want to find again.
How does functools.partial differ from a lambda?
  • partial pre-fills arguments of an existing callable and does nothing else. A lambda can run any single expression.
  • partial stores the argument values at the moment you create it. A lambda looks up the names in its body when it is called, so values from an enclosing loop can change underneath it (late binding).
  • partial(pow, 2) is shorter and clearer than lambda e: pow(2, e) when all you want is to fix an argument.
from functools import partial

fs = [lambda: i for i in range(3)]
gs = [partial(pow, i, 2) for i in range(3)]
print([f() for f in fs])
print([g() for g in gs])
output
[2, 2, 2]
[0, 1, 4]

Part 11 · Type Hints and Docstrings

Type hints: notes about intent

A type hint (also called an annotation) tells the reader, and any checking tool, what kind of value a parameter expects and what the function gives back. You write it with a colon after the parameter name and an arrow before the final colon of the def line. The hint documents your intent; it does not change how the function runs.

python
def area(w: float, h: float = 1.0) -> float:
    return w * h

print(area.__annotations__)

Hints are stored on the function object, not thrown away

output
{'w': <class 'float'>, 'h': <class 'float'>, 'return': <class 'float'>}

Python simply records the hints in a dictionary called __annotations__. The key return holds the hint for the result. Nothing reads that dictionary while your program runs, so a wrong type is not caught by the interpreter.

To see that nothing is enforced, pass a string where a float is promised. Python happily multiplies it, because str * int is legal.

python
print(area("ab", 3))

The hint says float, but Python does not object

output
ababab

The mistake only shows up when a tool such as mypy analyses the file, or when your editor underlines the call. The table lists the hint forms you will meet most often.

HintMeaningExample value
int, str, floatA plain value of that type42
list[int]A list whose items are all ints[1, 2, 3]
dict[str, int]Keys are strings, values are ints{"a": 1}
str | NoneEither a string or None"hi" or None
Callable[[int], str]A function taking one int and returning a strstr
python
from typing import Callable

def apply(fn: Callable[[int], str], n: int) -> str:
    return fn(n)

def first_or_none(nums: list[int]) -> int | None:
    return nums[0] if nums else None

print(apply(lambda n: "#" * n, 3))
print(first_or_none([7]))
print(first_or_none([]))

Callable, list[int] and int | None in real signatures

output
###
7
None
Common mistake

Believing a hint protects you. def area(w: float) still accepts a string, a list or None at run time. If the input comes from outside your code, validate it yourself.

Docstrings: the manual built into the function

A docstring is a string literal placed as the very first statement in a function body. Python stores it in the function's __doc__ attribute, and the built-in help(f) prints it nicely formatted. Editors show the same text when you hover over a call, so it is the documentation people actually read.

Start with a one-line summary in the imperative mood, such as Return the area of a rectangle., then leave a blank line. After that, list the arguments, the return value and any exceptions the function can raise.

python
def area(w: float, h: float = 1.0) -> float:
    """Return the area of a rectangle.

    Args:
        w: Width in metres.
        h: Height in metres. Defaults to 1.0.

    Returns:
        The area in square metres.

    Raises:
        ValueError: If w or h is negative.
    """
    if w < 0 or h < 0:
        raise ValueError("sizes must be non-negative")
    return w * h

print(area.__doc__.splitlines()[0])
print(area(3, 2))

The first line of __doc__ is the summary

output
Return the area of a rectangle.
6

Three layouts for the Args, Returns and Raises sections are common. All of them work with documentation tools, so the real rule is to choose one and use it everywhere in the project.

StyleHow a parameter looksFeel
Sphinx (reST):param w: Width in metres.Compact, field-list based
Googlew: Width in metres. under an Args: headingEasy to read as plain text
NumPyw : float then an indented description under ParametersRoomy, popular in data science
Read it back

In a terminal or REPL, run help(area) to see the formatted docstring together with the signature, or print area.__doc__ to get the raw text.

Hints vs docstrings, and the interview angle

Hints and docstrings do different jobs, so a well-written function usually has both. A hint answers what types go in and out. A docstring explains why the function exists and how to use it: units, side effects, edge cases and the errors it raises. Neither one replaces tests, because tests are the only thing that actually runs your code against real inputs.

Type hintsDocstrings
AnswerWhat typesWhy and how
Stored in__annotations____doc__
Checked bymypy, editorsHumans, help(), doc tools
Enforced at run timeNoNo

There is one trap that hints make easy to write. Giving a hinted parameter a mutable default such as items: list[int] = [] looks tidy, but the list is created once and shared by every call. The fix is the same as before: default to None and build the list inside the function.

python
def add(item: int, items: list[int] = []) -> list[int]:
    items.append(item)
    return items

def add_safe(item: int, items: list[int] | None = None) -> list[int]:
    if items is None:
        items = []
    items.append(item)
    return items

print(add(1))
print(add(2))
print(add_safe(1))
print(add_safe(2))

The hint does not stop the shared default

output
[1]
[1, 2]
[1]
[2]
Common mistake

Writing items: list[int] = [] and assuming the annotation makes it safe. The second call to add sees the item left behind by the first. Prefer items: list[int] | None = None.

Remember

Hints state types for tools, docstrings explain behaviour for people, and only tests prove the code works.

Are type hints enforced by Python?
  • No. Python stores them in __annotations__ and ignores them when the code runs.
  • Tools such as mypy and editors read them and report mismatches before you run the program.
  • Passing the wrong type still works, or fails later for an unrelated reason.
def area(w: float, h: float = 1.0) -> float:
    return w * h

area("ab", 3)   # runs, returns 'ababab'
Where does a docstring live and how do you read it?
  • It is the first string literal in the function body.
  • Python stores it in __doc__, so f.__doc__ returns the raw text.
  • help(f) prints it formatted, along with the signature.
What does Optional[int] / int | None mean?
  • The value is either an int or None.
  • int | None is the modern spelling; Optional[int] from typing means the same thing.
  • It does not mean the argument can be left out. That is what a default value does, so you usually combine them: x: int | None = None.

Part 12 · Common Function Mistakes

Four Traps That Look Like Working Code

Most function bugs are not exotic. They come from a handful of habits that look reasonable when you write them and only misbehave when the function runs a second time, or when someone uses its result. This page covers the four most common ones. Each runs without any error, which is what makes them hard to spot.

Mutable defaults leak state between calls

A default value is built once, when def executes, not each time the function is called. If that default is a list, dict or set, every call that relies on it shares the same object, and anything one call appends is still there for the next. The usual fix is to use None as the default and create the fresh list inside the body.

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

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

print(add_tag_bad('a'))
print(add_tag_bad('b'))
print(add_tag('a'))
print(add_tag('b'))

The first pair shares one list; the second pair gets a new list per call.

output
['a']
['a', 'b']
['a']
['b']
Common mistake: def f(x, acc=[])

The second call to add_tag_bad returns ['a', 'b'] even though you only passed 'b'. Use None as the default whenever the real default would be a list, dict or set.

No return, print instead of return, and f versus f()

A function that reaches the end of its body without a return hands back None. That is easy to cause by computing a value and forgetting to send it out. A close cousin is printing instead of returning: the number shows up on screen, so it feels finished, but the caller receives None and cannot use the value in further work. Finally, writing get_total without parentheses does not call anything. It names the function object, so you end up storing or passing the function when you meant its result.

python
def total(prices):
    s = 0
    for p in prices:
        s += p

def show_total(prices):
    print(sum(prices))

def get_total(prices):
    return sum(prices)

print(total([2, 3]))
x = show_total([2, 3])
print(x)
ref = get_total
print(type(ref).__name__, type(get_total([2, 3])).__name__)

Four lines, four different outcomes.

output
None
5
None
function int
MistakeWhat you seeFix
Mutable defaultState from earlier calls shows up in later onesDefault to None, build the object inside
Forgot returnThe result is NoneAdd return value on every path
print instead of returnOutput appears, but the caller gets NoneReturn the value, let the caller print it
f instead of f()A function object where a value was expectedAdd the parentheses and arguments
Common mistake: print is not return

A function that only prints cannot be reused. show_total([2, 3]) * 2 fails with a TypeError because the left side is None, while get_total([2, 3]) * 2 gives 10.

Scope and Argument Passing

The next group of bugs comes from misunderstanding what a function can change outside itself. There are two separate questions: which name a statement refers to, and whether the statement rebinds that name or mutates the object behind it.

UnboundLocalError from assigning a global

Python decides whether a name is local by looking at the whole function body before running it. If the body assigns to a name anywhere, including with +=, that name is local for the entire function. Reading it before the assignment then fails, because the local variable has no value yet. The global of the same name is not consulted. If you really mean to change the global, declare it with global count, or better, pass the value in and return the new one.

Rebinding versus mutating

Python passes object references, a model often called pass by assignment. Inside the call, the parameter is a new name bound to the very same object the caller holds. Calling a method like xs.append(2) changes that shared object, so the caller sees it. Writing xs = [] only points the local name at a new list, and the caller's variable is untouched.

python
count = 0

def bump():
    count += 1

try:
    bump()
except UnboundLocalError as e:
    print(type(e).__name__)

def mutate(xs):
    xs.append(2)

def rebind(xs):
    xs = []

a = [1]
mutate(a)
print(a)
rebind(a)
print(a)

mutate changes the caller's list; rebind does not.

output
UnboundLocalError
[1, 2]
[1, 2]
MutatingRebinding
Examplexs.append(2), xs[0] = 9xs = [], xs = xs + [2]
Changes the objectYesNo, it makes a new name-to-object link
Caller sees itYesNo
Common mistake: expecting xs = [] to clear the caller's list

To empty a list in place, mutate it with xs.clear() or xs[:] = []. Plain assignment inside a function never changes the caller's variable.

Subtler Bugs and When to Split

Shadowing built-ins

Names like list, sum, id and input are ordinary names that Python happens to define for you. If you assign to one of them, or use it as a parameter name, your version hides the built-in in that scope. The failure then shows up later, far from the line that caused it, as a confusing error such as an integer that is suddenly not callable.

python
sum = 10

try:
    sum([1, 2])
except TypeError as e:
    print(type(e).__name__, e)

del sum
print(sum([1, 2]))

Deleting the shadowing name makes the built-in visible again.

output
TypeError 'int' object is not callable
3
Common mistake: parameters named list, id or input

Pick names such as items, user_id and answer. Editors that highlight built-ins in a different color make this easy to catch.

Closures in loops capture the variable, not its value

A function created inside a loop looks up the loop variable when it is called, not when it is created. By then the loop has finished and the variable holds its last value. Every function therefore reports the same number. Binding the current value as a default argument, i=i, freezes it at creation time.

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

def make_fixed():
    funcs = []
    for i in range(3):
        funcs.append(lambda i=i: i)
    return funcs

print([f() for f in make_late()])
print([f() for f in make_fixed()])
output
[2, 2, 2]
[0, 1, 2]

**kwargs can hide typos

A function that accepts **options will happily take any keyword, including a misspelled one, and the misspelled option is silently ignored. A function with explicit parameters rejects the typo with a TypeError. Recent Python versions (3.13 and newer) even add a hint to that message, ending with "Did you mean 'timeout'?", while 3.11 and 3.12 print the same message without the hint. That hint only appears for explicit parameters; it never fires when the name lands in **kwargs. The example prints only the error type so its output is identical on every version.

python
def connect_loose(host, **options):
    return options.get('timeout', 30)

def connect_strict(host, timeout=30):
    return timeout

print(connect_loose('db', timout=5))

try:
    connect_strict('db', timout=5)
except TypeError as e:
    print(type(e).__name__)

The loose version quietly uses 30 instead of 5.

output
30
TypeError
Common mistake: using **kwargs when the options are known

If you know the set of options, list them as parameters. Reserve **kwargs for forwarding to another function or for truly open-ended input.

Signs a function should be split

Some problems are about shape rather than syntax. A function with six or more parameters is hard to call correctly, and a body nested three or four levels deep is hard to read and test. Either one usually means the function does more than one job.

SmellWhy it hurtsWhat to do
Many parametersCallers mix up the order, and many arguments always travel togetherGroup related values or split the job in two
Deep nestingThe reader has to hold several conditions in mind at onceReturn early, or move the inner block into a named helper
Long body with several stepsHard to test and hard to nameExtract each step into a small function

Interview Point

Is Python pass-by-value or pass-by-reference?
  • Neither label fits exactly. Python uses pass by assignment: the parameter is a new name bound to the same object the caller passed.
  • Mutating that object (xs.append(1)) is visible to the caller, because there is only one object.
  • Rebinding the parameter (xs = []) is not visible, because it only moves the local name.
  • Immutable objects such as ints and strings cannot be mutated, so they behave like values.
def mutate(xs):
    xs.append(1)

def rebind(xs):
    xs = []
Name three function bugs you have seen.
  • A mutable default argument (acc=[]) that kept growing across calls; fixed with None and a fresh list in the body.
  • A missing return (or a print in its place) that made a result None downstream.
  • An UnboundLocalError from count += 1 on a global, or a loop-captured variable where every function returned the same value.
How would you debug an unexpected None result?
  • Find which call produced the None and print or inspect its return value right there.
  • Read that function and check every path: is there a return on each branch, including the fall-through at the end?
  • Look for a print where a return belongs, and for methods like list.sort() or list.append() that return None by design.
  • Check that the call has parentheses and that you are not using a variable that held the old result.
Debugging an unexpected None
Remember

Defaults are built once, a missing return means None, assignment inside a function creates a local name, and mutating a shared object is visible to everyone who holds it.

Part 13 · Python Functions Cheat Sheet

Syntax, Returns and Defaults

A function header packs a lot into one line. Python reads the parameters in a fixed order, and a docstring then the body follow. The example below uses every kind of parameter at once, so you can see what each one collects.

Parameter order in a def
  1. 1positionala
  2. 2defaultsb=1
  3. 3*argsextra positionals as a tuple
  4. 4keyword-onlykey=None
  5. 5**kwargsextra keywords as a dict
python
def f(a, b=1, *args, key=None, **kw) -> int:
    """Add a, b and any extras; report the rest."""
    print("args:", args, "key:", key, "kw:", kw)
    return a + b + sum(args)

print(f(1))
print(f(1, 2, 3, 4, key="x", unit="cm"))

The docstring comes first, then the body. The arrow only documents the return type.

output
args: () key: None kw: {}
2
args: (3, 4) key: x kw: {'unit': 'cm'}
10

What a function hands back depends on its return statement. If it never reaches one, the caller gets None. If it returns several values separated by commas, Python quietly packs them into a single tuple, which you can unpack on the other side.

Body ends withCaller receives
no return at allNone
returnNone
return athe value a
return a, bthe tuple (a, b)

Default values are evaluated once, when the def line runs, not on every call. A list or dict used as a default is therefore one shared object that every call mutates. The cure is a None sentinel: test for it inside the body and build a fresh container there.

python
def silent():
    pass

def pair(x):
    return x, x * 2

def bad(item, bucket=[]):
    bucket.append(item)
    return bucket

def good(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

print(silent())
print(pair(4))
print(bad(1), bad(2))
print(good(1), good(2))
output
None
(4, 8)
[1, 2] [1, 2]
[1] [2]
Common mistake

Writing def add(x, items=[]) or def add(x, seen={}). The container is created once and shared by every call. Default to None and create it inside.

Calling and Scope

There are four ways to pass arguments. f(1, 2) matches by position. f(b=2, a=1) matches by name, so order no longer matters. f(*seq, **mapping) unpacks a sequence into positionals and a dict into keywords at the call site. Two markers in the signature can restrict how callers pass things.

Marker in defEffectCaller may not
/parameters before it are positional-onlyname them, as in f(x=1)
* (bare)parameters after it are keyword-onlypass them by position
*argscollects extra positionals into a tuple(also makes later parameters keyword-only)
**kwcollects extra keywords into a dict(must come last)
python
def area(w, h):
    return w * h

def move(x, y, /, *, speed=1):
    return (x + y) * speed

nums = (3, 4)
opts = {"h": 5, "w": 2}
print(area(3, 4), area(h=4, w=3), area(*nums), area(**opts))
print(move(1, 2, speed=3))
try:
    move(x=1, y=2)
except TypeError:
    print("positional-only")
try:
    move(1, 2, 3)
except TypeError:
    print("keyword-only")
output
12 12 12 10
9
positional-only
keyword-only

When Python meets a name inside a function, it searches four places in a fixed order and stops at the first match. This is the LEGB rule.

LEGB lookup order
  1. 1Localinside this function
  2. 2Enclosingouter functions
  3. 3Globalmodule level
  4. 4Built-inlen, print, ...

Reading an outer name needs nothing special. You need global or nonlocal only when you want to rebind the name, meaning point it at a new object. Mutating an object, such as appending to a list, never needs either keyword.

python
count = 0
items = []

def bump():
    global count
    count += 1

def record(v):
    items.append(v)

def make_counter():
    n = 0
    def inc():
        nonlocal n
        n += 1
        return n
    return inc

bump(); bump(); record("a")
c = make_counter()
c()
print(count, items, c())
output
2 ['a'] 2
Common mistake

Adding global items just to call items.append(...). It is harmless but pointless. Use global or nonlocal only when the function assigns to the name.

The Functional Toolkit

Functions are ordinary objects. You can pass one as an argument, store it in a list or dict, and return it from another function. Everything in this page follows from that single fact.

ToolWhat it doesUse it for
lambda x: exprone-expression anonymous functionshort key= functions
sorted/min/max(key=...)ranks items by the key's resultcustom ordering without a loop
functools.partialfixes some arguments in advancea specialised copy of a function
closureinner function remembers outer variablessmall stateful helpers
dispatch dictmaps a name to a functionreplacing long if/elif chains

A dispatch dict looks the function up by name and calls it, so adding a case means adding one entry. The closure example at the end shows late binding: a closure captures the *variable*, not its value at that moment. Every lambda in late therefore sees the final i. Writing i=i as a default freezes the current value for each lambda.

python
import operator
from functools import partial

words = ["pear", "fig", "banana"]
print(sorted(words, key=len), max(words, key=lambda w: w[-1]))

ops = {"add": operator.add, "mul": operator.mul}
print(ops["add"](2, 3), ops["mul"](2, 3))

to_int = partial(int, base=2)
print(to_int("101"))

late = [lambda: i for i in range(3)]
fixed = [lambda i=i: i for i in range(3)]
print([f() for f in late], [f() for f in fixed])
output
['fig', 'pear', 'banana'] pear
5 6
5
[2, 2, 2] [0, 1, 2]
Common mistake

Building lambdas or inner functions in a loop and expecting each to remember its own loop value. Without i=i they all see the last one.

Remember

Order the parameters positional, defaults, *args, keyword-only, **kwargs. A missing return gives None. Defaults are built once. Name lookup follows LEGB. Closures capture variables, so freeze values with i=i.

Interview Point: Quick-Fire Round

Interviewers like short, sharp questions on functions. Each one below has a one-line answer you should be able to give, then write or fix in under a minute.

What is the mutable-default trap and how do you fix it?
  • The default list or dict is created once at def time and shared across calls.
  • Use =None and create the container inside: if bucket is None: bucket = [].
def add(x, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(x)
    return bucket
In what order does Python look up a name?
  • Local, Enclosing, Global, Built-in (LEGB).
  • Assigning inside a function makes the name local for the whole function, unless global or nonlocal says otherwise.
What type is *args? And **kwargs?
  • args is a tuple, so it is read-only.
  • kwargs is a dict mapping keyword names to values.
What can a lambda not do?
  • Its body is a single expression, so no statements such as assignment, return, for or try.
  • Nothing is returned explicitly, since the expression's value is the result.
  • For anything longer, use a named def.
Do type hints enforce types?
  • No. Python ignores them at runtime; double("ab") on def double(n: int) -> int: return n * 2 returns 'abab'.
  • Tools such as mypy or an editor read them to flag mistakes before you run the code.
QuestionOne-line answer
Mutable defaultShared across calls; use a None sentinel
LEGBLocal, Enclosing, Global, Built-in
*args typetuple
Lambda limitone expression, no statements
Hints enforced?No, only checked by external tools
Drill it

Cover the right column of the table and answer each row out loud. Then write a function with a mutable default, fix it, and write the late-binding lambda list and its i=i fix from memory.

Part 14 · Check yourself

Quiz

Work out each answer in your head first, then open the answer. These questions ask you to predict what runs and to find the bug, so tracing the code matters more than remembering the rules.

What do the three print calls show, and which line surprises you?
  • It prints [1], then [1, 2], then [3], then [1, 2, 4].
  • The default list is created once, when def runs, and every call that omits bucket shares it.
  • add(3, []) gets a fresh list from the caller, so it never touches the shared one.
  • Fix: use bucket=None and write if bucket is None: bucket = [] inside the body.
def add(item, bucket=[]):
    bucket.append(item)
    return bucket

print(add(1))
print(add(2))
print(add(3, []))
print(add(4))
Both lines below look reasonable. What does each print, and why?
  • Both print None.
  • total has no return statement, so the caller gets None; the sum was computed and thrown away. Add return s.
  • list.sort() sorts in place and returns None, so ordered is None. Use sorted([3, 1, 2]) to get a new list back.
def total(xs):
    s = 0
    for n in xs:
        s += n

print(total([1, 2, 3]))
ordered = [3, 1, 2].sort()
print(ordered)
What does this print? Then change one thing so it prints [0, 1, 2].
  • It prints [2, 2, 2].
  • The lambdas capture the variable i, not its value, and look it up at call time, when the loop has already ended with i equal to 2.
  • Fix: bind the value at definition time with a default, lambda i=i: i.
fs = [lambda: i for i in range(3)]
print([f() for f in fs])
This code is meant to count calls. What happens when you run it, and how do you fix it?
  • It raises UnboundLocalError.
  • Because count is assigned inside inc, Python treats it as local for the whole function, so the read in count += 1 happens before any local value exists.
  • Fix: add global count as the first line of the function, or better, avoid the global and pass the value in and return the new one.
count = 0

def inc():
    count += 1
    return count

inc()
What does this print, and what does it show about how Python passes arguments?
  • It prints [0, 1].
  • xs.append(1) mutates the same list object the caller holds, so the caller sees it.
  • xs = [9] only rebinds the local name xs to a new list, so the later append(2) changes that new list and a is untouched.
  • Python passes object references by assignment: a function can mutate what it is given but cannot rebind the caller's variable.
a = [0]

def change(xs):
    xs.append(1)
    xs = [9]
    xs.append(2)

change(a)
print(a)

Summary

  • A function with no return, or a bare return, gives back None; return a, b gives back one tuple.
  • Defaults are evaluated once when def runs, so use a None sentinel and is None for lists and dicts.
  • *args is a tuple and **kwargs is a dict; a bare * makes later parameters keyword-only and / makes earlier ones positional-only.
  • Names resolve Local, Enclosing, Global, Built-in; assigning to a name anywhere in a function makes it local for the whole function.
  • Use global or nonlocal only to rebind a name, never to mutate, and remember closures capture variables, not values.
  • Functions are objects: pass key=len, not key=len(words), and keep lambda for short one-expression throwaways.
  • Type hints are not enforced at run time, and a docstring lives in __doc__.