The previous lesson ended with three unanswered questions. Why can ask_option read the constant PRIORITIES without our passing it in? What would happen if inside a function we wrote title = "another": would it change the title of the main program? And why does the variable value that lives inside ask_text not exist outside it?
All three are the same question in different clothes: where each name lives and who can see it. That is called scope, and it is one of those ideas which, until they are understood, produce baffling errors: functions that "cannot see" a variable clearly written further up, changes that vanish without trace, values that turn up where they should not.
Understanding scope is not a theoretical luxury: it is what turns a function into a safe box, something you can use knowing it will not spoil anything outside it. And it is, besides, the basis of a golden rule we will apply in the next lesson: a well-made function depends only on what it receives through its parameters.
Contents
- Local scope: born and dying with the call
- Global scope
- The LEGB rule
- Reading a global is not the same as assigning to it
- The word
globaland why it is discouraged - Name shadowing
- How arguments are passed in Python
- Global constants: the accepted exception
- Nested functions and
nonlocal - Scope and debugging
- EasyTask: an audit of the functions
- Common mistakes and tips
- Exercises
- Conclusion
- Local scope: born and dying with the call
Every variable created inside a function — whether by assignment or by being a parameter — is local to that function: it exists while the call is running and disappears the moment it finishes.
def calculate_cost(days, rate):
subtotal = days * rate
taxes = subtotal * 0.21
return subtotal + taxes
print(calculate_cost(4, 110.0))
print(subtotal)The first line works; the second does not. subtotal and taxes existed, did their job and vanished: outside the function Python has no name called subtotal, and that is why it answers with the NameError you already know from 04-01. This, which looks like a limitation, is in fact the best property functions have. It means you can write a function using whatever names you fancy — i, value, total — with the guarantee that you will not tread on anything outside. Without local scope, every name you used inside a function would be a landmine for the rest of the program, and you could not write fifty lines without colliding with yourself. It works between different functions too: if ask_title and ask_priority both use a variable called value, they do not get in each other's way at all, because they are two different variables that happen to share a name, each in its own box.
- Global scope
Global scope is the file's scope: everything defined at the left margin, outside any function. Those variables are called globals and are visible from any point in the module, including the inside of functions.
STUDIO = "Alba Studio" # global
def show_header():
print(f"EasyTask - {STUDIO}") # reading a global: it works
show_header() # EasyTask - Alba StudioHere is the answer to the lesson's first question: ask_option could read PRIORITIES because constants are global and functions can read what is global without asking permission.
That said, being able to does not mean it is advisable: a function that reads changing global variables stops being self-contained, because its result no longer depends only on what it receives but on the state the program is in at that moment, which makes it unpredictable to read and impossible to test in isolation. We will come back to this in section 8, with the only reasonable exception: constants.
- The LEGB rule
When Python meets a name, it looks for it in four places and in this order, keeping the first one it finds. The rule is known by its initials: LEGB.
| Layer | What it is | Example |
|---|---|---|
| L — Local | Inside the function that is running | subtotal, a parameter |
| E — Enclosing | The function that wraps this one, if there is one | see section 9 |
| G — Global | The file level | STUDIO, PRIORITIES |
| B — Built-in | What Python ships with | print, len, int, input |
flowchart TD
A["Local: inside the function"] --> B["Enclosing: the function that wraps it"]
B --> C["Global: the file"]
C --> D["Built-in: print, len, int, input"]
D --> E["Not in any layer: NameError"]
Read it as a search going down a staircase: if the name is in the local layer, that one is used and no lower step is looked at; if not, the enclosing one is tried; then the global one; then the built-in functions; and if it is not there either, NameError. An immediate and very useful consequence: the nearest name wins. If a function has a local variable total and a global total also exists, inside the function total always refers to the local one. The global carries on existing, untouched, but stays covered up for as long as the call lasts.
- Reading a global is not the same as assigning to it
Here is the point that causes most confusion in the whole lesson. We have just seen that reading a global from inside a function works with no fuss. Let us change one single thing: assigning instead of reading.
It seems contradictory, but the rule is simple and has no exceptions: if at any point in a function's body there is an assignment to a name, that name is local throughout the function. Python decides that when compiling the function, before running anything. So counter is local inside increment, and the line counter = counter + 1 tries to read a local that does not have a value yet. Hence the UnboundLocalError, which is a specialised NameError.
And watch out for the variant that does not raise an error, which is even more dangerous:
counter = 0
def set_to_ten():
counter = 10 # creates a new LOCAL variable; the global is untouched
print(f"Inside: {counter}")
set_to_ten()
print(f"Outside: {counter}")The function seems to have changed the counter, and even prints it changed, but outside everything stays as it was. This is the error we were talking about: it does not fail, it does not warn, and it does not do what it looks like it does. If you ever wonder "I have assigned the variable inside the function and outside it does not change", this is the explanation.
- The word
global and why it is discouraged
global and why it is discouragedPython offers a way to force the assignment to affect the global variable: declaring it with global at the start of the body.
counter = 0
def increment():
global counter # "when I say counter, I mean the global one"
counter = counter + 1
increment()
increment()
print(counter) # 2It works. And even so, the recommendation of practically the whole Python community is not to use it except in very rare cases. The reason is easier to grasp by looking at what it breaks:
total_hours = 0
def record_workday(hours):
global total_hours
total_hours = total_hours + hours
def calculate_average(days_worked):
global total_hours
total_hours = total_hours / days_worked # wrecks the running total
return total_hours
record_workday(8)
record_workday(6)
print(calculate_average(2)) # 7.0
record_workday(5)
print(total_hours) # 12.0, a value that no longer means anythingcalculate_average had an innocent name — "calculate" — and it has modified shared state. From then on, total_hours is neither total hours nor an average: it is a number with no meaning, and the program will carry on running without complaining. Imagine this in a thousand-line file with fifteen functions writing to the same global: to know what a variable holds at one point you would have to read the whole program.
With global |
With parameters and return |
|
|---|---|---|
| What does the result depend on? | The state of the program | Only the arguments |
| Can it be tested in isolation? | No | Yes |
| Who can break it? | Any function | Nobody else |
| Can you see at the call what it touches? | No | Yes |
The alternative is always the same: receive through a parameter and hand back with return. The healthy version would be def record_workday(total, hours): return total + hours, with the main program storing the result. One more line and one less problem.
- Name shadowing
Shadowing is declaring a name that covers up another one from an outer layer. It happens constantly and is often harmless, but it has a version that really does hurt: shadowing a built-in.
list = "Book fair poster" # covers up the built-in function list()
print(list) # works: prints the text
print(list("abc")) # TypeError: 'str' object is not callableBy assigning to list, the global layer has taken that name over and the LEGB search never reaches the built-in layer any more. What is more, the error appears far from the point where it was caused, sometimes hundreds of lines later. These are the names most often shadowed by accident:
| Built-in name | What it is for | Safe alternative |
|---|---|---|
list |
Creating lists (module 5) | task_list, items |
input |
Reading from the keyboard | entry, text_read |
str, int, float |
Converting types | text, number, amount |
type |
Checking the type | task_type, category |
sum, max, min, len |
Computations over collections | total, largest, smallest |
Shadowing input is especially cruel in a program like EasyTask: you write input = input("Title: ") once and the next call to input(...) gives TypeError: 'str' object is not callable, because input is no longer the function but the string you typed. The defence is simple: if your editor colours a name as if it were special, do not use it for a variable.
- How arguments are passed in Python
You already know that an argument is assigned to the parameter. But what exactly is assigned, the value or "the variable"? In Python, what is passed is the reference to the object: the parameter comes to point at the same object as the argument. The practical consequence, with the types you know — numbers, strings, booleans, all immutable — is reassuring: reassigning the parameter inside does not affect the variable outside.
def extend_deadline(days):
print(f" I receive days = {days}")
days = days + 5 # reassigns the LOCAL name 'days'
print(f" Inside now days = {days}")
return days
deadline = 4
result = extend_deadline(deadline)
print(f"Outside deadline = {deadline}, result = {result}")A trace of what happened: on the call, the parameter days points at the same object 4 as deadline. The line days = days + 5 creates a new object, 9, and makes the local name days point at it; deadline carries on pointing at 4, because the number 4 has not changed — it cannot: integers are immutable. The only way for the outside world to learn about the new value is the return.
The same happens with strings. If a function normalise(text) does text = text.strip().capitalize() and returns the result, the variable you passed in from outside still has its spaces and its lower case intact: it is what you saw in 02-01 about the immutability of strings, now in the context of a call. String methods return a new string; they do not modify the original.
An honest preview: with mutable objects — the lists of module 5 — the story has one more chapter, because a function can indeed modify the contents of the object it receives and have the change seen outside. What you have just learnt remains true (reassigning the name never affects the outside), but we will have to distinguish between reassigning and modifying. Today, with numbers and strings, no ambiguity is possible.
- Global constants: the accepted exception
After so much warning against globals, why do PRIORITIES and TEAM live at global level and get read from any function without remorse? Because they are constants, and a constant does not have the defects of a changing global: it never changes during execution, so the function's result stays predictable; it is declared in one single place, at the top of the file, where anybody can find it; it stands out at a glance thanks to the UPPERCASE convention of 02-01; and it documents the problem domain, because PRIORITIES = ("high", "medium", "low") says which priorities exist at Alba Studio, and that is business information, not program state.
Global constant (WIDTH, TEAM) |
Global variable (title, counter) |
|
|---|---|---|
| Does it change during execution? | No | Yes |
| Who modifies it? | Nobody | Any function |
| Does it complicate reasoning about the code? | No | A great deal |
| Acceptable to read it from a function? | Yes | Avoid it |
A nuance of style: even though reading a global constant is legitimate, sometimes it is worth passing it as a parameter anyway. That is what we did with ask_option(message, options): by passing it the options, the function serves for priorities, for assignees and for whatever comes next; by reading PRIORITIES directly, it would serve only for priorities. Practical rule: if the constant is part of what the function does, pass it in; if it is a presentation detail shared by the whole program, like WIDTH, read it.
- Nested functions and
nonlocal
nonlocalThe E of LEGB is still to be explained. In Python you can define a function inside another one, and the inner one sees the names of the outer one, which form its enclosing scope.
def prepare_report(client):
heading = f"Report for {client}"
def line(text):
return f"{heading} | {text}" # reads 'heading' from the enclosing scope
print(line("Task completed"))
prepare_report("Sole Bakery") # Report for Sole Bakery | Task completedAnd should the inner function need to assign to a name in the enclosing one, there is the word nonlocal, sister to global but pointing one layer up instead of at the whole file. We will not use it in the course: with what you know today, the clean solution is still to pass the value in as a parameter and hand it back. It is enough that you recognise nonlocal when you see it and know that it refers to the enclosing layer. Nested functions will indeed appear again, with a very specific purpose, in Functions as values.
- Scope and debugging
There is a very practical reason for taking scope seriously: scattered global state multiplies the time you take to find a bug. When a function depends only on its parameters, diagnosing a failure is a closed problem: you look at the arguments, you look at the returned value and the bug is either inside or it is not. When it depends on globals that anybody can modify, the question "why does total_hours hold 12 here?" cannot be answered by looking at the function: you have to reconstruct the whole history of the execution. It is the difference between checking a room and searching a building.
That is why, when something does not add up, the first useful question is: what does this function depend on? If the answer is "only on what it receives", you have already narrowed the problem down. The specific techniques for investigating — traces, breakpoints, reading a traceback — are the topic of Debugging and error handling; what scope gives you is a program in which those techniques work fast.
- EasyTask: an audit of the functions
It is time to review what was written in 04-02 with the previous section's question: what does each function depend on? Besides the four you already know, we had sketched a fifth, show_summary(), to paint the task's status line.
# The faulty version of show_summary
title = ""
priority = ""
days = 0
def show_summary():
"""Paint the task summary... by reading the global state."""
print(f"{title} | {priority} | {days} days")| Function | Depends on | Verdict |
|---|---|---|
ask_text(message, required=True) |
Its parameters | Correct |
ask_option(message, options) |
Its parameters | Correct |
ask_integer(message, minimum, maximum) |
Its parameters | Correct |
classify_urgency(priority, days) |
Its parameters | Correct |
show_summary() |
Globals title, priority, days |
Faulty |
The first four are clean: you can copy them to another file and they work as they are. show_summary() does not: outside EasyTask it is of no use at all, it cannot be tested with made-up data and, if tomorrow the program managed two tasks, there would be no way of telling it which one to paint. The fix is mechanical — turn every global it reads into a parameter:
def show_summary(title, priority, days):
"""Paint the summary of the given task on one line."""
print(f"{title} | {priority} | {days} days | {classify_urgency(priority, days)}")
show_summary("Book fair poster", "high", 2) # ... | 2 days | CRITICAL
show_summary("Sole Bakery menu", "low", 9) # ... | 9 days | NormalNotice the two consecutive calls with different data: that, which now looks trivial, was impossible with the version that read globals. And observe that show_summary does call classify_urgency, which is a global function: that is not a scope problem, because functions, like constants, are defined once and do not change. With this, easytask.py is left in a healthy state: global constants at the top, functions that depend only on their parameters, and the task's state still in loose variables of the main program. That loose state is the last thread left, and we will tie it up in the next lesson.
Common Mistakes and Tips
Using a local variable outside. NameError. What is born inside a function dies with it: if you need that value outside, hand it back with return.
Assigning to a global believing you are changing it. counter = 10 inside a function creates a new local and leaves the global untouched, without raising any error. If the value outside "does not update", this is why.
UnboundLocalError. It appears when you read a name that you also assign to inside the function: the assignment has made it local throughout the body. Solution: receive it as a parameter and hand it back.
Shadowing a built-in. Calling a variable list, input, str or sum breaks the program much later and somewhere else. Add a word: task_list, input_text. And do not overuse global: it works, and that is why it tempts you, but every global you write is a function you can no longer reason about on its own.
Tip: do the cut-out test. Copy a function into an empty file. If it works without dragging anything else along, it is self-contained; if names are missing, those names should be parameters. And put the constants at the top and in UPPERCASE: it is the visual signal for "this is global on purpose", and it makes any other global stand out as suspicious.
Exercises
Exercise 1: Predict three outputs
Say what each block prints and, where applicable, what error is produced and why.
# Block A
x = 5
def f():
print(x)
f()
# Block B
y = 5
def g():
y = 99
print(y)
g()
print(y)
# Block C
z = 5
def h():
print(z)
z = 99
h()Exercise 2: Turn globals into parameters
Rewrite this program so that no function depends on changing global variables, using parameters and return. The UPPERCASE constants may stay where they are.
RATE = 110.0
accumulated_hours = 0
def add_hours(hours):
global accumulated_hours
accumulated_hours = accumulated_hours + hours
def invoice():
global accumulated_hours
return accumulated_hours * (RATE / 8)
add_hours(8)
add_hours(4)
print(invoice())Exercise 3: Find the sabotage
This program prints Hello and then breaks. Explain exactly what happens, on which line the damage is caused and on which it shows up, and fix it.
Solutions
Solution 1.
| Block | Output | Explanation |
|---|---|---|
| A | 5 |
The function only reads the global: the LEGB search does not find it in local, goes down to global and uses it |
| B | 99 and then 5 |
The assignment creates a local y that covers up the global during the call; the global is untouched |
| C | UnboundLocalError |
There is an assignment to z in the body, so z is local throughout the function, including the print before the assignment |
Block C is the most instructive: the line that fails (print(z)) comes before the guilty line (z = 99). Python decides which names are local when compiling the function, not when running it line by line.
Solution 2.
RATE = 110.0
def add_hours(accumulated, hours):
"""Return the new total of hours after adding the given ones."""
return accumulated + hours
def invoice(accumulated, rate=RATE):
"""Return the amount corresponding to the accumulated hours."""
return accumulated * (rate / 8)
hours = 0
hours = add_hours(hours, 8)
hours = add_hours(hours, 4)
print(f"{invoice(hours):.2f} EUR") # 165.00 EURThe accumulator still exists, but now it lives in the main program and travels explicitly through parameters and returned values: on every line you can see who changes what. Besides, invoice accepts a different rate should it ever be needed, without touching the constant.
Solution 3. The line str = "Book fair poster" shadows the built-in function str, leaving that name pointing at a string. Nothing fails yet: print(str) prints the text without a problem. The damage shows up two lines later, at str(2026), which tries to call a string as if it were a function and produces TypeError: 'str' object is not callable. The fix is to rename the variable:
The moral is the distance between cause and symptom: in a long file, those two lines could be two hundred apart, and the error message would point at the innocent one.
Conclusion
You now know where each name lives. Variables created inside a function are local: they are born with the call, die when it ends and do not exist outside, which guarantees that a function treads on nothing in the rest of the program. Names are looked up following the LEGB rule — local, enclosing, global, built-in — and the nearest one always wins. From inside a function a global can simply be read, but assigning to it creates a new local variable; forcing the opposite with global works and is almost never advisable, because it turns every function into something you can only understand by reading the whole program. Shadowing built-in names such as list or input produces errors far from where they were caused. And when passing arguments, Python hands over the reference to the object: with numbers and strings, which are immutable, reassigning inside never affects what is outside, so the only channel of communication outwards is the return.
The rule that sums all of this up fits on one line: a function should depend only on its parameters, and communicate with the outside world only through its returned value. UPPERCASE constants are the accepted exception, because they do not change. Applying it, the EasyTask audit has left the four validation functions clean and has corrected show_summary(), which read the global state and now receives what it needs. You already have the three pieces of function engineering: defining and calling (04-01), parameters and return (04-02) and scope (04-03). What is missing is the judgement to use them at scale: how to split a whole hundred-line program into functions of the right size, how to decide what goes in each one and in what order to write them. That is Breaking a program down into functions, where EasyTask will finally move from v0.6 to v0.7.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
