Module 4 · Functions & Modularity ⏱ 18 min

Variable Scope & Lifetime

By the end of this lesson you will be able to:
  • Tell local variables apart from global variables, and explain why the two are kept separate
  • Explain a variable's lifetime - why a function's locals are rebuilt on every call and do not persist
  • Use the global keyword to write to a module-level variable, and recognise the UnboundLocalError trap

A variable's scope is the region of the program where a name can be seen and used. A name you create inside a function is local - it exists only while that function runs and is invisible everywhere else. A name created at the top level, outside any function, is global, and every function can read it.

counter = 0   # global

def show():
    message = "hi"   # local to show()
    print(message, counter)

show()             # hi 0
print(message)     # NameError: message is not defined

message lives only inside show. counter is global, so show reaches it freely.

flowchart LR
  G["module scope<br/>counter = 0"] --> F["function scope<br/>message = 'hi'"]
  F -. "exists only while<br/>the function runs" .-> G
  style G fill:#3776ab,color:#fff
  style F fill:#6b7280,color:#fff
A function has its own local scope; locals exist only while the function runs.

Why scopes are kept separate

Scope is what stops your functions from treading on each other. Imagine every function shared one big pool of names: the i in your loop, the total in a running sum, and the temp in someone else's swap would all collide, and a stray assignment in one function would silently rewrite a variable three functions away. Debugging that would be hopeless.

Local scope gives each call a private workspace. You can name a variable n inside show without checking whether some other function already used n, because each one gets its own. Globals still exist for values the whole module shares, but the default - private, disposable locals - is what keeps programs predictable.

Lifetime: locals do not outlive the call

A local variable is born when its function starts and destroyed when it returns. Nothing about it persists into the next call, so the function does not remember what happened last time - each call begins with a clean slate.

def add_one_local():
    total = 0
    total += 1
    return total

print(add_one_local())   # 1
print(add_one_local())   # 1 again, not 2

total is recreated at 0 on every call, so the function always returns 1. If a value genuinely needs to survive between calls, a local is the wrong tool - you store it at module level, or return it and let the caller hold on to it.

flowchart LR
  C["call the function"] --> N["fresh local scope<br/>locals are created"]
  N --> R["function returns"]
  R --> G["scope discarded<br/>locals are gone"]
  style N fill:#3776ab,color:#fff
  style G fill:#6b7280,color:#fff
Every call builds a fresh local scope; when the function returns, that scope is discarded.

Reading a global is allowed; assigning is not

There is an asymmetry beginners trip over constantly. A function may read a global name it never assigns to - that lookup quietly walks outward to the module scope. But the moment you assign to that same name inside the function, Python assumes you mean a brand-new local, and builds one that shadows the global for the duration of the call.

total = 0   # global

def reset_locally():
    total = 99   # a LOCAL total, not the global
    print(total)  # 99

reset_locally()
print(total)      # still 0 - the global was untouched

The module-level total is never modified, because the assignment created a separate local that happened to share its name. Reading reaches outward; writing stays inside, unless you say otherwise.

Assigning inside a function creates a local — the global is untouched.
counter = 0   # global

def reset_locally():
    counter = 99   # a LOCAL variable, not the global
    print("inside:", counter)

reset_locally()
print("outside:", counter)
`global` opts in to rebinding the module-level name. Compare with the run above.
total = 0   # global

def add_one():
    global total   # without this line, total += 1 raises UnboundLocalError
    total += 1

add_one()
add_one()
print("total:", total)
flowchart TD
  G["module: counter = 0"]
  R["function READS counter<br/>sees the module value, 0"]
  L["function ASSIGNS counter = 99<br/>a NEW local, not the global"]
  G --> R
  L -. "the module counter<br/>stays 0" .-> G
  style G fill:#3776ab,color:#fff
  style L fill:#b45309,color:#fff
  style R fill:#1e293b,color:#fff
Reading a global name reaches outward to the module; assigning to it creates a separate local that shadows it.

The global keyword

When a function truly must change a module-level variable, you say so out loud with the global keyword. Placing global total at the top of the body tells Python: this name refers to the module-level total, so assignments reach the real one instead of making a local.

total = 0   # global

def add_one():
    global total
    total += 1

add_one()
add_one()
print(total)   # 2 - now it remembers

The declaration is deliberate - it forces you to admit, in writing, that the function edits shared state. Anything that sneaks around that requirement, as the next section shows, tends to blow up rather than work by accident.

The sane use: configuration constants

Not every global is a hazard. Values the whole program treats as fixed - a tax rate, a page size, a retry limit - belong at module level in UPPER_CASE, where every function can read them without the global keyword. Reading is always permitted, so TAX_RATE is visible inside any function that needs it, and one edit at the top updates every use at once.

This is the normal, healthy relationship with module-level names: many readers, no writers. The sharp edges are reserved for functions that assign to a shared name, which the next section unpacks. That is why the add_tax exercise reads its rate from the module rather than receiving it as an argument.

The trap: UnboundLocalError

Mixing a read and a write on the same global name inside one function produces a genuinely confusing error. Suppose count is global and you write:

count = 0   # global

def increment():
    count = count + 1   # UnboundLocalError!

Because count appears on the left of an assignment, Python decides count is local for the entire function - not just that line. That decision retroactively turns the count on the right side into a local too, and since the local has no value yet, you get UnboundLocalError before anything runs. The cure is the keyword from above: declare global count, and both sides refer to the module variable. You will repair exactly this bug in the exercises.

Exercise

The global TAX_RATE is set to 0.08. Write add_tax(amount) that reads the global (do not declare it global - you are only reading) and returns amount plus the tax on it.

TAX_RATE = 0.08

def add_tax(amount):
    # read the global TAX_RATE and return amount + its tax
    pass
Exercise

The global score starts at 0. Write total_score(new_points) that adds new_points to the global score and returns the new total. This time you are writing, so you need the global keyword.

score = 0

def total_score(new_points):
    # add new_points to the global score, then return it
    pass
Exercise

What does this print? The function has its own local n. Predict both lines, then Check.

def bump():
    n = 10
    n = n + 1
    return n

n = 100
print(bump())
print(n)
Exercise

This function tries to increase the global count and return it, but it crashes with UnboundLocalError. Inside a function, assigning to a name makes Python treat it as local for the whole body - so even the read on the right-hand side looks at a local that has no value yet. Declare it global to fix it.

count = 0

def increment():
    count = count + 1
    return count
Exercise

Two functions never share their local variables, even when one calls the other. Write inner() that returns its own local message set to 'world', and outer() that sets its own local message to 'hello' and returns the two messages joined with a space as 'hello world'. Include both definitions.

def inner():
    # your own local message
    pass

def outer():
    # your own local message, then use inner()
    pass

Recap

  • A name's scope is where it is visible: local inside a function, global at module level.
  • A local's lifetime is one call - it is rebuilt each time and discarded on return, so functions do not remember previous calls.
  • A function can read a global freely, but assigning to that name creates a local that shadows it.
  • Use global only when a function must write shared module state - and prefer return.
  • Reading and assigning the same global name makes Python treat it as local everywhere, raising UnboundLocalError.

Next you will meet Python's containers - sets and tuples - where scope decides what a function can reach.

Checkpoint quiz

A variable created inside a function can be used...

Inside a function, assigning to a name that also exists globally (with no global keyword)...

Why does count = count + 1 inside a function raise UnboundLocalError when count is global?

Go deeper — technical resources