Gradient AtlasML Materials

Data science roadmap › Python for Data Science › Chapter 2

Objects and Data Types

What a Python object is, and the built-in type categories with their defining properties.

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:

In [1]2 lines
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.

In [2]6 lines
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():

In [3]3 lines
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):

In [4]3 lines
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:

In [5]1 line
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.

In [6]4 lines
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'>
In [7]1 line
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.

In [8]6 lines
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.

In [9]2 lines
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.

In [10]3 lines
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.

In [11]2 lines
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():

In [12]1 line
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.

In [13]3 lines
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.

In [14]2 lines
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:

In [15]4 lines
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.

In [16]2 lines
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:

In [17]2 lines
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:

In [18]4 lines
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.

In [19]2 lines
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.

In [20]2 lines
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.

In [21]2 lines
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:

In [22]2 lines
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:

In [23]3 lines
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.

In [24]2 lines
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.

In [25]2 lines
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:

In [26]8 lines
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).

In [27]2 lines
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.

In [28]3 lines
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.

In [29]5 lines
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:

In [30]3 lines
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:

In [31]1 line
a = 100
In [32]2 lines
b = 100
print(a is b)   # True -> within -5..256, same cached object
True

Now with a value outside the cached range:

In [33]1 line
a = 300
In [34]2 lines
b = 300
print(a is b)   # False -> outside the cached range, separate objects
False

bool — always the same two singletons, no range involved:

In [35]2 lines
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):

In [36]1 line
a = "hello"
In [37]2 lines
b = "hello"
print(a is b)   # True -> identifier-like, interned
True

Now with a string that isn't identifier-like:

In [38]1 line
a = "hello world!"
In [39]2 lines
b = "hello world!"
print(a is b)   # False -> contains a space/punctuation, not interned
False

float and complex — never cached, regardless of value:

In [40]1 line
a = 10.5
In [41]2 lines
b = 10.5
print(a is b)   # False -> separate float objects every time
False

Same story for complex:

In [42]1 line
a = 2 + 3j
In [43]2 lines
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:

In [44]2 lines
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)
In [45]6 lines
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:

In [46]7 lines
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:

In [47]8 lines
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:

In [48]6 lines
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:

In [49]9 lines
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