When you use a name in a Python program such as variable name function name,etc,  Python creates, changes or looks up the name in a namespace.  A namespace is the complete list of names  that exists in a given context.

There are two types of namespaces, global namespace and local namespace.

The scope of an object determines the locations in the program where it can be accessed, by default, objects are only accessible from within the namespace that they occur. Objects in the global namespace are accessible from anywhere in the program, this are the names that are declared at the top level of a module or a script i.e not inside a function, a class, etc. On the other hand,  names  declared inside a block such as a function are local to that block and are only accessible within the block.

When we define a function, Python sets up a local namespace for the function. Any object declared inside the function will only be accessible  within the function, the object is said to be local to that function. Trying to access local objects outside their scope will raise a NameError. For example:

def demo():
    x = 100
    print(x)

demo()
print(x)

Objects inside a function are localized such that they will not crash with objects with similar names outside that function.This allows same name to be used for different objects in different scopes without causing conflicts.

If a function declares a name that also exists in the global namespace, the local name takes precedence over the global name and overwrites it only inside that function. Example:

x = 200
def demo():
    x = 100
    print(x)

demo()

print(x)

In the above example,  there are two distinct variables with a name x, one in the global namespace with a value of 200 and another in function demo's local namespace with a value of 100. The value of x is, therefore,  determined by  the location where we are using it. Making any changes to the 'x' inside the function demo does not affect in any way the value of the 'x' outside its scope.

Name Resolution, The LEGB Rule

When we mention an object by name, the Python interpreter follows an inside-out approach in order to identify the object we are referring to. This approach is often referred to as the LEGB rule, which stands for Local, Enclosing, Global, Built-in. This rule can be summarized as follows:

  1. Local, first: Look for this name first in the local namespace and use local version if available. If not, go to higher namespace.
  2. Enclosing, second: If the current function is enclosed within another function, look for the name in that outer function. If not, go to higher namespace.
  3. Global, third: Look for the name in the objects defined at global namespace
  4. Built-in,last: Finally, look for the variable among Python’s built-in names.

LEGB rule

If the interpreter goes through all the 4 stages and does not locate the name, a NameError is raised, of course if the operation was not an assignment operation in which case an object with that name will be created in the local scope instead of raising the error. 

x = 200
def demo():
    x = 300
    def inner():
        print(x)
    inner()

demo()

In the above case, in the call to print x, the interpreter fails to find an object with a name x in the function inner's  scope, it moves outward to the enclosing function in which case it finds an 'x' with a value of 300 thus terminating the search. If still an object with a name 'x' was not located in the enclosing function 'demo',  the global x with a value of 200 would have been returned. 

x = 500
def demo():
   print(x)

demo()

def demo2():
   y = 400
   def inner():
       print(x + y)
   inner()

demo2()

The Global Statement

The global statement is the only thing  that is capable of transforming a name defined inside a function to  a global name. This means that the name will behave just like names defined in the global namespace and will be accessible from anywhere in the program.

def demo():
   global x
   x = 200

demo()
print(x)

If a global name exists similar to the name specified in the global statement, any modifications done to the object it identifies will take effect at a global level. 

x = 100
def demo():
    global x
    x = 500

demo()
print(x)

To declare multiple global names in one global statement, we use the global keyword followed by the comma separated names, example:

def demo():
   global x, y, z
   x = 4
   y = 3
   z = x + y

demo()
print(x, y, z)

While the global statement can be useful at times,  it can make code mor obscured as it can make it difficult to keep track of the global names. It should, therefore, be avoided whenever possible, after all names inside a function are made local to that function by default because it is the best policy. We can Instead,  pass global values as function arguments and then return the modified values.

The nonlocal Statement

The nonlocal statement is a close cousin of the global statement. While the global statement makes a name available at global level,  the  nonlocal statement makes a name available to the enclosing function's scope. This also allows the nested function to make changes to variables defined in the enclosing function. Examples:

def demo():
    x = 400
    def inner():
        nonlocal x
        x = 600
    inner()
    print(x)

demo()

def demo2():
    y = 0
    def inner():
        nonlocal y 
        y = 500
    inner()
    print(y)  #y has been changed

demo2()

Note: We cannot use the nonlocal statement with a top level function, trying this will raise a SyntaxError

def demo():
    nonlocal y
    y = 100

demo()