In Python, a statement is one complete instruction that tells the computer what to do. Python executes statements sequentially from top to bottom.
Python has two statement types: simple and compound. Simple statements are single-line instructions like pass
, return
, and import
. Compound statements control other statements using a header line followed by indented code blocks, such as if
, while
, and for
.
This guide covers all 19 Python simple statements from Python 1.4 (1996) through Python 3.13.2 (2025), including:
- Expression and assignment statements for running code and storing values
-
Functional statements like
assert
,pass
,del
,return
,yield
, andraise
-
Loop control with
break
andcontinue
- Import statements for accessing external code
-
Name control with
global
andnonlocal
-
Legacy statements like
print
andexec
By the end, you'll understand each simple statement's purpose, evolution, and practical usage.
1. Expression Statements
Definition
An expression statement is a single expression on its own line that Python evaluates and then discards the result unless used in the interactive shell. Any expression can be used as a statement. Expression statements are often used to call functions or methods for their side effects or to compute a value without saving or showing it. In the interactive shell, the result is displayed. In a script, the result is ignored unless assigned to a variable or used in some other way.
Analogy
An expression statement is like doing a calculation on scratch paper and throwing the paper away. You complete the calculation, but you do not keep the result. You might enter a number into a calculator to see the answer, but if you do not record it, it is gone. In the same way, an expression as a statement computes a value, but unless you use it, the value disappears after execution.
Historical reference
Expression statements have been part of Python since the early 1990s. Python has always allowed any expression to stand alone as a statement. In the interactive REPL (Read-Eval-Print Loop), Python automatically shows the result of expression statements. This is why typing 2 + 2
shows 4
. In scripts, expression statements do not produce output unless you do something with the result. This behavior has stayed the same across Python versions, though newer versions improved how results display in the interactive shell. The idea of expression statements has remained simple and unchanged.
Example 1: Calling a function as an expression statement
In this example, we define a function and then call it as a statement. The function call is an expression that produces a side effect (printing a message). We do not assign its return value (it returns None implicitly), we just use the call on its own line as a statement.
def shout(word):
print(word + "!")
# Using the function call as an expression statement (for its side effect)
shout("Python")
Output:
Python!
Example 2: Expression statement with no visible effect
Here, we use an arithmetic expression as a statement. In a script, this will compute the value but not display or store it. We follow it with a print to show that execution continues, and that the previous expression’s result was effectively unused.
x = 10
x + 5 # This computes 15 but the result is not used or printed
print("Done")
Output:
Done
(In this script, the expression x + 5
is evaluated (15) and then discarded, so nothing is output from that line. Only the "Done"
message is printed.)
2. Assignment Statements (=
)
Definition
An assignment statement binds a value to a variable name or multiple names. It uses the =
operator with a target on the left and an expression on the right. Python evaluates the expression on the right and assigns the result to the variable on the left. Assignment creates a reference within a specific scope. You can assign to multiple variables at once using tuple unpacking. You can also assign the same value to several names using chained assignment. Assignment in Python does not produce a value. This is why you cannot use an assignment inside an expression. It is a statement, not an expression.
Analogy
Assignment is like labeling a container. Imagine you have a box and write "Cookies" on it to show what is inside. In the same way, x = 5
in Python labels the name x
to refer to the value 5
. Multiple assignments are like labeling several containers or swapping labels between them.
Historical reference
Assignment has been part of Python from the beginning. Python has always allowed assigning values to single variables, as well as to tuples and lists. Over time, assignment gained new features. In Python 3, the "starred" expression from PEP 3132 made it possible to capture remaining items during unpacking. For example, a, *rest = [1, 2, 3, 4]
.
The basic behavior of assignment with =
has stayed the same. Python always evaluates the right side first, then assigns the results from left to right. Assigning to an undeclared name creates that name in the current local or global scope. Assigning to an expression, such as 5 = x
, is a syntax error.
Assignment statements are a core part of Python and have only seen small updates, such as extended unpacking.
Example 1: Basic and multiple assignment
Here we assign a single value to a variable, then use multiple assignment to swap values between two variables. Python allows multiple variables to be assigned in one statement by separating them with commas on the left (and optionally on the right using a tuple of values).
x = 5 # simple assignment
print(x)
a, b = 1, 2 # multiple assignment in one line
print(a, b)
# Swap the values of a and b using tuple unpacking in a single statement
a, b = b, a
print(a, b)
Output:
5
1 2
2 1
Example 2: Sequence unpacking assignment
In this example, a list of three numbers is unpacked into three variables in one assignment statement. The number of variables on the left must match the length of the sequence on the right. This shows how assignment can decompose data structures.
numbers = [1, 2, 3]
x, y, z = numbers # unpack the list into three variables
print(x, y, z)
Output:
1 2 3
3. Augmented Assignment Statements (e.g. +=
, -=
)
Definition
An augmented assignment is a shorthand that combines an arithmetic or bitwise operation with assignment. For example, x += 3
adds 3 to the current value of x
and assigns the result back to x
. Python supports augmented assignments for addition +=
, subtraction -=
, multiplication *=
, true division /=
, floor division //=
, modulus %=
, exponentiation **=
, bit shifts <<=
, >>=
, and bitwise AND, OR, and XOR &=
, |=
, ^=
. This works the same as writing x = x + 3
but may be faster for mutable types and avoids repeating the variable on the left-hand side.
Analogy
Augmented assignment is like a shortcut update. Imagine you have a counter on a website showing the number of visitors. Instead of writing new_count = old_count + 1
every time someone visits, you simply add 1 directly on the counter. Augmented assignment works the same way. It updates the value in place. It is like saying increase this by that amount in one step, instead of two.
Historical reference
Augmented assignment was added in Python 2.0 in the year 2000 to make updates more convenient. Before that, programmers had to write the full form each time, such as x = x + ...
. The addition of augmented assignment brought Python in line with other languages that use similar syntax.
Augmented assignment first evaluates the target variable and the right-hand expression. Then it performs the operation. If the object on the left supports in-place changes, Python uses them. If not, it falls back to the normal operation and assignment. This feature has stayed the same since it was introduced. Over time, support has grown to include new types, such as using +=
to combine sequences.
Example 1: Numeric augmented assignment
This example demonstrates using +=
on a number. We start with a value and then increment it by some amount using augmented assignment. The end result is the original value increased accordingly.
count = 10
count += 5 # equivalent to count = count + 5
print(count)
Output:
15
Example 2: String augmented assignment (concatenation)
Augmented assignment works on many types. Here we use +=
on a string to concatenate another string to it, and on a list to extend it with another list. These operations modify the original object (in case of the list) or create a new string in a shortened syntax.
greeting = "Hello"
greeting += " World" # concatenates to the original string
print(greeting)
numbers = [1, 2, 3]
numbers += [4, 5] # extends the list in place
print(numbers)
Output:
Hello World
[1, 2, 3, 4, 5]
(Notice that for the list, numbers += [4,5]
is essentially numbers.extend([4,5])
, modifying the list in place. For the string, a new string is created because strings are immutable.)
4. Annotated Assignment Statements (Variable Annotations)
Definition
An annotated assignment adds a type hint to a variable as part of the assignment. The syntax is variable: type = value
. The part after the colon is a type annotation, which can be any valid type expression. This statement assigns the value to the variable and stores the annotation in the variable’s metadata under __annotations__
in the containing namespace. Python does not enforce the type at runtime. Annotations are mostly for static type checkers, documentation, or runtime introspection. You can also use annotated assignment without giving a value, as in x: int
, to declare a type for a variable that will be assigned later. Annotated assignments do not limit how the variable is used. They only provide information.
Analogy
Annotated assignment is like labeling a variable with extra information about what type of value it should hold. It is similar to writing a note next to a box that says "should contain tools." The note does not lock the box so it can only hold tools. Someone could still put something else inside. But the note helps others, and tools, understand what is expected in the box.
Historical reference
Variable annotations were added in Python 3.6 in the year 2016 through PEP 526. Before that, Python had function annotations from PEP 3107 in Python 3.0 for parameters and return types but no direct way to annotate variables. Annotated assignment made it possible to add type hints to module-level, class-level, and local variables. These annotations were created mainly for static analysis tools like mypy and for documentation. Python’s interpreter ignores the annotations at runtime, except for storing them.
Over time, the use of type annotations has grown. Tools like IDEs, linters, and frameworks now use them to make code clearer and check for errors. Later versions improved how annotations work. Python 3.7 and 3.8 added an option from PEP 563 to store annotations as strings to reduce runtime cost. In Python 3.11 and later, annotations are once again evaluated immediately by default, unless you use from __future__ import annotations
to delay them. The syntax of annotated assignment has stayed the same as x: Type = value
. This feature is part of Python’s move toward optional static typing without losing its dynamic nature.
Example 1: Basic annotated assignment
In this example, we annotate a variable with a type and assign a value to it. We then print the value and type to show that the annotation does not enforce the type (Python still allows the variable to hold any type). We also demonstrate changing the variable to a different type — Python will not stop us, but a type checker might warn about it.
age: int = 25 # 'age' is intended to be an int
print(age)
age = "twenty five" # reassigning a string to the same variable (no runtime error)
print(age)
Output:
25
twenty five
(age
was annotated as an int, and initially had an int value 25. We then assigned a string to age
. Python allowed it because the annotation is not enforced at runtime — it’s just metadata. A static type checker, however, would flag that second assignment as incompatible with the declared type.)
Example 2: Accessing annotations metadata
This example shows that annotations are stored in a special dictionary. We create a global variable with an annotation and later inspect the __annotations__
dictionary to see the recorded types. We also define a class with annotated class variables to illustrate how annotations can be used in classes.
data: float = 4.5 # annotated at the module (global) level
class Point:
x: int # annotation without assignment (default value will be required later if used)
y: int
def __init__(self, x: int, y: int):
self.x = x
self.y = y
print(__annotations__) # annotations in the global namespace
print(Point.__annotations__) # annotations in the class namespace
Output:
{'data': <class 'float'>}
{'x': <class 'int'>, 'y': <class 'int'>}
In the output, the first line shows that the global variable data
is annotated as float. The second line shows the class Point
has annotations for x
and y
as ints. These annotations can be used by tools or libraries (for example, dataclass or pydantic) to automatically handle typing logic. Python itself doesn’t use these annotations to enforce types — they’re for the developers and tooling.
5. The assert
Statement
Definition
The assert statement is used to insert debugging checks into the code. It has the form assert condition, optional_message
. When an assert
runs, it evaluates the condition. If the condition is true, nothing happens and execution continues. If the condition is false, Python raises an AssertionError
and stops the program unless the exception is caught. If given, the optional message appears in the error. An assert
acts as a sanity check that should never fail if the program is correct. It is often used to check internal assumptions, inputs, or invariants during development. When Python runs in optimized mode with the -O
flag, all assert statements are removed and have no effect.
Analogy
Using an assert
is like having a red flag in your code that raises an alarm if something is wrong. Imagine a door with a sensor that triggers an alarm if the door opens when it should not. An assert
checks a condition. If the condition fails, it blows a whistle to alert you. If everything is fine, the sensor stays quiet and lets the program keep running.
Historical reference
The assert
statement has been part of Python since the early versions in the 1990s. It became common around Python 1.5, when it was documented as a tool for debugging checks. The ability to disable assertions with optimization was also there early on. Running Python with the -O
flag strips out all assert statements, so they should never handle logic that must always run.
The behavior of assert
has stayed mostly the same. Python 3 improved the way error messages display when an assertion with a message fails. assert
is a statement, not a function, so it does not use parentheses around the condition. Writing assert(condition)
does not work as a proper assert check. Use of assert
in testing has also grown over time. Tools like pytest rewrite assert statements to show clear details when they fail. In short, assert
is a stable feature for debugging and testing, with the key reminder that it is removed when running optimized bytecode.
Example 1: Using assert for a valid condition
In this example, we use an assert to check a condition that is true. The assert passes silently and the program continues normally, printing the success message.
x = 5
assert x < 10 # This will pass since 5 < 10 is True
print("x is small, continuing...")
Output:
x is small, continuing...
Example 2: Assert with a failing condition and message
Here we use an assert that is supposed to fail. We check a condition that is false (y < 5
when y
is 10). We also provide a message explaining the failure. When the assertion fails, Python raises an AssertionError
and prints the message. The code after the assert is never reached in this case.
y = 10
assert y < 5, "y is too large"
print("This line will not execute if assertion fails.")
Output:
Traceback (most recent call last):
File "script.py", line 2, in <module>
assert y < 5, "y is too large"
AssertionError: y is too large
In the output, we see a traceback indicating that an AssertionError
occurred, along with the message "y is too large"
. This means the assert caught a situation that shouldn’t happen (in our test, we intentionally made it happen). In a real program, an assertion failure typically signals a bug that needs to be fixed. (If Python were run with -O
, this assert would be skipped entirely.)
6. The pass
Statement
Definition
The pass statement does nothing. It is a placeholder used when a statement is required but no action is needed. Writing pass
performs no operation. It is often used to create minimal class or function definitions or as a stub in code blocks that are not yet implemented. pass
tells Python to do nothing and move on.
Analogy
Think of pass
as an empty placeholder, like a blank sign or an empty instruction. If you are writing a to-do list and have nothing planned for a day, you might leave it blank or write nothing to do. The pass
statement works the same way. It tells the interpreter there is nothing to do here.
Historical reference
The pass
statement has been part of Python since the earliest versions. It was added to support Python's block-indentation syntax. Sometimes you need an indented block after an if
, for
, while
, or def
, but you do not want any code there yet. Without pass
, leaving a block empty would cause a syntax error.
The behavior of pass
has never changed. It remains a simple way to create placeholder code. For example, when creating a new function or class that you plan to fill in later, you can write the header and add pass
to complete it. You can also use pass
in exception handling to ignore specific errors. Writing except SomeError: pass
will catch and ignore the error, doing nothing in that case. The purpose of pass
is clear and direct, matching Python’s style of explicit, simple syntax.
Example 1: Placeholder in function and class definitions
Here we create an empty function and an empty class using pass
to satisfy the syntax. We then show that calling the function does nothing (returns None
implicitly) and that we can instantiate the class (though it has no attributes or methods).
def do_nothing():
pass # function does nothing
class MyEmptyClass:
pass # class with no attributes or methods
result = do_nothing()
print(result)
obj = MyEmptyClass()
print("Created object of MyEmptyClass:", obj)
Output:
None
Created object of MyEmptyClass: <__main__.MyEmptyClass object at 0x7f...>
In the output, the function do_nothing()
returns None
(because it executed no return statement, just pass). The class MyEmptyClass
can be instantiated, though it doesn’t actually do anything. The printed object is just a default object representation. This demonstrates how pass
allows us to define placeholders that do nothing yet keep the code syntactically correct.
Example 2: Using pass in conditional or loop
In this example, we use pass
in an if
statement and in a loop. We want to skip certain cases without executing any code, so we put pass
there.
x = 5
if x > 0:
# We need a block here but decide to do nothing for positive x
pass
else:
print("x is not positive")
print("Checked x, moving on...")
for i in range(3):
if i < 2:
pass # skip the first two iterations
else:
print(f"Loop index {i}, executing action.")
Output:
Checked x, moving on...
Loop index 2, executing action.
Here, since x
was 5, the if x > 0
block executes pass
(doing nothing), and nothing is printed in the else. The program then prints the message after the if. In the loop, for i = 0
and i = 1
, the if i < 2
condition is true and pass
is executed (no output, no action). When i = 2
, the else
branch runs and prints the message. Using pass
in this way lets us handle “do nothing” cases explicitly.
7. The del
Statement
Definition
The del statement removes a binding from a name or deletes an item from a data structure. Writing del x
deletes the variable x
, removing the name from the current namespace. After this, using x
causes a NameError
unless it is defined elsewhere. You can also use del
with indexing and slicing. For example, del my_list[2]
removes the item at index 2 from a list, and del my_dict['key']
removes the entry with that key from a dictionary. Using del my_list[1:3]
removes a slice of elements from a list. In short, del
unbinds names or deletes elements from containers.
Analogy
Using del
is like throwing something away or removing a label. If a variable is a labeled reference to an object, del variable
is like tearing off the label. You can no longer refer to the object by that name, and if no other references exist, the object will be garbage collected. Deleting from a list or dictionary is like removing an item from a shelf. The item is gone, and the collection closes the gap around it.
Historical reference
The del
statement has been part of Python since the early versions in the 1990s. Its role is tied to Python’s memory model. When an object has no more references, such as after deleting a variable that pointed to it, it becomes ready for garbage collection. Over time, del
was extended to handle deleting slices, like del list[i:j]
.
In Python 2, del
could remove items from a list while looping, but this could cause problems since removing items shifts the indices. There are also limits to what you can delete. Trying to delete a built-in name or an attribute that does not exist raises an error.
In Python 3, the meaning of del
has stayed the same. One extra detail is that Python's garbage collector may not free memory right away, but the reference is still removed. The syntax and purpose of del
have remained simple and clear throughout Python’s history.
Example 1: Deleting a variable (name unbinding)
This example shows deleting a variable and then trying to use it. After using del
, the name is no longer defined, which results in a NameError if accessed. We catch the error to display it.
a = 5
del a # delete the variable 'a'
try:
print(a)
except NameError as e:
print("NameError:", e)
Output:
NameError: name 'a' is not defined
We see that after deleting a
, attempting to print a
causes a NameError
because the name has been removed from the namespace. This confirms that del a
unbound the name a
. (If there were no except block catching the error, the program would have crashed with the NameError traceback.)
Example 2: Deleting items from a list and dictionary
In this example, we use del
to remove an element from a list by index, and to remove a key-value pair from a dictionary by key.
fruits = ["apple", "banana", "cherry", "date"]
del fruits[1] # remove the item at index 1 ("banana")
print(fruits)
info = {"name": "Alice", "age": 30, "city": "NYC"}
del info["age"] # remove the entry with key "age"
print(info)
Output:
['apple', 'cherry', 'date']
{'name': 'Alice', 'city': 'NYC'}
After deleting, the list no longer contains "banana" (the list collapsed that gap, now index 1 is "cherry"). The dictionary no longer has the "age"
key. This shows how del
can remove elements from collections. If we tried to del info["age"]
again after it’s gone, Python would raise a KeyError
because the key is not found. Similarly, attempting to delete an index out of range in the list would raise an IndexError
. So del
should be used on valid targets.
8. The return
Statement
Definition
The return statement is used inside a function to exit the function and optionally pass a value back to the caller. When return <expression>
runs, the function stops, and the expression is evaluated and sent back as the result. If the function uses return
without an expression, or if it reaches the end without a return, it returns None
by default. The return
statement is only allowed inside functions. Using it outside a function causes a syntax error. It provides a way to give a result and stop the function.
Analogy
A return
in a function is like exiting a room with a gift. Imagine a function as a room where work happens. The return
statement is the door out. You can hand something to the person waiting outside, or you can leave with nothing. If you leave without giving anything, the default gift is None
, which means nothing to give.
Historical reference
The return
statement has been part of Python since version 1.0 in the early 1990s. Its behavior has stayed the same. You can return any object, and the function ends when return
runs.
One detail is that returning multiple values with commas automatically creates a tuple. For example, return 1, 2
returns the tuple (1, 2)
. This has been part of Python from the start.
Before generators, return
could not end a loop by sending values. Generators changed that. In a generator, yield
sends values, and a bare return
ends the generator. In Python 3, return value
inside a generator raises StopIteration
with the value, though this is used rarely.
In daily use, return
is simple. It ends a function and gives back a result. Good practice is to make functions return a single clear outcome and avoid many unrelated exit points for better readability.
Example 1: Returning a value from a function
This function adds two numbers and returns the result. We call it and use the returned value. If we didn’t use return
, the function would return None by default, which is not what we want for an addition operation.
def add(a, b):
return a + b
result = add(3, 4)
print(result)
Output:
7
The add
function uses return a + b
to send back the sum of the two arguments. The caller receives 7 and we print it. If we had additional code after the return in the function, it would never execute.
Example 2: Early return and default return
In this example, we demonstrate how return
can be used to exit a function early. We write a function to find the first even number in a list. It returns as soon as it finds one. If it finishes looping without finding any even number, it returns None
implicitly (no explicit return at the end).
def find_first_even(numbers):
for n in numbers:
if n % 2 == 0:
return n # return immediately when an even number is found
# If loop completes with no even number, function reaches end
# (implicitly returns None)
print(find_first_even([1, 3, 5, 6, 7])) # 6 is the first even, should return 6
print(find_first_even([1, 3, 5, 7])) # no even number, should return None
Output:
6
None
In the first call, the function encounters 6 and returns it, so we see 6
printed. In the second call, the loop finds no even number; the function ends without hitting a return, so it returns None
by default. We print that result, which shows up as None
. This illustrates both using return
to exit early and how a missing return is equivalent to return None
.
9. The yield
Statement
Definition
The yield statement is used inside a special kind of function called a generator. It allows the function to produce a sequence of values over time, pausing after each yield and resuming where it left off when called again. When a function contains yield
, it becomes a generator function. Calling the function returns a generator object. Using yield <expression>
sends a value to the caller and pauses the function. The next time the generator is asked for a value, such as with next()
or a loop, the function picks up right after the last yield. This continues until the function ends or returns, at which point the generator is exhausted and raises StopIteration
. Generators are useful for creating iterators with saved state, without building the full list in memory.
Analogy
Using yield
is like a pause button in a TV series. Imagine a generator function as a TV show, and each yield
is an episode. After yielding an episode, the show takes a break. When the audience asks for the next episode, the show resumes from where it left off and plays the next one. The show's state is remembered between episodes. When the show ends, there are no more episodes to yield.
Historical reference
The yield
statement was added in Python 2.2 in 2001 as an optional feature and became fully available in Python 2.3. This addition, described in PEP 255, brought generator functions to Python and made it easier to create iterators. Early generators could only yield values. In Python 2.5, PEP 342 added the ability for generators to receive values with send()
and handle exceptions. Python 3.3 introduced yield from
with PEP 380, which allowed generators to delegate to sub-generators and build more complex pipelines.
Through these updates, the core behavior of yield
stayed the same. It pauses the function, saves local state, and resumes later. A generator can yield many times, while a normal function can only return once. The introduction of yield
was a milestone that influenced other languages and later helped shape Python’s async features, which use await
in a similar way. Today, yield
is widely used for streaming data, reading large files in parts, and creating infinite sequences. Its syntax has not changed since it was first introduced and remains a key part of Python's iterator protocol.
Example 1: Simple generator using yield
This generator function yields a series of values (from 1 up to a given number). We use a for
loop to iterate through the generator’s output. The advantage is that the generator yields one value at a time and maintains state between yields without constructing the full list in memory.
def count_up(to):
for i in range(1, to+1):
yield i # yield each number one by one
# Using the generator
for num in count_up(3):
print(num)
Output:
1
2
3
When count_up(3)
is called, it returns a generator object. The for
loop then repeatedly asks this generator for the next value. The generator function runs and yields 1, then pauses. The loop prints 1. Next iteration, the generator resumes right after the yield, yields 2, pauses; the loop prints 2. Next, yields 3, pauses. After yielding 3 and looping back, the for
in count_up
finishes, and the generator function exits, raising StopIteration internally which tells the loop to stop. This demonstrates the basic use of yield to create a sequence of values.
Example 2: Generator that remembers state between yields
Here’s a generator that demonstrates the function state being preserved between yields. We print messages before and after yielding to show when the function is active. We also manually fetch values using next()
to illustrate the pause/resume behavior.
def demo_generator():
print("Start of generator")
yield 1
print("Between yields")
yield 2
print("End of generator")
gen = demo_generator()
print(next(gen)) # start generator, should print "Start of generator", then yield 1
print(next(gen)) # resume generator, should print "Between yields", then yield 2
try:
next(gen) # resume again, generator will finish and StopIteration will be raised
except StopIteration:
print("No more values")
Output:
Start of generator
1
Between yields
2
End of generator
No more values
Let’s break down what happened:
- The first
next(gen)
call started execution. The generator printed "Start of generator", then hityield 1
. It paused there, returning the value 1. That’s why we see "Start of generator" then1
on the output. - The second
next(gen)
resumed execution right after the first yield. The generator printed "Between yields", then hityield 2
. It paused again, returning 2. So we see "Between yields" then2
. - The third
next(gen)
resumes after the second yield. The generator prints "End of generator" and then reaches the end of the function (no more yields), thus raisingStopIteration
. We catch this and print "No more values".
This example clearly shows how the state is maintained: variables, the position in code, etc., all persist between yields. Each yield
is like a checkpoint that we can resume from later. Generators allow writing code that produces a sequence of results over time, which can be more memory-efficient and convenient than building the whole result list at once.
10. The raise
Statement
Definition
The raise statement is used to raise an exception on purpose. Its syntax is raise ExceptionType(value)
or just raise
to re-raise the current exception inside an exception handler. When Python runs raise
, it stops normal flow and creates an exception object. If no surrounding try/except
handles the exception, it moves up the call stack. If still unhandled, the program stops with a traceback. Use raise
to signal errors or unexpected situations, such as raise ValueError("invalid input")
. The exception must be a class that comes from BaseException
, usually Exception
or a subclass. In Python 3, the syntax is raise ExceptionType("detail message")
or raise
. The old Python 2 form raise ExceptionType, message
is no longer used.
Analogy
Using raise
is like pulling a fire alarm in a building. When you raise an exception, you are signaling that something is wrong. Normal activities stop right away, and the alarm goes up to someone who can handle it. If no one does, the program shuts down with an error message.
Historical reference
The raise
statement has been part of Python from the start, as a core part of its exception handling system in the early 1990s. Python 2 allowed different syntax, like raise IOError, "file not found"
, and even allowed raising strings as exceptions before version 2.0. By Python 2.5, string exceptions were removed. Python 3 cleaned up the syntax to allow only raise ExceptionType(value)
, and all exceptions must come from BaseException
.
Python 3 also added exception chaining with raise Exception(...) from original_exception
, which shows that one error was caused by another and helps with debugging. The way exceptions work has stayed the same: raise
stops the current work and searches for a matching except
block. Developers use raise
for handling errors and assert
for checks. Changes over time removed old forms to make code clearer, but raising an exception has always been how Python signals an error up the call stack.
Example 1: Raising a custom exception for invalid input
In this example, we have a function that checks an age value. If the age is negative, it’s not valid, so we raise ValueError
with an error message. We call the function in a try/except to catch the exception and handle it gracefully.
def check_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return "Valid age"
try:
print(check_age(-5))
except Exception as e:
print("Error:", e)
Output:
Error: Age cannot be negative
Here, check_age(-5)
triggers the raise ValueError("Age cannot be negative")
. This immediately stops check_age
and jumps to the except block. Our except catches it (we used a broad Exception
catch for demonstration) and prints an error message containing the exception’s message. The program doesn’t crash because we handled the exception. If we didn’t catch it, this would have produced a traceback and terminated execution.
Example 2: Raising an exception to stop execution (uncaught)
This example demonstrates an uncaught exception. We simply do a raise RuntimeError
without catching it. This will terminate the program and show a traceback.
raise RuntimeError("Something went wrong")
print("This line will not execute.")
Output:
Traceback (most recent call last):
File "script.py", line 1, in <module>
raise RuntimeError("Something went wrong")
RuntimeError: Something went wrong
As shown, when the raise RuntimeError("Something went wrong")
executes, it produces a traceback. The second print statement is never reached. The Python interpreter prints the traceback with the file name, line number, and the exception type and message. In a real program, you would typically have this inside a try/except if you wanted to recover or handle it; otherwise, as in this snippet, it will crash the program.
(If you wanted to see exception chaining, you could do something like raise RuntimeError("New error") from original_error
, but that is an advanced usage. By default, just raise
or raise ExceptionType(message)
is the standard way to throw an error.)
11. The break
Statement
Definition
The break statement is used inside loops, either for
or while
, to end the loop immediately. When Python runs break
, it jumps out of the innermost loop and skips the rest of the loop's work. Control moves to the first line after the loop. break
only stops the loop it is in, not any outer loops. It is often used to exit a loop early when a certain condition is met, such as finding an item and stopping the search.
Analogy
Hitting break
in a loop is like breaking out of a circle or leaving a meeting early. Imagine searching for a word in a book and stopping as soon as you find it. You break out of the search and move on. In the same way, break
says to stop the loop right away.
Historical reference
The break
statement has been part of Python from the start and works like break
in many other languages. In Python 2, break
could not be used inside a finally
block because of a technical limit. This was fixed in Python 3.8, so now break
works inside finally
as well.
Python also has loop else
clauses. A for
or while
loop can have an else
block that runs only if the loop finishes without hitting break
. This has been in Python since the 2.x versions and is often used in search loops to show that nothing was found.
The behavior of break
has stayed the same over time. Using break
outside of a loop is a syntax error. Best practices suggest keeping break
use simple and clear, avoiding complex loops with many break
statements.
Example 1: Using break in a loop to stop early
In this example, we loop from 1 to 5 but we decide to break out of the loop when we reach 3. Only the numbers 1 and 2 will be fully processed, and the loop will terminate once 3 is encountered (so 3 is not printed, and 4 and 5 are never reached).
for i in range(1, 6):
if i == 3:
print("Found 3, breaking out")
break # exit the loop when i is 3
print(i)
print("Loop ended")
Output:
1
2
Found 3, breaking out
Loop ended
As we see, the loop printed 1 and 2. When i
became 3, the if
condition triggered, it printed the message and executed break
. The loop then stopped completely. The final print statement after the loop confirms that we have exited the loop. (Notice that 3 was not printed inside the loop body because we broke before reaching the print(i)
for that iteration, and 4,5 were never processed at all.)
Example 2: Loop with else and break
Here we demonstrate the use of a loop else
in conjunction with break. We search a list for a specific value. If we find it, we break out. If we finish the loop without finding it, the else
clause will execute.
values = [5, 7, 11, 15]
target = 10
for v in values:
if v == target:
print("Found", target)
break
else:
print(target, "not found in the list")
Output:
10 not found in the list
In this case, the list does not contain 10. The loop goes through all values (5,7,11,15), none trigger the break. After the loop completes normally, the else
clause executes, printing the "not found" message. If the target
were in the list, say 11, the output would be just Found 11
and the else clause would be skipped (because the loop was broken out of). This example shows a Python idiom: using for...else
to handle the "searched but not found" scenario, relying on break.
12. The continue
Statement
Definition
The continue statement is used inside loops to skip the rest of the current iteration and move to the next one. When Python runs continue
, it stops the current cycle of the loop and goes back to the start. In a for
loop, it moves to the next item. In a while
loop, it checks the condition again. Any code below continue
in that iteration is skipped. Use continue
when you want to skip certain items based on a condition but keep looping through the rest.
Analogy
continue
is like saying skip this one and move on. Imagine you are reading a book and decide to skip any page with a coffee stain. When you see a stained page, you turn to the next page without reading the rest. In a loop, continue
works the same way. It ignores the rest of the current step and moves to the next.
Historical reference
The continue
statement has been in Python from the start, working as a companion to break
. Early versions of Python did not allow continue
inside a finally
block in a try/finally
, but Python 3.8 removed that limit. This was a small technical change that most people never noticed.
The behavior of continue
has stayed the same. It is simple and works well when used carefully. Overusing continue
(and break
) can make loops harder to read, so it is best used with clear conditions. In loops with an else
block, continue
does not stop the loop, so the else
block will still run if no break
happens.
Using continue
outside a loop is a syntax error. Aside from the small change in Python 3.8, continue
has remained stable through Python’s history.
Example 1: Skipping even numbers in a loop
In this loop, we only want to process odd numbers. We use continue
to skip over any even number immediately, so only odds are printed.
for n in range(1, 6):
if n % 2 == 0:
continue # skip even numbers
print(n)
Output:
1
3
5
The loop goes 1 through 5. When n
is even (2 and 4), the if
triggers and continue
executes, which jumps to the next iteration (so it doesn’t reach the print for those values). For odd n
(1,3,5), the continue is not hit and the number gets printed. This way, we filtered out the even numbers using continue.
Example 2: Using continue in a while loop
Here we use a while loop and continue
to skip a particular iteration. We increment a counter and want to skip printing the number 3 for some reason.
i = 0
while i < 5:
i += 1
if i == 3:
continue # skip the rest when i is 3
print(i)
Output:
1
2
4
5
In the output, you see 1, 2, and then it jumps to 4, 5. The value 3 is not printed because when i
was 3, the continue
caused the loop to skip the print and go straight to the next iteration (where i becomes 4). Notice that we incremented i
before the continue check. If we had the i += 1
after the continue, we would have had an infinite loop because continue would skip the increment. This example underscores that any code after continue
in that iteration won’t run, so loop variables should be managed accordingly. The loop eventually prints 4 and 5 normally and then ends when i < 5
is false.
13. The import
Statement
Definition
The import statement is used to bring modules or specific parts of modules into your current namespace. There are a few forms:
-
import module_name
brings in the whole module, and you usemodule_name.identifier
to access its contents. -
import module_name as alias
does the same but gives the module a shorter name to use in your code.
Another form using from module import ...
is covered in the next section.
When Python runs import
, it looks for the module in the search path, loads it if needed, and runs its top-level code. Then it creates a module object and binds it to the name in your namespace. If the module was already loaded, import
uses the existing one without running its code again. Modules hold functions, classes, and variables you can use in your program.
Analogy
Importing a module is like bringing in a toolbox. Each Python module is a toolbox full of tools, like functions and classes. When you write import math
, you bring in the math toolbox and use its tools by prefixing them with math.
. If you write import math as m
, you give the toolbox a shorter name. A module is also like a reference book, and import
is like checking out that book so you can look things up as you work.
Historical reference
The import
statement has been part of Python from the start. Over time, the system improved. Packages and the package import system arrived in Python 1.5, and relative imports changed in Python 2 and 3.
Early Python only used full module names. With packages (folders with __init__.py
), nested modules like import os.path
became possible. Python 3 made all imports absolute by default, unless you add a dot to show a relative import.
Python also added the importlib
library for dynamic imports and reloading. The import ... as ...
syntax was added to give modules short names. Python caches modules in sys.modules
so they do not reload if already imported. If a module cannot be found, Python raises ImportError
(changed to ModuleNotFoundError
in Python 3.6).
Behind the scenes, the system keeps getting faster, with improvements like .pyc
bytecode caching. But from a user’s view, import module
has always been the simple, clear way to bring in code.
Example 1: Importing a module and using its functions
We import the built-in math
module and use one of its functions. We then import the datetime
module with an alias to show how aliasing works.
import math
print(math.sqrt(16)) # using math module's sqrt function
import datetime as dt
today = dt.date.today()
print("Today is", today)
Output:
4.0
Today is 2025-03-03
In the first part, we did import math
. That gives us access to everything in the math
module, but we refer to them with the prefix math.
. Calling math.sqrt(16)
returns 4.0. In the second part, import datetime as dt
imports the datetime module but lets us refer to it as dt
. We then use dt.date.today()
to get today’s date. The output shows the date (in ISO format). The exact date will vary depending on when you run the code (here it was 2025-03-03). Using aliases like dt
can make code cleaner or avoid name conflicts. Note that without import datetime
, if we tried to use date.today()
, it would error because date
isn’t defined by default; it lives in the datetime module.
Example 2: Importing a module that isn’t in the standard library (demonstration)
For this example, assume we have a custom module named custom_util.py
located in the same directory or installed. We show how import
will load that module and how to use it. (If you actually run this, ensure custom_util.py
exists or replace it with a known module.)
# Imagine custom_util.py exists with a function named greet()
import custom_util
custom_util.greet("Alice")
Output:
Hello, Alice!
In this hypothetical output, custom_util.greet("Alice")
produced a greeting. The important part is that by doing import custom_util
, Python looked for a file named custom_util.py
(or package) in the current directory or in the Python path. If found, it executed that file, created a module object, and bound it to the name custom_util
. We then accessed its greet
function. If the module wasn’t found, Python would raise an ImportError
. This illustrates using import for your own modules. The mechanics are the same: whether it’s built-in or your code, import module
will load it.
14. The from ... import ...
Statement
Definition
The from-import statement lets you bring specific names from a module directly into your current namespace. The syntax is from module_name import name1, name2
. After this, you can use name1
and name2
without the module name. You can also use an alias with from module import name as alias
. You can import several names separated by commas or use *
to import everything, though *
is discouraged because it clutters the namespace and makes it unclear where names come from. In short, from X import Y
means bring Y
from module X
into your local namespace as if you defined Y
in this file. This is helpful when you only need a few parts of a module.
Analogy
If import math
brings in the math toolbox, then from math import sqrt
is like taking just the sqrt
tool out of the toolbox and putting it on your workbench. Now you can use sqrt
directly without opening the whole toolbox each time. It gives you direct access to specific items.
Historical reference
The from module import name
syntax has been in Python since the early 1.x versions. It has stayed mostly the same. In Python 2, you could not use from module import *
inside a function unless it was at the top of the module, though this was rare.
Python 2 also allowed implicit relative imports, where from module import name
could pull from nearby files. Python 3 removed this and made all imports absolute by default unless you use a dot for explicit relative imports.
For absolute imports, from module import name
works the same in Python 2 and 3, though Python 3 is stricter about not confusing local files with standard modules.
When you use from module import name
, Python first loads the full module if needed, then pulls out the name. If the name is missing, Python raises an ImportError
. With star imports (from module import *
), Python checks the module’s __all__
list, if present, to decide what to import.
Over time, best practices moved away from star imports to avoid clutter and confusion. The from-import
syntax is for pulling out attributes or submodules, not for renaming whole modules. For that, use regular import ... as ...
.
Example 1: Import specific functions from a module
We import just the factorial
function from math
, and two attributes from math
in one line (pi
and sqrt
). Then we use them without the math.
prefix.
from math import factorial
from math import pi, sqrt
print(factorial(5))
print(pi)
print(sqrt(25))
Output:
120
3.141592653589793
5.0
Here, factorial(5)
returns 120. We imported pi
and sqrt
as well, so pi
prints the value of π and sqrt(25)
gives 5.0. We did not need to write math.factorial
or math.pi
because using the from-import brought those names into the local namespace. If we had not imported sqrt
and tried to use it, we’d get a NameError because only factorial
was imported in the first line. Each from-import only brings the specified names.
Example 2: Using alias in from-import and wildcard import
In this example, we show how to alias an imported name, and (for demonstration) how a wildcard import might work.
from math import pow as power # aliasing math.pow to 'power'
print(power(2, 3))
from math import * # import all names (not recommended generally)
print(sin(pi/2))
Output:
8.0
1.0
First, we did from math import pow as power
. This means we can use power
in place of math.pow
. The output 8.0
is 2 to the power of 3. Next, from math import *
brings in all public names from math. After that, we can call sin
and use pi
without qualification. sin(pi/2)
gives 1.0. Normally, from math import *
is discouraged because it dumps a lot of names into your namespace and you may not know which module they came from or if they override existing names. In fact, many linters will warn against it. However, it’s sometimes used in interactive settings or certain specialized scenarios. In regular code, it’s better to import only what you need or import the module and use the prefix for clarity. The aliasing feature (as power
) is helpful when the original name is too long or conflicts with another name in your code.
15. Future Statements (from __future__ import ...
)
Definition
A future statement is a special import that enables new language features not yet standard in the current Python version. The syntax is from __future__ import feature_name
. Future statements are handled at compile time and change how the code runs. They let you use features from future Python versions while still on an older version. Each future feature becomes standard in a specific later version. Past examples include from __future__ import division
to make /
do true division and from __future__ import print_function
to use the Python 3 style print()
in Python 2. In modern Python 3, some future features still exist, such as annotations
from Python 3.7 and generator_stop
. A future statement must appear at the top of a file, after any comments or docstrings but before other code.
Analogy
A future import is like a preview mode or compatibility switch. Imagine a car maker introduces a new fuel, but your car uses the old kind. A future statement is like adding a kit that lets your car run on the new fuel before all cars switch. It lets you use the future standard early.
Historical reference
The __future__
system started in Python 2.1 in 2001 to help with breaking changes. PEP 236 described the idea. One of the first uses was enabling nested scopes, which became standard in Python 2.2. Another early example was true division, which became standard in Python 3.0.
Over time, other features were added through future imports:
- Generators with
yield
in Python 2.2 - Absolute imports in Python 2.5
- The print function and unicode literals in Python 2.6 and 2.7
- Annotations in Python 3.7
Each future feature has two version numbers: the version where it is optional and the version where it becomes standard. For example, division
was optional in Python 2.2 and mandatory in Python 3.0.
In Python 3, most future features from Python 2 are now standard. The __future__
module still exists for backward compatibility and new ideas. For example, from __future__ import annotations
from Python 3.7 is still optional in Python 3.10 and later. Future imports today are mostly used when writing code that needs to run on several Python versions with different defaults. Future statements were important during the move from Python 2 to 3, making it possible to test and use new features early while keeping code working on old versions.
Example 1: Using a future import for true division (Python 2.x scenario)
Note: In Python 3, true division is default, but let's demonstrate what it would have been like in Python 2. In Python 2, 5/2
would give 2 (floor division for integers) unless you import division from future. The example below shows intended behavior under Python 2.7:
# Python 2.x example, in Python 3 this import is not needed as it's default
from __future__ import division
print(5 / 2) # with the future import, this will do true division
Output (if run in Python 2.x):
2.5
Without the future import, Python 2 would have printed 2
(because it would perform integer division). The future statement from __future__ import division
tells the Python 2 interpreter to use Python 3’s division behavior, so 5/2
yields 2.5. In Python 3, this import is unnecessary since true division is already the default. This future import was available starting in Python 2.2 (future — Future statement definitions — Python 3.13.2 documentation) and became standard in Python 3.0.
Example 2: Using a future import for annotations (Python 3.7+ feature)
Python 3.7 introduced an optional future feature annotations
(PEP 563), which changes how function/variable annotations are stored (as strings rather than evaluated objects). This can improve performance by not evaluating annotation expressions at function definition time. Here's an example demonstrating its effect:
from __future__ import annotations
def greet(name: str) -> str:
return "Hello, " + name
print(greet.__annotations__)
Output:
{'name': 'str', 'return': 'str'}
Normally, without the future import (in Python 3.10 for instance), greet.__annotations__
would be {'name': <class 'str'>, 'return': <class 'str'>}
where the values are actual types. But with from __future__ import annotations
, the annotations are kept as un-evaluated strings ('str'
instead of class 'str'
). This was a future feature to postpone evaluation of annotations to speed up runtime and avoid issues with forward references. As of Python 3.11, this future import is still honored (the switch to make it default was deferred to a later version or a different approach). The example shows that by using the future import, our annotations are stored as strings. This doesn’t change program logic but could affect tools or introspection. It's a modern example of a future statement in action.
(These future imports must be at the top of the file. If you try to put them later, they won’t work and Python will complain. They essentially communicate with the compiler.)
16. The global
Statement
Definition
The global statement declares that certain variable names inside a function refer to global variables at the module level. The syntax is global var1, var2
and is placed at the top of a function before using the variables. Normally, when you assign to a variable inside a function, Python treats it as a new local variable. To assign to a variable from the global scope, you must declare it as global in the function. The global
statement does not create the variable itself. It only tells Python that assignments to those names should affect the global versions. You can list multiple names separated by commas. Using global
lets a function modify module-level variables.
Analogy
Declaring a variable as global in a function is like telling the function to use a billboard instead of a sticky note. Normally, a function writes on its own sticky notes (local variables). By marking a name as global, you tell the function to write on the big billboard outside (the global variable), where everyone can see it. Changes happen on the billboard, not just inside the function’s private notebook.
Historical reference
Global variables and the global
statement have been part of Python from the beginning. Early Python had simple scoping with only local and global scopes. The global
statement let functions change variables in the module’s global scope, and this has stayed the same.
One limit is that global
only works with module-level variables, not variables in an outer function. Python 3 added nonlocal
to fill that gap. In Python 2, there was no direct way to assign to a variable from an enclosing function scope, so people used workarounds like mutable containers.
The global
keyword has not changed. If you only read a global variable in a function, you do not need to declare it. But if you assign to it, you must use global
, or Python will treat it as a new local variable.
While global variables can make code harder to manage, the global
statement is there when needed.
Example 1: Modifying a global variable inside a function
We have a global counter. We create a function that increments it using the global statement. Without declaring global count
, the assignment inside increment()
would make a local variable named count and not affect the global one.
count = 0 # global variable
def increment():
global count
count += 1 # modifies the global 'count' instead of creating a local one
increment()
increment()
print(count)
Output:
2
The output is 2 because each call to increment()
increased the module-level count
by 1. If we omitted global count
inside the function, Python would have thrown an UnboundLocalError at count += 1
because it would think we’re trying to use a local variable count
before assigning any value to it (since count
was assigned in the function, Python would treat it as local by default, making the count += 1
invalid as it had no initial local value). By declaring it global, we ensure count
refers to the variable defined outside. After two increments, the global count
changed from 0 to 2.
Example 2: Using multiple globals and interacting with locals
In this example, we show that global only affects the names listed. We use a global and a local variable with the same name to illustrate the difference (this is generally not a good practice, but for demonstration).
x = 10
y = 20
def set_vars():
global x # we declare x as global, but not y
x = 100 # this will change the global x
y = 200 # this creates/uses a local y, shadows global y
set_vars()
print("x =", x)
print("y =", y)
Output:
x = 100
y = 20
Here’s what happened:
- We declared
global x
in the function, so when we assign to x, it affects the globalx
. Thus, after callingset_vars()
, the globalx
becomes 100. - We did not declare
global y
. So when we assigny = 200
inside the function, it treatsy
as a local variable (this does not touch the globaly
). That localy
exists only insideset_vars
and goes away after the function. The globaly
remains unchanged at 20. The output confirms this:x
was changed globally,y
stayed as it was. This example illustrates that you must list each name you want to be global. Also, it shows how a global name can be shadowed by a local if not declared global.
17. The nonlocal
Statement
Definition
The nonlocal statement is used in nested functions to show that a variable refers to a previously defined variable in the nearest enclosing function scope, not the global scope. This lets inner functions assign to variables from an outer function. The syntax is nonlocal var1, var2
inside a function that is nested within another function. Declaring a name as nonlocal makes assignments affect the variable in the outer function, instead of creating a new local variable. This works like global
, but for enclosing function scopes. It does not work at the module level; for that, you use global
.
Analogy
If local variables are sticky notes and global variables are billboards, then nonlocal
is like writing on your parent function’s whiteboard. The inner function normally has its own notepad. Marking a variable as nonlocal says, “Don’t use the notepad. Use the whiteboard in the next room.” It is like a child using a parent’s variable instead of making a new one.
Historical reference
The nonlocal
keyword was added in Python 3.0 in 2008 to fill a gap in variable scoping. Before this, nested functions had no way to reassign variables from outer functions. People worked around this by using mutable objects or making the variable global. PEP 3104 introduced nonlocal
to fix this.
Since then, the behavior has stayed the same. nonlocal
only works inside nested functions. If you try to use it for a variable that does not exist in an outer function or is global, Python raises an error.
Nonlocal helps when making closures that need to update values from an outer scope, such as function factories or cases where you want to keep state without using globals. The variable must already exist in the outer function before you can mark it as nonlocal. This keyword has not changed since it was added and remains a simple but important tool for working with closures.
Example 1: Using nonlocal to modify an outer variable
Here we have an outer function that defines a variable, and an inner function that uses nonlocal
to modify that variable. We call the inner function to see the effect on the outer variable.
def outer():
x = 5
def inner():
nonlocal x # declare that we're using the 'x' from outer scope
x += 3 # modify outer's x
print("inner x:", x)
inner()
print("outer x:", x)
outer()
Output:
inner x: 8
outer x: 8
Explanation: Initially, x
in outer
is 5. The inner
function declares nonlocal x
, meaning it will refer to the x
in outer
's scope. The line x += 3
thus increments the outer x
. When we call inner()
, it prints inner x: 8
(since 5+3=8). After that, back in outer
, we print outer x: 8
, confirming that the outer function’s x
was indeed updated by the inner function. Without nonlocal x
, the statement x += 3
inside inner
would have tried to create a local x
and given an UnboundLocalError (because it would treat x as local but it's used in an expression before assignment). The nonlocal
statement enables this closure behavior.
Example 2: Nonlocal vs global
This example shows a scenario with a global, an outer function variable, and an inner function. It highlights how nonlocal and global target different scopes.
y = 100 # global variable
def outer_func():
y = 10 # outer function variable (shadows global y inside this function)
def inner_func():
global y # refers to the global y
y = 1 # this will change the global y, not the outer_func's y
inner_func()
print("outer_func y:", y)
outer_func()
print("global y:", y)
Output:
outer_func y: 10
global y: 1
Let’s interpret this:
- We have a global
y = 100
. - In
outer_func
, we assigny = 10
. This creates a localy
inouter_func
, unrelated to the global (the globaly
is shadowed within outer_func). - In
inner_func
, we declareglobal y
. That means when we doy = 1
in inner_func, it will assign to the globaly
. - We call
inner_func()
. It sets the globaly
from 100 to 1. - We then print
y
inside outer_func. Outer_func’s owny
is still 10, unaffected by inner_func (inner_func was dealing with global y). - After outer_func returns, we print the global
y
, which is now 1. This shows the difference:global y
in inner_func did not touch outer_func’s localy
. If we wanted to affect the outer_func’s y from inner_func, we would usenonlocal y
(and not have a global y at all in this example). This demonstrates that global and nonlocal apply to different scopes. In summary, nonlocal is for the nearest enclosing function scope, and global is for the module scope.
(If we changed inner_func
to use nonlocal y
instead, it would have modified the outer_func’s y from 10 to 1, and left the global y at 100. But nonlocal wouldn’t be allowed here unless we remove the global y or use a different name, because currently the nearest enclosing scope’s y is 10 (outer_func’s y), which is fine, but we also have a global y which might confuse the scenario. Generally, avoid using the same name for globals and enclosing variables to prevent confusion.)
18. The print
Statement (Python 2) / print()
Function (Python 3)
Definition
In Python 2, print
was a statement that sent output to the console. In Python 3, print
became a built-in function. Both do the same job of displaying text, but the syntax changed. In Python 2, you wrote print "Hello"
without parentheses and used special syntax like commas to control line endings or >>
to redirect output. In Python 3, you write print("Hello")
. The print
function takes flexible arguments like sep
, end
, file
, and flush
, making it more powerful and consistent. Changing print
from a statement to a function removed it as a keyword and made its behavior clearer.
Analogy
The print
statement or function is like a loudspeaker or logger for your program. It is how your program speaks to the outside world, usually through the console. In Python 2, it was like a built-in command with its own special rules. In Python 3, it became a tool you can configure, like a megaphone you can adjust with settings such as the separator, end character, or output location.
Historical reference
In Python 1 and 2, print
was a statement, like if
or for
, and handled output with its own syntax. It had quirks, like using a trailing comma to avoid a newline or print >> file, ...
to send output to a file. In Python 3.0, print
was changed to a regular function through PEP 3105. This removed it as a special-case keyword and added options through function arguments instead of special syntax.
Python 2.6 and later let you write from __future__ import print_function
to use the Python 3 style in Python 2.
In Python 3, print
is only a function. Writing print
without parentheses is now a syntax error. Turning print
into a function also made it possible to rebind or replace it, since it is just a name in the built-in namespace. Python 3 added more features to print
, like the flush
argument to control output buffering.
Since Python 3.7, the print()
function has stayed the same. Switching old Python 2 code to use print()
is one of the most visible changes when moving to Python 3.
Example 1: Python 2 style print vs Python 3 print()
The following shows how one would print in Python 2 versus Python 3. (This is for illustration; the Python 2 style will not run in Python 3 without the future import.)
# Python 2 syntax (will cause SyntaxError in Python 3)
# print "Hello", "World"
# Python 3 syntax
print("Hello", "World")
Output (Python 3):
Hello World
In Python 2, you could write print "Hello", "World"
and it would print Hello World
(with a space by default between arguments, and a newline at the end). In Python 3, the equivalent is print("Hello", "World")
. The output shown is for the Python 3 code. If you tried the Python 2 line in Python 3, you’d get a syntax error. In Python 2, the print statement automatically adds a space between items and a newline at the end, similar to the print function’s default behavior. The difference is mostly syntactical here.
Example 2: Using print() function features (Python 3)
Python 3’s print being a function offers more control. Here we demonstrate using the sep
(separator) and end
parameters, which were not available with the old print statement (at least not in as clear a way).
print("Monday", "Tuesday", "Wednesday", sep=" | ")
print("Hello", end=" ")
print("World", end="!\n")
Output:
Monday | Tuesday | Wednesday
Hello World!
In the first print call, we printed three words separated by " | "
. Normally, print separates multiple arguments with a space by default, but by specifying sep=" | "
, we changed it. The result is those words joined by " | "
. In the second and third print calls, we demonstrate changing the end
argument. The first print("Hello", end=" ")
prints "Hello" but ends with a space instead of a newline. Therefore, the next print’s output will continue on the same line. The next print("World", end="!\n")
prints "World" and ends with an exclamation and a newline (we explicitly put \n
). The combined output of these two prints is "Hello World!" on one line (the first print output "Hello " then second output "World!"). In Python 2, to achieve the same effect, you might have written print "Hello",
(with a comma to avoid newline) and then print "World!",
etc., which was less flexible. The Python 3 approach is more powerful and clear.
Example 3: Legacy Python 2 print to file vs Python 3
For completeness: In Python 2, you could direct print output to a file using the syntax print >>file_obj, "message"
. In Python 3, you do this by print("message", file=file_obj)
. For example:
import sys
print("Error occurred!", file=sys.stderr)
Output: (to standard error, not standard output)
Error occurred!
This will print the message to the standard error stream (which in many environments appears separately from standard output). In Python 2, you’d write print >> sys.stderr, "Error occurred!"
. The new syntax is more consistent with how you pass other parameters to functions. This example shows the modern way, leveraging the file
parameter of the print function.
19. The exec
Statement (Python 2) / exec()
Function (Python 3)
Definition
exec
was a statement in Python 2 that executed a string of Python code at runtime. In Python 3, exec()
is a built-in function that does the same thing. It takes Python code, usually as a string or compiled code object, and runs it as part of the program. In Python 2, the syntax was exec code_str
or exec code_str in globals_dict, locals_dict
. In Python 3, the syntax is exec(code_str, globals_dict, locals_dict)
. The exec()
function runs the code with optional global and local namespaces. It returns None
and runs code only for its side effects.
Analogy
exec
is like a tiny Python interpreter inside your program. It lets your program write a script and run it immediately. This is powerful but risky if used with untrusted input because it can execute any code. It is like having a magic book where anything you write becomes real actions in your program.
Historical reference
In Python 2, exec
was a statement with its own syntax. You could write exec "x = 5"
to run that code. In Python 3, as part of language cleanup, exec
became a function-like built-in. Now you write exec("x = 5")
. This change simplified the language and fixed some parsing issues.
Python 2 also had execfile()
to run the contents of a file. In Python 3, the same thing is done with exec(open(file).read())
.
Over time, the use of exec
has been discouraged except in specific cases, such as code generation or educational tools, because it can make code harder to understand and creates security risks if handling outside input.
One small difference is how exec
handles local variables. In Python 2, exec
could change the local scope directly. In Python 3, since exec
is a function, it does not easily update the caller's local variables unless you provide a dictionary for the context.
Aside from these changes, exec("code")
in Python 3 works much like exec "code"
in Python 2 when used in a global context or with a provided namespace. The main historical change was turning exec
from a statement into a function in Python 3 to make the language more consistent.
Example 1: Executing dynamically created code
We have a string containing Python code. We use exec()
to run it. After exec, any definitions made by that code (variables, functions) will be available in our scope (if we exec in the global namespace).
code = """
a = 5
b = 10
print(a + b)
"""
exec(code)
Output:
15
Explanation: The string code
contains three lines of Python code. The exec(code)
call executes those lines as if they were written in the program at that point. It creates variables a
and b
, adds them, and prints the result. We see 15 printed. After this, we could also access a
and b
in our Python environment (they would exist in the global namespace following exec). This demonstrates how exec can define variables on the fly. Note that using exec like this can be risky if code
comes from an untrusted source, because it can execute arbitrary commands. But it's useful for dynamic programming tasks.
Example 2: Using exec to define a function dynamically
This example shows exec creating a new function at runtime and then calling it. We compose a function definition as a string, execute it, and then use the newly created function.
func_code = """
def greet(name):
print("Hello, " + name)
"""
exec(func_code)
# Now greet is defined
greet("Bob")
Output:
Hello, Bob
Here, the string in func_code
defines a function greet(name)
. After exec(func_code)
, the function greet
exists in the current global namespace. We then call greet("Bob")
and it prints "Hello, Bob". This shows how exec can be used to generate functions or classes dynamically. While you could achieve similar results by other means (like using closures or factory functions), exec provides a very direct way to materialize code from text. Again, for security and clarity, this is typically done in controlled situations.
Example 3: Using exec with a custom namespace
In this example, we’ll execute code in a separate dictionary to avoid polluting our global namespace.
code = "x = 42"
namespace = {}
exec(code, namespace)
print("x in global?", "x" in globals())
print("x in namespace?", namespace.get("x"))
Output:
x in global? False
x in namespace? 42
We created an empty dictionary namespace
. By passing namespace
to exec
, the code "x = 42"
executes such that x
is set in that dictionary rather than in our global variables. After exec, we check that x
is not in globals (so our module’s global scope was untouched), but in the namespace dict, x
is 42. This way, exec can be used in a sandboxed manner (to some extent) or at least to control where the executed code has effect. If the code had a print statement, it would still print to standard output, but variables and definitions went into that provided dictionary. Using exec(code, globals_dict, locals_dict)
allows fine control of execution context.
This example underscores that exec
in Python 3 is a function that can take separate global and local dictionaries. In Python 2, one would write exec code in globals_dict, locals_dict
with slightly different syntax. The outcome is the same idea.
Got it! Here's the {next-steps} section in that style:
Next Steps with Python Simple Statements
You’ve just gone through a full guide on Python simple statements. Now’s the time to make it stick. Keep this close—print it out, bookmark it, or read through it during lunch. The goal is to make these statements feel like second nature.
Print the Guide
Set aside a paper copy. There’s something about seeing it on the page. Mark it up. Highlight the parts that trip you up. Keep it at your desk or in your bag.
Read During Breaks
Take five or ten minutes when you can. A lunch break or a quiet moment is perfect. Skim the examples. See if you can spot how they work without looking at the notes.
Bookmark for Quick Checks
Save this guide in your browser. When you’re coding and hit a wall—check back. Forgot how yield
works? Need a reminder on global
? It’s all here.
Practice One at a Time
Pick one statement a day. Write it out. Change it. Break it. Fix it. You don’t need a big project—just a few lines to get the feel for it.
Watch for Them in the Wild
As you read other people’s code, notice which simple statements show up. How do they use them? What tricks do they have? Borrow what works.
Stick with it. Small steps add up fast. Before long, these statements won’t just make sense—they’ll feel like old friends.
What’s Your Take on Python Simple Statements?
Now that you've seen how Python simple statements work, I'd love to hear what you think. Are there any statements you reach for often? Are some still a bit unclear?
What’s been the most useful or surprising Python simple statement in your own work? Have you found any clever ways to use them?
Share your thoughts below. Your tips and questions help others learn, too. What do you like?
Mike Vincent is an American software engineer and writer based in Los Angeles. Mike writes about technology leadership and holds degrees in Linguistics and Industrial Automation. More about Mike Vincent
Top comments (0)