Variables and Types
A variable is a label on a value. Python picks the type of the value for you — and the type decides what you can do with it.
Overview
Why types matter
The type of a value decides what the operators mean. + on two ints adds them; + on two strs concatenates them. Neither is "wrong" — the behaviour follows the type. When a program does something surprising, nine times out of ten a value is a different type than you assumed, and type() settles it in one line.
Assign, then ask what it is
The = sign binds a name to a value. type() asks Python what kind of value the name currently points at.
Looks can deceive
"10" and 10 look almost identical. They are different types, and the type changes the behaviour.
The types you will meet first
36. Arithmetic keeps them exact.3.14.+ on a string glues rather than adds — see the editor on the left.True or False, the answer to yes/no questions.x = "10" and x = 10 are both fine — they just make x different kinds of thing. This is why type() is your friend: when code behaves oddly, the first question is always "what did Python think this was?"Variables and Types: A Practical Guide
A name is not a box. It is a label that points at a value.
A variable is a label, not a box
Most explanations say a variable is a box that holds a value. Python does not work that way, and the difference explains several things that otherwise look like bugs.
In Python a variable is a name attached to an object. Assignment does not copy anything into a container; it points a label at something that already exists.
x = 10 # the label x now points at the integer object 10
y = x # y points at the same object
x = 20 # x now points at a different object; y still points at 10
print(y) # 10For numbers and strings this behaves exactly like the box picture, so nobody notices. For lists it does not:
a = [1, 2, 3]
b = a # b is another label for the SAME list
b.append(4)
print(a) # [1, 2, 3, 4] -- a changed tooThere is only one list, with two names. If you wanted a separate copy, you have to ask for one: b = a.copy() or b = a[:].
The types you will use every day
count = 42 # int - whole numbers, unlimited size
price = 19.99 # float - decimals, with rounding error
name = "Ada" # str - text, immutable
is_ready = True # bool - True or False
nothing = None # NoneType - the absence of a value
items = [1, 2, 3] # list - ordered, changeable
point = (10, 20) # tuple - ordered, unchangeable
unique = {1, 2, 3} # set - unordered, no duplicates
person = {"name": "Ada"} # dict - key to valuetype(x) tells you what something is; isinstance(x, int) asks whether it is a particular type and is the one to use in real code, because it also accepts subclasses.
Two properties matter more than the names:
- Mutable or immutable. Lists, dicts and sets can be changed in place. Numbers, strings and tuples cannot — "changing" them creates a new object.
- Ordered or not. Lists, tuples and (since Python 3.7) dicts keep their order. Sets do not.
Dynamic typing, and what it does not mean
Python is dynamically typed: a name can point at an integer now and a string later, and nothing complains.
value = 42
value = "forty-two" # perfectly legalIt is also strongly typed, which is a different thing and often confused with the first. Python will not silently mix types that do not belong together:
"5" + 3 # TypeError: can only concatenate str (not "int") to str
5 + 3 # 8
"5" * 3 # '555' -- legal, and probably not what you meant
int("5") + 3 # 8 -- convert explicitlyThat TypeError is a feature. A language that guessed here would turn a typo into a wrong answer instead of an error message.
The most common place beginners meet it is user input: input() always returns a string, so input("Age: ") + 1 fails and int(input("Age: ")) + 1 works.
Type hints: saying what you meant
Python 3 lets you annotate types. They are not enforced at runtime — the interpreter ignores them — but editors and tools such as mypy use them to catch mistakes before the code runs.
def total_price(quantity: int, unit_price: float) -> float:
return quantity * unit_price
names: list[str] = []
lookup: dict[str, int] = {}They are optional, and they pay off as soon as a file is read by someone other than the person who wrote it — including you, later. Start with function signatures; that is where most of the value is.
Guided tour
- Read the outputs. In the first editor, check the order: the two values print, then their two types. The last line runs only after the first three have finished.
- Reuse a name. Add
age = "six"as a new last line and run. The labelagenow points at a string — same name, new value, new type. - Compare the arithmetic. The second editor prints
"10"+"10" = "1010"but10+10 = 20. That is the whole lesson in two lines. - Break the glue. Try
print(a + b)in the second editor and run. Python's error message is a type complaint: you cannot add a string and an int, and it tells you exactly which types collided.
The short of it
A variable is a label, not a container. The type of the value it labels is chosen by Python, and it dictates behaviour — especially what + means. Whenever behaviour looks wrong, ask type() first; it is the single most useful question you can ask a running program.
Naming, and the rules that are not rules
Python enforces very little about names: they must start with a letter or underscore, may contain letters, digits and underscores, and cannot be a keyword such as class or for.
Convention does the rest, and following it makes your code readable to every other Python programmer:
| Kind | Convention | Example |
|---|---|---|
| Variables and functions | lower_snake_case | user_count, send_email |
| Constants | UPPER_SNAKE_CASE | MAX_RETRIES |
| Classes | CapWords | ShoppingCart |
| "Internal" names | Leading underscore | _cache |
| Throwaway values | A single underscore | for _ in range(3): |
Two habits worth forming early. Name things after what they mean, not what type they are — user_count beats int_value, and customers beats list1. And never shadow a builtin: assigning to list, dict, str, id, type or sum makes the original unavailable for the rest of the scope, and the resulting error appears far from the cause.
Converting between types
int("42") # 42
int(3.99) # 3 - truncates towards zero, does not round
round(3.99) # 4 - use this when you want rounding
float("3.14") # 3.14
str(42) # '42'
bool(0) # False
list("abc") # ['a', 'b', 'c']
int("abc") # ValueError - not a valid integerint() truncating rather than rounding surprises people regularly: int(9.99) is 9, and if that is a price in pounds you have just lost almost a pound.
bool() has rules worth memorising, because they drive every if statement: 0, 0.0, "", [], {}, () and None are falsy; everything else is truthy. That is why if items: is the idiomatic way to ask "is this list non-empty".
Checking what you actually have
When a program does something inexplicable, the fastest first move is to stop assuming what a value is and ask:
values = ["42", 42, 42.0, True, None, [42]]
for v in values:
print(f"{str(v):6} {type(v).__name__:9} {isinstance(v, int)}")42 str False
42 int True
42.0 float False
True bool True
None NoneType False
[42] list FalseThree things in that output are worth pausing on. "42" and 42 print identically and are entirely different values — which is exactly why a plain print can leave you none the wiser and print(f"{v=}"), which shows the repr, would have made the quotes visible.
True reports as an int, because bool is a subclass of int. That is not a quirk to work around; it is what makes sum(conditions) count how many held.
And isinstance says yes for the subclass where type(v) is int would say no. That is the reason isinstance is the one to use in real code: it accepts subclasses, which is almost always what you want, and type(x) is Y should be reserved for the rare case where you deliberately mean to exclude them.
Everything from outside is text
The single most common type surprise has one cause: data arriving from beyond your program is text, whatever it looks like.
input() returns a string. A file read line by line gives strings. sys.argv holds strings. os.environ values are strings. A CSV read without a converter gives strings, including the columns full of digits. HTML form fields are strings. Command output is bytes, which decode to strings.
JSON is the one exception worth knowing, and it is a partial one: json.load does produce real numbers, booleans and None, because the format records types. But its object keys always come back as strings, so a dictionary keyed by integers does not survive a save-and-load round trip.
The habit that follows is to convert at the boundary. Parse the input once, where it arrives, into the types the rest of the program should work with, and handle the failure there. Everything downstream then gets a real int, date or Decimal, and none of it has to defend against text.
The alternative — converting at each point of use — means the same conversion written in five places, five chances for them to disagree about what an empty string means, and a value whose type a reader has to infer from context rather than read.
Objects outlive names, and names outlive nothing
Assignment moves a label; it does not create or destroy the thing being labelled. Two consequences are worth being explicit about.
An object survives as long as *something* refers to it. Rebinding a name does not delete what it pointed at — if another name still refers to it, that object is unchanged and perfectly usable. This is why b = a followed by a = something_else leaves b holding the original.
An object with no remaining references is cleaned up automatically. CPython does this immediately, by counting references, which is why a file left open in a short script usually gets closed anyway when the variable goes out of scope — and why relying on that instead of with is a habit that fails the moment the program is long-running or the object is caught in a reference cycle.
del name removes the name, not the object. If other names refer to it, nothing is freed; if none do, the object becomes unreachable and is collected. Reading the name afterwards raises NameError, which is the same error as using a name that never existed, because from Python's point of view that is now the situation.
None of this needs managing. It is worth knowing only because it explains why "deleting" a variable does not always free memory, and why two names for one list keep behaving as one list no matter what happens to either name.
Questions people ask
Do I have to declare types? No. Type hints are optional and ignored at runtime; they exist for tools and readers.
What is None? A single object meaning "no value". It is what a function returns when it has no return statement. Test for it with is None, not == None.
Why does 0.1 + 0.2 give 0.30000000000000004? Because floats are binary approximations and 0.1 has no exact binary form. Use the decimal module for money, or compare with math.isclose rather than ==.
When should I use a tuple instead of a list? When the contents should not change — coordinates, database rows, a function returning several values. Tuples can also be dictionary keys; lists cannot.
Is there a limit on integer size? No. Python integers grow to whatever memory allows, which is why 2 ** 1000 just works.
What does id(x) show? The object's identity — effectively its address. It is how you can prove two names point at the same object.
Does Python have constants? Not enforced. The convention is an uppercase name, MAX_RETRIES = 5, which tells readers not to reassign it and does not stop them.
Why is type(True) bool but isinstance(True, int) True? Because bool is a subclass of int. True genuinely is an integer, equal to 1, as well as being a boolean.
How much memory does a variable use? The name costs almost nothing; the object is what has a size. sys.getsizeof(obj) reports it, excluding anything the object refers to.
Can a variable change type? Yes — the name is a label, so it can point at anything. Whether it *should* is a design question, and a name that holds two different kinds of thing usually wants to be two names.
What is the difference between = and ==? = binds a name to a value; == asks whether two values are equal. Using = in a condition is a SyntaxError rather than a silent bug.
Recap in one screen
- A variable is a name pointing at an object, not a box containing a value.
- Assigning a mutable object to a second name gives you two names for one object.
- Python is dynamically typed (names can change type) and strongly typed (it will not silently mix them).
int()truncates;round()rounds;input()always returns a string.- Falsy values:
0,"",[],{},(),None. Everything else is truthy. - Type hints are optional documentation that tools can check.
Three habits that prevent most beginner bugs
Convert input immediately. input() returns a string, always. Convert at the point of reading, not three functions later where the TypeError will be confusing:
age = int(input("Age: ")) # fails immediately on bad inputBetter still, handle the failure where it happens:
try:
age = int(input("Age: "))
except ValueError:
print("Please enter a whole number.")Use is only for None, True and False. is asks whether two names point at the same object; == asks whether the values are equal. For small integers and short strings, Python reuses objects, so a is b sometimes appears to work for equality — and then stops working on larger values, which is a genuinely horrible bug to track down.
if result is None: # correct
if name == "Ada": # correct
if count is 100: # wrong, and a SyntaxWarning in modern PythonPrint the type when confused. print(type(x), repr(x)) answers more debugging questions than any amount of staring. repr() shows the quotes, so you can immediately see whether 5 is the number or the string.