Lesson 3 of Python Basics · Updated June 2026

Python Variables
Naming, Assignment, and Scope

Variables are the containers that hold your data. Learn how Python binds names to values, how scope determines what a variable refers to, and how Python's memory model differs from other languages.

variables_demo.py
name = "Alice"
age = 30
is_student = False

print(f"{name} is {age} years old.")

age = age + 1
print(f"Next year, {name} will be {age}.")
$ python variables_demo.py
Alice is 30 years old.
Next year, Alice will be 31.

📋 What This Lesson Covers

Everything you need to know about Python's most fundamental building block

📦 Why Variables Matter

A variable is a name that refers to a value stored in memory. Without variables, every program would have to hardcode its data — you couldn't read user input, loop over items, or build up results incrementally. Variables are how programs remember things.

Python's variable model is subtly different from most languages. In C or Java, a variable is a box that holds a value. In Python, a variable is a name that refers to an object — assignment doesn't copy values, it binds names to objects. This difference matters: it explains why two variables can point to the same list, why id() is useful, and why reassigning one variable doesn't always affect the other.

This lesson walks through assignment, multiple assignment, naming rules, scope (the LEGB rule), the memory model, and conventions for constants. By the end, you'll have a solid mental model for how Python handles data.

20–25 minutes 8 sections Beginner friendly
📦

What is a Variable?

Names, values, and the binding between them — the conceptual foundation.

Core
✏️

Assignment

The = operator, augmented assignment, and why order matters.

Core
🔭

Scope & LEGB

Local, enclosing, global, built-in — how Python resolves names.

Intermediate
🧠

Memory Model

Objects, references, id(), and the difference between is and ==.

Intermediate

📦 What Is a Variable?

A name that refers to a value — but the exact meaning is more subtle than it first appears

🎯 The Conceptual Model

In Python, a variable is a name bound to an object. When you write x = 42, Python:

  1. Creates an integer object with value 42 somewhere in memory.
  2. Binds the name x to that object.
  3. Stores the binding in the current scope (usually the local namespace of your script or function).
x = 42
print(x)
print(id(x))
print(type(x))

The id() function returns the object's memory address — a unique integer that identifies which object x refers to. Two names referring to the same object will have the same id().

💡 Many tutorials describe variables as "boxes that hold values." That's a useful metaphor for C or Java, but inaccurate for Python. In Python, the "box" is the object, and the variable is a sticky note stuck to the outside of the box.

🔁 Reassignment Rebinds, Not Mutates

When you reassign a variable, Python doesn't modify the original object — it binds the name to a new object. The old object may still exist if other names refer to it.

a = 10
b = a
print("a:", a, "b:", b)

a = 20
print("a:", a, "b:", b)
print("Same object?", a is b)

Output:

a: 10 b: 10
a: 20 b: 10
Same object? False

When a = 20 runs, Python creates a new integer object with value 20 and binds a to it. The name b still refers to the original 10 object. Both objects coexist in memory — 10 because b still references it, 20 because a now does.

⚠️ This is different from C or Java, where a = 20 would change the value inside a's box, and b would be a separate box holding the original copy. Python's behavior is more like "referencing" than "copying."

📋 Variables in Python vs Other Languages

LanguageModelBehavior of x = 42
PythonName → objectBinds the name x to a new object 42.
CBox → valueStores 42 in a memory location named x.
JavaBox → value/referencePrimitives store the value; objects store a reference to the object.
JavaScriptReference or valueSimilar to Python for objects, but primitive types are copied.
💡 Python uses a uniform model: every value is an object, and every variable is a name. There are no primitive types at the language level. Even integers and booleans are objects — they just happen to be small, immutable, and optimised heavily by CPython.

✏️ Assignment — Binding Names to Values

The = operator, augmented assignment, and common pitfalls

🎯 Basic Assignment

An assignment statement uses the = operator. The left side is the name; the right side is an expression that evaluates to a value.

x = 42
name = "Alice"
is_active = True
pi = 3.14159
empty = None

result = 10 + 20 * 3

user = {"name": "Bob", "age": 25}

print(x, name, is_active, pi, empty)
print(result)

The right side is evaluated first — fully, to a single value — and then the assignment happens. So x = x + 1 works because x + 1 is computed using the old value of x, and only after that does x get rebound to the result.

➕ Augmented Assignment

Python provides shorthand operators that combine arithmetic with assignment: +=, -=, *=, /=, //=, %=, **=. These are equivalent to the long form but more concise.

counter = 0
counter += 1
counter += 1
counter += 1
print(counter)

total = 100
total -= 25
total *= 2
total //= 5
print(total)

message = "Hello"
message += ", World!"
print(message)
💡 Augmented assignment on mutable objects (like lists) can behave subtly differently from the long form. x += [1] on a list mutates in place; x = x + [1] creates a new list. For immutable objects like integers and strings, they're equivalent.

🚫 Assignment Is Not Equality

A common beginner mistake is confusing = (assignment) with == (equality comparison). They are completely different operators.

Wrong
if x = 5:
    print("x is five")

Error: SyntaxError: invalid syntax. You cannot use assignment inside a condition.

Correct
if x == 5:
    print("x is five")

== compares; = assigns. Use the right one.

💡 Python 3.8 added the "walrus operator" := which does assign inside an expression: if (n := len(data)) > 10: print(f"Too long: {n}"). This is the only case where assignment appears inside a condition, and it's still fairly rare in day-to-day code.

🔗 Chained Assignment

You can assign the same value to multiple names in a single statement:

a = b = c = 0
print(a, b, c)

x = y = [1, 2, 3]
x.append(4)
print(y)

Chained assignment with mutable objects is dangerous — all three names point to the same object. Modifying through one name affects the others. This is often a subtle bug.

⚠️ Avoid a = b = []. Use a = []; b = [] instead if you want each to be independent. The chain form is safest with immutable values like 0, None, or strings.

🔗 Multiple Assignment & Unpacking

Assign several variables in one line — one of Python's most convenient features

🔀 Tuple Unpacking

Python evaluates the right side as a tuple, then matches each element to a name on the left:

x, y, z = 1, 2, 3
print(x, y, z)

name, age, city = "Alice", 30, "Manila"
print(name, age, city)

If the number of names doesn't match the number of values, Python raises a ValueError:

# ValueError: too many values to unpack
# a, b = 1, 2, 3

# ValueError: not enough values to unpack
# a, b, c = 1, 2

⭐ Starred Assignment

A single name can be prefixed with * to absorb all "extra" values into a list. This is called extended unpacking and is useful when you know the first item or two but not the rest.

first, *middle, last = [1, 2, 3, 4, 5]
print("First:", first)
print("Middle:", middle)
print("Last:", last)

head, *tail = ["a", "b", "c", "d"]
print(head, tail)
💡 Only one starred name can appear per assignment, and it doesn't have to be the middle one. *head, last = ... works too.

🔄 Swapping Variables Without a Temp

Because Python evaluates the right side first, you can swap two variables without a temporary:

a = 10
b = 20
a, b = b, a
print(a, b)

Compare to the C-style approach, which needs a temp variable:

temp = a
a = b
b = temp

Python's version is shorter, safer, and more readable — no risk of accidentally overwriting a value before it's been used.

🎯 Unpacking Function Return Values

When a function returns a tuple, you can unpack it directly into names:

def min_max(numbers):
    return min(numbers), max(numbers)

low, high = min_max([3, 1, 4, 1, 5, 9, 2, 6])
print(f"Low: {low}, High: {high}")

values = min_max([1, 2, 3])
print(values)
💡 This pattern is used constantly in real code — divmod(17, 5) returns (3, 2), and you can write quotient, remainder = divmod(17, 5) for a clean, direct destructuring.

🔤 Variable Naming Rules & Conventions

What Python allows, and what the community expects

✅ Rules the Interpreter Enforces

These are hard requirements — violating any of them raises a SyntaxError or NameError at runtime:

  • ✓Must begin with a letter (a–z, A–Z) or underscore (_)
  • ✓Can contain letters, digits, and underscores after the first character
  • ✓Cannot be a reserved keyword (class, def, if, for, etc.)
  • ✓Case-sensitive — name, Name, and NAME are three different variables
  • ✓Cannot contain spaces, hyphens, or most punctuation
# Valid names:
name = "Alice"
_user_id = 42
MAX_SIZE = 100
count2 = 5
myLongVariableName = True

# Invalid — will raise SyntaxError:
# 2fast = 10
# first-name = "Bob"
# class = "Physics"
# my var = 5

💡 Conventions Python Expects (PEP 8)

These aren't enforced by the interpreter, but they're followed universally in professional Python code. Following them makes your code instantly readable to any other Python developer.

Name StyleUsed ForExample
snake_caseVariables, functions, modulesuser_name, total_price
PascalCaseClassesUserAccount, HttpClient
UPPER_SNAKE_CASEConstantsMAX_RETRIES, DEFAULT_TIMEOUT
_leading_underscoreInternal / private use_cache, _helper
__double_leadingName mangling (rare)__secret (used inside classes)
__dunder__Python-defined special names__init__, __repr__

🎯 What Makes a Good Variable Name

Beyond the rules, good names have a few qualities:

  • ✓Descriptive, not abbreviated. user_account_balance is clearer than uab or bal.
  • ✓Pronounceable. If you can't say it out loud, you'll struggle to talk about the code with others.
  • ✓Not too long. the_total_quantity_of_items_in_the_shopping_cart is excessive — cart_total is better.
  • ✓No type suffixes. user_list or name_str are un-Pythonic — let the value speak for itself.
  • ✓Not shadowing built-ins. Never name a variable list, dict, type, id, or input — it breaks the built-in function.
Clear
active_users = get_active_users()
max_retry_count = 3
is_authenticated = check_auth()
Unclear
au = get_active_users()
mrc = 3
flag = check_auth()

au, mrc, and flag tell the reader nothing. They were fast to type and will be slow to read six months from now.

⚠️ Never Shadow Built-ins

Python has 35+ built-in functions and constants available by default. Naming a variable after one of them "shadows" the built-in, making it unavailable in the current scope — and leading to confusing bugs.

Shadows built-in
list = [1, 2, 3]
print(list(x))  # TypeError: 'list' object is not callable

After list = [1, 2, 3], the name list refers to a list, not the built-in function. Anywhere else in this scope that calls list() will fail.

Clear and safe
my_list = [1, 2, 3]
print(list(my_list))
💡 Common built-ins to avoid as variable names: list, dict, set, tuple, str, int, float, bool, type, id, input, print, sum, min, max, len, range, open, file, bytes, object.

🔭 Variable Scope & the LEGB Rule

How Python decides which object a name refers to — and why UnboundLocalError happens

Not every name is visible everywhere. Python has four scopes, and its rules for resolving names are governed by the LEGB rule: Local, Enclosing, Global, Built-in. Understanding this is essential for debugging errors and writing clean, side-effect-free functions.

🧭 The Four Scopes

ScopeLetterWhat It Contains
LocalLNames defined inside the current function.
EnclosingENames defined in any enclosing function (for nested functions).
GlobalGNames defined at the top level of the module.
Built-inBNames Python provides by default (print, len, range, etc.).

When Python encounters a name, it searches these scopes in order: L → E → G → B. The first match wins.

x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)

    inner()
    print(x)

outer()
print(x)

Output:

local
enclosing
global

Each function defines a new local scope. The inner function's x shadows the enclosing function's, which shadows the global. Reading the name at different points in the program gives different results.

🌐 Global Statement

By default, assignment inside a function creates a local variable — it doesn't touch the global. To reassign a global variable from inside a function, use the global keyword.

counter = 0

def increment_bad():
    counter = counter + 1  # UnboundLocalError

def increment_good():
    global counter
    counter += 1

increment_good()
print(counter)
⚠️ increment_bad raises UnboundLocalError — not NameError. This confuses beginners. The reason: Python sees counter = ... inside the function, so it treats counter as a local name for the entire function. When the right side tries to read counter (before assignment), it isn't yet bound locally — hence "referenced before assignment."
💡 global is often considered a code smell. Prefer returning values from functions over mutating global state. Global variables make code harder to test, reason about, and parallelise.

🏠 nonlocal Statement

For nested functions, nonlocal rebinds a name in the nearest enclosing scope — useful for closures and counters.

def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment

counter = make_counter()
print(counter())
print(counter())
print(counter())

Each call to increment() modifies the count in the enclosing make_counter scope. The nonlocal keyword tells Python "this name is not local to increment; look in the enclosing scope."

🔍 Inspecting Scope — locals() and globals()

You can inspect the current scope's namespaces at any time:

x = "global_x"
y = 42

def demo():
    local_var = "local"
    print("Locals:", locals())
    print("Globals count:", len(globals()))

demo()
💡 locals() returns a dict of the current local namespace. globals() returns a dict of all module-level names — including the ones you defined plus the built-ins that have been imported. Both are useful for debugging.

🧠 The Memory Model — Objects & References

Understanding id(), is vs ==, and why mutable variables behave surprisingly

🎯 Every Value Is an Object

In Python, everything is an object: integers, strings, functions, classes, modules, and even None. Every object has:

  • ✓An identity — a unique integer, retrievable with id(obj), never changes during the object's lifetime
  • ✓A type — retrievable with type(obj), never changes
  • ✓A value — mutable objects can change; immutable objects cannot
x = 42
print(id(x))
print(type(x))
print(x)

name = "Alice"
print(id(name))
print(type(name))
print(name)

🆔 Understanding id() and Identity

id() returns the memory address of an object (in CPython). Two variables referring to the same object will have the same id():

a = [1, 2, 3]
b = a
c = [1, 2, 3]

print(a is b)
print(a is c)
print(a == c)

print(id(a) == id(b))
print(id(a) == id(c))

a and b refer to the same object — same id, is returns True. c is a distinct object with the same value — different id, is returns False, but == returns True.

⚖️ is vs == — The Critical Distinction

OperatorComparesWhen to Use
isIdentity (same object)Comparing to None, True, False, or checking for object identity
==Equality (same value)Almost everything else — numbers, strings, lists, custom objects
Correct
if value is None:
    pass

if a == b:
    pass
Fragile
if value == None:
    pass

if a is b:
    pass  # rarely what you want
💡 The is comparison with None works because None is a singleton — there's only ever one None object in Python. Comparing x == None is technically allowed but discouraged — a custom __eq__ could make it return non-True for a genuinely-None value. is bypasses __eq__ entirely.

🔒 Mutable vs Immutable Objects

Python objects fall into two camps:

  • ✓Immutable: int, float, str, bool, tuple, frozenset, bytes, None — cannot be changed after creation
  • ✓Mutable: list, dict, set, bytearray, custom classes — can be modified in place
# Immutable — reassignment creates a new object
s = "hello"
s2 = s
s += " world"
print(s, s2)  # s2 is unchanged

# Mutable — in-place mutation affects all references
nums = [1, 2, 3]
nums2 = nums
nums.append(4)
print(nums, nums2)  # both are [1, 2, 3, 4]
⚠️ The mutation gotcha: when you pass a list to a function and the function mutates it, the caller sees the change. This is a common source of surprising bugs. To avoid it, either pass a copy (my_list.copy()) or design functions to return new lists instead of mutating in place.

📋 Copying — copy() vs deepcopy()

For nested mutable structures, a shallow copy doesn't protect you:

import copy

original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)

original[0][0] = 99
print("Shallow:", shallow)
print("Deep:", deep)

Both shallow and original share the same inner lists, so changing one changes both. deepcopy recursively copies every nested object — the truly independent copy.

💡 Use deepcopy sparingly — it's slow and can't copy objects that don't support deep copying (like file handles). For most flat structures, a shallow copy is enough.

🔒 Constants — Python's Convention

Python has no true constants — but the convention is well-established

📌 UPPER_CASE by Convention

Python doesn't have a const keyword. Instead, the community follows a naming convention: a variable whose name is entirely uppercase (UPPER_SNAKE_CASE) is treated as a constant — meaning "don't reassign this."

MAX_RETRIES = 3
DEFAULT_TIMEOUT = 30
API_BASE_URL = "https://api.example.com"
PI = 3.141592653589793

print(MAX_RETRIES)
print(API_BASE_URL)
⚠️ Python will let you reassign a constant — the language doesn't enforce the convention. But doing so violates the implicit contract and confuses anyone reading the code. Some linters (like pylint) will warn if you try.

🔐 Enforcing Constants with Enums or Frozen Dataclasses

If you truly need immutability, Python has options:

from enum import Enum

class Status(Enum):
    PENDING = "pending"
    ACTIVE = "active"
    COMPLETE = "complete"

print(Status.PENDING.value)
# Status.PENDING = "other"  # AttributeError: cannot reassign

from dataclasses import dataclass

@dataclass(frozen=True)
class Config:
    host: str = "localhost"
    port: int = 8080

config = Config()
# config.port = 9000  # FrozenInstanceError
💡 For most projects, UPPER_CASE variables at the top of the module are enough. Use Enum when you need a fixed set of related constants; use frozen=True on a dataclass when a whole record should be immutable.

📍 Where to Put Constants

Two common patterns:

  1. At the top of a module — best when the constants belong to a single file.
  2. In a dedicated constants.py — best when shared across many modules in a project.
# constants.py
DATABASE_URL = "postgresql://localhost/mydb"
MAX_LOGIN_ATTEMPTS = 5
SESSION_TIMEOUT_SECONDS = 3600

# other_module.py
from constants import MAX_LOGIN_ATTEMPTS

if attempts >= MAX_LOGIN_ATTEMPTS:
    lock_account()

❓ Frequently Asked Questions

Common questions about Python variables

No — Python is dynamically typed. When you write x = 42, Python infers the type from the value. You never write int x = 42 like in C or Java. However, Python 3.6+ supports optional type hints: x: int = 42. These hints are ignored at runtime, but tools like mypy and your editor use them to catch type errors before they reach production. Hints are highly recommended for any codebase larger than a single script.

Python allows it — a variable can refer to an integer at one moment and a string the next. This is a consequence of the "name → object" model: reassigning just rebinds the name to a new object of the new type. However, the type checker (if you use one) will flag the inconsistency, and code that switches types is usually harder to follow. If a variable genuinely needs to hold different types at different times, consider whether a union type hint (int | str) or a redesign would be clearer.

This is one of Python's most confusing error messages for beginners. It happens because Python decides at compile time that any name assigned anywhere in a function is a local name for the whole function. So this code:

counter = 0

def increment():
    print(counter)  # UnboundLocalError
    counter = 1

fails on the print line — Python sees counter = 1 somewhere in the function, decides counter is local to the whole function, and the print reads an unassigned local. The fix is to remove the assignment, or declare global counter at the top of the function.

x is None is preferred — and it's not just style. None is a singleton: there's only one None object in memory, so is always gives the right answer, and it's faster. x == None works for None, but if x happens to be a custom class with a broken __eq__ method, the comparison could return something unexpected. Using is bypasses __eq__ entirely. Every Python linter will flag == None and suggest is None.

del x removes the binding entirely — afterward, x no longer refers to anything, and trying to read it raises NameError. x = None rebinds x to the None object — the name still exists, just referring to a different value. Use del when you want to free the memory associated with the object (if nothing else references it), and x = None when you want to mark a variable as "empty" but keep it around.

Yes, strictly. myvar, myVar, MyVar, and MYVAR are four completely distinct names — Python treats them as unrelated. This catches beginners who mix camelCase and snake_case across a file. Follow PEP 8's snake_case convention consistently to avoid confusion.

No. Identifiers must start with a letter (a–z, A–Z) or underscore. 2fast is a syntax error. You can have digits later: var2, player_1, and _2fast are all valid. The underscore prefix is the standard workaround when you need a name that conceptually starts with a digit.

Letters (a–z, A–Z), digits (0–9), and underscores (_). Python 3 also technically allows Unicode letters, so variable_eñe, 变数, or 变量 work — but PEP 8 strongly recommends ASCII-only identifiers for portability and readability. Don't use $, @, #, -, or any other punctuation — Python will reject it.

Sometimes. Standard exceptions: i, j, k for loop counters; x, y, z for coordinates; e for exception variables in except blocks; _ for throwaway values (unused tuple elements, unused loop variables). Everywhere else, prefer descriptive names. If a single-letter name needs a comment to explain it, use a longer name instead.

By convention, _ is used to indicate "I don't care about this value." Examples: for _ in range(10): when you just need to loop 10 times, or first, _, third = my_tuple when you only need the first and third elements. The underscore is a normal variable, but the community treats it as a marker of intent — a signal to readers that this value is intentionally ignored.

🗣️ Learner Voices

No comments yet — your voice starts the conversation.