Skip to content

Variables & Types

In Python you assign a value to a name — no type declaration needed. The variable’s type is determined by the object it currently references, not by a declaration.

x = 10 # x references an int
x = "hello" # x now references a str — perfectly valid
x = [1, 2, 3] # x now references a list

This is called dynamic typing. The type travels with the object, not the variable name.

A Python name is not a box that holds a value — it is a label tied to an object. = never copies; it only makes a name point at an object that already exists (or was just created). If two names point at the same mutable object, a mutation through one name is visible through the other:

a = [1, 2]
b = a # b is bound to the SAME object as a, not a copy
b.append(3)
print(a) # [1, 2, 3] -- mutating through b also changes what a sees
print(a is b) # True -- same object, two names
c = a.copy() # a new, independent object with equal contents
c.append(99)
print(a) # [1, 2, 3] -- unaffected by mutating c
print(a is c) # False

id(obj) returns a number that is unique to an object for its lifetime (in CPython, its memory address). Two names refer to the same object exactly when id(a) == id(b) — which is exactly what a is b checks for you. This is the single most common bug shape in code that “worked in testing”: a function receives a list, mutates it in place assuming it owns a private copy, and the caller’s list changes too.

== asks “do these two objects have the same value?” (it calls __eq__). is asks “are these two names bound to the exact same object?” (it compares id()). For small integers and some literals, CPython’s implementation details make is look like it also checks value — right up until it doesn’t:

small1 = 100
small2 = 100
print(small1 is small2) # True -- CPython caches small ints -5..256
big1 = 257
big2 = 257
print(big1 is big2) # True -- literal 257 folded to one constant in this file
big3 = int("257")
big4 = int("257")
print(big3 is big4) # False -- built at runtime, two separate objects

big1 is big2 prints True here because both 257 literals live in the same compiled code and the compiler deduplicated the constant — a detail of this file, not a guarantee of the language. The moment the same value is produced by a computation (int("257"), a sum, a value read from a file), it is a fresh object and is returns False. Never use is to compare values — use ==. Reserve is for identity checks that are meant to be identity checks: x is None, x is True, x is some_specific_object.

Python ships with four scalar types you will use in every program:

TypeLiteral exampleNotes
int42, -7, 0Arbitrary precision — never overflows
float3.14, -0.5IEEE 754 double precision
str"hello", 'world'Immutable Unicode sequence
boolTrue, FalseSubclass of int; True == 1, False == 0

There is also None — a singleton that represents the absence of a value. None has its own type, NoneType, and there is exactly one None object in the entire Python process. Always test for it with is None, never == None.

Whether an object can change in place after creation — without becoming a different object — is called mutability. This is independent of variable names: it is a property of the object itself.

TypeMutable?Note
int, float, bool, complexNoAny “change” creates a new object
strNoEvery string method returns a new string
tupleNoThe tuple’s slots can’t be reassigned — but an element that is itself mutable (a list inside a tuple) can still change
frozensetNoThe immutable sibling of set
listYes.append, .sort, slice assignment all mutate in place
dictYesKeys and values can be added, changed, removed
setYesElements can be added or removed

Aliasing (two names, one object) only causes surprises for mutable types. Aliasing an int or a str is harmless — there is no operation that changes a string or an int in place, so every name bound to it will only ever see the value it was created with.

type() returns the exact type of an object. isinstance() tests whether an object is an instance of a type or any of its subclasses.

age = 30
print(type(age)) # <class 'int'>
print(isinstance(age, int)) # True
print(isinstance(True, int)) # True — bool is a subclass of int

Python does not silently coerce types. You convert explicitly using the type name as a function:

n = int("42") # str -> int
f = float("3.14") # str -> float
s = str(100) # int -> str
b = bool(0) # int -> bool -> False

If the conversion is impossible, Python raises a ValueError:

int("hello") # ValueError: invalid literal for int()

Every Python object has a boolean interpretation used in if and while conditions. The following values are falsy — everything else is truthy:

  • False, None
  • Zero: 0, 0.0, 0j
  • Empty containers: "", [], {}, (), set()
name = ""
if name:
print("has name")
else:
print("name is empty") # this branch runs

That list is exactly the trap: 0, "", and [] are valid, present values that happen to be falsy — they are not the same thing as “no value was given.” if x: cannot tell “the caller passed 0” apart from “the caller passed nothing.” Whenever 0, "", or [] are meaningful inputs, check identity against None instead of truthiness:

def set_volume(vol=None):
if vol: # BUG: 0 (mute) is falsy
return vol
return 50
print(set_volume(0)) # 50 -- wrong: mute (0) silently became the default
def set_volume_fixed(vol=None):
if vol is not None: # checks "was something given", not "is it truthy"
return vol
return 50
print(set_volume_fixed(0)) # 0 -- correct

float is IEEE 754 double precision — a fixed number of binary bits, which cannot represent most decimal fractions exactly. This is not a Python bug; every language with binary floats has it.

import math
from decimal import Decimal
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False
print(math.isclose(0.1 + 0.2, 0.3)) # True
print(Decimal("0.1") + Decimal("0.2")) # 0.3

Never compare floats with == unless you constructed both sides from the exact same literal/computation. Use math.isclose(a, b) for general comparisons, or switch to decimal.Decimal (constructed from strings, not floats) when you need exact decimal arithmetic — money is the canonical example.

list, str, dict, type, id, input, sum, and every other builtin are just names in the built-in scope — nothing stops you from rebinding one:

items = [1, 2, 3]
list = items # shadows the builtin `list` type in this scope
print(list([4, 5])) # TypeError: 'list' object is not callable

Once list is rebound, the name no longer refers to the builtin type anywhere later in that scope — calling list(...) now tries to call the object stored in list (a plain list, not callable) instead of constructing a new list. The fix is simply never using a builtin’s name for a variable; del list also removes the shadow, but renaming the variable is the real fix.

True and False are not a separate primitive type bolted onto Python — bool is a subclass of int, and True/False behave as 1/0 in every numeric context:

print(True + True) # 2
print(isinstance(True, int)) # True
print(True == 1) # True

This is why sum([True, True, False]) is a perfectly normal (if surprising the first time) way to count how many items in a list of booleans are True — it evaluates to 2.

age: int = 30
price: float = 9.99
greeting: str = "Hello"
active: bool = True
nothing = None
print(type(age))
print(type(price))
print(type(greeting))
print(type(active))
print(type(nothing))
x = int("42")
y = float("3.14")
z = str(100)
print(x, y, z)
falsy_values = [0, "", None, False, [], 0.0]
for v in falsy_values:
print(f"bool({v!r}) = {bool(v)}")

The second playground below is a self-contained tour of everything on this page that can silently go wrong: aliasing, is vs ==, truthiness, float precision, shadowing, and bool arithmetic.

import math
from decimal import Decimal
# 1. Names bind to objects; assignment never copies.
a = [1, 2]
b = a # b is bound to the SAME list object, not a copy
b.append(3)
print("a:", a)
print("a is b:", a is b)
c = a.copy() # an independent object with an equal value
c.append(99)
print("a after c mutated:", a)
print("a == c:", a == c, "| a is c:", a is c)
# 2. is vs ==: small-int cache and same-file constant folding make "is"
# LOOK like a value comparison... until the value is built at runtime.
small1 = 100
small2 = 100
print("100 is 100 (cached -5..256):", small1 is small2)
big1 = 257
big2 = 257
print("257 is 257 (literals, same file):", big1 is big2)
big3 = int("257")
big4 = int("257")
print("int('257') is int('257'):", big3 is big4)
# 3. Truthiness: 0 / "" / [] are valid values, not "missing".
def set_volume(vol=None):
if vol: # BUG: a real 0 (mute) is falsy, so it falls through to the default
return vol
return 50 # default volume
print("set_volume(0), buggy:", set_volume(0))
def set_volume_fixed(vol=None):
if vol is not None:
return vol
return 50
print("set_volume(0), fixed:", set_volume_fixed(0))
# 4. Float precision.
print("0.1 + 0.2 ==", 0.1 + 0.2)
print("0.1 + 0.2 == 0.3:", 0.1 + 0.2 == 0.3)
print("isclose:", math.isclose(0.1 + 0.2, 0.3))
print("Decimal:", Decimal("0.1") + Decimal("0.2"))
# 5. Shadowing a builtin.
items = [1, 2, 3]
list = items # shadows the builtin list type in this scope
print("list (shadowed name):", list)
try:
list([4, 5])
except TypeError as e:
print(f"list([4, 5]) -> TypeError: {e}")
del list # remove the shadow; the builtin is visible again
print("list([4, 5]) after del:", list([4, 5]))
# 6. bool is a subclass of int.
one = 1
print("True + True ==", True + True)
print("isinstance(True, int):", isinstance(True, int))
print("True == 1:", True == 1, "| True is one:", True is one)

Think of a name as a label tied to an object with a piece of string, not a box that stores a value. a = [1, 2] ties the label a to a list object. b = a ties a second label b to that same object — no new object is created. b.append(3) writes into the object; both labels still see the change, because they point at the same thing. c = a.copy() creates a brand-new object with equal contents and ties c to it — from then on the two objects change independently.

flowchart LR
  subgraph Names
    aL["a"]
    bL["b"]
    cL["c"]
  end
  obj1(["list object #1: [1, 2, 3]"])
  obj2(["list object #2: [1, 2, 99]"])
  aL --> obj1
  bL --> obj1
  cL --> obj2
a and b label the same list object; c labels an independent copy
What you getBenefitCost
Dynamic typingNo type declarations; fast prototyping; flexible containersType errors surface at runtime instead of before the program runs
Assignment binds, never copiesO(1) regardless of object size — passing large data around is freeAliasing bugs: mutating through one name mutates every name bound to that object
is identity checkO(1), never calls __eq__Looks like it works for small ints and same-file literals, then silently breaks for larger or runtime-built values
Truthy/falsy coercion in ifConcise conditionals (if items:)Valid values (0, "", []) collapse into the same branch as “missing”
Arbitrary-precision intNo silent integer overflow, everfloat still has only 53 bits of precision — 0.1 + 0.2 != 0.3
  • “= copies the value, like it does in some other languages.” It does not — = binds a name to an object. For mutable objects, two names can end up referring to the very same one.
  • “is and == are interchangeable for numbers and strings.” They often agree by implementation accident (the small-int cache, literal folding) until a value falls outside that range or is built at runtime.
  • “0 means the same thing as None.” 0, "", and [] are legitimate values that happen to be falsy; None is the actual “no value” sentinel.
  • “0.1 + 0.2 == 0.3 because that’s basic arithmetic.” Binary floating point cannot represent 0.1 or 0.2 exactly; use math.isclose or Decimal when exactness matters.
  • “Reassigning a builtin name like list is harmless if I don’t use it again nearby.” It shadows the builtin for the rest of that scope — any later call to list(...) in the same scope breaks.
  • Does the code compare to None with == instead of is? if value == None: works by accident (NoneType.__eq__ happens to agree) but is None is the correct, idiomatic check.
  • Does a variable reuse a builtin’s name (list, str, type, id, dict, input, sum, …)? If so, check whether that builtin is called anywhere later in the same scope.
  • Does if value: need to accept 0, "", or [] as a real, valid input? If so it should be if value is not None:.
  • Does the code compare two int or str values with is instead of ==? Only identity checks (is None, is True, is some_sentinel) should use is.
  • Does the code compare floats with == directly, instead of math.isclose or Decimal?
  • Does a function receive a mutable argument (a list or dict) and mutate it in place without the caller clearly expecting that?

🐛 Spot the bug

def process(data):
list = get_items(data) # shadows the builtin list
if list == None: # should be "is None"
return []
return list
Show the answer

Two bugs stacked in four lines. First, list = get_items(data) shadows the builtin list type inside this function — if process ever needs to call list(...) later, it will fail with TypeError: 'list' object is not callable (or silently do the wrong thing if get_items can return something callable). Second, if list == None: relies on __eq__ agreeing with identity, which works for None today but is not the idiomatic or guaranteed-safe check; the correct form is if list is None:. Fix: rename the variable (items = get_items(data)) and use is None.

What does this print?
a = [1, 2]
b = a
b.append(3)
print(a)
What does this print?
def set_volume(vol=None):
    if vol:
        return vol
    return 50

print(set_volume(0))
Why can x = 257; y = 257; print(x is y) print True in a script, while the same values built with int("257") at runtime print False for is?
Which of these types is mutable in Python?
What happens?
list = [1, 2, 3]
print(list([4, 5]))
What does True + True evaluate to, and why?