2.1 Overview of Built-in Data Types
Object — every value in Python is an object. Every object has an identity
(id()), a type (type()) and a value; each data type below is simply a built-in
category of object, with its own value representation and behaviour.
Basic Data Types
| Type | Definition | Declaration |
|---|---|---|
int |
Whole numbers of arbitrary precision. | x = 10 |
float |
Decimal / floating-point numbers. | x = 10.5 |
complex |
Numbers with a real and an imaginary part. | x = 2 + 3j |
bool |
Boolean truth value — True or False. |
x = True |
str |
Sequence of Unicode characters (text). | x = "hello" |
NoneType |
Represents the absence of a value; its only instance is None. |
x = None |
Composite Data Types
| Type | Definition | Declaration |
|---|---|---|
list |
Ordered, mutable collection of items. | x = [1, 2, 3] |
tuple |
Ordered, immutable collection of items. | x = (1, 2, 3) |
set |
Unordered, mutable collection of unique items. | x = {1, 2, 3} |
frozenset |
Unordered, immutable collection of unique items. | x = frozenset({1, 2, 3}) |
dict |
Collection of key-value pairs (insertion-ordered). | x = {"a": 1, "b": 2} |
bytes |
Immutable sequence of raw bytes. | x = b"hello" |
bytearray |
Mutable sequence of raw bytes. | x = bytearray(b"hello") |
range |
Immutable, lazily generated sequence of numbers. | x = range(10) |
Chapter plan — the sections below go deeper into each group above, grouped the same way the Python docs group them:
- 2.2 Basic Data Types —
int(2.2.1),float(2.2.2),bool(2.2.3),str(2.2.4),complex(2.2.5) - 2.3 Composite Data Types —
list(2.3.1),tuple(2.3.2),set(2.3.3),frozenset(2.3.4),dict(2.3.5),bytes(2.3.6),bytearray(2.3.7),range(2.3.8) - 2.4 Object Reuse and Caching (Interning)
- 2.5 The
delStatement
x = 5
print(id(x), type(x), x) # e.g. 132695655973232 <class 'int'> 5 -- identity, type, value
10454408 <class 'int'> 5
2.2 Basic Data Types
These are the 5 basic data types in Python — int, float, bool, str and complex.
All five are immutable: once created, the object itself can never be changed — a name
bound to one always points to that single object, and anything that looks like a
modification actually creates a new object and rebinds the name to it.
Deep dive into each one below — literal forms, conversions and inbuilt behaviour, one sub-section per type.
2.2.1 Integers (int)
Python has three numeric types — int, float and complex (defined in 2.1). Of the
three, int is the only one with multiple literal forms, since a whole number can be
written in decimal, binary, octal or hexadecimal and still be the exact same value/object.
Integer literals in different bases
| Base | Prefix | Example | Value |
|---|---|---|---|
| Decimal (base 10) | none | x = 42 |
42 |
| Binary (base 2) | 0b / 0B |
x = 0b101010 |
42 |
| Octal (base 8) | 0o / 0O |
x = 0o52 |
42 |
| Hexadecimal (base 16) | 0x / 0X |
x = 0x2A |
42 |
The prefix only affects how the literal is written — run the cell below to confirm all
four produce the exact same int object.
Note — type(x) returns the actual class object x was built from, and
printing a class shows it in <class '...'> form — so <class 'int'> just means "this
object's type is the built-in class int". Every type from here on prints the same way:
<class 'float'>, <class 'str'>, <class 'list'>, and so on.
x_dec = 42
x_bin = 0b101010
x_oct = 0o52
x_hex = 0x2A
print(x_dec, x_bin, x_oct, x_hex) # 42 42 42 42 -> all the same value
print(type(x_dec), x_dec == x_bin == x_oct == x_hex) # <class 'int'> True
42 42 42 42 <class 'int'> True
Converting between bases — int → base-prefixed string with bin(), oct(), hex():
print(bin(42)) # '0b101010'
print(oct(42)) # '0o52'
print(hex(42)) # '0x2a'
0b101010 0o52 0x2a
String in a given base → int with int(string, base):
print(int('101010', 2)) # 42
print(int('52', 8)) # 42
print(int('2a', 16)) # 42
42 42 42
Python's int also has arbitrary precision — it grows to fit however large the value
is, with no fixed bit-width and no silent overflow:
print(2**100) # 1267650600228229401496703205376 -- a fixed-width 64-bit int would overflow here
1267650600228229401496703205376
2.2.2 Floating-Point Numbers (float)
A number with a decimal point (or written in exponential notation) is a float.
x = 10.5
y = 1.5e3 # exponential notation
print(x, y) # 10.5 1500.0
print(type(x)) # <class 'float'>
10.5 1500.0 <class 'float'>
print(0.1 + 0.2) # 0.30000000000000004 -- not exactly 0.3
0.30000000000000004
Why — float is stored as binary floating-point (IEEE 754), and most decimal
fractions (including 0.1 and 0.2) have no exact binary representation, the same way
1/3 has no exact finite decimal representation. Each is stored as the closest
representable binary value, so arithmetic on them accumulates tiny rounding error. Never
compare floats with ==; use math.isclose() instead.
Going deeper — for cases that genuinely need exact decimal arithmetic (money, for
example), decimal.Decimal and fractions.Fraction avoid this error entirely by not
using binary floating-point at all.
import math
print(0.1 + 0.2 == 0.3) # False -- fails due to representation error
print(math.isclose(0.1 + 0.2, 0.3)) # True -- the correct way to compare floats
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2")) # 0.3 -- exact, no representation error
False True 0.3
In practice — this is exactly why financial systems never use float for money.
Payment processors, banking software, and billing systems universally use
decimal.Decimal (or integer cents) instead of float for currency amounts — a
rounding error like the one just shown is unacceptable when it means a transaction
total is off by a fraction of a cent, which compounds into real discrepancies at scale.
2.2.3 Booleans (bool)
A bool holds one of two truth values, True or False.
x = True
print(x, type(x)) # True <class 'bool'>
True <class 'bool'>
Common mistake — treating bool as an unrelated type. In fact bool is a subclass
of int — True/False behave as 1/0 in arithmetic, and isinstance(True, int) is
True.
print(isinstance(True, int)) # True -- bool IS an int subclass
print(True + True) # 2 -- arithmetic treats them as 1/0
print(True == 1) # True
True 2 True
In practice — a genuinely useful idiom, not just a curiosity. sum(user.is_active
for user in users) to count how many users are active, or df["flag"].sum() in a
pandas-style data pipeline to count True values in a boolean column, both lean
directly on bool being an int subclass — counting truthy conditions by summing them
is a common, idiomatic pattern rather than a coincidence to avoid.
2.2.4 Strings (str)
A str is a sequence of characters, written inside single, double or triple quotes.
x = "hello"
print(x, type(x)) # hello <class 'str'>
hello <class 'str'>
If the string holds a valid whole number, it can be typecast to int with int():
print(int("42")) # 42
42
2.2.5 Complex Numbers (complex)
A complex number has a real part and an imaginary part, the latter suffixed with j.
x = 2 + 3j
print(x, type(x)) # (2+3j) <class 'complex'>
print(x.real, x.imag) # 2.0 3.0 -> the real and imaginary parts, as floats
(2+3j) <class 'complex'> 2.0 3.0
2.3 Composite Data Types
These are the 8 composite data types in Python — list, tuple, set, frozenset,
dict, bytes, bytearray and range. Unlike the basic types (2.2), mutability varies
by type: list, set, dict and bytearray are mutable; tuple, frozenset,
bytes and range are immutable.
Deep dive into each one below — literal forms and inbuilt behaviour, one sub-section per type.
2.3.1 Lists (list)
An ordered, mutable collection of items. Day-to-day operations — indexing, slicing, methods, comprehensions — are covered in depth in 5.1.
x = [1, 2, 3]
print(x, type(x)) # [1, 2, 3] <class 'list'>
[1, 2, 3] <class 'list'>
Mutable means the object can be changed in place after creation — it stays the same
object (same id()), just with different contents:
x = [1, 2, 3]
print(id(x)) # e.g. 140023456
x.append(4) # changes the object in place
print(x, id(x)) # [1, 2, 3, 4] <- same id, same object
140554110596672 [1, 2, 3, 4] 140554110596672
2.3.2 Tuples (tuple)
An ordered, immutable collection of items. Day-to-day operations — unpacking, methods, hashability — are covered in depth in 5.5.
x = (1, 2, 3)
print(x, type(x)) # (1, 2, 3) <class 'tuple'>
(1, 2, 3) <class 'tuple'>
Ordered means each item keeps a fixed position, accessible by index:
x = (10, 20, 30)
print(x[0], x[1], x[2]) # 10 20 30 -> items keep their position/index
10 20 30
Immutable means the object can never be changed in place after creation — anything that looks like a modification actually builds a new object:
x = (1, 2, 3)
print(id(x)) # e.g. 140099876
x = x + (4,) # no in-place way to add an item -> builds a new tuple
print(x, id(x)) # (1, 2, 3, 4) <- different id, a different object
140554110594816 (1, 2, 3, 4) 140554110685712
In practice — tuples as cache/memoization keys. A function that caches results
keyed by several arguments at once (e.g. cache[(user_id, page_number)]) has to use a
tuple for that key, not a list — a dict key must be hashable (5.3.1/5.4.6), and a
list's mutability disqualifies it; this is one of the most common real reasons to
reach for a tuple instead of a list.
2.3.3 Sets (set)
An unordered, mutable collection of unique items. Day-to-day operations — methods, mathematical set operations, hashing — are covered in depth in 5.3.
x = {1, 2, 2, 3}
print(x, type(x)) # {1, 2, 3} <class 'set'> -> the duplicate 2 is dropped
{1, 2, 3} <class 'set'>2.3.4 Frozensets (frozenset)
An unordered, immutable collection of unique items. Covered alongside set in 5.3,
including why immutability makes it hashable.
x = frozenset({1, 2, 3})
print(x, type(x)) # frozenset({1, 2, 3}) <class 'frozenset'>
frozenset({1, 2, 3}) <class 'frozenset'>2.3.5 Dictionaries (dict)
A collection of key-value pairs, insertion-ordered. Day-to-day operations — methods, comprehension, hashing — are covered in depth in 5.4.
x = {"a": 1, "b": 2}
print(x, type(x)) # {'a': 1, 'b': 2} <class 'dict'>
{'a': 1, 'b': 2} <class 'dict'>It can also be created with the dict() constructor:
x = dict(a=1, b=2)
print(x) # {'a': 1, 'b': 2}
{'a': 1, 'b': 2}Being mutable, a new key-value pair can be added in place:
x = {"a": 1, "b": 2}
x["c"] = 3
print(x) # {'a': 1, 'b': 2, 'c': 3}
{'a': 1, 'b': 2, 'c': 3}2.3.6 Bytes (bytes)
An immutable sequence of raw bytes.
x = b"hello"
print(x, type(x)) # b'hello' <class 'bytes'>
b'hello' <class 'bytes'>
2.3.7 Bytearrays (bytearray)
A mutable sequence of raw bytes.
x = bytearray(b"hello")
print(x, type(x)) # bytearray(b'hello') <class 'bytearray'>
bytearray(b'hello') <class 'bytearray'>
Each element is a single byte — an integer in the range 0 to 255 (a byte is 8 bits,
i.e. 2⁸ = 256 possible values). Assigning a value outside this range raises ValueError:
x = bytearray(b"hello")
x[0] = 255 # within range -> ok
print(x) # bytearray(b'\xffello')
try:
x[0] = 256 # out of range
except ValueError as e:
print("ValueError:", e)
bytearray(b'\xffello') ValueError: byte must be in range(0, 256)
Bridging to str — bytes/bytearray hold raw byte values, while str holds text
characters; converting between them is .encode() (str → bytes) and .decode()
(bytes → str). This boundary matters the moment data leaves the process — files and
network connections deal in raw bytes, not text (see 16.1).
print("hello".encode()) # b'hello' -- str -> bytes
print(b"hello".decode()) # hello -- bytes -> str
b'hello' hello
In practice — this boundary shows up the moment data leaves the process. A file
upload handled by a web backend, an image read from disk, or a payload received over a
raw network socket all arrive as bytes, not str — a real backend routinely calls
.decode() on incoming data and .encode() before sending data out, exactly at this
str/bytes boundary.
2.3.8 Ranges (range)
An immutable, lazily generated sequence of numbers.
x = range(10)
print(x, type(x)) # range(0, 10) <class 'range'> -> stored lazily, not as a list
print(list(x)) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] -> values generated on demand
range(0, 10) <class 'range'> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
2.4 Object Reuse and Caching (Interning)
How reassignment works — reassigning a name does not change the existing object; it creates a new object and rebinds the name to it. If nothing else refers to the old object, it becomes eligible for garbage collection.
x = 11
print(id(x)) # id of the "11" object
x = 10 # creates a NEW int object; the "11" object has no more references
print(id(x)) # a different id now
10454600 10454568
Now if another name is assigned the same value, CPython checks whether an object for
that value already exists in a small internal cache and, if so, reuses it — so y ends up
pointing at the same object as x:
x = 10
y = 10
print(id(x), id(y), x is y) # same id both times -> True, same cached object
10454568 10454568 True
This is a CPython implementation detail, not a language guarantee — it exists purely
as a memory/speed optimization for common, immutable values. Never rely on is for value
comparison; use ==.
Caching ranges by type
| Type | Cached / interned? | Range / rule |
|---|---|---|
int |
Yes | Small integers from -5 to 256 (inclusive) are pre-created and reused |
bool |
Always | Only two bool objects ever exist — the singletons True and False |
str |
Sometimes | Identifier-like literals (letters/digits/underscore only) and compile-time constants are interned; arbitrary or computed strings usually are not |
float |
No | Every float object is distinct, even for the same value |
complex |
No | Every complex object is distinct, even for the same value |
In practice — a real bug that only shows up once data grows. Code that
accidentally uses is instead of == to compare integers can pass every test in
development (where sample IDs/counts often happen to be small, cached values) and then
fail unpredictably in production once real data pushes those same values past 256 —
exactly the kind of bug this caching behavior sets a trap for.
Going deeper (skippable on first read)
Note on the cells below — each comparison is deliberately split across two cells (assign, then compare in the next cell). Writing both assignments in the same cell would let Python's compiler pre-merge identical literal constants at compile time, which hides the actual runtime caching behaviour being demonstrated here.
int — cached only for -5 to 256; outside that range, two equal values are separate
objects:
a = 100
b = 100
print(a is b) # True -> within -5..256, same cached object
True
Now with a value outside the cached range:
a = 300
b = 300
print(a is b) # False -> outside the cached range, separate objects
False
bool — always the same two singletons, no range involved:
a, b = True, True
print(a is b) # True -> there is only ever one True object
True
str — interned only for identifier-like literals (letters/digits/underscore only):
a = "hello"
b = "hello"
print(a is b) # True -> identifier-like, interned
True
Now with a string that isn't identifier-like:
a = "hello world!"
b = "hello world!"
print(a is b) # False -> contains a space/punctuation, not interned
False
float and complex — never cached, regardless of value:
a = 10.5
b = 10.5
print(a is b) # False -> separate float objects every time
False
Same story for complex:
a = 2 + 3j
b = 2 + 3j
print(a is b) # False -> separate complex objects every time
False
Composite (mutable) types never share objects — list, set, dict, bytearray,
etc. always create a brand-new object on every assignment, regardless of value. Caching
only makes sense for values that can never change; sharing a mutable object across
unrelated names would let a change made through one name silently affect the other:
a, b = [1, 2, 3], [1, 2, 3]
print(a is b) # False -> always a new object
False
2.5 The del Statement
del removes a name binding (or an item from a mutable container) — it does not
necessarily destroy the underlying object. An object is only garbage-collected once its
reference count drops to zero (2.4), so del just removes one reference; if another
name still points to the same object, that object lives on.
| Form | Effect |
|---|---|
del name |
removes the variable binding — using name afterwards raises NameError |
del name1, name2, ... |
deletes multiple bindings in one statement |
del seq[i] |
removes the item at index i from a mutable sequence (list, 5.1.2) |
del seq[i:j] |
removes a whole slice at once (5.1.1) |
del d[key] |
removes a key-value pair from a dict (KeyError if absent, 5.4.2) |
x = 10
del x
try:
print(x)
except NameError as e:
print("NameError:", e) # 'x' is unbound entirely, not just set to None
NameError: name 'x' is not defined
Common mistake — del lst[i] looks like lst.pop(i) (5.1.2), but del just
discards the removed value; pop() returns it. On a list, both mutate the same object
in place — del also works on a whole slice at once:
lst = [10, 20, 30, 40]
del lst[1] # removes the item at index 1 -> nothing returned
print(lst) # [10, 30, 40]
lst2 = [10, 20, 30, 40, 50]
del lst2[1:3] # removes a whole slice at once
print(lst2) # [10, 40, 50]
[10, 30, 40] [10, 40, 50]
The same syntax removes a key from a dict (5.4.2) — accessing a missing key with del
raises KeyError, the same as a failed d[key] lookup:
d = {"a": 1, "b": 2}
del d["a"]
print(d) # {'b': 2}
try:
del d["z"]
except KeyError as e:
print("KeyError:", e) # 'z' -> raised, same as a failed d[key] lookup
{'b': 2}
KeyError: 'z'A single del statement can also target several names at once, deleting all of them:
a, b, c = 1, 2, 3
del a, b, c # deletes all three names in one statement
try:
print(a)
except NameError as e:
print("NameError:", e)
NameError: name 'a' is not defined
del deletes a reference, not necessarily the object — if a second name still
points to the same object, deleting the first name doesn't touch it:
original = [1, 2, 3]
alias = original # alias points at the SAME list object
del original # removes only the "original" binding
print(alias) # [1, 2, 3] -> the object is still alive, reachable via "alias"
try:
print(original)
except NameError as e:
print("NameError:", e)
[1, 2, 3] NameError: name 'original' is not defined