Python Variables Explained: Everything You Need to Know

Final Exam & Certification

Complete this tutorial and pass the 10-question final exam to earn a downloadable certificate of completion.

skip to exam

Variables are the single most fundamental concept in programming. Every program you will ever write — from a one-line script to a million-line application — uses variables to store, retrieve, and manipulate data. Python makes working with variables remarkably intuitive compared to other languages, but that simplicity hides a powerful and nuanced system underneath. This guide will take you from your very first assignment all the way through scope, mutability, type conversion, and the memory model that makes Python variables behave the way they do.

What's in this Python Tutorial
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton

That famous quip applies doubly to variables. You will spend a surprising amount of your programming life choosing good names, deciding where to place assignments, and reasoning about what a variable holds at any given point. Getting comfortable with how Python handles variables is not just a beginner exercise — it is a skill that separates clear, bug-free code from the kind that keeps you debugging at midnight.

What Is a Variable, Really?

In many programming languages, a variable is a named box in memory that holds a value. Python works differently. In Python, a variable is a name tag that points to an object somewhere in memory. When you write x = 42, Python creates an integer object with the value 42 in memory, then attaches the label x to it. The variable is the label, not the box. This distinction matters more than you might expect, especially once you start working with mutable objects like lists.

Box model versus name-tag model of Python variables Two side-by-side illustrations. On the left, the incorrect box model shows variable x as a rectangular container holding the value 42. On the right, the correct Python name-tag model shows x as a label with an arrow pointing to a separate object containing 42 in memory. OTHER LANGUAGES PYTHON variable is a box variable is a name tag x 42 value lives inside the variable x OBJECT 42 name points to a separate object in memory
Python's mental model: variables are name tags attached to objects, not boxes that contain values. Rebinding x to something else changes what the label points to — it does not change the original object.
python
# Creating your first variables
message = "Hello, Python!"
year = 2026
pi = 3.14159
is_learning = True

print(message)      # Hello, Python!
print(year)         # 2026
print(pi)           # 3.14159
print(is_learning)  # True

The = sign in Python is called the assignment operator. It does not mean "equals" in the mathematical sense. It means "make the name on the left point to the object on the right." You can reassign a variable to a completely different value — even a different type — at any time:

python
x = 10
print(x)        # 10

x = "ten"
print(x)        # ten

x = [1, 2, 3]
print(x)        # [1, 2, 3]

This flexibility is one of Python's greatest strengths for beginners, but it also means you need to be mindful of what a variable currently holds as your programs grow more complex.

Naming Rules and Conventions

Python enforces a small set of hard rules for variable names. A name must start with a letter (a-z, A-Z) or an underscore, and the rest of the name can contain letters, digits (0-9), and underscores. Names are case-sensitive, so age, Age, and AGE are three different variables. You cannot use any of Python's 35 reserved keywords (like if, for, class, return, True, None) as variable names.

python
# Valid variable names
first_name = "Kandi"
_private_value = 99
student2 = "Alex"
MAX_RETRIES = 5

# Invalid variable names (these will cause errors)
# 2nd_place = "silver"   # Cannot start with a digit
# my-name = "Kandi"      # Hyphens are not allowed
# class = "Python 101"   # 'class' is a reserved keyword

Beyond the hard rules, the Python community follows strong conventions documented in PEP 8, the official style guide. Regular variables and functions use snake_case (all lowercase with underscores): user_age, total_price, database_connection. Constants use UPPER_SNAKE_CASE: MAX_CONNECTIONS, API_KEY, DEFAULT_TIMEOUT. Class names use PascalCase: StudentRecord, HttpClient. Following these conventions makes your code instantly recognizable to other Python developers.

Pro Tip

Never shadow built-in names. Naming a variable list, dict, str, type, or input will override those built-in functions for the rest of your scope. This is one of the most common beginner bugs and can produce baffling error messages. If you accidentally do this in the interpreter, use del list to restore the built-in.

Python Pop Quiz tap any option

Which of these is a valid Python variable name?

not quite

Variable names in Python cannot start with a digit. 3rd_place begins with 3, so Python rejects it at parse time with a SyntaxError. Digits are allowed anywhere else in the name — just not as the first character. A common fix is to spell the number out or move the digit later:

python
3rd_place = "bronze"      # SyntaxError: invalid decimal literal

third_place = "bronze"    # Works: letters first
place_3 = "bronze"        # Works: digit is not the first character
correct

A valid Python variable name can start with a letter or an underscore, then contain any mix of letters, digits, and underscores. _user_id satisfies every rule. The leading underscore is also a convention in PEP 8 for internal or non-public names — it is not enforced by the language, but other developers will read it as a hint that the value is not part of the public interface:

python
_user_id = 42          # Valid: starts with underscore
user_id = 42           # Also valid: starts with a letter
user_id_2 = 42         # Also valid: digits allowed after first character

print(_user_id)        # 42
not quite

Hyphens are not allowed in Python identifiers. To Python, user-name looks like the expression user minus name — the minus sign is an operator, not part of a name. Only letters, digits, and underscores can appear in a variable name. Use an underscore instead:

python
user-name = "Kandi"    # SyntaxError: Python reads this as user - name

user_name = "Kandi"    # Works: snake_case is the PEP 8 standard
print(user_name)       # Kandi

Data Types and Dynamic Typing

Python is a dynamically typed language. This means you never declare a variable's type — Python infers it from the value you assign. When you write age = 30, Python sees the integer literal and creates an int object. When you write name = "Kandi", it creates a str object. You can always check what type a variable currently holds with the built-in type() function.

python
# Python's core data types
name = "Kandi"              # str   (string)
age = 30                     # int   (integer)
gpa = 3.85                   # float (floating-point number)
is_enrolled = True           # bool  (boolean)
courses = ["Python", "Net+"] # list
grades = (95, 88, 92)        # tuple
student = {"name": "Kandi"}  # dict  (dictionary)
unique_ids = {101, 102, 103} # set
nothing = None               # NoneType

# Checking types
print(type(name))        # <class 'str'>
print(type(age))         # <class 'int'>
print(type(gpa))         # <class 'float'>
print(type(is_enrolled)) # <class 'bool'>
print(type(nothing))     # <class 'NoneType'>

Dynamic typing gives you speed and flexibility during development, but it also means Python will not stop you from accidentally assigning the wrong type to a variable. In larger projects, many developers use type hints (introduced in Python 3.5) to document what type a variable should hold, even though Python does not enforce them at runtime:

python
# Type hints (documentation only, not enforced)
username: str = "kandi_codes"
login_attempts: int = 0
account_balance: float = 1250.75
is_active: bool = True
"Readability counts." — Tim Peters, The Zen of Python (PEP 20)
Python Pop Quiz tap any option

What does isinstance(True, int) return?

not quite

It looks right, but Python treats bool as a subclass of int. That means every boolean is also an integer as far as isinstance() is concerned — True behaves like 1 and False behaves like 0 in arithmetic. This often surprises people because booleans feel like a different kind of value, but under the hood Python reuses the integer machinery:

python
print(isinstance(True, int))   # True
print(isinstance(False, int))  # True
print(True + True)             # 2
print(True * 5)                # 5
print(issubclass(bool, int))   # True
correct

Exactly. In Python, bool is defined as a subclass of int, so every boolean is also an integer in the type hierarchy. True equals 1 and False equals 0, which means you can do arithmetic with booleans directly. If you truly need to reject booleans, check type(x) is int instead of isinstance(x, int):

python
print(isinstance(True, int))   # True  (bool IS a kind of int)
print(type(True) is int)       # False (but it's not literally int)
print(type(True).__name__)     # bool

# Counting truthy flags is a classic use of this behavior
flags = [True, False, True, True, False]
print(sum(flags))              # 3
not quite

No TypeErrorisinstance() is specifically designed to check type relationships and never raises on valid arguments. It accepts any object and any type (or tuple of types) and returns a boolean. The interesting quirk here is that bool inherits from int, so isinstance(True, int) returns True:

python
# isinstance() always returns a bool, never raises
print(isinstance(True, int))       # True
print(isinstance("hi", int))       # False
print(isinstance(3.14, (int, float, str)))  # True

# The only TypeError happens if the second arg isn't a type
# print(isinstance(5, "int"))      # TypeError: isinstance() arg 2 must be a type
PREDICT

Given x = 256; y = 256 on two separate lines, what does x is y evaluate to? What about the same code with 257?

With 256, x is y is True. With 257, the same expression is False. This looks like a language bug. It is not. CPython pre-allocates every integer from −5 through 256 as a shared singleton object at interpreter startup, so any literal in that range returns a reference to the same pre-existing object. The name tag lands on the cached singleton instead of a fresh allocation. Outside that range, Python creates a new integer object each time — two separate objects with equal values but distinct identities.

This is the cleanest possible proof that is checks identity, not equality, and that you should reserve it for None, True, and False. The full mechanism is covered in fact #1 of the expert-level section near the end of this tutorial.

Type Conversion and Checking

There are many situations where you need to convert a value from one type to another. Python provides built-in functions for this: int(), float(), str(), bool(), list(), and more. This is called explicit type conversion, or casting. Python will also perform some conversions automatically (implicit conversion), such as promoting an integer to a float during division.

python
# Explicit type conversion (casting)
price_str = "49.99"
price_float = float(price_str)
price_int = int(price_float)    # Truncates to 49, does NOT round

print(price_float)  # 49.99
print(price_int)    # 49

# Converting numbers to strings
age = 25
message = str(age) + " years old"
print(message)          # 25 years old

# In practice, use an f-string for readability
message = f"I am {age} years old"
print(message)          # I am 25 years old

# Implicit conversion
result = 10 + 3.5   # int + float = float
print(result)        # 13.5
print(type(result))  # <class 'float'>

# Boolean conversion (truthy and falsy values)
print(bool(0))       # False
print(bool(""))      # False
print(bool([]))      # False
print(bool(None))    # False
print(bool(42))      # True
print(bool("hello")) # True
print(bool([1, 2]))  # True
Watch Out

The input() function always returns a string, even if the user types a number. If you need to do math with user input, you must convert it first: age = int(input("Enter your age: ")). Forgetting this conversion is one of the most common beginner errors in Python.

You can also verify a variable's type at runtime using isinstance(), which is generally preferred over comparing type() directly because it correctly handles inheritance:

python
value = 42

# Preferred approach
if isinstance(value, int):
    print("It's an integer")

# Works with multiple types
if isinstance(value, (int, float)):
    print("It's a number")
Python Pop Quiz tap any option

What does int(7.9) return in Python?

not quite

int() does not round — it truncates toward zero, which means it throws away the fractional part. So int(7.9) gives 7, not 8. If you actually want rounding, use the round() function instead. This distinction trips up a lot of beginners because the results look the same for small positive numbers but differ for values like 7.5 or negative numbers:

python
print(int(7.9))       # 7   (truncated, not rounded)
print(int(-7.9))      # -7  (toward zero, not down to -8)
print(round(7.9))     # 8   (round() actually rounds)
print(round(7.5))     # 8
print(round(6.5))     # 6   (banker's rounding: ties go to even)
correct

Spot on. int() converts by truncating toward zero, not by rounding. The fractional part is simply dropped, regardless of whether it is below or above .5. For negative numbers this means the result moves closer to zero, not further from it. When you actually want mathematical rounding, reach for round(), math.floor(), or math.ceil():

python
import math

print(int(7.9))        # 7   truncation toward zero
print(int(-7.9))       # -7  still toward zero
print(round(7.9))      # 8   nearest integer
print(math.floor(7.9)) # 7   always down
print(math.ceil(7.1))  # 8   always up
not quite

int() happily accepts a float directly — no round() call is needed beforehand. What int() refuses is a non-numeric string like int("hello") — that raises a ValueError. When given a float, int() simply truncates toward zero and returns an integer:

python
print(int(7.9))        # 7      no error, just truncation
print(int("42"))       # 42     a numeric string is fine
print(int("3.14"))     # ValueError: int() can't parse floats from strings
print(int(float("3.14")))  # 3  convert to float first, then to int

Multiple Assignment and Unpacking

Python offers several elegant shortcuts for assigning variables. You can assign the same value to multiple variables in a single line, assign different values to multiple variables simultaneously, and unpack sequences directly into named variables. These patterns appear constantly in real Python code.

python
# Assign the same value to multiple variables
x = y = z = 0
print(x, y, z)  # 0 0 0

# Assign different values in one line
name, age, city = "Kandi", 30, "Atlanta"
print(name)  # Kandi
print(age)   # 30
print(city)  # Atlanta

# Swap two variables (no temp variable needed!)
a = "first"
b = "second"
a, b = b, a
print(a)  # second
print(b)  # first

# Unpacking a list
coordinates = [33.749, -84.388]
latitude, longitude = coordinates
print(f"Lat: {latitude}, Lon: {longitude}")

# Using * to capture the rest
first, *middle, last = [1, 2, 3, 4, 5]
print(first)   # 1
print(middle)  # [2, 3, 4]
print(last)    # 5

The variable swap trick (a, b = b, a) is a classic example of Python's elegance. In most other languages, swapping requires a temporary third variable. Python evaluates the entire right side of the assignment before binding any names on the left, making the swap seamless.

Scope: Where Variables Live and Die

Not every variable is accessible from everywhere in your program. The region of code where a variable is visible and usable is called its scope. Python follows the LEGB rule, which stands for Local, Enclosing, Global, and Built-in. When you reference a variable name, Python searches these four scopes in order until it finds a match.

The LEGB rule: Python's variable lookup order Four nested rectangles showing the scopes Python searches when resolving a variable name. From outermost to innermost: Built-in scope containing names like print and len, Global module scope, Enclosing function scope, and the innermost Local function scope. An arrow on the right illustrates that Python searches from Local outward to Built-in in that order. B — BUILT-IN print, len, type, isinstance, range… G — GLOBAL (module) status = "active" E — ENCLOSING (outer function) def outer(): counter = 0 L — LOCAL (current function) def inner(): x = 10 name resolution starts here start lookup order last
Python resolves a name by searching scopes in the order Local → Enclosing → Global → Built-in. The first match wins, and the search stops there.
python
# Global scope
status = "active"

def check_status():
    # Local scope
    status = "inactive"
    print(f"Inside function: {status}")

check_status()                  # Inside function: inactive
print(f"Outside function: {status}")  # Outside function: active

In this example, the status inside the function is a completely separate local variable that just happens to have the same name. It does not affect the global status. If you actually need to modify a global variable from inside a function, you must explicitly declare it with the global keyword — but doing so is generally discouraged because it makes code harder to reason about and debug.

python
# Using global (use sparingly)
counter = 0

def increment():
    global counter
    counter += 1

increment()
increment()
increment()
print(counter)  # 3

# Better approach: use parameters and return values
def increment_value(current):
    return current + 1

counter = 0
counter = increment_value(counter)
counter = increment_value(counter)
counter = increment_value(counter)
print(counter)  # 3
"Explicit is better than implicit." — Tim Peters, The Zen of Python (PEP 20)
Python Pop Quiz tap any option

Inside a function, you assign total = 10. What does Python do with the module-level total that already exists?

not quite

A plain assignment inside a function does not modify the module-level variable. Python treats the name as local to that function, so the outer total stays exactly as it was. To actually reassign a global from inside a function you need the global keyword first — and even then, most Python code avoids doing this because it makes functions harder to reason about:

python
total = 0

def set_local():
    total = 10          # Local only, outer total is untouched

def set_global():
    global total        # Declare intent to modify module-level total
    total = 10

set_local()
print(total)            # 0   the global is still 0

set_global()
print(total)            # 10  now it has been reassigned
not quite

Python is happy to have a local name and a global name spelled the same way at the same time — there is no collision and no error. Each scope has its own namespace, and inside the function the local name simply shadows the global. One thing that will raise an error, however, is reading a name before assigning to it in the same function. Python inspects the whole function body first and classifies total as local as soon as it sees any assignment, even a later one:

python
total = 0

def ok():
    total = 10          # Fine: pure assignment, creates a local

def broken():
    print(total)        # UnboundLocalError at runtime
    total = 10          # Because this line makes total local

ok()        # No error
broken()    # UnboundLocalError: cannot access local variable 'total'
correct

Exactly right. Assignment inside a function creates a new local name by default — even if a global with the same spelling already exists. The local name shadows the global for the duration of the call, and the global is left untouched. This is how Python keeps function bodies predictable: a function cannot accidentally change the outer world just by assigning to a variable. To deliberately modify the module-level name, you have to opt in with the global keyword:

python
total = 0

def shadow():
    total = 10          # A separate local; global is untouched
    print("inside:", total)

shadow()
print("outside:", total)

# inside:  10
# outside: 0            <-- global never changed
PREDICT

Inside a function, you write count = count + 1 where count is a module-level global that starts at 0. What happens when the function runs — and why?

It raises UnboundLocalError: cannot access local variable 'count' where it is not associated with a value. The function never touches the global at all. Python decides whether a name is local or outer at compile time, not at runtime — and the compiler sees the assignment to count on the left-hand side and marks the name as local for the entire function body. So when the right-hand side runs first and tries to read count, it looks in the local scope, finds the name reserved but not yet bound to any object, and raises the error.

The fix is either global count at the top of the function (if you truly want to mutate the global), or passing count in as a parameter and returning the new value (almost always the better choice). This is the single most surprising consequence of LEGB: the binding decision is statically determined from the code shape, before a single line runs.

Mutability: The Hidden Trap

This is where Python's "name tag" model of variables becomes critically important. Some Python objects are mutable (they can be changed in place), while others are immutable (once created, they cannot be modified). Strings, integers, floats, booleans, and tuples are immutable. Lists, dictionaries, and sets are mutable.

When two variables point to the same mutable object, changes made through one name are visible through the other. This behavior is called aliasing, and it is the source of countless beginner bugs:

Aliasing: two names pointing to one list object Two panels side by side. The left panel shows the starting state after list_b equals list_a: both names point to the same list object containing one, two, three. The right panel shows what happens after list_a.append(four): both names still point to the same object, which now contains one, two, three, four. The object was mutated; the names were not copied. BEFORE list_a = [1, 2, 3]; list_b = list_a AFTER list_a.append(4) list_a list_b LIST OBJECT [1, 2, 3] list_a list_b SAME OBJECT [1, 2, 3, 4] two names, one list object mutated — both names see the change
Assignment binds a name to an object — it does not copy. When the shared object is mutated, every name pointing at it sees the update. Use .copy() or copy.deepcopy() when you need independence.
python
# Immutable objects are safe
a = "hello"
b = a
a = "world"
print(b)  # Still "hello" - strings are immutable

# Mutable objects share changes!
list_a = [1, 2, 3]
list_b = list_a        # Both names point to the SAME list
list_a.append(4)
print(list_b)          # [1, 2, 3, 4] - surprise!

# To make an independent copy, use .copy() or slicing
list_c = [1, 2, 3]
list_d = list_c.copy() # list_d is a separate object
list_c.append(4)
print(list_d)          # [1, 2, 3] - unaffected

# You can verify identity with 'is'
print(list_a is list_b)  # True  (same object)
print(list_c is list_d)  # False (different objects)

The id() function returns the memory address of an object, and the is keyword checks whether two names point to the exact same object in memory (as opposed to ==, which checks whether two objects have the same value). Understanding the difference between identity and equality is essential for writing correct Python.

python
# Identity vs. equality
x = [1, 2, 3]
y = [1, 2, 3]

print(x == y)   # True  (same value)
print(x is y)   # False (different objects in memory)
print(id(x))    # e.g., 140234567890
print(id(y))    # e.g., 140234567950 (different address)
Python Pop Quiz tap any option

After a = [1, 2, 3] and b = a, you run b.append(4). What does print(a) show?

not quite

This is the classic aliasing mistake — and it is the single most common source of mutable-data bugs in Python. b = a does not copy the list. It binds a second name to the same list object, so any in-place change made through one name is visible through the other. To get a real, independent copy you need a.copy(), list(a), or a full slice a[:]:

python
# Aliasing: b = a does NOT copy
a = [1, 2, 3]
b = a
b.append(4)
print(a)          # [1, 2, 3, 4]   both names see the change
print(a is b)     # True            same object

# Three ways to make a real, independent copy
a = [1, 2, 3]
b = a.copy()      # list method
c = list(a)       # list constructor
d = a[:]          # full slice
b.append(4)
print(a)          # [1, 2, 3]       untouched
print(b)          # [1, 2, 3, 4]
correct

Exactly. b = a is not a copy — it is a second name tag pointing at the same list object in memory. An in-place operation like .append() mutates that one shared object, so both a and b see the new element. You can confirm the names refer to the same object with is, which compares identity rather than value. This is also why passing a list into a function lets the function change your original data unless you copy it first:

python
a = [1, 2, 3]
b = a
b.append(4)
print(a)          # [1, 2, 3, 4]
print(b)          # [1, 2, 3, 4]
print(a is b)     # True   one list, two names

# Same thing happens across function boundaries
def add_item(items):
    items.append(99)

data = [1, 2, 3]
add_item(data)
print(data)       # [1, 2, 3, 99]   function mutated the caller's list

# To protect the caller, copy inside the function (or before the call)
def add_item_safe(items):
    items = items.copy()
    items.append(99)
    return items
not quite

append always adds to the end of a list, never the front. If you need to add to the beginning, use insert(0, value), or reach for collections.deque when you need efficient inserts at both ends. The bigger thing this question is testing, though, is aliasing: b = a does not copy the list, so modifying b modifies a too:

python
# append adds to the END
nums = [1, 2, 3]
nums.append(4)
print(nums)               # [1, 2, 3, 4]

# To add at the FRONT, use insert(0, value)
nums = [1, 2, 3]
nums.insert(0, 4)
print(nums)               # [4, 1, 2, 3]

# For heavy front-inserts, deque is much faster
from collections import deque
queue = deque([1, 2, 3])
queue.appendleft(4)
print(list(queue))        # [4, 1, 2, 3]
PREDICT

A function is defined as def add_item(items=[]): items.append(1); return items. You call it three times in a row with no arguments. What does the third call return?

It returns [1, 1, 1], not [1]. This is the most famous mutable-default-argument trap in Python, and it follows directly from the name-tag model. Default argument values are evaluated once, when the def statement runs, not each time the function is called. That single list object is bound to the parameter name every time the function is invoked without an argument — so every call that omits the argument shares the same list, and each append accumulates.

The idiomatic fix is the sentinel pattern: def add_item(items=None): if items is None: items = [] — which creates a fresh list on every call that does not supply one. This single gotcha has bitten every Python developer at least once; understanding why it happens means you understand assignment.

Constants and Best Practices

Python does not have a built-in mechanism for true constants — any variable can be reassigned at any time. However, the convention is to use UPPER_SNAKE_CASE names to signal that a value should not be changed. Every experienced Python developer recognizes this convention and treats such variables as read-only.

python
# Constants by convention
MAX_LOGIN_ATTEMPTS = 5
DATABASE_URL = "postgresql://localhost:5432/mydb"
PI = 3.14159265358979
DEFAULT_ENCODING = "utf-8"

# These CAN be reassigned, but SHOULD NOT be
# MAX_LOGIN_ATTEMPTS = 100  # Don't do this!
"Code is read much more often than it is written." — Guido van Rossum, creator of Python

Here are the habits that will serve you well from day one. Choose descriptive names that reveal intent: elapsed_time_seconds is far better than ets or x. Keep your variables as close to their point of use as possible. Avoid global variables whenever you can, preferring function parameters and return values instead. Use type hints in any code that other people (or future you) will need to read. And always remember that in Python, variables are name tags pointing to objects — not boxes containing values.

How to Use Python Variables Effectively

A step-by-step routine for declaring, naming, and reasoning about Python variables so your code stays readable and free of common pitfalls.

  1. Choose a descriptive name

    Pick a name that tells the reader what the variable represents without needing a comment. Use a noun or a noun phrase for data, and prefer elapsed_seconds or user_email over ambiguous names like x or data. Resist the temptation to abbreviate. A name that takes an extra second to type saves minutes of confusion later, both for teammates and for your future self.

  2. Follow PEP 8 casing conventions

    Use snake_case for regular variables and function parameters, UPPER_SNAKE_CASE for module-level constants, and PascalCase for class names. Keep names lowercase with underscores between words for readability. Avoid mixing casing styles within the same codebase. Consistent casing is one of the fastest ways to make a Python file look professional and makes your code fit naturally alongside the standard library and popular packages.

  3. Assign with intent

    Before typing the equals sign, know exactly what object the name should point to. Assignment is a binding, not a copy, so two names pointing at the same mutable object will see each other's changes. If you need an independent copy of a list or dict, use list.copy(), dict.copy(), or the copy module's copy() and deepcopy() functions. This simple habit prevents a whole class of aliasing bugs.

  4. Check types at runtime when it matters

    Python trusts you with dynamic typing, which makes defensive checks valuable at the edges of your program — especially where user input, file data, or network responses enter your code. Use isinstance(value, int) rather than type(value) == int because isinstance respects inheritance. For internal functions, type hints like def add(a: int, b: int) -> int document intent without enforcing anything, which is usually enough.

  5. Keep variables close to their use

    Declare a variable as close as possible to the code that uses it. A name defined twenty lines before its first use forces readers to hold state in their head while they scan the surrounding code. Short-lived local variables are easier to reason about than long-lived global ones. If a value is only needed inside a loop or a conditional branch, bind it there — not at the top of the function.

  6. Avoid globals and shadowing built-ins

    Global variables make code hard to test because any function can change them. Prefer passing values as arguments and returning results explicitly. Also avoid using names that match built-in functions or types — list, dict, str, type, input, and id all have meanings you probably do not want to overwrite. A quick mental check before you commit a name: does the built-in namespace already use it?

Four things even seasoned Python developers get wrong

Four concrete pieces of CPython behavior that each finish a question the earlier sections opened — and that all trace back to the single name-tag idea you have been holding since the beginning.

1CPython pre-allocates integers from −5 through 256

This finishes what the mutability section and the identity-vs-equality FAQ started. When you saw that a = 256; b = 256; a is b returns True but the same code with 257 returns False, you were looking straight at an implementation detail. On interpreter startup, CPython builds a fixed array of 262 integer objects spanning −5 to 256, controlled by the macros NSMALLNEGINTS = 5 and NSMALLPOSINTS = 257 in Objects/longobject.c. Any literal in that range hands back a reference to the pre-existing singleton rather than allocating a new object. Outside that range, a fresh object is created each time — equal values, distinct identities. This is why is must be reserved for None, True, and False, the only identity comparisons Python genuinely guarantees.

Source: CPython Objects/longobject.c

2String literals that look like identifiers are automatically interned

This finishes what the naming rules section quietly implied. When the tutorial said that names like user_id follow the regex pattern of Python identifiers, it was describing the same criterion CPython's compiler uses to decide whether to intern a string literal. The internal helper all_name_chars() returns true when a string matches [a-zA-Z0-9_]* in ASCII — the exact identifier alphabet. Strings that match are interned at compile time, so "user_id" is "user_id" evaluates to True across the entire module. A literal with a space like "user id" is not interned because the space disqualifies it. The length threshold also changed over time: before Python 3.7 the peephole optimizer capped automatic interning at 20 characters; since 3.7 the AST optimizer raised that ceiling to 4,096 characters. For runtime-constructed strings you can force interning with sys.intern(), which replaces character-by-character comparison with a single pointer check — a real speedup when many long strings are compared repeatedly.

Source: CPython InternalDocs/string_interning.md

3The walrus operator deliberately leaks out of comprehensions

This is the exception to the LEGB rule. The scope section taught you that each comprehension creates its own local scope — which is why you cannot see the loop variable after the comprehension ends. PEP 572 carved out exactly one explicit exception: an assignment expression with the walrus operator inside a list, set, dict comprehension or generator expression binds its target in the containing scope, not in the comprehension's own scope. That is why [x := item for item in seq] leaves x readable after the comprehension finishes, even though item itself does not leak. The carve-out was intentional — the PEP authors wanted a way to capture "the last value" or a running total from inside a comprehension without needing a separate statement before it. Two constraints: the walrus target cannot share a name with any for-target in the same comprehension, and if the containing scope declares the name as nonlocal or global, the walrus honors that declaration.

Source: PEP 572, “Scope of the target”

4Comprehensions stopped using a separate frame in Python 3.12

This revises a subtle detail in the scope section. For the entire Python 3 era up through 3.11, every list, set, and dict comprehension was compiled as a hidden nested function — each invocation created a throwaway function object and pushed a new Python frame onto the stack. That frame was what isolated the comprehension's loop variable from the surrounding code. PEP 709 changed the implementation in Python 3.12: comprehensions are now inlined directly into the surrounding code, giving up to 2× speedup on comprehension-heavy microbenchmarks and roughly 11% on real-world workloads. The visible side effect is that locals() called from inside a comprehension now returns the enclosing function's locals, not just the comprehension's own bindings, because there is no longer a separate frame to isolate. Loop-variable isolation is instead achieved by a new LOAD_FAST_AND_CLEAR bytecode opcode that saves any outer value before the comprehension and restores it after — exactly the same end result, but no frame overhead.

Source: PEP 709 (Python 3.12)
SECURE CODING

Writing Python variables that do not become security bugs

Every concept in this tutorial has a security dimension. The same name-tag model that produces aliasing bugs produces data-leakage bugs; the same dynamic typing that speeds development lets attacker-controlled values reach places they should not. These are the habits that turn variable-handling into a safety boundary rather than a liability.

  • Never store secrets in source-code variables

    Hardcoded API keys, database passwords, and private tokens assigned directly to a module-level variable end up in Git history, container images, backup archives, and log scrapes — every one of them a disclosure path. Load secrets from environment variables with os.environ["API_KEY"] (or os.environ.get() when a default is acceptable), and for local development read them from a .env file that is explicitly listed in .gitignore. For production, use a proper secrets manager like AWS Secrets Manager, HashiCorp Vault, or Azure Key Vault. A hardcoded secret is a breach waiting for the next code review you miss.

  • Treat every value from outside your program as untrusted until proven otherwise

    Dynamic typing means Python will happily bind a variable to whatever an HTTP request, file, database, or environment variable hands it — including values of the wrong type, malicious strings, or unexpected sizes. Validate at the boundary: check the type with isinstance(), enforce shape and range with explicit conditionals, and prefer libraries like Pydantic or attrs for structured validation. The same isinstance() pattern covered in the type-checking section is how you stop an attacker-controlled value from silently flowing into code that assumes a particular shape.

  • Remember aliasing when handling sensitive data

    If you pass a credentials dictionary into a logging helper and that helper is written carelessly, the same object your authentication code still references is now inside the log-formatting pipeline — where any mutation, any stray __repr__, or any accidental serialization exposes the secret. The mutability section covered this mechanic neutrally; the security version is the same mechanic weaponized. Pass a copy when handing sensitive objects to code you do not control, and scrub sensitive keys out of the copy before it leaves your trust boundary.

  • Do not shadow built-ins, ever — especially not security-relevant ones

    Writing type = request.form["type"] or input = get_user_input() silently replaces built-in functions for the rest of the scope. Beyond the obvious bug risk, this can mask security checks: a later call to isinstance(x, type(y)) fails in a confusing way, or a sanitization routine that relied on the real input function quietly behaves differently. Pick descriptive names instead: submission_type, user_input. The PEP 8 convention of using a trailing underscore (type_, id_) is the standard workaround when the domain truly requires the word.

  • Use == for value comparison, is only for singletons — and hmac.compare_digest() for secrets

    The expert-facts section explained why is on integers and strings is unreliable outside the small-integer cache and the intern pool. In security contexts there is a further trap: plain == on a secret string short-circuits on the first differing character, which leaks timing information to an attacker measuring request latency. For comparing passwords, tokens, signatures, or HMAC values, always use hmac.compare_digest(a, b) from the standard library. It runs in constant time regardless of where the difference appears, closing the timing side channel.

Python Learning Summary Points

  1. Python variables point to objects in memory. Understanding this model is the key to avoiding aliasing bugs with mutable types like lists and dictionaries.
  2. Python infers types automatically, which speeds up development. Use type(), isinstance(), and type hints to keep your code clear and predictable.
  3. Follow PEP 8 conventions: snake_case for variables and functions, UPPER_SNAKE_CASE for constants, and PascalCase for classes. Never shadow built-in names.
  4. Python searches Local, Enclosing, Global, and Built-in scopes (LEGB) in order. Favor local variables and function parameters over global state.
  5. Know which types are mutable (lists, dicts, sets) and which are immutable (strings, ints, tuples). Use .copy() when you need independent copies of mutable objects.

Variables may be the simplest concept in programming on the surface, but the depth hidden underneath — scope resolution, reference semantics, mutability, and memory management — is what separates someone who can write Python from someone who truly understands it. Master the ideas in this guide, and you will have a foundation solid enough to support everything else you build.

Frequently Asked Questions

A Python variable is a named label that points to an object stored in memory. Unlike languages where a variable is a box that holds a value, in Python the variable is the name tag and the object lives separately. Writing x = 42 creates an integer object with the value 42 and binds the name x to it. Rebinding x to a different object does not change the original object — it only changes what x points to.

Use the equals sign: name = value. Python evaluates the expression on the right, creates or reuses an object for the resulting value, and binds the name on the left to that object. You do not need to declare the variable or its type in advance. A single name can be reassigned to values of different types at any point in your program, because the name is just a label.

Python has six core built-in types you will use every day: int for whole numbers, float for decimals, str for text, bool for True and False, list for ordered mutable collections, and dict for key-value mappings. Other common types include tuple for ordered immutable collections, set for unique unordered values, and NoneType for the special None value representing the absence of data.

Dynamic typing means Python decides a variable's type at runtime based on the value assigned to it, rather than requiring you to declare the type up front. The same name can point to an integer on one line and a string on the next. This makes code faster to write but also means type errors surface only when the offending line executes, which is why runtime checks like isinstance() and optional type hints matter in larger codebases.

Python resolves names using the LEGB rule: it looks first in the Local scope of the current function, then in any Enclosing function scopes, then in the Global module scope, then finally in the Built-in scope. The first match wins. Assigning to a name inside a function creates a new local binding by default, which is why the global and nonlocal keywords exist for the rare cases when you genuinely need to write to an outer scope.

Immutable objects cannot be changed in place once created — examples include int, float, str, tuple, bool, and frozenset. Operations that appear to modify them actually produce new objects. Mutable objects can be modified in place, and their contents can change while the object identity stays the same — examples include list, dict, and set. This distinction matters because passing a mutable object to a function lets the function change it, while immutable arguments are effectively pass-by-value.

Writing list = [1, 2, 3] or str = "hello" replaces the built-in function with your variable for the rest of the scope, which can cause confusing errors when later code tries to call the built-in. Linters will flag this, but it is easy to do accidentally. Safer alternatives are descriptive names like items, words, or names that reflect what the data represents. If you do accidentally shadow a built-in in the interpreter, use del name to restore it.

The == operator tests equality of value — whether two objects contain the same data. The is operator tests identity — whether two names point to the exact same object in memory. Two separate lists with identical contents are equal (==) but are not the same object (is). Always use == for value comparisons and reserve is for checking against singletons like None, True, and False.

Python has no language-level constants, but the convention is to use UPPER_SNAKE_CASE for names you intend never to reassign. MAX_RETRIES = 5 at module level signals to other developers that this value should be treated as fixed. Nothing prevents someone from reassigning it, so constants are a discipline rather than an enforcement. For enforced constants, use the enum module or a typing.Final annotation.

Multiple assignment lets you bind several names in a single statement. Writing a = b = 10 points both a and b at the same integer object. Writing x, y, z = 1, 2, 3 unpacks the tuple on the right into three separate names. Python also supports extended unpacking with a starred target, so first, *middle, last = [1, 2, 3, 4, 5] binds first to 1, middle to [2, 3, 4], and last to 5.

The name is a play on words rooted in two sides of the same person. PythonCodeCrack is run by Kandi Brian, who holds an M.S. in Cybersecurity and Information Assurance and works as an active cybersecurity defender — her daily tradecraft includes cybersecurity, backed by CompTIA Security+, CySA+, and PenTest+ certifications. The word "crack" nods to that security background, but that is the secondary meaning. The primary meaning is about being hooked on Python itself. The term "crack addict" has long been used in pop culture as shorthand for someone who cannot put a thing down, and Python developers famously fall into that exact relationship with the language — the clean syntax, the readability, the way idiomatic Pythonic code can express an algorithm in a handful of lines that would take a page in another language. PythonCodeCrack is written for the people addicted to that elegance: developers who read PEPs for fun, who care about the difference between a list comprehension and a generator expression, and who want tutorials that respect the craft of writing good Python rather than just gluing snippets together.

Certificate of Completion
Final Exam
Pass mark: 80% · Score 80% or higher to receive your certificate

Enter your name as you want it to appear on your certificate, then start the exam. Your name is used only to generate your certificate and is never transmitted or stored anywhere.

Question 1 of 10