📌 Quick Summary: Python Variables
- What: Named references to objects stored in memory
- Creation: Assign a value using
=— no prior declaration needed - Types: Dynamic — variables can hold any type and change during execution
- Best Practice: Use snake_case naming (e.g.,
user_count) - Scope: Follows LEGB rule (Local, Enclosing, Global, Built-in)
A Python variable is a named label that points to a value stored in memory. You create one the moment you assign it: user_name = "Alex". No type declaration, no memory allocation — Python handles all of that automatically. That simplicity is intentional, but it hides behaviors that trip up beginners and experienced developers alike.
This tutorial walks you through everything that matters: how variables actually work in memory, naming rules, dynamic typing, scope, and the pitfalls that cause real bugs. Each section includes working code examples you can run immediately.
What You’ll Learn
- Memory model: Why Python variables are references, not containers — and why that changes how you write code.
- Assignment patterns: Simple assignment, multiple assignment, tuple unpacking, augmented operators.
- Dynamic typing: How to check and convert types safely without runtime errors.
- Naming conventions: PEP 8 rules, constants, reserved words, and what to avoid.
- Scope (LEGB): Where Python looks for a variable name and what happens when scopes collide.
- Common pitfalls: Mutable defaults, variable shadowing, and late-binding closures — with fixes.
What Are Variables in Python?
In Python, a variable is not a container that holds a value. It is a name that references an object stored in memory. When you write x = 5, Python creates an integer object with the value 5 somewhere in memory, and the name x becomes a pointer to that object. This is a reference model, not a storage model.
This distinction matters in practice. If two variables point to the same mutable object — like a list — modifying that object through one variable will be visible through the other. Understanding this model prevents an entire class of hard-to-debug errors.
- Variables are references (labels), not memory slots that hold values
- Multiple variables can point to the same object in memory
- Python manages object creation and cleanup automatically
- The
id()function returns the memory address of any object
Python’s memory system handles object creation, reference counting, and garbage collection without any input from you. This frees you to focus on program logic — but it requires you to understand how the reference model behaves when you copy, modify, or pass variables around.
Variables vs Objects: Understanding the Difference
Consider this code:
a = 42
b = 42
print(id(a)) # e.g., 140234567
print(id(b)) # same address — both point to the same object
print(a is b) # True
In C or Java, this would create two separate memory slots. In Python, both a and b reference the same integer object. Python caches small integers (typically -5 to 256) as an optimization, so this is expected behavior.
The behavior is more consequential with mutable objects:
list_a = [1, 2, 3]
list_b = list_a # list_b points to the same list object
list_b.append(4)
print(list_a) # [1, 2, 3, 4] — list_a is affected too
To create an independent copy of a mutable object, use .copy() or the copy module:
import copy
list_b = list_a.copy() # shallow copy
list_b = copy.deepcopy(list_a) # deep copy (for nested structures)
- Use
id()to check if two variables reference the same object - Use
isto compare object identity (same object in memory) - Use
==to compare object equality (same value) - Use
.copy()orcopy.deepcopy()when you need an independent copy
Python vs C/C++ Variables
If you’re coming from C or C++, the mental shift is significant. In C++, int x = 5; allocates a fixed memory location for an integer. The variable name maps directly to that address. In Python, the variable name is just an entry in a namespace dictionary — a label that can be reattached to any object at any time.
| Aspect | Python | C/C++ |
|---|---|---|
| Variable type | Reference to object | Direct memory location |
| Memory management | Automatic (GC + reference counting) | Manual (malloc/free or RAII) |
| Type declaration | Dynamic, inferred at runtime | Static, declared at compile time |
| Memory address | Object address via id() |
Variable address via & |
| Type flexibility | Can reference any object type | Fixed type after declaration |
The trade-off: Python’s model gives you flexibility and automatic memory management at the cost of some runtime overhead. C++ gives you direct memory control and maximum performance at the cost of complexity. For most application-level Python work, the trade-off is well worth it.
Understanding Built-in Object Types
Every value in Python is an object, and every object has a type. The most important property to know for each type is whether it is mutable (can be changed after creation) or immutable (cannot be changed — operations create a new object).
| Data Type | Mutability | Example |
|---|---|---|
| Integer | Immutable | 42 |
| Float | Immutable | 3.14 |
| String | Immutable | 'Hello' |
| Boolean | Immutable | True |
| Tuple | Immutable | (1, 2, 3) |
| List | Mutable | [1, 2, 3] |
| Dictionary | Mutable | {'key': 'value'} |
| Set | Mutable | {1, 2, 3} |
When you “modify” an immutable object, Python creates a new one. This is why string concatenation in a loop is inefficient — every + creates a brand-new string object. Use ''.join(parts) instead. Mutable objects like lists can be changed in place, which is efficient but requires care when multiple variables point to the same object.
Creating and Using Python Variables
Python variables are created with the assignment operator =. There is no separate declaration step. The moment you write name = "Python", the variable exists. This section covers every assignment pattern you will encounter in real code.
The Assignment Operator and Its Behavior
The basic operator = creates or updates a variable reference. Python evaluates the right side first, creates (or reuses) an object for that value, then binds the variable name to that object.
# Basic assignment
x = 10
name = "Alice"
is_active = True
# Chained assignment — all three names point to the same object
a = b = c = 0
# Augmented assignment — shorthand for modify-and-rebind
score = 0
score += 5 # same as: score = score + 5
score *= 2 # same as: score = score * 2
| Operator | Effect | Example |
|---|---|---|
= |
Basic assignment | x = 5 |
+= |
Add and assign | x += 3 |
-= |
Subtract and assign | x -= 2 |
*= |
Multiply and assign | x *= 4 |
/= |
Divide and assign | x /= 2 |
//= |
Floor divide and assign | x //= 3 |
%= |
Modulo and assign | x %= 5 |
Caution with chained assignment and mutable objects. When you write a = b = [], both a and b point to the same list. Appending to a changes what b sees. If you need two independent lists, create them separately: a = [] and b = [].
The assignment operator binds variable names to objects in memory. To see how variable values print at different stages of execution, visit our Python print with variable tutorial.
Multiple Assignment and Tuple Unpacking
Python lets you assign multiple variables in a single line by unpacking any iterable. This is one of the most readable features in the language.
# Assign multiple variables at once
x, y, z = 1, 2, 3
# Swap two variables — no temp variable needed
a, b = 10, 20
a, b = b, a
print(a, b) # 20 10
# Unpack a function that returns multiple values
def get_dimensions():
return 1920, 1080
width, height = get_dimensions()
# Ignore unwanted values with _
name, _, age = ("Alice", "ignored", 30)
# Capture variable-length sequences with *
first, *rest = [1, 2, 3, 4, 5]
print(first) # 1
print(rest) # [2, 3, 4, 5]
*beginning, last = [1, 2, 3, 4, 5]
print(last) # 5
- Use
a, b = b, ato swap variables — no temp variable required - Use
_as a throwaway variable for values you don’t need - Use
*nameto capture the remaining elements of a sequence - Unpack function return values directly into named variables for readability
If the number of variables on the left doesn’t match the iterable on the right (and you haven’t used *), Python raises a ValueError. This is a common error when unpacking database rows or API responses — always verify the structure before unpacking.
Mismatched unpacking is a frequent source of runtime errors. Learn how to handle the cannot unpack non-iterable NoneType object error with proper unpacking techniques.
Deleting Python Variables
The del statement removes a variable name from the namespace. It does not necessarily delete the underlying object — that only happens when no other references to the object remain and Python’s garbage collector runs.
data = [1, 2, 3, 4, 5]
process(data)
del data # Remove reference; object is freed if no other references exist
# Accessing a deleted variable raises NameError
# print(data) # NameError: name 'data' is not defined
delremoves the variable name, not necessarily the object in memory- Accessing a deleted variable raises
NameError - Use
deldeliberately when releasing large objects in long-running functions - In most cases, letting variables go out of scope naturally is sufficient
Dynamic Typing in Python
In Python, types belong to objects, not to variables. A variable can reference an integer now and a string a moment later — the interpreter determines the type at runtime based on what the variable currently points to. This is called dynamic typing.
value = 42 # value points to an integer object
print(type(value)) # <class 'int'>
value = "hello" # value now points to a string object
print(type(value)) # <class 'str'>
value = [1, 2, 3] # now a list
print(type(value)) # <class 'list'>
| Feature | Static Typing (C++, Java) | Dynamic Typing (Python) |
|---|---|---|
| Type declaration | Required at compile time | Inferred at runtime |
| Type checking | Compile-time errors | Runtime errors |
| Flexibility | Fixed type per variable | Variables can change types |
| Performance | Generally faster | Runtime overhead |
| Development speed | More setup required | Faster prototyping |
The practical consequence: type errors only appear when the problematic line actually executes. A bug in a rarely-called function can go undetected for a long time. This is why testing matters more in Python than in statically typed languages, and why type hints (covered below) are worth adopting as your projects grow.
Checking Variable Types
Python gives you two main tools for type introspection. Use them correctly to avoid brittle type checks:
x = 42
# type() — returns the exact type
print(type(x)) # <class 'int'>
print(type(x) == int) # True — but avoid this pattern
# isinstance() — preferred; respects inheritance
print(isinstance(x, int)) # True
print(isinstance(x, (int, float))) # True — checks multiple types at once
# Check if an object has a specific attribute (duck typing)
items = [1, 2, 3]
print(hasattr(items, '__len__')) # True — object has a length
| Method | Use case | Example |
|---|---|---|
type() |
Exact type check | type(x) is int |
isinstance() |
Type + subclass check (preferred) | isinstance(x, (int, float)) |
hasattr() |
Duck typing — check for attribute | hasattr(x, '__len__') |
callable() |
Check if object can be called | callable(my_func) |
Prefer isinstance() over type() ==. If a subclass of int is passed to your function, type(x) == int returns False, while isinstance(x, int) returns True. The latter is almost always what you want.
Type Conversion
Python’s built-in conversion functions let you convert between types explicitly. Conversions can fail — always handle exceptions when the input comes from outside your control:
# Safe type conversion with error handling
def parse_integer(value):
try:
return int(value)
except (ValueError, TypeError) as e:
print(f"Conversion failed: {e}")
return None
print(parse_integer("42")) # 42
print(parse_integer("abc")) # Conversion failed: ... None
print(parse_integer(None)) # Conversion failed: ... None
# Common conversions
int("10") # 10
float("3.14") # 3.14
str(100) # "100"
list("abc") # ['a', 'b', 'c']
bool(0) # False
bool("hello") # True
- DO: Use
isinstance()for type checking - DON’T: Use
type() ==when inheritance matters - DO: Wrap conversions in
try/exceptfor external data - DON’T: Assume strings from user input represent valid numbers
- DO: Validate input before conversion when possible
- DON’T: Convert
floattointsilently when precision matters
Type conversion is essential for numeric operations. For integer division behavior specifically, see our floor division Python guide — including how int() and // differ.
Type Hints
Type hints, available since Python 3.5, let you annotate variables and function signatures with expected types. They don’t enforce types at runtime, but they make code more readable and enable static checkers like mypy to catch type errors before you run the code.
from typing import Optional, List
# Variable annotations
username: str = "alice"
score: int = 0
items: List[str] = []
# Function signatures with type hints
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
def find_user(user_id: int) -> Optional[str]:
# Returns a string if found, None if not
...
Type hints are especially valuable in team projects and larger codebases. They act as machine-readable documentation that IDEs use for autocomplete and error detection. You don’t need to add them everywhere — start with function signatures on public interfaces and grow from there.
Naming Conventions and Best Practices
Variable names are the primary tool you have for communicating intent to the next person who reads your code (often future you). Python’s PEP 8 style guide defines the community standard: use snake_case — all lowercase, words separated by underscores.
| Good Name | Bad Name | Why |
|---|---|---|
user_count |
uc |
Descriptive, no mental decoding needed |
is_valid |
flag |
Boolean intent is obvious |
MAX_RETRIES |
max_retries |
Constants use ALL_CAPS by convention |
student_grades |
data |
Specific content is clear |
filtered_results |
results2 |
Processing state is communicated |
Rules for valid Python variable names: must start with a letter or underscore; subsequent characters can be letters, digits, or underscores; names are case-sensitive (value and Value are different variables). Names cannot start with a digit.
Choosing Descriptive Names
A good variable name answers: “what does this hold, and what state is it in?” Short names like x and n are fine in narrow scopes (a 3-line math formula, a loop counter), but they cause confusion in longer functions.
- Use full words, not abbreviations (
error_messagenoterr_msg— unless your team uses a standard shorthand) - Booleans should read as yes/no questions:
is_authenticated,has_permission,can_retry - Collections should be plural:
user_ids,active_orders - Include the processing stage in the name:
raw_data,parsed_records,validated_input - If the name requires a comment to explain it, rename it
If you struggle to name a variable, that’s often a signal the variable is doing too much. Split it, or reconsider your function’s structure.
Reserved Words and Naming Restrictions
Python’s reserved keywords cannot be used as variable names. You’ll get a SyntaxError if you try. More subtle — and more dangerous — is accidentally shadowing built-in names like list, dict, str, id, or input. These are not reserved, so Python won’t complain, but your code will break unexpectedly when you try to use the built-in later.
| Reserved Word | Purpose | Safe Alternative |
|---|---|---|
class |
Define classes | cls, class_name |
def |
Define functions | func, definition |
for |
Loop construct | for_loop |
import |
Module import | module |
return |
Function return | result |
list (built-in) |
List constructor | items, values |
str (built-in) |
String constructor | text, label |
To see all 35 reserved keywords, run import keyword; print(keyword.kwlist) in any Python interpreter.
Constants in Python
Python has no enforced constants. By convention, a variable written in ALL_CAPS is a signal to other developers: “don’t modify this.” The interpreter won’t stop you from changing it, but the naming makes the intent clear during code review.
MAX_CONNECTIONS = 100
PI = 3.14159
DEFAULT_TIMEOUT = 30 # seconds
API_BASE_URL = "https://api.example.com/v1"
- Define constants at the top of the module, before any functions
- Group related constants in a dedicated
constants.pymodule for larger projects - Use Python’s
Enumclass for related constant groups that need iteration or comparison - Always comment why a constant has a specific value — the name explains what, the comment explains why
Variable Scope and Lifetime
Scope defines where in your code a variable name is visible. Python uses the LEGB rule to resolve names: it searches four scopes in order, and uses the first match it finds.
- Local — Inside the currently executing function
- Enclosing — In any enclosing (outer) function scopes, for nested functions
- Global — At the module level (top of the file)
- Built-in — Python’s built-in namespace (
len,range,print, etc.)
x = "global" # Global scope
def outer():
x = "enclosing" # Enclosing scope
def inner():
x = "local" # Local scope
print(x) # Prints "local" — LEGB finds local first
inner()
print(x) # Prints "enclosing"
outer()
print(x) # Prints "global"
Local vs Global Variables
Local variables are created when a function is called and destroyed when it returns. Global variables are defined at module level and persist for the entire program’s life. Use local variables by default — they’re isolated, predictable, and easier to test.
| Aspect | Local Variables | Global Variables |
|---|---|---|
| Accessibility | Only inside the function | Available across the module |
| Lifetime | Duration of function call | Entire program execution |
| Testability | Easy to isolate and test | Hard to isolate — creates hidden dependencies |
| Debugging | Scope is predictable | Can be modified from anywhere — harder to trace |
To modify a global variable from inside a function, use the global keyword. Without it, Python creates a new local variable with the same name instead of modifying the global — a common source of bugs:
counter = 0
def increment():
global counter # Without this line, counter inside is a new local variable
counter += 1
increment()
print(counter) # 1
In nested functions, use nonlocal to modify a variable in the enclosing scope:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
Variable scope determines where variables are accessible in your code. For a deeper look at how variables interact with function parameters, return values, and closures, see our Python functions tutorial.
Variable Lifetime and Memory
Python tracks object references with a reference counter. When the counter reaches zero (no more variables pointing to an object), Python marks the object for collection. Local variables are cleaned up automatically when a function returns, which handles the vast majority of cases.
- Circular references (objects pointing to each other) can delay garbage collection — Python’s cyclic GC handles most cases, but it’s not immediate
- Variables captured in closures keep their referenced objects alive as long as the closure exists
- Global variables persist for the entire program lifetime — don’t accumulate large datasets in globals
- Use
deldeliberately to release references to large objects in memory-intensive functions
Advanced Variable Techniques
These techniques are what separate readable Python code from merely functional code. They leverage Python’s strengths to express common patterns more concisely — but each one has a right context and a wrong one.
- List and dictionary comprehensions for concise data transformation
- Generator expressions for memory-efficient processing of large sequences
- The walrus operator (
:=) for assignment within expressions - Extended unpacking (
*name) for flexible variable-length sequences
Comprehensions and Generator Expressions
List comprehensions replace multi-line loops with a single, readable expression. The syntax is: [expression for item in iterable if condition].
# Traditional loop
squares = []
for n in range(10):
if n % 2 == 0:
squares.append(n ** 2)
# List comprehension — same result, one line
squares = [n ** 2 for n in range(10) if n % 2 == 0]
# [0, 4, 16, 36, 64]
# Dictionary comprehension
word_lengths = {word: len(word) for word in ["apple", "banana", "cherry"]}
# {'apple': 5, 'banana': 6, 'cherry': 6}
# Generator expression — lazy evaluation, no list created in memory
total = sum(n ** 2 for n in range(1_000_000)) # Uses minimal memory
| Approach | Readability | Memory | Best for |
|---|---|---|---|
| Traditional loop | Verbose | Higher | Complex multi-step logic |
| List comprehension | Concise | Higher (stores full list) | Building a list you’ll use multiple times |
| Generator expression | Concise | Lower (lazy) | One-pass processing of large datasets |
Use a generator expression when you only need to iterate over the results once (e.g., pass to sum(), max(), or a loop). Use a list comprehension when you need to index into the results or use them multiple times.
The Walrus Operator (:=)
Introduced in Python 3.8, the walrus operator assigns a value to a variable and returns it in the same expression. Its main use is eliminating redundant function calls in loops and comprehensions.
import re
# Without walrus — function called twice
data = "Order #12345"
if re.search(r'\d+', data):
match = re.search(r'\d+', data)
print(match.group())
# With walrus — function called once
if match := re.search(r'\d+', data):
print(match.group()) # 12345
# Useful in while loops
import sys
while chunk := sys.stdin.read(8192):
process(chunk)
# In comprehensions — compute once, filter and use
results = [
processed
for raw in dataset
if (processed := expensive_transform(raw)) is not None
]
- Best use case:
whileloops that read data until exhausted - Useful in comprehensions where a computed value is both filtered and used
- Avoid nesting multiple walrus operators in one expression — readability suffers
- Requires Python 3.8 or later
Common Pitfalls and How to Avoid Them
Most Python variable bugs don’t come from syntax errors. They come from Python behaving exactly as designed, in ways that aren’t obvious until you understand the reference model and scope rules. Here are the most common traps.
| Pitfall | What goes wrong | Fix |
|---|---|---|
| Mutable default arguments | Shared state persists across calls | Use None, create object inside function |
| Variable shadowing | Wrong variable accessed silently | Descriptive unique names; use global/nonlocal |
| Late-binding closures | Loop variable captured by reference | Use default parameter: lambda x=x: x |
| Shadowing built-ins | list() stops working |
Never name variables list, str, id, etc. |
| Unintended aliasing | Modifying one variable affects another | Use .copy() or copy.deepcopy() |
Mutable Default Arguments
Default parameter values are evaluated once, when the function is defined — not each time the function is called. If the default is a mutable object, all calls share the same object.
# Problematic — the list is created once and shared
def add_item(item, target_list=[]):
target_list.append(item)
return target_list
print(add_item("first")) # ['first']
print(add_item("second")) # ['first', 'second'] — unexpected!
print(add_item("third")) # ['first', 'second', 'third'] — the list is growing
# Correct — create a fresh list on each call
def add_item(item, target_list=None):
if target_list is None:
target_list = []
target_list.append(item)
return target_list
print(add_item("first")) # ['first']
print(add_item("second")) # ['second'] — independent list
- DO: Use
Noneas the default for any mutable parameter - DON’T: Use
[],{}, or any mutable object as a default value - DO: Create the mutable object inside the function body
- DO: Use
copy.deepcopy()when you need to pass and preserve a mutable object
Mutable default arguments are closely tied to how Python lists behave in memory. Before using lists as function parameters, review our Python lists tutorial for a solid foundation.
Variable Shadowing and Name Collisions
Shadowing happens when a variable in an inner scope uses the same name as one in an outer scope. Python resolves the inner one without any warning — which means you can silently access the wrong variable.
# Problematic — local variable shadows global
count = 0
def process_items(items):
count = len(items) # Creates a LOCAL variable named count
return count # Returns local count
process_items([1, 2, 3])
print(count) # Still 0 — global was never modified
# Fixed — explicit with descriptive names and global keyword
total_count = 0
def process_items(items):
global total_count
item_count = len(items) # Local variable with distinct name
total_count += item_count # Explicitly modify global
return item_count
- Use distinct, descriptive names to avoid accidental shadowing
- Be explicit: use
globalornonlocalwhen you intend to modify an outer variable - Never use built-in names (
list,dict,str,id,type) as variable names - Enable linter warnings in your editor — most will flag shadowing automatically
Practical Examples
The concepts above come together in real code. Here are two focused examples that demonstrate naming, unpacking, type hints, and pitfall avoidance in patterns you’ll actually use.
Data Processing Example
from typing import List, Dict, Optional
def parse_user_records(raw_rows: List[Dict]) -> List[Dict]:
"""Parse and clean raw user records from a data source."""
parsed_users = []
skipped_count = 0
for row_index, raw_record in enumerate(raw_rows):
# Extract fields with defaults — no bare indexing that raises KeyError
full_name = raw_record.get("name", "").strip()
email = raw_record.get("email", "").strip().lower()
age_raw = raw_record.get("age")
# Skip incomplete records
if not full_name or not email:
skipped_count += 1
continue
# Safe type conversion
try:
age = int(age_raw) if age_raw is not None else None
except (ValueError, TypeError):
age = None
parsed_users.append({
"name": full_name.title(),
"email": email,
"age": age,
"source_index": row_index,
})
print(f"Parsed {len(parsed_users)} records, skipped {skipped_count}")
return parsed_users
Notice the naming pattern: raw_record makes it clear the data hasn’t been cleaned yet; parsed_users signals the output is processed. Each variable has one clear purpose. The age_raw → age progression documents the transformation explicitly.
Reusable Function Design
from typing import Optional, List, Callable, Dict
def filter_and_transform(
records: List[Dict],
transform_fn: Callable[[Dict], Optional[Dict]],
max_results: Optional[int] = None,
) -> List[Dict]:
"""
Apply a transformation function to each record, filtering out None results.
Args:
records: Input list of records
transform_fn: Function applied to each record; return None to skip
max_results: Cap on output length (None = no limit)
Returns:
List of successfully transformed records
"""
transformed = []
for record in records:
if max_results is not None and len(transformed) >= max_results:
break
result = transform_fn(record)
if result is not None:
transformed.append(result)
return transformed
# Usage
def normalize_product(record: Dict) -> Optional[Dict]:
if not record.get("price"):
return None # Filter out records with no price
return {
"id": record["id"],
"name": record["name"].strip().title(),
"price_usd": round(float(record["price"]), 2),
}
products = filter_and_transform(raw_products, normalize_product, max_results=50)
- Type hints on parameters make the interface self-documenting
- Use
Noneas default for any optional mutable parameter to avoid the shared-object pitfall - Name intermediate variables to reflect their state:
raw_recordvsresultvstransformed - Return meaningful, consistently structured data — keys should be obvious without a comment
More Python Guides
- Python List Comprehension — master one of Python’s most powerful features with real examples
- Python Dictionary Methods — complete reference for working with dicts in Python
- Python String Formatting — f-strings,
.format(), and when to use each - Python Practice Problems — apply what you’ve learned with hands-on exercises
- Common Python Errors — the errors beginners hit most, and how to fix them
Frequently Asked Questions
Python variables are named references that point to objects stored in memory. When you write x = 5, Python creates an integer object with value 5 and makes the name x point to it. Unlike languages such as C, variables in Python don’t hold values directly — they reference objects, which means multiple variables can point to the same object.
Create a variable by assigning a value with the = operator: x = 5 or name = "Alice". Python infers the type automatically — no declaration is needed. You can also assign multiple variables at once: a, b, c = 1, 2, 3, or use augmented assignment to update a value: x += 10.
Dynamic typing means that a variable’s type is determined at runtime, not at compile time. A variable can reference an integer, then be reassigned to hold a string or a list — the type follows the object, not the variable name. This makes Python flexible and fast to write, but it also means type errors only appear when the problematic line executes, not before.
Use snake_case for regular variables and function names (user_count, is_active), ALL_CAPS for constants (MAX_RETRIES), and CamelCase only for class names. Variable names must start with a letter or underscore, cannot start with a digit, and are case-sensitive. Avoid using Python built-in names like list, str, or id as variable names.
Mutability is a property of the object, not the variable. Immutable objects (integers, strings, tuples) cannot be changed after creation — operations on them produce new objects. Mutable objects (lists, dictionaries, sets) can be changed in place. This matters most when multiple variables reference the same mutable object: a change through one variable affects all others pointing to the same object.

