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.
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.
def greet(name): return 'Hi ' + name print(greet('Sam'))
def creates greet; the call greet('Sam') runs the body
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.
- 1def runsPython reaches the statement
- 2Function object createdbody stored, not executed
- 3Name boundgreet now points to it
- 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.
def shout(text): print('running body') return text.upper() print('after def') print(shout('hey'))
Defining prints nothing; calling runs the body
after def
running body
HEYdef 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.
| Parameter | Argument | |
|---|---|---|
| Where it appears | In the def line | At the call site |
| What it is | A name | A value |
| Example | price, rate | 100, 0.5 |
| When it exists | Whenever the def has run | Only for that one call |
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
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.
def counter(): count = 0 count += 1 return count print(counter()) print(counter())
Each call starts with a brand-new count
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.
f = greet
print(type(f).__name__)
print(type(greet('Ana')).__name__)greet is a function; greet('Ana') is a str
function
strWriting 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.
try: hello() except NameError as err: print('NameError:', err) def hello(): return 'hello' print(hello())
The first call happens before def has executed
NameError: name 'hello' is not defined
helloThe 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.
def report(): return helper() + '!' def helper(): return 'done' print(report())
helper is defined after report, but before report is called
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.
def todo():
pass
print(todo())A valid placeholder; calling it returns None
NoneCalling 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
nameindef greet(name). - An argument is the value supplied at the call site, such as
'Sam'ingreet('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.
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.
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
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".
| Form | What the caller gets |
|---|---|
return x | The value of x |
return (bare) | None, and the function stops there |
| No return at all | None, after the last line runs |
return a, b | The 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.
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
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.
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
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.
| return | ||
|---|---|---|
| Purpose | Show text to a person | Give a value to the calling code |
| Caller receives | None | The returned value |
| Ends the function | No | Yes |
| Reusable result | No | Yes |
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.
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
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 None | Builds 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} |
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
None [1, 2, 3] [3, 2, 1] None ['b', 'a', 'c']
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.
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
31 None Error: unsupported operand type(s) for +: 'NoneType' and 'int'
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")setsresultto None.
def f(): pass print(f()) # None
How does Python return multiple values?
- It doesn't, strictly:
return a, bpacks 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
xbecomes None, and if you reused the namemy_list, the list is lost. - Call
my_list.sort()alone, or usex = sorted(my_list)for a new sorted list.
my_list = [3, 1, 2] x = my_list.sort() print(x) # None
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.
| Positional | Keyword | |
|---|---|---|
| How it is matched | By position, left to right | By the parameter name |
| Call looks like | area(3, 5) | area(width=3, height=5) |
| Does order matter? | Yes, swapping values changes the result | No, any order works |
| Reads well when | The meaning is obvious from the function name | The 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.
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))
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.
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.
positional argument follows keyword argument
15- 1Positional valuesfill parameters from the left
- 2Keyword valueseach lands on the parameter with that name
- 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.
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.
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
| Call | What went wrong | Error word to look for |
|---|---|---|
area(3, width=4) | width was filled twice | got multiple values |
area(3) | height never got a value | missing 1 required positional argument |
area(3, 5, depth=2) | No parameter is named depth | unexpected keyword argument |
area(3, 5, 7) | More values than parameters | takes 2 positional arguments but 3 were given |
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.
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)
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.
def double(x, /): return x * 2 print(double(4)) try: double(x=4) except TypeError as error: print(error)
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.
print("a", "b", sep="-") print("a", "b", "-") try: len(obj=[1, 2]) except TypeError as error: print(error)
a-b a b - len() takes no keyword arguments
| len(x) | print(sep='-') | |
|---|---|---|
| Style | Positional-only | Keyword-only |
| Works | len([1, 2]) | print("a", "b", sep="-") |
| Fails | len(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,verboseortimeout. - 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 exampledef 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 aSyntaxError: 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.
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.
def power(x, n=2): return x ** n print(power(5)) print(power(5, 3)) print(power.__defaults__)
n is optional; x is required
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.
| Signature | Result |
|---|---|
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.
- 1def runsthe default expression is evaluated once
- 2Object storedkept in f.defaults
- 3Call without the argumentPython reads the stored object
- 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.
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))
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.
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
[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.
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
{'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.
| Default | Safe? | Why |
|---|---|---|
0, 3.5, True | Yes | Numbers and bools cannot change |
"hi" | Yes | Strings are immutable |
(1, 2) | Yes | Tuples cannot change (if their contents are immutable) |
None | Yes | A single fixed value, perfect as a marker |
[] | No | Shared list grows across calls |
{} | No | Shared dict collects entries across calls |
set() | No | Shared set collects members across calls |
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."
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
[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.
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.
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
[]
['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.
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.
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
defstatement 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
Noneas 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 bucketis also true for an empty list the caller passed in, so the function would replace the caller's list.is Nonedetects 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.
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
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.
log("only")only
()
{}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.
- 1positionaltitle
- 2*argsextra positionals
- 3keyword-onlysep=", "
- 4**kwargsextra keywords
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=...
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='-').
def show(*items, sep=" "): print(sep.join(str(i) for i in items)) show(*[1, 2, 3], **{"sep": "-"})
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.
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
TypeError 1+2
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.
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))
calling add
5The 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.
def sum_all(*nums): return sum(nums) print(sum_all(1, 2, 3), sum_all())
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.
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
('db', 30) connect_strict() got an unexpected keyword argument 'timout'
| Explicit signature | **kwargs | |
|---|---|---|
| Readability | Names and defaults visible in the def | Caller must read the body or docs |
| Typo in a keyword | TypeError right away | Passes silently, default is used |
| Tools and editors | Can autocomplete and check calls | Cannot know the valid names |
| Best for | Normal functions with known options | Wrappers, forwarding, open-ended config |
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?
argsis a tuple of the extra positional arguments, in the order they were passed.kwargsis 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 def | In a call | |
|---|---|---|
| * | Packs extras into a tuple | Unpacks an iterable into positionals |
| ** | Packs extras into a dict | Unpacks a dict into keyword arguments |
Why can **kwargs hide misspelled argument names?
**kwargsaccepts any keyword, so Python has no list of valid names to check against.- A misspelled name like
timoutjust 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.
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.
| Scope | What lives there | Example |
|---|---|---|
| Local | Names assigned inside the current function, including its parameters | total = 0 inside def f(): |
| Enclosing | Local names of any function that wraps the current one | a variable of an outer def |
| Global | Names assigned at the top level of the module | limit = 10 in the file body |
| Built-in | Names Python provides everywhere | len, 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.
x = 'global' def outer(): x = 'enclosing' def inner(): x = 'local' print(x) inner() print(x) outer() print(x) print(len('abc'))
local
enclosing
global
3Reading 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.
limit = 10 def check(n): return n < limit print(check(5)) print(check(50))
True False
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.
count = 0 def inc(): count += 1 try: inc() except UnboundLocalError as e: print(type(e).__name__) print(e)
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.
total = 5 def show(): print(total) total = 1 try: show() except UnboundLocalError: print('total is local, so it has no value yet')
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.
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.
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.
list = [1, 2] try: list('abc') except TypeError as e: print(e) del list print(list('abc'))
'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.
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)
n is gone
2| Comprehension | for loop | |
|---|---|---|
| Own scope | Yes | No |
| Variable after it ends | Gone, raises NameError | Still defined, holds the last value |
| Empty sequence | Variable never created | Variable never assigned, so NameError |
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 += 1assigns tocount, so Python treats it as local for the whole function. Reading it before it has a value raisesUnboundLocalError.- 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.
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.
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.
x = 'global' def outer(): x = 'outer' def inner(): nonlocal x x = 'changed by inner' inner() print(x) outer() print(x)
changed by inner global
| global x | nonlocal x | |
|---|---|---|
| Rebinds a name in | The module (global) scope | The nearest enclosing function |
| Name must already exist? | No, it can be created by the assignment | Yes, an enclosing function must define it |
| Can reach the module scope? | Yes | Never |
| Needed for x.append(...)? | No | No |
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.
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.
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.
- 1make_counter() runsn = 0 is created in a cell
- 2step is definedit refers to n, so n is a free variable
- 3step is returnedmake_counter ends, but the cell survives
- 4a() is callednonlocal n updates the cell in place
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.
funcs = [] for i in range(3): def show(): return i funcs.append(show) print([f() for f in funcs])
[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.
funcs = [] for i in range(3): def show(i=i): return i funcs.append(show) print([f() for f in funcs])
[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.
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])
[2, 2, 2] [0, 1, 2]
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 state | Closure | Parameter | |
|---|---|---|---|
| Where the data lives | Module level, visible to everything | Private to the returned function | Passed in by the caller on each call |
| Who can change it | Any code that uses global | Only the inner function | The caller, explicitly |
| Testing | Hard: reset it before every test | Easy: make a fresh closure | Easiest: pass any value |
| Hidden coupling | High | Low | None |
Interview: what is the difference between global and nonlocal?
global xlets a function rebind a module-level name.nonlocal xlets 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_freevarsholds 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)orlambda 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.
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
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.
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'))
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.
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'))
6 11 HI
| You can do this with a function | Example |
|---|---|
| Give it another name | f = len |
| Store it in a list or dict | {'add': add, 'sub': sub} |
| Pass it as an argument | apply_twice(add5, 1) |
| Return it from a function | return adder |
| Read its attributes | greet.__name__, greet.__defaults__ |
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.
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
['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.
- 1Receive lenthe function object itself
- 2Call len(word) for each word4, 3, 6, 4
- 3Order by those numbers3, 4, 4, 6
- 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.
try: sorted(words, key=len(words)) except TypeError as e: print(e)
Wrong on purpose: len(words) is already the int 4
'int' object is not callable| key=len | key=len(words) | |
|---|---|---|
| What is passed | The function len | The int 4 |
| When len runs | Once per item, inside sorted | Once, before sorted starts |
| Result | Sorted list | TypeError |
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.
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)
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.
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'))
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.
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'))
HELLO ANA BYE BEN
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,mapandfilter, 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. sortedcalls it once per item and orders by the results.- Add
reverse=Truefor 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))
['banana', 'fig', 'kiwi', 'pear'] ['banana', 'pear', 'kiwi', 'fig']
Why does key=len(words) fail?
- Python evaluates
len(words)first, so it callslenright away and gets the int4. sortedreceives4as 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 iskey=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.
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
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.
| lambda | def | |
|---|---|---|
| Name | None; it shows up as <lambda> | Has its own name |
| Body | One expression, value returned for you | Any number of statements, explicit return |
| Docstring | Not possible | Supported |
| Annotations | Not possible on parameters | Supported |
| Traceback | Shows <lambda>, which is hard to trace | Shows the function name |
| Where it lives | Inline, inside another expression | Its own statement |
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.
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
['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.
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
<lambda>
add_twoWhen 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.
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
[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.
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
32 1024 5
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.
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,whileandimportare 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?
partialpre-fills arguments of an existing callable and does nothing else. A lambda can run any single expression.partialstores 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 thanlambda 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])
[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.
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
{'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.
print(area("ab", 3))
The hint says float, but Python does not object
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.
| Hint | Meaning | Example value |
|---|---|---|
int, str, float | A plain value of that type | 42 |
list[int] | A list whose items are all ints | [1, 2, 3] |
dict[str, int] | Keys are strings, values are ints | {"a": 1} |
str | None | Either a string or None | "hi" or None |
Callable[[int], str] | A function taking one int and returning a str | str |
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
### 7 None
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.
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
Return the area of a rectangle.
6Three 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.
| Style | How a parameter looks | Feel |
|---|---|---|
| Sphinx (reST) | :param w: Width in metres. | Compact, field-list based |
w: Width in metres. under an Args: heading | Easy to read as plain text | |
| NumPy | w : float then an indented description under Parameters | Roomy, popular in data science |
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 hints | Docstrings | |
|---|---|---|
| Answer | What types | Why and how |
| Stored in | __annotations__ | __doc__ |
| Checked by | mypy, editors | Humans, help(), doc tools |
| Enforced at run time | No | No |
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.
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
[1] [1, 2] [1] [2]
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.
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__, sof.__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
intorNone. int | Noneis the modern spelling;Optional[int]fromtypingmeans 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.
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.
['a'] ['a', 'b'] ['a'] ['b']
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.
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.
None 5 None function int
| Mistake | What you see | Fix |
|---|---|---|
| Mutable default | State from earlier calls shows up in later ones | Default to None, build the object inside |
Forgot return | The result is None | Add return value on every path |
print instead of return | Output appears, but the caller gets None | Return the value, let the caller print it |
f instead of f() | A function object where a value was expected | Add the parentheses and arguments |
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.
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.
UnboundLocalError [1, 2] [1, 2]
| Mutating | Rebinding | |
|---|---|---|
| Example | xs.append(2), xs[0] = 9 | xs = [], xs = xs + [2] |
| Changes the object | Yes | No, it makes a new name-to-object link |
| Caller sees it | Yes | No |
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.
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.
TypeError 'int' object is not callable 3
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.
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()])
[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.
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.
30
TypeErrorIf 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.
| Smell | Why it hurts | What to do |
|---|---|---|
| Many parameters | Callers mix up the order, and many arguments always travel together | Group related values or split the job in two |
| Deep nesting | The reader has to hold several conditions in mind at once | Return early, or move the inner block into a named helper |
| Long body with several steps | Hard to test and hard to name | Extract 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 withNoneand a fresh list in the body. - A missing
return(or aprintin its place) that made a resultNonedownstream. - An
UnboundLocalErrorfromcount += 1on 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
Noneand print or inspect its return value right there. - Read that function and check every path: is there a
returnon each branch, including the fall-through at the end? - Look for a
printwhere areturnbelongs, and for methods likelist.sort()orlist.append()that returnNoneby design. - Check that the call has parentheses and that you are not using a variable that held the old result.
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.
- 1positionala
- 2defaultsb=1
- 3*argsextra positionals as a tuple
- 4keyword-onlykey=None
- 5**kwargsextra keywords as a dict
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.
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 with | Caller receives |
|---|---|
no return at all | None |
return | None |
return a | the value a |
return a, b | the 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.
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))
None (4, 8) [1, 2] [1, 2] [1] [2]
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 def | Effect | Caller may not |
|---|---|---|
/ | parameters before it are positional-only | name them, as in f(x=1) |
* (bare) | parameters after it are keyword-only | pass them by position |
*args | collects extra positionals into a tuple | (also makes later parameters keyword-only) |
**kw | collects extra keywords into a dict | (must come last) |
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")
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.
- 1Localinside this function
- 2Enclosingouter functions
- 3Globalmodule level
- 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.
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())
2 ['a'] 2
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.
| Tool | What it does | Use it for |
|---|---|---|
lambda x: expr | one-expression anonymous function | short key= functions |
sorted/min/max(key=...) | ranks items by the key's result | custom ordering without a loop |
functools.partial | fixes some arguments in advance | a specialised copy of a function |
| closure | inner function remembers outer variables | small stateful helpers |
| dispatch dict | maps a name to a function | replacing 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.
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])
['fig', 'pear', 'banana'] pear 5 6 5 [2, 2, 2] [0, 1, 2]
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.
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
deftime and shared across calls. - Use
=Noneand 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
globalornonlocalsays otherwise.
What type is *args? And **kwargs?
argsis a tuple, so it is read-only.kwargsis 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,forortry. - 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")ondef double(n: int) -> int: return n * 2returns'abab'. - Tools such as mypy or an editor read them to flag mistakes before you run the code.
| Question | One-line answer |
|---|---|
| Mutable default | Shared across calls; use a None sentinel |
| LEGB | Local, Enclosing, Global, Built-in |
*args type | tuple |
| Lambda limit | one expression, no statements |
| Hints enforced? | No, only checked by external tools |
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 += 1happens before any local value exists. - Fix: add
global countas 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 barereturn, gives backNone;return a, bgives back one tuple. - Defaults are evaluated once when
defruns, so use aNonesentinel andis Nonefor lists and dicts. *argsis a tuple and**kwargsis 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
globalornonlocalonly to rebind a name, never to mutate, and remember closures capture variables, not values. - Functions are objects: pass
key=len, notkey=len(words), and keeplambdafor short one-expression throwaways. - Type hints are not enforced at run time, and a docstring lives in
__doc__.