Names, not boxes
Most programming tutorials describe variables as "boxes that store values." That mental model works for a while, then breaks in confusing ways. Python's model is different.
In Python, a variable is a name bound to an object. Think of it as a sticky note you put on an object, not a box you pour a value into. The object lives somewhere in memory. The variable is just a label pointing to it.
1x = 42
This line does two things: creates an integer object with value 42, then binds the namexto it. The object exists independently. The name is just how you refer to it.
A variable is a sticky note on an object. Multiple sticky notes can point to the same object. Moving a sticky note doesn't change the object.
Where this matters: aliasing
When you assign one variable to another, you get two names pointing to the same object, not two copies. This is called aliasing, and it surprises almost every new Python programmer at some point.
1a = [1, 2, 3]2b = a # b points to the SAME list, not a copy34b.append(4)5print(a) # [1, 2, 3, 4] - a sees the change6print(b) # [1, 2, 3, 4]7print(a is b) # True - same object
Both names point to the same list, so a change through either name is visible from both. If you want a copy, you have to ask for one explicitly.
1a = [1, 2, 3]2b = a.copy() # a real copy, new object34b.append(4)5print(a) # [1, 2, 3] - unchanged6print(b) # [1, 2, 3, 4]7print(a is b) # False - different objects
Why integers feel different
Aliasing seems alarming until you notice that integers and strings don't have this problem. That's because they're immutable: you can't change them in place, so there's no way to affect one name through another.
1a = 102b = a34b = b + 1 # creates a NEW integer object5print(a) # 10 - unchanged6print(b) # 11 - b now points to a different object
b = b + 1 doesn't modify the integer 10. It creates a new integer 11 and moves the sticky note. The object a still points to is untouched.
The five fundamental types
Python has five types you'll use in nearly every program. Everything else is built on top of them.
int: whole numbers
Python integers are exact and have no size limit. Unlike most languages, you can't overflow an int in Python. It grows to fit whatever you need.
1age = 302count = -73big = 1_000_000_000 # underscores for readability, same as 100000000045# No overflow: Python handles arbitrarily large integers6factorial_20 = 24329020081766400007print(factorial_20 * 100)
float: decimal numbers
Floats represent numbers with decimal points using IEEE 754 double precision. They're fast and cover an enormous range, but they can't represent every decimal exactly.
1price = 9.992ratio = 0.53scientific = 1.5e10 # 15000000000.045# The classic float gotcha6print(0.1 + 0.2) # not 0.37print(0.1 + 0.2 == 0.3) # False!
0.1 + 0.2 != 0.3 because floats are stored in binary. 0.1 in binary is a repeating fraction, like 1/3 in decimal. The full story is in the Numbers article. For money, use decimal.Decimal.
str: text
An immutable sequence of Unicode characters. Covered in depth in the Strings article.
1name = "Alice"2message = 'Hello, world!'3multi = """Line one4Line two"""56print(type(name)) # <class 'str'>
bool: true or false
Booleans represent truth values. There are exactly two: True and False. The capitalization matters: lowercase versions are just undefined names.
1is_active = True2has_error = False34# Surprising: bool is a subclass of int5print(True + True) # 26print(True * 5) # 57print(int(True)) # 18print(int(False)) # 0
True and False are literally just 1 and 0 with extra behavior. This is occasionally useful for counting: summing a list of booleans gives you the count of True values.
None: the absence of a value
Noneis Python's way of saying "no value here." It's not zero, not an empty string, not False. It's the explicit absence of a value. There is exactly one None object in all of Python.
1result = None # no value yet23# Functions that don't return a value implicitly return None4def say_hello():5 print("Hello")67x = say_hello()8print(x) # None9print(x is None) # True - use "is", not "=="1011# Common pattern: optional parameter12def connect(host, port=None):13 if port is None:14 port = 443
Dynamic typing
In languages like Java or C++, you declare the type of a variable upfront and it can never hold anything else. Python works differently: the type lives with the object, not the variable. A name can point to any type of object at any time.
1x = 422print(type(x)) # <class 'int'>34x = "hello"5print(type(x)) # <class 'str'>67x = [1, 2, 3]8print(type(x)) # <class 'list'>910# x didn't change type - x just points to a different object each time
Python is dynamically typed, not untyped. Objects always have a type. Variables don't. The distinction is important.
Type annotations: hints, not rules
Python 3.5+ lets you annotate variables and function signatures with types. This is purely for documentation and tooling. Python itself ignores annotations at runtime. They don't enforce anything. Tools like mypy and your IDE use them to catch type errors before you run the code.
1# Annotations add clarity and enable editor autocomplete2name: str = "Alice"3age: int = 304scores: list[int] = [95, 87, 91]56# Function annotations7def greet(name: str, times: int = 1) -> str:8 return (f"Hello, {name}! " * times).strip()910print(greet("Alice", 2))
1# Annotations don't stop you from doing the "wrong" thing at runtime2def double(n: int) -> int:3 return n * 245print(double(5)) # 10 - works as intended6print(double("ha")) # "haha" - Python doesn't enforce the annotation7print(double([1, 2])) # [1, 2, 1, 2] - same
In practice: annotate your functions and class attributes. Skip annotations on local variables inside functions unless the type is non-obvious. Run mypy or use an IDE that checks types as you write. The earlier you catch type errors, the cheaper they are to fix.
Everything is an object
In Python, every value is an object: numbers, strings, functions, classes, modules. This isn't just philosophy: it means every value in Python has three things attached to it.
Identity
id(x)A unique integer identifying this object. Think of it as its memory address. Guaranteed unique for the lifetime of the object.
Type
type(x)What kind of object it is. The type determines which operations are valid and what the data means.
Value
xThe actual data. For an integer it's the number. For a string it's the characters. For a list it's the elements.
1x = 4223print(id(x)) # memory address, e.g. 97885764print(type(x)) # <class 'int'>5print(x) # 4267# Works for every object, even functions8def greet(): pass910print(type(greet)) # <class 'function'>11print(id(greet)) # functions have identities too
Identity vs equality
Python gives you two ways to compare things. They test different questions.
a == bDo they have the same value? Two separate lists with the same contents are equal.
a is bAre they the same object? Same identity (id). Like asking whether two people have the same name vs are literally the same person.
1a = [1, 2, 3]2b = [1, 2, 3] # same values, different object3c = a # same object45print(a == b) # True - same value6print(a is b) # False - different objects78print(a == c) # True - same value9print(a is c) # True - same object
Rule: use == to compare values. Use is only when you specifically mean "same object", most commonly x is None. Using is to compare integers or strings can silently give wrong answers due to interning.
Checking and converting types
Checking types
You have two tools: type() returns the exact type. isinstance() checks whether an object is an instance of a type or any of its subclasses. Useisinstance()almost everywhere.
1x = True23# type() checks the exact type4print(type(x) == bool) # True5print(type(x) == int) # False - even though True is stored as 167# isinstance() checks the type and its parents8print(isinstance(x, bool)) # True9print(isinstance(x, int)) # Also True - bool is a subclass of int1011# isinstance() handles multiple types at once12def process(value):13 if isinstance(value, (int, float)):14 return value * 215 if isinstance(value, str):16 return value.upper()1718print(process(5)) # 1019print(process("hi")) # "HI"
Converting types
Python won't silently convert types for you. When you need a different type, you ask for it explicitly.
1# str → int2int("42") # 423int(" 10 ") # 10 - strips whitespace45# str → float6float("3.14") # 3.1478# number → str9str(42) # "42"10str(3.14) # "3.14"1112# int ↔ float13int(3.9) # 3 - truncates toward zero, does NOT round14float(7) # 7.01516# anything → bool17bool(1) # True18bool(0) # False19bool("hello") # True20bool("") # False - empty string is falsy21bool([1, 2]) # True22bool([]) # False - empty list is falsy
Gotcha: int(3.9) is 3, not 4. It truncates toward zero, not rounds. Use round(3.9) if you want rounding.
Conversions that fail
Not every conversion makes sense. Python raises an exception rather than silently producing a wrong result.
1int("hello") # ValueError: invalid literal for int()2int("3.14") # ValueError - use float() first, then int()3int(None) # TypeError: int() argument must be a string or number45# The safe pattern: try/except6def safe_int(value):7 try:8 return int(value)9 except (ValueError, TypeError):10 return None1112print(safe_int("42")) # 4213print(safe_int("hello")) # None14print(safe_int(None)) # None
Truthiness: what counts as True
Every Python object has a boolean interpretation. You don't have to write if len(items) > 0. Just write if items. The rule is simple: empty and zero-like values are falsy, everything else is truthy.
Falsy values
Falsethe boolean FalseNoneno value0zero integer0.0zero float""empty string[]empty list{}empty dictset()empty setTruthy values
Truethe boolean True1any non-zero int"hello"any non-empty string[0]list with items (even if 0){'a': 1}dict with items{0}set with itemsobject()any object instance1items = []2if items:3 print("has items")4else:5 print("empty") # this runs67name = " "8if name.strip():9 print("has name")10else:11 print("blank name") # this runs - " ".strip() is ""1213# Common pattern: default value14user_input = ""15display = user_input or "Anonymous"16print(display) # "Anonymous"
Assignment patterns
Python has several assignment forms beyond the basic x = value. Each one has a specific use case.
Augmented assignment
A shorthand for updating a variable in terms of its current value.
1count = 02count += 1 # same as count = count + 13count += 14print(count) # 256total = 1007total -= 20 # subtract8total *= 1.1 # multiply (apply 10% markup)9print(total) # 88.01011text = "hello"12text += " world" # works on strings too13print(text)
Multiple assignment
Assign the same value to several names in one line. Note that this binds all names to the same object, fine for immutables but potentially confusing for mutables.
1x = y = z = 0 # all three names point to the same 02print(x, y, z) # 0 0 034# Fine for immutables, 0 can't be mutated5x += 16print(x, y, z) # 1 0 0 - x now points to a different object78# Be careful with mutables9a = b = [] # both point to the SAME list10a.append(1)11print(a, b) # [1] [1] - surprise!
Tuple unpacking
Assign multiple values in one line by unpacking from a tuple, list, or any iterable. The number of names must match the number of values. Use a star to capture the rest.
1# Basic unpacking2x, y = 10, 203print(x, y) # 10 2045# Swap without a temp variable6x, y = y, x7print(x, y) # 20 1089# Unpack a list10first, second, third = [1, 2, 3]1112# Star captures the remainder13head, *tail = [1, 2, 3, 4, 5]14print(head) # 115print(tail) # [2, 3, 4, 5]1617first, *middle, last = [1, 2, 3, 4, 5]18print(first, last) # 1 519print(middle) # [2, 3, 4]
Walrus operator (Python 3.8+)
The := operator assigns a value and returns it at the same time. Useful when you want to test a value and use it in the same expression.
1import re23text = "Order #12345 placed"45# Without walrus: runs the regex twice6if re.search(r"\d+", text):7 match = re.search(r"\d+", text)8 print(match.group())910# With walrus: assign and test in one step11if match := re.search(r"\d+", text):12 print(match.group()) # "12345"1314# Also useful in while loops15data = [3, 7, 1, 9, 2]16while (n := data.pop()) > 2:17 print(n) # 2 < threshold, stops after 9