Skip to main content
Python · lesson 9 of 34
Python beginner Lesson 9 of 34

Python Data Structures

Use Python lists, tuples, dicts, sets and comprehensions through small runnable programs, plus a real timing of list vs set membership tests.

By SkillByExample · Updated

Output below is from Python 3.13.

Lists — Ordered, Mutable Sequences

Lists hold an ordered collection of items of any type and can be changed after creation. They’re the go-to structure when you need to maintain order, allow duplicates, and modify contents over time — appending results, sorting records, or building up a collection incrementally.

# lists.py
scores = [95, 82, 78, 91, 88]

print(scores[0])
print(scores[-1])
print(scores[1:3])

scores.append(100)
scores.insert(0, 70)
scores.remove(78)
popped = scores.pop()

scores.sort(reverse=True)
sorted_scores = sorted(scores)

print(len(scores))
print(scores.count(82))
print(scores.index(91))
$ python lists.py
95
88
[82, 78]
5
1
1

By the time len runs, the list is [95, 91, 88, 82, 70]: append(100) was undone by pop(), insert(0, 70) added a value that sorting moved to the end, and remove(78) deleted it. That’s why index(91) is 1 — positions refer to the list after the in-place sort(reverse=True), while sorted_scores is a separate ascending copy.

List Comprehensions

List comprehensions create a new list by applying an expression to each element of an iterable, with an optional filter. They’re preferred over for loops with .append() because they’re more concise and compiled to faster bytecode.

# comprehensions.py
numbers = range(1, 11)

squares = [n ** 2 for n in numbers]
print(squares)

even_squares = [n ** 2 for n in numbers if n % 2 == 0]
print(even_squares)

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [cell for row in matrix for cell in row]
print(flat)
$ python comprehensions.py
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
[4, 16, 36, 64, 100]
[1, 2, 3, 4, 5, 6, 7, 8, 9]

Tuples — Ordered, Immutable Sequences

Tuples behave like lists but cannot be modified after creation. Their immutability is their value: it signals to the reader that the data is fixed, it allows tuples to be used as dictionary keys, and it makes them slightly faster to create and access than lists.

# tuples.py
point = (10.5, 20.3)
x, y = point

from collections import namedtuple

Colour = namedtuple("Colour", ["red", "green", "blue"])
white = Colour(red=255, green=255, blue=255)
print(white.red)
print(white)

grid = {(0, 0): "start", (3, 4): "end"}
print(grid[(0, 0)])
$ python tuples.py
255
Colour(red=255, green=255, blue=255)
start

Dictionaries — Key-Value Maps

Dictionaries store key-value pairs and provide O(1) average-time lookup. They’re the right choice when you need to associate pieces of data by a meaningful key rather than a positional index — user records, configuration objects, counters, and caches are all natural fits. Since Python 3.7, insertion order is preserved.

# dicts.py
user = {
    "name": "Alice",
    "age": 30,
    "email": "alice@example.com",
}

print(user["name"])
print(user.get("phone", "N/A"))

user["age"] = 31
user["role"] = "admin"
del user["email"]

for key, value in user.items():
    print(f"{key}: {value}")

word = "hello"
char_count = {char: word.count(char) for char in word}
print(char_count)
$ python dicts.py
Alice
N/A
name: Alice
age: 31
role: admin
{'h': 1, 'e': 1, 'l': 2, 'o': 1}

Reassigning age kept it in its original position; only the new role key went to the end, and deleting email closed the gap. The comprehension iterates over word itself, so keys appear in first-seen order and the repeated l simply overwrites its own entry.

Merging Dictionaries (Python 3.9+)

The | operator merges two dicts into a new one. Keys from the right-hand dict override keys from the left, which makes it natural for applying user overrides on top of defaults.

# merge_dicts.py
defaults = {"theme": "dark", "lang": "en"}
overrides = {"lang": "fr", "timezone": "UTC"}

merged = defaults | overrides
print(merged)
$ python merge_dicts.py
{'theme': 'dark', 'lang': 'fr', 'timezone': 'UTC'}

Sets — Unordered, Unique Collections

Sets automatically deduplicate their contents and provide O(1) membership testing — the same speed as a dict lookup, much faster than scanning a list. They’re the right choice when you need uniqueness guarantees or want to compute relationships between groups of items (intersection, union, difference).

# sets.py
tags = {"python", "web", "api", "python"}
print(len(tags), sorted(tags))

print("python" in tags)

backend  = {"python", "django", "postgres"}
frontend = {"javascript", "react", "python"}

shared = backend & frontend
all_tech = backend | frontend
only_backend = backend - frontend
either_not_both = backend ^ frontend

print(sorted(shared))
print(sorted(all_tech))
print(sorted(only_backend))
print(sorted(either_not_both))

tags.add("backend")
tags.discard("api")
print(sorted(tags))
$ python sets.py
3 ['api', 'python', 'web']
True
['python']
['django', 'javascript', 'postgres', 'python', 'react']
['django', 'postgres']
['django', 'javascript', 'postgres', 'react']
['backend', 'python', 'web']

The duplicate "python" collapsed, leaving three tags. Every set is passed through sorted() before printing for a reason: string hashes are randomised per process, so printing a set of strings directly gives a different order on each run. &, |, - and ^ are intersection, union, difference and symmetric difference.

Choosing the Right Structure

Picking the wrong data structure usually shows up as slow code or awkward access patterns. This table summarizes the key trade-offs:

Use caseBest type
Ordered, changeable collectionlist
Fixed record (coordinates, config row)tuple
Fast key-value lookupdict
Membership tests, deduplicationset
Immutable set (used as a dict key)frozenset

The most common mistake is using a list for membership testing when the collection is large. Searching a list is O(n); a set lookup is O(1). If you’re writing if item in my_list inside a loop, convert my_list to a set first.

# membership_timing.py
import time

big_list = list(range(100_000))
big_set = set(big_list)

item = 99_999

start = time.perf_counter()
for _ in range(1000):
    item in big_list
list_time = time.perf_counter() - start

start = time.perf_counter()
for _ in range(1000):
    item in big_set
set_time = time.perf_counter() - start

print(f"List lookup: {list_time:.4f}s")
print(f"Set lookup: {set_time:.4f}s")
print(f"Speedup: {list_time / set_time:.1f}x")
$ python membership_timing.py
List lookup: 0.7169s
Set lookup: 0.0001s
Speedup: 9489.2x

Timings vary from run to run and machine to machine, but the set lookup is thousands of times faster here: in on a list compares against every element until it finds 99_999 at the very end, while the set hashes the item and jumps straight to its slot. The gap widens as the collection grows.

Practice

1. List mutation chain: Given a list of scores, write code that removes the lowest and highest scores, then returns the average of the remaining middle values. Predict the result for [100, 85, 92, 78, 90, 88].

Solution
scores = [100, 85, 92, 78, 90, 88]
scores.remove(max(scores))
scores.remove(min(scores))
print(sum(scores) / len(scores))
$ python middle_average.py
88.75

After removing the max (100) and min (78), the remaining scores are [85, 92, 90, 88]. Their sum is 355, divided by 4 gives 88.75.

2. Dictionary merge with override: Create two dictionaries — one with defaults and one with user settings — then merge them so user settings override defaults. Print the merged result.

Solution
defaults = {"host": "localhost", "port": 5432, "debug": False}
user_settings = {"port": 8080, "debug": True}

merged = defaults | user_settings
print(merged)
$ python config_merge.py
{'host': 'localhost', 'port': 8080, 'debug': True}

The | operator merges left-to-right with right-side keys winning on conflicts. "port" changed to 8080 and "debug" changed to True, while "host" stayed as "localhost".

3. Set deduplication in log analysis: Given a list of log levels from a log file, use a set to find all unique levels and count how many times “ERROR” appears.

Solution
log_levels = ["INFO", "WARN", "ERROR", "INFO", "ERROR", "DEBUG", "ERROR", "WARN"]

unique_levels = set(log_levels)
error_count = log_levels.count("ERROR")

print(sorted(unique_levels))
print(error_count)
$ python log_analysis.py
['DEBUG', 'ERROR', 'INFO', 'WARN']
3

The set automatically removes duplicates, giving you just ['DEBUG', 'ERROR', 'INFO', 'WARN']. The .count() method counts how many times “ERROR” appears — 3 in this case.

Frequently Asked Questions

When should I use a tuple instead of a list?
Use tuples for fixed, heterogeneous data (a coordinate, a record) and lists for homogeneous, variable-length sequences. Tuples are also hashable so they can be dict keys or set members.
What's the time complexity of dict lookups?
O(1) average case. Python dicts are hash tables. Lookups, insertions, and deletions are all O(1) on average.
What makes a set element or dict key valid?
It must be hashable — immutable built-in types (int, str, tuple of hashables) are hashable. Lists and dicts are not.
Which Python data structure should I use for a queue?
For a queue (FIFO), use `collections.deque` which provides O(1) append/pop from both ends. Lists are O(n) for `pop(0)` because all elements must shift. For a stack (LIFO), a regular list's `.append()` and `.pop()` are fine.
Are dictionaries ordered in Python?
Yes, since Python 3.7, dictionaries preserve insertion order. Iterating over `.items()`, `.keys()`, or `.values()` returns elements in the order they were first inserted. This was an implementation detail in CPython 3.6 but became an official language guarantee in 3.7.