Skip to main content
Python · lesson 3 of 34
Python beginner Lesson 3 of 34

Python Variables and Type System

See how Python variables work by running them: names as references, mutable vs immutable objects, dynamic typing, PEP 8 names and LEGB scope.

By SkillByExample · Updated

Variables in Python are references to objects in memory. Unlike languages like C++ or Java, Python does not require explicit type declarations. The type of a variable is determined at runtime based on the object it references. Output below is from Python 3.13.

Variables as References

Assignment never copies an object; it makes a name refer to one. Two names can therefore refer to the same object.

x = 42
y = x
print(x is y)

x = x + 1
print(x, y)

a = [1, 2, 3]
b = a
b.append(4)
print(a, a is b)

c = a.copy()
c.append(5)
print(a, c)
$ python references.py
True
43 42
[1, 2, 3, 4] True
[1, 2, 3, 4] [1, 2, 3, 4, 5]

y = x made both names refer to the same 42 — is checks exactly that. x = x + 1 didn’t change that object (ints are immutable); it built a new 43 and moved the name x to it, so y still says 42. Lists are mutable: b.append(4) changed the one list both names share, so the change shows through a. copy() makes a separate list when you want independence.

Dynamic typing

A name has no type of its own — the object it refers to does — so the same name can refer to objects of different types over time.

value = 42
print(type(value).__name__)
value = "forty-two"
print(type(value).__name__)
value = [4, 2]
print(type(value).__name__, len(value))

try:
    print("answer: " + 42)
except TypeError as e:
    print("TypeError:", e)
$ python dynamic.py
int
str
list 2
TypeError: can only concatenate str (not "int") to str

Rebinding value to a string or a list is allowed. But Python is strongly typed: it won’t silently turn 42 into text — you have to convert it with str(42) or an f-string. Type hints (see type hints) let a checker catch such mismatches before the code runs.

Naming Conventions (PEP 8)

PEP 8, the standard style guide, uses snake_case for variables and functions and UPPER_SNAKE_CASE for constants. The real trap is reusing a built-in’s name:

user_name = "Alice"
MAX_RETRY_COUNT = 3

list = [1, 2, 3]            # shadows the built-in list type
try:
    letters = list("abc")
except TypeError as e:
    print("TypeError:", e)

del list                    # remove the shadowing name
print(list("abc"))
$ python shadowing.py
TypeError: 'list' object is not callable
['a', 'b', 'c']

After list = [1, 2, 3], the name list found your list before the built-in type, so calling it failed — often far from the line that caused it. Deleting the global name let lookup fall through to the built-in again. Avoid names like list, dict, str, id and input for your variables.

Scope resolution (LEGB)

Python looks up a name in four scopes in order: Local (the current function), Enclosing (outer functions), Global (the module) and Built-in.

prefix = "User: "                     # global

def greet(name):
    message = f"Hello, {name}"        # local to greet

    def format_output():
        return f"{prefix}{message}"   # message: enclosing, prefix: global

    return format_output()

print(greet("Alice"))
print(len(prefix))                    # len: built-in
try:
    print(message)
except NameError as e:
    print("NameError:", e)
$ python scope.py
User: Hello, Alice
6
NameError: name 'message' is not defined

format_output has no variables of its own, yet it found message one level out and prefix at module level. Outside greet, message doesn’t exist — local names live only while the function runs.

global and nonlocal

Reading an outer name needs nothing special, but assigning to it inside a function needs global (module level) or nonlocal (enclosing function).

count = 0

def increment():
    global count
    count += 1

increment()
increment()
print(count)

def make_counter():
    total = 0
    def step():
        nonlocal total
        total += 1
        return total
    return step

tick = make_counter()
print(tick(), tick(), tick())
$ python global_nonlocal.py
2
1 2 3

Without the declarations, count += 1 and total += 1 would each create a new local variable — and fail, as Practice 2 shows. Prefer nonlocal closures or return values over global; module-level state shared by many functions is hard to test.

Practice

1. Predict whether two names bound to the same short string literal refer to the same object, then check with is. Is that something to rely on?

Solution
a = "hello"
b = "hello"
print(a is b, a == b)

c = "".join(["hel", "lo"])
print(c is a, c == a)
$ python task1.py
True True
False True

CPython reuses identifier-like string literals, so a is b happens to be true; a string built at runtime is a different object with equal contents. That’s an implementation detail — compare strings with ==.

2. Remove the global count line from global_nonlocal.py and call increment(). What error appears, and why?

Solution
count = 0

def increment():
    count += 1  # no global statement

increment()
$ python task2.py
Traceback (most recent call last):
  File "/home/user/py-demo/variables/task2.py", line 6, in <module>
    increment()
    ~~~~~~~~~^^
  File "/home/user/py-demo/variables/task2.py", line 4, in increment
    count += 1  # no global statement
    ^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

Because increment assigns to count, Python treats count as local for the whole function. count += 1 then reads that local before it has a value. global count tells Python the name belongs to the module scope.

3. Write add_tag(tags) that appends "new" to the list it receives, call it with a list, and print the list afterwards. Then make a version that leaves the caller’s list untouched.

Solution
def add_tag(tags):
    tags.append("new")
    return tags

def add_tag_copy(tags):
    return tags + ["new"]

mine = ["a"]
add_tag(mine)
print(mine)

theirs = ["b"]
result = add_tag_copy(theirs)
print(theirs, result)
$ python task3.py
['a', 'new']
['b'] ['b', 'new']

The function received a reference to the caller’s list, so append changed it. tags + ["new"] builds a new list and leaves the argument alone.

Frequently Asked Questions

Is Python statically or dynamically typed?
Python is dynamically typed. This means variables do not have fixed types; they are references to typed objects in memory.
What is variable scoping in Python?
Variable scoping determines the accessibility of variables. Python resolves names using the LEGB rule: Local, Enclosing, Global, and Built-in scopes.
Are Python variables like C variables?
No. C variables are memory containers with a fixed type; Python variables are references to objects that already exist in memory. Reassigning a Python variable just changes which object it points to.
Why does Python use references instead of values?
References avoid expensive copies for large objects like lists and dicts. When you pass them to a function, only the reference is copied — the underlying data isn't duplicated. This enables efficient in-place mutation patterns.
What happens when two variables reference the same mutable object?
Mutations through one variable are visible through the other, since they point to the same object in memory. Use `copy()` or `list()` for a shallow copy, or `copy.deepcopy()` for nested structures that need full isolation.