Up to now your programs have run top to bottom, once. That works until you need the same logic in three places — so you copy it, and later you fix a bug in one copy and forget the other two.
A function is a named, reusable block of code. You define it once with def, then call it as often as you like. Every serious program is mostly functions calling other functions.
Functions buy you three things:
- Reuse — write the logic once, call it everywhere.
- Naming —
calculate_tax(salary)says what it does; twelve lines of arithmetic do not. - Isolation — a function's variables belong to it alone, so it can't quietly corrupt the rest of your program.
That third one is the subtle one, and it's where most beginner confusion lives. We'll come back to it.
flowchart LR A["Caller<br/>result = double(5)"] -->|"argument 5"| B["def double(n)"] B --> C["body runs<br/>n * 2"] C -->|"return 10"| D["result = 10"] style B fill:#3776ab,color:#fff style C fill:#1e293b,color:#fff
The anatomy of a definition
Every definition has the same shape: the keyword def, the name you'll call it by, a parameter list in parentheses, and a colon.
def double(n):
return n * 2
The indented lines below the header are the body. The body only executes when the function is called, never when it is defined. Writing a def is like writing down a recipe; calling it is cooking.
Two words get used loosely but mean different things. n is the parameter — the placeholder name in the definition. The 5 in double(5) is the argument — the actual value you handed over. Keeping those straight makes error messages far easier to read later on.
def double(n):
return n * 2
# Defining it printed nothing. Calling it does the work.
print(double(5))
print(double(21))
print(double(double(3)))
Arguments: positional, keyword, and default
Python gives you three ways to pass values in, and real code uses all three.
Positional arguments are matched by order — power(2, 3) sets base=2 and exponent=3. Order is everything.
Keyword arguments are matched by name — power(exponent=3, base=2) does the same job, and order stops mattering. Reach for these when a call would otherwise be a row of mystery numbers.
Default values make a parameter optional. def power(base, exponent=2) lets callers write power(5) and get squaring for free, or override it with power(5, 3).
One rule trips everyone up once: parameters with defaults must come after parameters without them. def f(a=1, b) is a syntax error, because Python would have no way to know what a lone argument meant.
def power(base, exponent=2):
return base ** exponent
print(power(5)) # default exponent
print(power(5, 3)) # positional
print(power(base=5, exponent=3)) # keyword
print(power(exponent=3, base=5)) # keyword, any order
flowchart TD
subgraph G["Global scope"]
X["total = 100"]
subgraph L["Local scope of change()"]
Y["total = 120<br/>a different variable"]
end
end
L -.->|"can READ outer names"| X
style Y fill:#b45309,color:#fff
style X fill:#3776ab,color:#fff
Scope: a function's variables are its own
When a function runs, Python hands it a private workspace called the local scope. Names you assign in the body live there and are thrown away when the call ends.
This is what stops a function from clobbering your program's state. Suppose a global total exists and your function assigns total = 120. Python does not overwrite the global — it builds a brand-new local that shadows it for the duration of the call.
Reading is more permissive than writing: a function may read an outer name it never assigned. That asymmetry surprises people, so lean on a simple habit — take what you need as parameters, hand results back with return. A function that touches only its parameters and its return value is one you can test, move, and reason about on its own.
return versus print
These look interchangeable in a tutorial and are nothing alike.
print writes text to the screen for a human. return hands a value back to the calling code, which can then store it, add to it, or pass it along.
A function with no return still gives something back: None. That's the source of a classic bug — the function displayed the right answer, so it looked correct, but the value it handed over was None, and None + 1 raises a TypeError.
Rule of thumb: anything that computes should return. Save print for the outermost layer that actually talks to the user.
def show_double(n):
print(n * 2) # displays, hands back None
def give_double(n):
return n * 2 # hands the value back
a = show_double(5) # displays 10 as a side effect
b = give_double(5) # displays nothing
print("show_double gave back:", a)
print("give_double gave back:", b)
print("and we can keep computing:", b + 1)
Write greet(name, greeting="Hello") that returns the string "<greeting>, <name>!" — so greet("Ada") gives "Hello, Ada!". It must return, not print.
def greet(name, greeting="Hello"):
# your code here
pass
An f-string is tidiest here: f"{greeting}, {name}!"
If a test complains about None, check you wrote
returnrather thanprint.
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
Scope check. Read it carefully and predict both lines before you check.
x = 10
def change():
x = 20
print(x)
change()
print(x)
Assigning
xinside the body creates a NEW local variable.The global was never modified, so what does it still hold on the last line?
This is meant to start an empty basket whenever it's called with one argument. It doesn't — the second call comes back with two items. Repair it with the None idiom from the callout.
def add_item(item, basket=[]):
basket.append(item)
return basket
Default to None, then build the list inside the body.
Test with
if basket is None:—if not basket:would also wrongly replace a caller's empty list.
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
Functions are values too — you can hand one to another function. Write apply_twice(fn, value) that calls fn on value, then calls fn again on that result, and returns it. So apply_twice(double, 3) means double(double(3)).
def apply_twice(fn, value):
# your code here
pass
fnis just a name holding a function — call it with fn(...).Feed the first result straight into a second call: fn(fn(value)).
def apply_twice(fn, value):
return fn(fn(value))
Recap
def name(params):defines a function; the body only runs when you call it.- Arguments travel positionally or by keyword; defaults make a parameter optional and must come last.
- Assigning a name in the body creates a local variable — outer state is safe from it.
returnhands a value to the caller; without one you getNone.- Defaults are built once at definition time — never use a mutable one like
[]or{}.
Next you'll combine functions into something real, and the isolation you just met is exactly what makes those pieces safe to snap together.