Variables, Data Types & Type Conversion
Every value in Python is an object. Variables, the core data types, dynamic typing, and how to convert safely between them.
Variables Are Names, Not Boxes
Most beginner explanations describe a variable as a labelled box that stores a value. In Python, that mental model is wrong in a way that will cause real confusion later. A more accurate picture: every value you create (a number, a string, a list) lives somewhere in memory as an object, and a variable is simply a name that points at that object. This is sometimes called a "name binding."
age = 25
name = "Maria"
# "age" does not contain the number 25 inside it.
# "age" is a name that points at an integer object whose value is 25.This distinction matters the moment you assign one variable to another:
scores = [90, 85, 77]
backup = scores # backup now points at the SAME list object as scores
scores.append(100)
print(backup) # [90, 85, 77, 100] — backup changed too!backup = scores did not copy the list — it made backup point at the exact same object in memory as scores. Both names refer to one list, so a change through either name is visible through both. This becomes critical once you learn about mutable vs immutable types later in this module, and again when you learn proper copying techniques in the Lists module.Naming rules and conventions
A variable name must start with a letter or underscore, can contain letters, numbers, and underscores, and is case-sensitive (age and Age are different names). Names cannot be one of Python's reserved keywords (if, class, for, and about 35 others). By convention, Python variable names use snake_case — lowercase words separated by underscores, like total_price — not camelCase. This is not enforced by the language, but every professional Python codebase follows it, and you will cover this and the rest of the PEP 8 style guide in depth in the Best Practices module later in this track.
user_name = "ok" # valid
_private = "ok" # valid — leading underscore is a convention for internal use
age2 = 25 # valid — numbers allowed after the first character
2age = 25 # SyntaxError — cannot start with a digit
user-name = "no" # SyntaxError — hyphens are the subtraction operator, not allowed
class = "no" # SyntaxError — "class" is a reserved keywordEvery Value Has a Type
Python has several built-in types you will use constantly. These five form the foundation for everything else in this track:
age = 25 # int — whole numbers, positive or negative
price = 19.99 # float — decimal numbers
name = "Maria" # str — text, in single or double quotes
is_active = True # bool — True or False (capitalised — this is not JavaScript)
middle_name = None # NoneType — represents "no value" / absence of a valueYou can check any value's type with the built-in type() function — genuinely useful while learning, and something you will still reach for occasionally as a working engineer when debugging unexpected behaviour.
print(type(age)) # <class 'int'>
print(type(price)) # <class 'float'>
print(type(name)) # <class 'str'>
print(type(is_active)) # <class 'bool'>
print(type(middle_name)) # <class 'NoneType'>None is not zero, not False, not an empty string
None is Python's way of representing the deliberate absence of a value — it is its own distinct type with exactly one possible value. A function that does not explicitly return anything returns None automatically. You will use None constantly as a default placeholder for "not set yet" — for example, a user's optional middle name, or a search result that found nothing.
if value is None:, never if value == None:. is checks that two names point at the exact same object — the correct check for a unique singleton like None — while == checks value equality, which can occasionally be overridden by custom objects and technically does the wrong kind of comparison here.Integers and Floats Behave Differently Than You Might Expect
Python integers have unlimited precision
In many languages, integers are stored in a fixed number of bits (commonly 32 or 64), which means they overflow — wrap around or error — past a certain size. Python's int type has no fixed size limit; it automatically grows to accommodate arbitrarily large numbers, limited only by available memory.
big_number = 2 ** 100
print(big_number)
# 1267650600228229401496703205376
# A 64-bit integer in most other languages would have overflowed long before this.Floats are not perfectly precise — and this is not a Python bug
Python's float type uses the IEEE 754 double-precision standard, the same binary floating-point representation used by nearly every mainstream programming language. Binary floating point cannot represent every decimal fraction exactly, for the same reason that 1/3 cannot be written as a finite decimal in base 10.
print(0.1 + 0.2)
# 0.30000000000000004 — not exactly 0.3
print(0.1 + 0.2 == 0.3)
# False0.1 + 0.2. It is not a Python defect. The practical consequence: never compare floats with == directly, and never use float to represent money. For financial calculations, use Python's decimal.Decimal type instead, which represents decimal numbers exactly. You will see this exact problem — and its correct fix — again when you reach data engineering and financial data topics elsewhere on this site.# Comparing floats safely — check they're "close enough" instead of exactly equal
import math
math.isclose(0.1 + 0.2, 0.3) # True
# Representing money correctly
from decimal import Decimal
price = Decimal("19.99") # pass a STRING, not a float, to avoid inheriting float's imprecision
tax = Decimal("1.60")
print(price + tax) # Decimal('21.59') — exactComplex numbers exist too
Python has a built-in complex type for complex numbers (3 + 4j), used in scientific and engineering computation. You are unlikely to need it in typical application development, but it is worth knowing it exists as a genuine built-in type, not a library add-on.
Mutable vs Immutable — The Distinction That Explains Half of Python's Behaviour
Every Python object is either mutable (its internal state can change after creation) or immutable (once created, it can never be changed — any "modification" actually creates a brand new object). This single distinction explains a huge amount of behaviour that otherwise looks inconsistent.
IMMUTABLE: int, float, bool, str, tuple, frozenset, NoneType
MUTABLE: list, dict, set, and custom objects (by default)x = 5
y = x
x = x + 1
print(x) # 6
print(y) # 5 — unaffected, because x + 1 created a brand NEW integer object,
# and only x was re-pointed at it. y still points at the original 5.a = [1, 2, 3]
b = a
a.append(4)
print(a) # [1, 2, 3, 4]
print(b) # [1, 2, 3, 4] — b changed too, because a and b point at the SAME mutable objectNotice the difference is not about = behaving differently — assignment always just points a name at an object, consistently, in every case. The difference is entirely about whether the underlying object itself can be changed in place once created. Numbers and strings cannot; lists, dicts, and sets can.
id(), Object Identity, and CPython's Small-Integer Cache
Every object in Python has a unique identity — effectively its memory address — which you can inspect with the built-in id() function. Two names with the same id() are, by definition, the exact same object.
a = [1, 2, 3]
b = a
c = [1, 2, 3]
print(id(a) == id(b)) # True — same object (this is exactly what "a is b" checks)
print(id(a) == id(c)) # False — different objects, even with identical contents
print(a == c) # True — equal VALUE, different objectsA well-known CPython quirk: small integer caching
As a memory optimisation, CPython pre-creates and reuses integer objects for the range -5 to 256, since small integers are used extremely often. This is an implementation detail, not a guaranteed language feature — but it explains a genuinely confusing observation beginners run into when experimenting with is.
a = 100
b = 100
print(a is b) # True — CPython reused the same cached object for both, coincidentally
x = 1000
y = 1000
print(x is y) # False — 1000 is outside the small-int cache range;
# these are two separate objects that happen to hold equal values== for value comparison and reserve is for identity checks. Relying on small-integer caching to make is "work" for numbers produces code that appears correct in testing and then fails unpredictably in production once the values involved happen to fall outside the cached range.Dynamic Typing — What It Actually Means
In a statically-typed language like Java or C, you declare a variable's type up front, and it can never hold a value of a different type: int age = 25; means age can only ever hold integers. Python is dynamically typed: you never declare a type, and the same name can be reassigned to point at a completely different type of object at any time.
x = 25 # x points at an int
x = "twenty-five" # now x points at a str — completely legal
x = [1, 2, 3] # now x points at a list — also completely legalThis is not the variable "changing type" — there is no such thing in Python. Each line creates a new object and simply re-points the name x at it. The old object (if nothing else refers to it) becomes eligible for automatic memory cleanup, handled by Python's garbage collector — not something you manage manually.
Converting Between Types Safely
Data rarely arrives in the exact type you need. User input from input() is always a string, even if the user typed a number. Data read from a file or an API is frequently text that represents numbers. Converting between types — called type casting — is something you will do constantly.
int("42") # 42 (str -> int)
float("19.99") # 19.99 (str -> float)
str(42) # "42" (int -> str)
bool(0) # False (0 is falsy)
bool(1) # True (any nonzero number is truthy)
bool("") # False (empty string is falsy)
bool("no") # True (any non-empty string is truthy — even the string "False"!)bool("False") evaluates to True. This surprises almost everyone the first time. bool() on a string only checks whether the string is empty or not — it has no idea the text inside says "False". If you are parsing a text value that is meant to represent a boolean (common when reading environment variables or config files), compare the string directly instead: value.lower() == "true".Conversions that fail
Not every conversion is possible. Converting text that does not represent a valid number raises an exception rather than silently producing a wrong answer — a deliberate design choice that surfaces bad data immediately instead of letting it corrupt your program quietly.
int("hello")
# ValueError: invalid literal for int() with base 10: 'hello'
int("42.5")
# ValueError: invalid literal for int() with base 10: '42.5'
# int() cannot parse a decimal point directly —
# convert to float first, then to int, if truncation is intended:
int(float("42.5")) # 42You will learn to handle these failures properly — instead of letting them crash your program — in the Exception Handling module later in this track.
A Chicago Fintech's $0.03 Bug That Took Two Days to Find
A payments startup's nightly reconciliation job compares the sum of all transaction amounts recorded in their database against the total reported by their payment processor. One morning, the job fails: the two totals differ by $0.03 out of $2.4 million processed that day. Not a huge amount — but reconciliation is required to match exactly, and the on-call engineer is paged.
What the investigation finds
The transaction amounts were stored and summed as Python float values. Summing hundreds of thousands of individually tiny floating-point rounding errors — each one invisible on its own — had accumulated into a $0.03 discrepancy by the end of the day. No single line of code was "wrong." Every individual calculation looked correct in isolation. The bug only became visible at scale, after enough additions compounded the imprecision.
The fix, and why it mattered
The team migrated all monetary values from float to decimal.Decimal, exactly as described in Part 03 above. Decimal arithmetic is slightly slower than float arithmetic, but for financial calculations correctness is not negotiable — a payments company that cannot reconcile its own numbers exactly has a business-critical problem, not just an engineering inconvenience.
This is precisely why this module spent real time on floating-point imprecision instead of treating it as a footnote. It is one of the most common real production bugs that traces directly back to a fundamental data type decision made on day one of a project.
Four Misconceptions About Variables and Types
5 Interview Questions — With Complete Answers
Type Mistakes That Look Like Something Else
🎯 Key Takeaways
- ✓A variable is a name that points at an object in memory — not a box that stores a value. Understanding this now prevents real confusion later.
- ✓The five foundational types: int, float, str, bool, and NoneType. Check any value's type with type().
- ✓Python integers have unlimited precision and never overflow. Floats use IEEE 754 double precision and cannot represent every decimal fraction exactly — never use float for money; use decimal.Decimal instead.
- ✓Every object is mutable or immutable. Immutable: int, float, bool, str, tuple. Mutable: list, dict, set. This distinction explains why some assignments "share" changes and others don't.
- ✓id() reveals object identity. CPython caches small integers (-5 to 256) as an implementation detail — never rely on this for correctness.
- ✓Python is dynamically typed — the same name can be reassigned to a completely different type at any time. No type declarations, no compile-time type checking.
- ✓None represents the deliberate absence of a value. Always check for it with "is None", never "== None".
- ✓Type conversion functions — int(), float(), str(), bool() — let you cast between types explicitly. Invalid conversions raise a ValueError immediately rather than failing silently.
What comes next
Module 03 covers every operator Python has — arithmetic, comparison, and logical — and the precedence rules that cause real, hard-to-spot bugs when ignored.
Module 03 → OperatorsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.