<< Home

Chapter 3: Data Types and Structures

Everything in Python is an object, and every object belongs to a type. Understanding those types (and how they interact) forms the basis of writing reliable, elegant code. Data in Python is not just stored; it carries meaning, behaviour, and relationships that shape how programs work and communicate.

This chapter explores how Python represents information. From simple numeric values to collections that can hold other objects, each type contributes to a flexible and expressive model of data. The same principles that make Python readable also make its data structures intuitive: they feel natural to use because they reflect how people already think about lists, mappings, and sequences.

Unlike many languages that require you to declare variable types in advance, Python determines them at runtime. This dynamic model lets you work quickly and focus on what your code expresses rather than how it must be declared. But it also rewards understanding as knowing what kind of object you are dealing with helps you write cleaner, faster, and more predictable code.

Built into Python are a small number of powerful data types that cover almost every use case. They can be combined, nested, or transformed to model complex structures with minimal effort. Mastering them is essential, because these types form the vocabulary of all Python programs.

Core types

Python’s built-in types cover numbers, text, binary data, sequences, mappings, sets, and the special “no value” object. These are the types you will use every day, and they are available without any imports. Knowing their literal forms and basic behaviour makes code shorter, clearer, and faster to read.

# Literals and basic forms
n = 42                     # int
x = 3.5                    # float
z = 1+2j                   # complex

flag = True                # bool
text = "hello"             # str

data = b"\xDE\xAD"         # bytes
buf = bytearray(b"hi")     # bytearray

nums = [1, 2, 3]           # list
point = (10, 20)           # tuple
r = range(5)               # 0..4

caps = {"A": 65, "B": 66}  # dict
letters = {"a", "b", "c"}  # set
frozen = frozenset({1, 2})

missing = None             # NoneType
💡 Prefer literals for clarity and speed (for example, [] instead of list(), {} for dicts with items, () or comma tuples). Use constructors when converting types or building from iterables.
⚠️ Mutability matters. Lists, dicts, sets, and bytearrays can change in place, which affects all names that reference them. Tuples, strings, bytes, frozensets, numbers, and None are immutable, so operations return new objects instead of altering originals (see the following section).

None and truthiness

None is Python’s way of expressing “nothing here.” It is a special singleton object used to represent the absence of a value, the result of a function that returns nothing, or a placeholder meaning “not yet assigned.” Only one instance of None exists in any running program, and comparisons should always use is rather than ==:

x = None
if x is None:
  print("x has no value")
💡 Use is None and is not None when testing for the absence of a value. Comparing with == None can lead to subtle bugs if an object defines its own equality method.

Unlike 0, an empty string, or an empty list, None is not a value that evaluates to false by content because it simply represents the absence of content altogether. Its type, NoneType, exists only for this single object. Python uses a clear and consistent rule for deciding what counts as “true” or “false” in a Boolean context. Any object can be tested for truth value using an if or while statement. By default, the following evaluate as false:

Everything else is considered true. This behaviour, known as truthiness, makes it easy to write natural, readable conditions:

name = ""
items = [1, 2, 3]

if not name:
  print("Name is missing")

if items:
  print("There are", len(items), "items")

Here, the empty string counts as false, while the non-empty list counts as true. You can make your own classes participate in this system by defining the __bool__() or __len__() method to control how they evaluate in Boolean contexts.

⚠️ Relying on truthiness can sometimes hide errors. For example, an empty list and None both evaluate as false, but they do not mean the same thing. Use explicit checks when the distinction matters.

Python sequences

Sequences are ordered collections of items that can be accessed by position. In Python, the main built-in sequence types are lists, tuples, and ranges. They share a common interface: you can index, slice, iterate, and measure them with len(). This consistency makes Python easy to learn and its data structures predictable.

Lists

A list is a mutable sequence that can hold any combination of objects. Lists are created with square brackets and can grow or shrink dynamically. They are ideal for collections that will change during execution.

fruits = ["apple", "banana", "cherry"]
print(fruits[0])        # apple
fruits.append("date")   # add to the end
fruits[1] = "blueberry" # modify in place
print(fruits)           # ['apple', 'blueberry', 'cherry', 'date']

Lists support slicing, concatenation, repetition, and membership tests:

numbers = [1, 2, 3, 4, 5]
print(numbers[1:4])     # [2, 3, 4]
print(numbers + [6])    # [1, 2, 3, 4, 5, 6]
print(3 in numbers)     # True
💡 Because lists are mutable, methods like append(), extend(), and remove() modify them in place and return None. This avoids confusion between mutating and creating new sequences.

Tuples

A tuple is an immutable sequence, written with parentheses or commas. Once created, its contents cannot change. Tuples are often used to group related data or return multiple values from a function.

point = (3, 4)
print(point[0]) # 3

# unpacking
x, y = point
print(x, y)     # 3 4

Because tuples are immutable, they can serve as dictionary keys or elements of sets, unlike lists. They also offer a small performance advantage when creating fixed collections.

⚠️ The trailing comma, not the parentheses, defines a tuple. For example, (3) is just the number 3, but (3,) is a one-element tuple.

Ranges

A range represents a sequence of integers, often used for looping. It doesn’t store all numbers in memory but generates them on demand, making it efficient even for large ranges.

for i in range(3):
  print(i)     # 0, 1, 2

r = range(2, 10, 2)
print(list(r)) # [2, 4, 6, 8]

Ranges can be sliced, compared, and tested for membership, but they are immutable and support only integers. They provide a predictable, memory-friendly way to represent arithmetic progressions.

💡 Use range() instead of creating lists when you just need to iterate through numbers. It’s faster and uses almost no memory, since values are generated as needed.

Together, lists, tuples, and ranges form the backbone of Python’s sequence model. They share common features like indexing and iteration but differ in mutability and purpose. Lists are flexible, tuples are fixed, and ranges are efficient numeric sequences.

Sets and dictionaries

While sequences maintain order and position, sets and dictionaries organize data by content and association. They are both built on Python’s fast hash table implementation, which gives them efficient lookups and membership tests. Sets store unique elements, while dictionaries store key–value pairs that map one piece of data to another.

Sets

A set is an unordered collection of unique items. Duplicate elements are automatically removed, and membership checks are extremely fast. Sets are created with braces or the set() constructor.

colors = {"red", "green", "blue"}
colors.add("yellow")
colors.add("red")         # ignored (already present)
print(colors)             # {'red', 'green', 'blue', 'yellow'}

print("green" in colors)  # True

Sets support mathematical operations such as union, intersection, and difference, making them powerful tools for comparing or combining collections:

a = {1, 2, 3, 4}
b = {3, 4, 5}
print(a | b) # union: {1, 2, 3, 4, 5}
print(a & b) # intersection: {3, 4}
print(a - b) # difference: {1, 2}

Because sets are unordered, their contents may appear in different orders when printed. If you need a fixed, immutable version, use frozenset, which behaves like a regular set but cannot be changed.

⚠️ Since sets rely on hashing, all elements must be hashable. Lists and dictionaries cannot be added to a set, but tuples and strings can.

Dictionaries

A dict (dictionary) maps keys to values. Each key must be unique and hashable, while values can be of any type. Dictionaries are created with braces containing key–value pairs separated by colons.

person = {"name": "Ada", "age": 36, "job": "engineer"}
print(person["name"])      # Ada
person["age"] = 37         # update
person["city"] = "London"  # add new key
print(person)

Accessing a missing key raises a KeyError, but the get() method lets you supply a default value instead:

print(person.get("country", "unknown"))  # unknown

Dictionaries are iterable, returning keys by default. You can also iterate over values or key–value pairs with .values() and .items():

for key, value in person.items():
  print(key, "→", value)

As of Python 3.7, dictionaries preserve insertion order, so items appear in the same sequence they were added. This makes them suitable for structured data, configuration objects, and lightweight records.

💡 Use dictionary comprehensions for concise transformations, for example: {x: x**2 for x in range(5)} creates {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}.

Together, sets and dictionaries complete Python’s core collection types. Sets handle uniqueness and membership, while dictionaries express relationships between pieces of information. Both are fundamental tools for organising and reasoning about data efficiently.

Type conversion and coercion

Python makes it easy to convert between types when it makes sense to do so. This process, known as type conversion, allows data to move smoothly between contexts. Conversion can be explicit, where you call a function like int() or str(), or implicit, where Python automatically adjusts types during an operation, otherwise known as coercion.

Explicit conversion

Explicit conversion (or casting) uses built-in constructor functions to create a new object of the desired type. This is the safest and clearest approach, because the transformation is intentional and visible to readers of the code.

x = "42"
y = int(x)         # string → int
z = float(y)       # int → float
s = str(z)         # float → string

print(x, y, z, s)  # '42' 42 42.0 '42.0'
⚠️ Be careful when converting floating point numbers to integers. int(3.9) truncates toward zero, not toward the nearest whole number. Use round() when rounding is required.

You can also convert sequences and collections using constructors like list(), tuple(), set(), or dict() where appropriate:

nums = (1, 2, 3)
lst = list(nums)
print(lst)           # [1, 2, 3]

letters = "abc"
print(set(letters))  # {'a', 'b', 'c'}

Implicit conversion (coercion)

In certain arithmetic operations, Python automatically converts operands to a compatible type so that the calculation can proceed. For example, combining an integer and a float produces a float result, ensuring that precision is not lost.

a = 5
b = 2.0
c = a + b
print(c)        # 7.0
print(type(c))  # <class 'float'>

This kind of conversion is limited to compatible numeric types. Python does not automatically mix unrelated types, such as adding a string and an integer. Attempting to do so raises a TypeError rather than producing an ambiguous result.

age = 30
msg = "Age: " + str(age)  # correct
# "Age: " + age           # TypeError
💡 Use explicit conversion whenever the intent matters. Code that converts types clearly is easier to understand and less likely to break when input changes.

Type conversion and coercion reflect Python’s balance between flexibility and safety. It automates simple cases but leaves ambiguous ones to the programmer, ensuring that conversions are deliberate where meaning could be lost.

Copy vs. reference

When you assign a variable in Python, you are not copying a value. Instead you are creating a new reference to an existing object. This distinction is fundamental to understanding how mutability works. Mutable objects (like lists, dictionaries, and sets) can be changed through any reference that points to them, while immutable objects (like strings, tuples, and numbers) cannot be altered once created.

a = [1, 2, 3]
b = a
b.append(4)
print(a)  # [1, 2, 3, 4]
💡 Immutable objects like numbers, strings, and tuples don’t require copying. Assigning or passing them simply reuses the same value safely, since it can never change.

In this example, a and b both point to the same list. The append() call changes the underlying object, so both names reflect the update. This is a shared reference not duplication. To create an independent copy of a mutable object, you must make an explicit copy. There are two kinds of copying: shallow and deep. A shallow copy creates a new outer object but keeps references to the same inner objects. A deep copy creates new copies of everything recursively.

import copy

original = [[1, 2], [3, 4]]
shallow = original.copy()
deep = copy.deepcopy(original)

original[0][0] = 99
print(original)  # [[99, 2], [3, 4]]
print(shallow)   # [[99, 2], [3, 4]] (inner lists shared)
print(deep)      # [[1, 2],  [3, 4]] (fully independent)

Most of the time, a shallow copy is enough—use methods like list.copy() or slicing (a[:]) to duplicate lists, and dict.copy() for dictionaries. When your data structures contain nested mutable objects, use copy.deepcopy() to avoid unexpected coupling between layers.

⚠️ Passing mutable objects into functions can lead to side effects if the function modifies them. If you need to protect data from change, pass a copy instead of the original.

Understanding the difference between copies and references makes debugging easier and helps prevent subtle state-related bugs. Python’s object model is simple once grasped: names bind to objects, and those objects may or may not be mutable. However, names themselves are always just references.