Scope, Side Effects, and Pure Functions
Context
Names created inside a function belong to that function and disappear when it returns. A function that only reads its inputs and returns a result is pure, and one that reaches outside itself to change something has a side effect.
The distinction decides how much of a pipeline you can trust. A pure cleaning step can be run twice on the same rows and give the same answer both times, which is what makes a result reproducible and a bug findable. A step with a side effect, one that appends to a file or mutates the rows it was handed, changes the world as it runs, so running it again can continue the experiment instead of repeating it.
Ignoring the boundary produces the worst kind of bug. A function that quietly modifies the rows passed to it leaves every later step working on data that no longer matches what was loaded, and nothing in the code reads as wrong. The mirror failure is a value assigned inside a function and expected outside, which simply is not there. The previous lesson controlled what goes into a function, this lesson controls what escapes it.
Goal
- Predict which names a function can read and which it can change.
- Explain why assigning inside a function creates a local name instead of changing a global one.
- Distinguish a pure function from one that has a side effect.
- Document a function's contract with a docstring readable by
help. - Identify side-effecting code and isolate it from the pure logic around it.
Prerequisites
Optional, both of them. The lesson does not assume you opened either, they are here if you want the picture first.
- YouTube - "Python Tutorial: Variable Scope - Understanding the LEGB rule and global/nonlocal statements" by Corey Schafer. Watch for the picture of names living inside versus outside a function.
- Official docs - The Python Tutorial, "Python Scopes and Namespaces". The canonical description of local and global names.
Table of Contents
- Local Scope
- Global Scope
- Side Effect
- Pure Function
- Docstring
Theory
Nothing needs installing for this lesson. Scope, side effects, purity, and docstrings are all properties of plain Python, with nothing to import. The file-writing examples under Side Effect and Docstring use the built-in open, which also needs no installation.
Local Scope
What. Local scope is the set of names that exist only inside a single function call, its parameters and any variables it assigns, and that vanish when the call ends.
Why. Without local scope, every variable in every function would collide with every other, and a name used inside one function could silently wreck another. Locals keep each function's working names private, which is what makes functions safe to combine.
When. Every parameter and every variable you assign inside a function body is local, unless the function declares that name global, the keyword shown under Global Scope, so you are using local scope every time you write a function, whether you think about it or not.
A local name is born when the function is called and dies when the call returns. Code outside the function cannot see it at all. Two functions can use the same local name with zero interference, because each name lives only inside its own call.
# 'scaled' and 'value' exist only while 'normalize' runs.
def normalize(value): # 'value' is local
scaled = value / 10 # 'scaled' is local too
return scaled
print(normalize(50)) # 5.0
# print(scaled) # NameError: name 'scaled' is not definedBecause locals are private, the same name used in two functions refers to two completely separate variables.
# Two functions, same local name 'total', no collision.
def sum_pair(a, b): # 'total' here is local to sum_pair
total = a + b
return total
def sum_triple(a, b, c): # a DIFFERENT 'total', local to sum_triple
total = a + b + c
return total
print(sum_pair(1, 2)) # 3
print(sum_triple(1, 2, 3)) # 6 - its own 'total', untouched by sum_pairThis isolation is precisely what lets you write hundreds of functions without bookkeeping every variable name across the whole program.
DON'T:
- DON'T try to read a function's local variable from outside, it does not exist there, so you get a
NameError, or whatever that name already means outside the function, a global that shares it or a built-in such asround. - DON'T assume two functions sharing a variable name share the value, each name is local and separate.
Global Scope
What. Global scope is the set of names defined at the top level of a module (the .py file itself), outside any function, and visible throughout the file.
Why. Functions often need to read shared constants like a configuration value, and globals make those available everywhere. Understanding what is global also explains the most common scope confusion of all, covered just below.
When. A name is global when you assign it at the top level of the file, not indented inside any function.
A function can freely read a global name. When you use a name inside a function, Python looks for it locally first, and if it is not local, it looks in the global scope.
# 'LEARNING_RATE' is global: defined at the top level.
LEARNING_RATE = 0.01 # global constant
def step(gradient): # reads the global, does not assign it
return gradient * LEARNING_RATE # found locally? no -> found globally
print(step(5)) # 0.05 - global value was readReading a global from inside a function is allowed and common, and a shared constant is the typical reason to do it.
Now the trap, and it is the single most common scope confusion. Assigning a name inside a function creates a new local name, it does not change the global of the same name. This is why a change made inside a function so often "doesn't stick."
counter = 0 # global
def bump(): # tries to change the global... but doesn't
counter = counter + 1 # this CREATES a local 'counter', UnboundLocalError here
return counter
# print(bump()) # UnboundLocalError: cannot access local variable 'counter' where it is not associated with a valueThe decision is made for the whole function and before a single line of it runs. Python scans the body, sees counter on the left of an assignment, and marks the name local everywhere in that function, including on the right-hand side of the same line. So counter + 1 reads a local that has no value yet, and the read fails before the assignment it was meant to feed. Even had it run, it would have updated a throwaway local and left the global at 0. The clean fix is not to reassign the global at all, pass the value in and return the new one.
# The clean pattern: read via parameter, hand the new value back.
counter = 0 # global
def bump(current): # take the value as input
return current + 1 # return the new value, change nothing global
counter = bump(counter) # caller reassigns the global explicitly
print(counter) # 1The change is now visible at the call site rather than hidden in a body. Python does offer a global keyword that lets a function reassign a global directly, but it makes code hard to follow and test, so treat it as a last resort and prefer passing values in and returning them out.
DON'T:
- DON'T assign to a global name inside a function expecting the global to change, you create a local and likely an
UnboundLocalError. - DON'T reach for the
globalkeyword to "fix" this, pass the value in and return the result instead.
Side Effect
What. A side effect is any change a function makes to the world outside returning a value, printing, writing a file, or modifying something that outlives the call. Why. Side effects are how a program acts on the world, so they are necessary, but they are also where bugs hide, because they let a function alter things beyond its inputs. Knowing which functions have them is essential to reasoning about a program. When. A function has a side effect whenever it does something observable other than computing and returning a value.
The clearest everyday side effect is print, it changes what appears on screen, which is outside the function's return value. A function can both return and have a side effect, and recognizing the side effect is the point.
# This function RETURNS a value AND has a side effect (it prints).
def normalize_loud(value): # mixes computation with a side effect
print("normalizing", value) # SIDE EFFECT: writes to the screen
return value / 10 # the actual computed result
r = normalize_loud(50) # prints a line, then returns 5.0
print(r) # 5.0The computation and the side effect are tangled in one function here, which the next subtitle argues against.
A more permanent side effect is writing to a file, which connects directly to the Memory-versus-Storage distinction from Module 0: a returned value lives in memory and disappears, while a file persists on storage after the program ends.
# Writing a file is a side effect that PERSISTS on storage (Module 0 idea).
def save_metric(name, score): # returns nothing useful, acts on the world
# 'with open(...)' opens a file and closes it automatically afterward.
with open("metrics.txt", "a") as f: # "a" = append text to the file
# SIDE EFFECT: changes a file on disk
f.write(name + ":" + str(score) + "\n")
save_metric("loss", 0.42) # after this, 'metrics.txt' exists and grows on diskThe with closes the file when the block ends, the guarantee Lesson 0.1.2 showed. File handling is covered fully in a later lesson, this is a deliberate small preview to make "writes a file" concrete.
DON'T:
- DON'T hide a side effect inside a function named like a pure calculation, a reader who calls
normalizedoes not expect a file to change. - DON'T assume a function with no
returndoes nothing, its whole job may be a side effect.
Pure Function
What. A pure function is one whose output depends only on its inputs and that has no side effects, same inputs always give the same output, and nothing outside changes. Why. Pure functions are the easiest code to trust: they are predictable, testable in isolation, and safe to call anywhere because they cannot disturb anything else. Purity is a discipline that keeps large programs sane. When. Make a function pure whenever its job is genuinely a computation, transform an input into an output, and push the unavoidable side effects to the edges of your program.
A pure function reads its inputs, computes, and returns, no printing, no file writing, no changing of outside state. Call it a thousand times with the same arguments and you get the same answer every time, with no trace left behind.
# PURE: output depends only on inputs, no side effects.
def normalize(value, center): # reads only its parameters
return (value - center) / 10 # computes and returns, nothing else
print(normalize(88, 50)) # 3.8
print(normalize(88, 50)) # 3.8 - identical, always, foreverThat predictability is what "pure" buys you.
The practical discipline is to separate the pure computation from the side effect, rather than tangling them as normalize_loud did.
# Keep the math pure...
def normalize(value, center): # pure
return (value - center) / 10
# ...and isolate the side effect in its own clearly-named function.
def report(value, center): # impure: its job IS the side effect
result = normalize(value, center) # call the pure part
print("result:", result) # the side effect lives here, alone
report(88, 50) # prints result: 3.8You can now test normalize in isolation with no screen output to capture, and the side effect is quarantined in one obvious place. That discipline is exactly what keeps a data pipeline reproducible later.
DON'T:
- DON'T sprinkle
printor file writes through functions you call "calculations", keep computations pure. - DON'T judge a function pure just because it returns a value, it must also have no side effects.
Docstring
What. A docstring is a string written as the first statement inside a function that describes what the function does, readable by tools and by the built-in help.
Why. A function's name and parameters rarely tell the whole story, what it returns, what it assumes, whether it has a side effect. A docstring records that contract right next to the code.
When. Write a docstring for any function whose purpose, return value, or side effects are not blindingly obvious from its name alone, which is most functions worth keeping.
A docstring is just a string literal, conventionally triple-quoted, placed as the very first statement of the body. Python stores it so that help(function) prints it and editors show it on hover.
def normalize(value, center):
# the docstring
"""Return 'value' centered on 'center' and scaled by 10. Pure, no side effects."""
return (value - center) / 10
help(normalize) # prints the docstring as the function's documentationRun from a terminal, help shows its text in a pager, press q to get your prompt back.
A good docstring states the side effects explicitly, so callers are never surprised.
def save_metric(name, score):
# documents the side effect
"""Append 'name:score' to 'metrics.txt'. SIDE EFFECT: writes to disk, returns None."""
with open("metrics.txt", "a") as f: # append mode
f.write(name + ":" + str(score) + "\n")Documenting the side effect is how you keep an impure function honest about what it does, and it is a fact no caller can recover from the signature.
DON'T:
- DON'T restate the obvious, a docstring saying "this function normalizes" adds nothing, state the contract and any side effects instead.
- DON'T put the docstring below the first line of code, it must be the first statement to be recognized as the docstring.
Lab Exercises
Lab Exercise 1: Private Locals and the Change That Did Not Stick
Define area(width, height) that assigns a local result = width * height and returns it. Call it and print the result. Then add a commented-out print(result) outside the function, and a comment stating which error it would raise and why.
Then create a global total = 0. Write add_wrong(x) whose body is total = total + x followed by return total, leave the call commented out, and note in a comment which error it raises and what Python decided about the name total. Finally write add_right(current, x) returning current + x, use it as total = add_right(total, 5), and print total to show the update actually landed.
Lab Exercise 2: Pure Core, Documented Shell
Write a pure discount(price, percent) that returns the price after subtracting percent percent of it, and give it a docstring stating what it returns and that it is pure.
Then write discount_report(price, percent) that calls discount and prints a formatted line. Give it its own docstring that names its side effect explicitly and states what it returns. Call both, print the value discount returns, and add a comment identifying which of the two is pure and which of the two conditions of purity the other one fails.
Solution 1: Private Locals and the Change That Did Not Stick
# 'result' is local to 'area', it lives only during the call.
def area(width, height): # both parameters are local
result = width * height # 'result' is a local variable
return result
print(area(4, 5)) # 20
# print(result) # NameError: name 'result' is not definedtotal = 0 # global
# WRONG: assigning 'total' inside marks it local for the whole function.
def add_wrong(x): # tries to update the global
total = total + x # reads a local that has no value yet
return total
# print(add_wrong(5)) # UnboundLocalError: cannot access local variable 'total' where it is not associated with a value
# Python decided 'total' was local the moment it saw the assignment, before running a line.
# RIGHT: take the value in, return the new value, let the caller reassign.
def add_right(current, x): # pure, touches no global
return current + x
total = add_right(total, 5) # the caller updates the global, in the open
print(total) # 5DON'T:
- DON'T confuse the two errors,
NameErrormeans the name never existed here, whileUnboundLocalErrormeans it exists as a local but has not been given a value yet. - DON'T try to rescue
add_wrongwith theglobalkeyword, pass-and-return is clearer and it is testable.
Solution 2: Pure Core, Documented Shell
# PURE: depends only on its inputs, changes nothing outside.
def discount(price, percent): # pure calculation
# states the contract
"""Return 'price' reduced by 'percent' percent. Pure, no side effects."""
return price - price * (percent / 100)
# IMPURE: its job includes a side effect, and the docstring says so.
def discount_report(price, percent): # impure wrapper
# warns the caller
"""Print the discounted price. SIDE EFFECT: writes to the screen, returns None."""
result = discount(price, percent) # reuse the pure part
print("final price:", result) # SIDE EFFECT lives here, alone
print(discount(200, 10)) # 180.0
discount_report(200, 10) # final price: 180.0
# 'discount' is pure. 'discount_report' satisfies the first condition of purity,
# its output depends only on its inputs, and fails the second, it has a side effect.DON'T:
- DON'T add a
printtodiscountfor convenience, one side effect is enough to make it impure and harder to test in isolation. - DON'T write a docstring that only restates the name, say what comes back and whether anything outside changes.
Theory Assessment
Q1: Where It Went and Why Two of Them Do Not Collide
For def area(w, h): result = w * h; return result, why does print(result) outside the function raise a NameError? Then explain whether two different functions that each assign a local named total interfere with each other, and why.
Hint: Ask when a local is born and when it dies. Two things with the same spelling are not necessarily the same thing.
Q2: Read Freely, Assign and Everything Changes
Why can a function read a global like LEARNING_RATE with no special keyword, while assigning a global of the same name inside the function does not change it? Then explain precisely why calling bump(), defined as def bump(): counter = counter + 1, raises UnboundLocalError when counter is a global, given that the read runs before the assignment finishes.
Hint: Reading falls outward when a name is not found locally. Assignment does something else entirely, and it does it to the whole function rather than to one line.
Q3: Pure or Not, and Why It Matters
Is def f(x): print(x); return x * 2 pure? Justify the answer against the definition. Then give one concrete reason a pure function is easier to test than one that writes to a file.
Hint: Purity has two conditions and this function meets one of them. For the second half, think about what you have to set up before a test and clean up after it.
Q4: One Sticks, One Does Not
Read the following without running it, then state what each print shows.
scores = [1, 2, 3] # a list, an ordered collection of values
def rebind(data): # assigns a new value to the parameter name
data = [9, 9, 9]
return data
def mutate(data): # changes the object the parameter points at
data.append(9)
return data
rebind(scores)
print(scores)
mutate(scores)
print(scores)Both functions were handed the same object and both look like they change it. Only one change survives the call. Explain which one and why, in terms of what a parameter name is and what it points at.
Hint: Assigning to a parameter is still an assignment, so the earlier rule about what assignment does to a name applies here too. Calling a method on the object (a function that belongs to the object and is called through it with a dot, as data.append(9) is) is not an assignment at all, and nothing about scope stands in its way.
Answer 1:
result is a local variable, created when area is called and destroyed the moment it returns. The name never exists in the outer scope at all, so print(result) outside makes Python look for a name that is not there, which is a NameError. If the caller needs the value, the function already returns it, capture the return value instead.
Two functions each assigning a local total do not interfere. Each total belongs to its own call, so they are two entirely separate variables that happen to share a spelling. Neither can read or change the other. That isolation is exactly what makes it safe to reuse ordinary names like total, result and count across an entire program without keeping a register of them.
DON'T assume a shared name means shared storage, and don't expect a local to outlive the call that created it.
Answer 2:
Reading is allowed because when a name is not local, Python looks outward to the global scope and finds it there. Assigning is different: an assignment to a name anywhere in a function marks that name local for the entire function, so the assignment builds a new local rather than reaching the global.
That whole-function rule is what makes bump fail. Python decides the scope of every name before running any of the body, sees counter on the left of the assignment, and marks it local throughout. By the time counter + 1 is evaluated, it is reading the local counter, which exists but has never been given a value, and that is precisely what UnboundLocalError reports. The read runs first on the line, before anything is stored, but the scoping decision was made before the line ran at all, which is why reading before assigning does not save it.
DON'T expect an inner assignment to update an outer name, and don't read and assign the same global in one function expecting the read to reach the global.
Answer 3:
No, it is not pure. Purity requires two things: the output depends only on the inputs, and there are no side effects. f returns x * 2, which depends only on x, so it satisfies the first. It also calls print(x), which changes what appears on screen, so it fails the second. One side effect is enough to make a function impure no matter how predictable its return value is.
A pure function is easier to test because a test is just a call and a comparison, pass inputs, check what came back, nothing to arrange beforehand and nothing left behind afterwards. A file-writing function forces the test to care about the file: whether it already exists, what it already contained, and removing it afterwards so the next run is not affected by this one. That machinery is fragile and it is entirely a consequence of the side effect.
DON'T call a function pure just because its return value is deterministic (the same output every time for the same inputs), check for side effects as well.
Answer 4:
The first print shows [1, 2, 3]. The second shows [1, 2, 3, 9].
A parameter is a local name pointing at an object the caller already owns. rebind assigns to data, and assignment rebinds the name, pointing the local data at a brand new list while leaving the caller's list exactly as it was. The local name dies at the return, and the new list it hands back is dropped, because rebind(scores) stores nothing, so scores still names the original list. This is the same rule that made the global assignment fail earlier, seen from the parameter side.
mutate never assigns to data at all. It calls a method on the object data points at, and that object is the caller's list, not a copy. Changing the object changes the one thing both names refer to, so the caller sees 9 appended after the call returns.
The distinction is between changing which object a name points at, which is private to the function, and changing the object itself, which is visible to everyone holding it. That second one is a side effect, and it is the quiet kind, because the function returns normally and nothing at the call site looks like it modified anything.
DON'T assume a function cannot affect its caller's data just because assignment inside a function stays local, mutating a passed-in object bypasses that protection entirely.
Assignment
Assignment: A Pure Core, an Impure Shell, and a Contract You Can Read at Runtime
Build the following in one file, in this order.
Write a pure grade(score) returning "pass" when score >= 60 and "fail" otherwise, with a docstring stating the contract and that it is pure. All of the branching belongs here and nowhere else.
Write an impure record_grade(name, score) that calls grade, prints a formatted line, and appends name:grade to grades.txt using with open(...). Its docstring must list both side effects and state what it returns.
Then prove the contract is readable without opening the source: print record_grade's docstring at runtime.
Finally, demonstrate the scope rule rather than asserting it. Add a global recorded = 0, then write count_wrong() whose body is recorded = recorded + 1 followed by return recorded, call it inside a try and print the error Python raises. A try: block runs the code indented under it, and an except UnboundLocalError as e: block after it catches that error and binds it to e, so printing e shows Python's message and the script carries on. Then write count_right(current) that returns current + 1, use it to advance recorded twice, and print the value after each. Add a comment stating which of the two functions is pure and why the broken one failed before it ever reached the addition.
Hints:
- Keep every
ifinsidegrade, sorecord_gradecontributes exactly two things, a print and a file append, and its docstring can name them both. record_grade.__doc__is the docstring as a plain string you can print, whilehelp(record_grade)renders it as documentation, and either only works when the docstring is the first statement in the body.
Solution:
# PURE core: every decision lives here, so this is the part worth testing.
def grade(score): # pure
# contract
"""Return 'pass' if 'score' >= 60, else 'fail'. Pure, no side effects."""
if score >= 60:
return "pass"
return "fail"
# IMPURE shell: two side effects, both named in the docstring.
def record_grade(name, score): # impure
"""Print 'name -> grade' and append 'name:grade' to 'grades.txt'.
SIDE EFFECTS: writes to the screen and to disk. Returns None."""
g = grade(score) # reuse the pure core
print(name, "->", g) # SIDE EFFECT 1: the screen
with open("grades.txt", "a") as f: # "a" appends, and the block closes the file
f.write(name + ":" + g + "\n") # SIDE EFFECT 2: disk, persists after the run
print(grade(72)) # pass
record_grade("goku", 72) # goku -> pass, and a line lands in grades.txt
# The contract, read at runtime without opening the source file.
print(record_grade.__doc__)recorded = 0 # global
# BROKEN: the assignment marks 'recorded' local for the whole function.
def count_wrong(): # do not use
recorded = recorded + 1 # the read on the right is already a local read
return recorded
# 'try' runs the call, 'except' catches the error instead of stopping the script.
try:
count_wrong()
except UnboundLocalError as e: # bind the error object to 'e' so it can be printed
print("count_wrong failed:", e)
# RIGHT: pure, takes the value in and hands the next one back.
def count_right(current): # pure
# contract
"""Return 'current' plus one. Pure, no side effects."""
return current + 1
recorded = count_right(recorded) # the caller does the reassigning, in the open
print(recorded) # 1
recorded = count_right(recorded)
print(recorded) # 2
# 'count_right' is the pure one: it reads only its parameter and changes nothing outside.Running the second block prints:
count_wrong failed: cannot access local variable 'recorded' where it is not associated with a value
1
2count_wrong never reached the addition. Python decided recorded was local for the whole function the moment it saw the assignment, so the read on the right-hand side was already a read of an empty local, and it failed there. The error message says exactly that, a local variable accessed where it has no value, and notice it names recorded as a local even though a global of that name is assigned at the top of the block.
record_grade.__doc__ is worth one more note. The docstring is not a comment, it is a string object Python stores on the function, which is why it can be printed at runtime while a # comment is dropped when the file is parsed and leaves nothing on the function. That is the entire reason a contract belongs in a docstring rather than in a comment above the def.
DON'T move the if into record_grade, and don't leave a side effect out of a docstring, an undocumented disk write is exactly the kind of thing a caller discovers by finding a file they did not expect.
Additional Resources
Neither is needed for anything that follows. Take a look if you want to see which objects can be changed in place, or how to write a docstring worth reading.
- YouTube - "Programming Terms: Mutable vs Immutable" by Corey Schafer. A short, concrete explanation of which objects can be changed in place and which cannot, the property that decides what a function can change underneath its caller.
- Official docs - The Python Tutorial, "Documentation Strings". The conventions for writing a docstring worth reading.
Summary
Scope is invisible, and that is exactly why it causes so much confusion. Every parameter and every variable you assign inside a function is local, born when the call begins, gone when it ends, and private from the rest of the program. That privacy is a gift: it is what lets you write function after function without tracking variable names across the whole file. The flip side is the trap that catches nearly everyone once, assigning a name inside a function makes it local, so a change meant for a global silently lands on a throwaway local, or raises an UnboundLocalError outright. The cure is not a special keyword but a habit: take values in as parameters, return new values out, and let the caller decide what to keep.
Side effects and purity are the other half of the lesson, and they are really one idea seen from two sides. A pure function only reads its inputs and returns a result, so it is predictable, testable, and safe to call anywhere. A side effect, a print, a file write, a change to outside state, is how a program finally acts on the world, but it is also where behavior becomes entangled with things beyond the inputs. The discipline that pays off for the rest of this program is to keep the computation pure and push the side effects to clearly-named edges, so the trustworthy core stays separate from the messy boundary. Writing a line to a file, the small preview here, is the same persistence idea from Module 0, a value in memory vanishes, a file on storage remains.
Docstrings tie it together by making a function's contract explicit right beside the code it describes. A good docstring says what comes back and, crucially, whether anything outside changes, so the difference between a pure calculation and an impure action is documented, not discovered the hard way. These habits feel optional now, with functions small enough to hold in your head. They stop feeling optional the moment a data pipeline has to give the same answer twice, which is precisely the reliability the next lesson builds on when it composes pure functions into pipelines.
Cheat Sheet
| Concept | Rule | Notes | When to Avoid |
|---|---|---|---|
| Local | Params + assigned vars | Private, gone after the call | Never, this is how scope works |
| Global read | Used, not assigned | Function reads outer value | When the value should be a parameter |
| Global assign trap | Inner assign = new local | A silent local, or UnboundLocalError if read first |
Always, pass in and return out |
| Side effect | Acts beyond returning | print, file write, outside change | Inside functions named like calculations |
| Pure | Output from inputs only, no side effects | Predictable, easy to test | When the job genuinely is an action |
| Docstring | First statement, a string | Read via help() |
Never skip it for non-obvious functions |
LR = 0.01 # global constant (read-only inside functions)
def normalize(value, center): # PURE
# docstring
"""Center then scale 'value'. Pure, no side effects."""
return (value - center) / 10 # only reads params, returns
def report(value, center): # IMPURE: side effect isolated here
# documents it
"""Print the result. SIDE EFFECT: screen."""
print(normalize(value, center)) # the side effect lives alone
count = 0 # global
def step(current): # avoid 'global', pass in, return out
return current + 1
count = step(count) # caller reassigns the global