DEV Community

Cover image for Ultimate Guide to Python's 19 Simple Statements (Definitions, History, Examples)
Mike Vincent
Mike Vincent

Posted on

Ultimate Guide to Python's 19 Simple Statements (Definitions, History, Examples)

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, and raise
  • Loop control with break and continue
  • Import statements for accessing external code
  • Name control with global and nonlocal
  • Legacy statements like print and exec

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")
Enter fullscreen mode Exit fullscreen mode

Output:

  Python!
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Output:

  Done
Enter fullscreen mode Exit fullscreen mode

(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)
Enter fullscreen mode Exit fullscreen mode

Output:

  5
  1 2
  2 1
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  1 2 3
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  15
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  Hello World
  [1, 2, 3, 4, 5]
Enter fullscreen mode Exit fullscreen mode

(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)
Enter fullscreen mode Exit fullscreen mode

Output:

  25
  twenty five
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

Output:

  {'data': <class 'float'>}
  {'x': <class 'int'>, 'y': <class 'int'>}
Enter fullscreen mode Exit fullscreen mode

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...")
Enter fullscreen mode Exit fullscreen mode

Output:

  x is small, continuing...
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  None
  Created object of MyEmptyClass: <__main__.MyEmptyClass object at 0x7f...>
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

Output:

  Checked x, moving on...
  Loop index 2, executing action.
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  NameError: name 'a' is not defined
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  ['apple', 'cherry', 'date']
  {'name': 'Alice', 'city': 'NYC'}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  7
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Output:

  6
  None
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  1
  2
  3
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Output:

  Start of generator  
  1  
  Between yields  
  2  
  End of generator  
  No more values  
Enter fullscreen mode Exit fullscreen mode

Let’s break down what happened:

  • The first next(gen) call started execution. The generator printed "Start of generator", then hit yield 1. It paused there, returning the value 1. That’s why we see "Start of generator" then 1 on the output.
  • The second next(gen) resumed execution right after the first yield. The generator printed "Between yields", then hit yield 2. It paused again, returning 2. So we see "Between yields" then 2.
  • 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 raising StopIteration. 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)
Enter fullscreen mode Exit fullscreen mode

Output:

  Error: Age cannot be negative
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

Output:

  Traceback (most recent call last):
    File "script.py", line 1, in <module>
      raise RuntimeError("Something went wrong")
  RuntimeError: Something went wrong
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Output:

  1  
  2  
  Found 3, breaking out  
  Loop ended  
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Output:

  10 not found in the list
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  1  
  3  
  5  
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  1  
  2  
  4  
  5  
Enter fullscreen mode Exit fullscreen mode

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 continuecaused 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 use module_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)
Enter fullscreen mode Exit fullscreen mode

Output:

  4.0  
  Today is 2025-03-03  
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Output:

  Hello, Alice!
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

Output:

  120  
  3.141592653589793  
  5.0  
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

Output:

  8.0  
  1.0  
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Output (if run in Python 2.x):

  2.5
Enter fullscreen mode Exit fullscreen mode

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__)
Enter fullscreen mode Exit fullscreen mode

Output:

  {'name': 'str', 'return': 'str'}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  2
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

  x = 100  
  y = 20  
Enter fullscreen mode Exit fullscreen mode

Here’s what happened:

  • We declared global x in the function, so when we assign to x, it affects the global x. Thus, after calling set_vars(), the global x becomes 100.
  • We did not declare global y. So when we assign y = 200 inside the function, it treats y as a local variable (this does not touch the global y). That local y exists only inside set_vars and goes away after the function. The global y 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()
Enter fullscreen mode Exit fullscreen mode

Output:

  inner x: 8  
  outer x: 8  
Enter fullscreen mode Exit fullscreen mode

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 xand 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)
Enter fullscreen mode Exit fullscreen mode

Output:

  outer_func y: 10  
  global y: 1  
Enter fullscreen mode Exit fullscreen mode

Let’s interpret this:

  • We have a global y = 100.
  • In outer_func, we assign y = 10. This creates a local y in outer_func, unrelated to the global (the global yis shadowed within outer_func).
  • In inner_func, we declare global y. That means when we do y = 1 in inner_func, it will assign to the global y.
  • We call inner_func(). It sets the global y from 100 to 1.
  • We then print y inside outer_func. Outer_func’s own y 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 local y. If we wanted to affect the outer_func’s y from inner_func, we would use nonlocal 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")
Enter fullscreen mode Exit fullscreen mode

Output (Python 3):

  Hello World
Enter fullscreen mode Exit fullscreen mode

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 endparameters, 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")
Enter fullscreen mode Exit fullscreen mode

Output:

  Monday | Tuesday | Wednesday  
  Hello World!
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output: (to standard error, not standard output)

  Error occurred!
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

15
Enter fullscreen mode Exit fullscreen mode

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 codecomes 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")
Enter fullscreen mode Exit fullscreen mode

Output:

Hello, Bob
Enter fullscreen mode Exit fullscreen mode

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"))
Enter fullscreen mode Exit fullscreen mode

Output:

x in global? False  
x in namespace? 42  
Enter fullscreen mode Exit fullscreen mode

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)