Lists and Dictionaries¶
Lists and dictionaries are Python's two most important built-in data structures for analytics. Before Pandas DataFrames, you manipulate data using these. Even after, you use them constantly — for configurations, lookup tables, grouping results, and building data pipelines. Mastery of lists and dicts is the difference between writing readable Python and fighting the language.
Learning Objectives¶
- Create and manipulate lists: indexing, slicing, appending, sorting
- Create and work with dictionaries: access, update, iteration, nested structures
- Use sets for deduplication and fast membership testing
- Understand tuples and when to prefer them over lists
- Apply list and dict comprehensions to transform data compactly
- Represent tabular data as a list of dicts
Lists¶
A list is an ordered, mutable collection. Elements can be of any type and can repeat.
# Creating lists
revenues = [42_000, 38_500, 51_200, 49_800, 55_100]
months = ["Jan", "Feb", "Mar", "Apr", "May"]
mixed = [1, "hello", 3.14, True, None] # valid — but unusual in practice
empty = []
# Confirm type and length
print(type(revenues)) # <class 'list'>
print(len(revenues)) # 5
Indexing and slicing¶
Python uses zero-based indexing. Slicing uses [start:stop:step] where stop is excluded.
revenues = [42_000, 38_500, 51_200, 49_800, 55_100]
# index: 0 1 2 3 4
# -index: -5 -4 -3 -2 -1
# Single element
print(revenues[0]) # 42000 — first element
print(revenues[-1]) # 55100 — last element (no need to know length)
print(revenues[-2]) # 49800 — second from last
# Slices [start:stop:step] — stop is exclusive
print(revenues[1:4]) # [38500, 51200, 49800] — indices 1, 2, 3
print(revenues[:3]) # [42000, 38500, 51200] — first 3
print(revenues[2:]) # [51200, 49800, 55100] — from index 2 to end
print(revenues[::2]) # [42000, 51200, 55100] — every 2nd element
print(revenues[::-1]) # [55100, 49800, 51200, 38500, 42000] — reversed copy
# Slicing never raises IndexError
print(revenues[10:20]) # [] — out-of-range slice is just empty
Modifying lists¶
revenues = [42_000, 38_500, 51_200, 49_800, 55_100]
# Add elements
revenues.append(48_250) # add to end — O(1)
revenues.insert(0, 35_000) # insert at position 0 — O(n), shifts everything
revenues.extend([52_000, 60_000]) # add multiple items — modifies in place
# Remove elements
revenues.remove(38_500) # removes first occurrence of VALUE (ValueError if missing)
popped = revenues.pop() # remove and return last element
popped_at_0 = revenues.pop(0) # remove and return element at index 0
# Replace
revenues[1] = 40_000 # direct assignment
# Sort
revenues.sort() # sort in place, ascending
revenues.sort(reverse=True) # sort in place, descending
sorted_copy = sorted(revenues) # sorted() returns NEW list — original unchanged
# Reverse
revenues.reverse() # reverses in place
list.sort() vs sorted()
list.sort() modifies the list in place and returns None. A common mistake is revenues = revenues.sort() — this sets revenues to None. Use sorted(revenues) when you need a new sorted list without changing the original.
Useful list operations¶
revenues = [42_000, 38_500, 51_200, 49_800, 55_100]
print(len(revenues)) # 5
print(sum(revenues)) # 236600
print(max(revenues)) # 55100
print(min(revenues)) # 38500
print(42_000 in revenues) # True — membership check
print(99_000 in revenues) # False
print(revenues.count(42_000)) # 1 — how many times does 42000 appear?
print(revenues.index(51_200)) # 2 — index of first occurrence (ValueError if missing)
# Copy a list (to avoid modifying the original)
copy = revenues.copy() # shallow copy — correct for flat lists
copy2 = revenues[:] # same effect — slice of entire list
import copy as copymod
deep_copy = copymod.deepcopy(revenues) # deep copy — needed for nested lists
Lists of dictionaries — the analytics pattern¶
Before DataFrames, tabular data lives as a list of dicts. Each dict is a row; keys are column names.
orders = [
{"order_id": "O001", "customer": "Priya", "total": 298.00, "status": "completed"},
{"order_id": "O002", "customer": "Sarah", "total": 389.00, "status": "completed"},
{"order_id": "O003", "customer": "Arjun", "total": 149.97, "status": "pending"},
{"order_id": "O004", "customer": "Priya", "total": 35.99, "status": "completed"},
{"order_id": "O005", "customer": "Sarah", "total": 89.50, "status": "cancelled"},
]
# Filter completed orders
completed = [o for o in orders if o["status"] == "completed"]
print(f"Completed: {len(completed)} orders") # 3
# Total revenue from completed
total_rev = sum(o["total"] for o in completed)
print(f"Total: £{total_rev:,.2f}") # £722.99
# Sort by total descending
sorted_orders = sorted(orders, key=lambda o: o["total"], reverse=True)
for o in sorted_orders:
print(f" {o['order_id']}: £{o['total']:.2f} ({o['status']})")
# Find orders for a specific customer
priya_orders = [o for o in orders if o["customer"] == "Priya"]
priya_total = sum(o["total"] for o in priya_orders if o["status"] == "completed")
print(f"Priya completed: £{priya_total:,.2f}")
Dictionaries¶
A dictionary maps keys to values. Keys must be unique and immutable (strings, numbers, tuples). Values can be anything.
# Creating a dictionary
customer = {
"id": "C001",
"name": "Priya Sharma",
"city": "Mumbai",
"total_orders": 9,
"total_spend": 1456.25,
"is_vip": True,
"email": None, # field exists but value is unknown
}
Accessing and modifying¶
# Access by key
print(customer["name"]) # Priya Sharma
print(customer.get("email")) # None — key exists, value is None
print(customer.get("phone")) # None — key does not exist, no error
print(customer.get("phone", "not provided")) # not provided — with default
# Modify
customer["total_orders"] += 1 # 10
customer["segment"] = "VIP" # add a new key
# Delete
del customer["city"]
removed = customer.pop("is_vip") # removes and returns value: True
customer.pop("missing_key", None) # safe pop — no error if key absent
# Check existence
print("name" in customer) # True
print("phone" in customer) # False
print("name" not in customer) # False
Iterating over dictionaries¶
# Keys only
for key in customer:
print(key)
# Values only
for val in customer.values():
print(val)
# Keys and values together — most common pattern
for key, val in customer.items():
print(f" {key}: {val}")
# Output:
# id: C001
# name: Priya Sharma
# total_orders: 10
# total_spend: 1456.25
# segment: VIP
# email: None
Dictionary comprehension¶
products = {"headphones": 149, "yoga_mat": 36, "desk": 389, "analytics_book": 50}
# Filter: only products over £100
premium = {name: price for name, price in products.items() if price > 100}
print(premium) # {'headphones': 149, 'desk': 389}
# Transform: apply a 10% discount to all
discounted = {name: round(price * 0.9, 2) for name, price in products.items()}
print(discounted) # {'headphones': 134.1, 'yoga_mat': 32.4, 'desk': 350.1, 'analytics_book': 45.0}
# Invert a dictionary (swap keys and values)
id_to_name = {"C001": "Priya", "C002": "Sarah", "C003": "Arjun"}
name_to_id = {v: k for k, v in id_to_name.items()}
print(name_to_id) # {'Priya': 'C001', 'Sarah': 'C002', 'Arjun': 'C003'}
Nested dictionaries¶
monthly_data = {
"Jan": {"revenue": 42_000, "orders": 180, "customers": 145},
"Feb": {"revenue": 38_500, "orders": 162, "customers": 130},
"Mar": {"revenue": 51_200, "orders": 215, "customers": 178},
}
# Access nested value
print(monthly_data["Mar"]["revenue"]) # 51200
# Iterate and compute derived metrics
for month, data in monthly_data.items():
avg_order = data["revenue"] / data["orders"]
print(f" {month}: £{avg_order:.2f} avg order value, {data['customers']} customers")
# Add a computed field to each nested dict
for month, data in monthly_data.items():
data["revenue_per_customer"] = round(data["revenue"] / data["customers"], 2)
Building a group-by with defaultdict¶
The defaultdict from collections is the standard tool for accumulating grouped data.
from collections import defaultdict
orders = [
{"customer": "C001", "total": 298.00, "status": "completed"},
{"customer": "C001", "total": 35.99, "status": "completed"},
{"customer": "C002", "total": 149.97, "status": "pending"},
{"customer": "C003", "total": 389.00, "status": "completed"},
{"customer": "C002", "total": 89.50, "status": "completed"},
]
# Group by customer — list of orders per customer
by_customer = defaultdict(list)
for order in orders:
by_customer[order["customer"]].append(order)
# Summarise each customer
for customer, customer_orders in sorted(by_customer.items()):
completed = [o for o in customer_orders if o["status"] == "completed"]
total_spend = sum(o["total"] for o in completed)
print(f" {customer}: {len(completed)} completed orders, £{total_spend:,.2f} spent")
# Output:
# C001: 2 completed orders, £333.99 spent
# C002: 1 completed orders, £89.50 spent
# C003: 1 completed orders, £389.00 spent
For mid-level analysts
defaultdict(list) removes the boilerplate of checking if a key exists before appending. defaultdict(float) does the same for running sums. It is one of the most useful standard library tools for analytics work without Pandas.
Sets — Unique Values and Membership¶
A set is an unordered collection of unique values. It excels at two tasks: deduplication and fast membership testing.
# Deduplication — fastest way to find unique values
all_customers = ["C001", "C002", "C001", "C003", "C002", "C004"]
unique = set(all_customers)
print(unique) # {'C001', 'C002', 'C003', 'C004'} — order not guaranteed
print(len(unique)) # 4
# Membership test — O(1) vs O(n) for a list
vip_ids = {"C001", "C003", "C012"}
print("C001" in vip_ids) # True — very fast even with millions of elements
# Set operations — very useful for cohort analysis
jan_buyers = {"C001", "C002", "C003"}
feb_buyers = {"C002", "C003", "C004"}
retained = jan_buyers & feb_buyers # intersection — bought both months
all_buyers = jan_buyers | feb_buyers # union
jan_only = jan_buyers - feb_buyers # churned — only January
feb_new = feb_buyers - jan_buyers # new in February
symmetric = jan_buyers ^ feb_buyers # in one but not both
print(f"Retained: {retained}") # {'C002', 'C003'}
print(f"Jan churned: {jan_only}") # {'C001'}
print(f"Feb new: {feb_new}") # {'C004'}
For mid-level analysts
Set operations (&, |, -) translate directly to cohort and funnel analysis. "Who signed up in January and also bought in February?" is jan_signups & feb_buyers. "Who churned this month?" is last_month_actives - this_month_actives. These are 10x faster than writing loops for the same logic.
Tuples — Immutable Sequences¶
Tuples are like lists but cannot be changed after creation. Use them to represent fixed, structured data.
# Creating tuples
coordinates = (28.6139, 77.2090) # Delhi lat/lon
dimensions = (1920, 1080) # screen resolution
rgb_teal = (13, 148, 136)
# Access and unpack
lat, lon = coordinates # tuple unpacking
print(lat) # 28.6139
# Single-element tuple needs a trailing comma
single = (42,) # tuple
not_a_tuple = (42) # this is just the integer 42 in parentheses
# Tuples as dictionary keys — lists cannot be dict keys, tuples can
region_data = {
("India", "Mumbai"): {"revenue": 45_200, "orders": 190},
("India", "Delhi"): {"revenue": 38_900, "orders": 162},
("UK", "London"): {"revenue": 61_500, "orders": 248},
}
print(region_data[("India", "Mumbai")]["revenue"]) # 45200
# Tuple unpacking in a loop
for (country, city), data in region_data.items():
print(f" {country}/{city}: £{data['revenue']:,} ({data['orders']} orders)")
When to use a tuple vs a list: - Tuple: fixed structure (a point, a date range, a key); something that should not change - List: a collection that grows, shrinks, or gets sorted
Choosing the Right Data Structure¶
| Structure | Ordered | Mutable | Unique | Use Case |
|---|---|---|---|---|
list |
Yes | Yes | No | Sequence of values, rows of data |
dict |
Yes (3.7+) | Yes | Keys only | Key-value mapping, records |
set |
No | Yes | Yes | Deduplication, membership, cohort ops |
tuple |
Yes | No | No | Fixed record, dict key, return value |
Practice Exercises¶
Warm-up
1. Create a list of 6 monthly revenues. Use slicing to get the last 3 months. Calculate sum, max, and min of the full list without using a loop.
2. Create a dictionary representing a product with keys: product_id, name, category, price, in_stock. Print each key-value pair using .items().
3. Given customers = ["C1", "C2", "C1", "C3", "C2", "C4", "C1"], find the number of unique customers using a set.
Main
4. You have the orders list from this file. Write a function filter_and_sort(orders, status) that returns orders of the given status, sorted by total descending.
5. Write a dict comprehension that creates {customer_id: total_spend} for completed orders from the orders list. Customers with multiple orders should have their totals summed (hint: use defaultdict or a regular loop, not a comprehension alone).
6. Given jan_buyers and feb_buyers sets, write code to find: (a) buyers who shopped in both months, (b) buyers who churned (only January), (c) all unique buyers across both months.
Stretch
7. Write a function group_by(records, key) that takes a list of dicts and a field name, and returns a dict where keys are unique values of that field and values are lists of matching records. Do not use defaultdict — implement it from scratch.
8. Write a function customer_summary(orders) that takes a list of order dicts and returns a summary dict: {customer_id: {"orders": int, "total_spend": float, "statuses": set}}. The statuses field should contain the unique statuses for that customer's orders.
Interview Questions¶
[Beginner] What is the difference between a list and a tuple in Python?
Show answer
Both are ordered sequences. The key difference:
- List (
[]): mutable — you can append, remove, and change elements - Tuple (
()): immutable — once created, it cannot be changed
my_list = [1, 2, 3]
my_tuple = (1, 2, 3)
my_list[0] = 99 # works
my_tuple[0] = 99 # TypeError: tuple object does not support item assignment
Use tuples for fixed data (a date range, a geographic coordinate, a record key) where mutability would be a bug. Use lists when you need to grow, shrink, or sort the collection.
Performance note: tuples are slightly faster to create and access than lists, and they use less memory.
[Beginner] How do you check if a key exists in a dictionary?
Show answer
customer = {"id": "C001", "name": "Priya", "segment": "VIP"}
# Method 1: 'in' operator (preferred)
if "email" in customer:
print(customer["email"])
else:
print("No email on file")
# Method 2: .get() with a default (most concise)
email = customer.get("email", "not provided")
# Method 3: .get() without default returns None
email = customer.get("email") # None if key absent
if email is not None:
print(email)
# WRONG: direct access without checking raises KeyError
print(customer["email"]) # KeyError: 'email'
[Mid-level] Why is checking value in set faster than value in list for large collections?
Show answer
A list stores elements in an array. Checking value in list scans every element one by one: O(n) time. For a list of 10,000 elements, checking membership takes up to 10,000 comparisons.
A set uses a hash table. Checking value in set computes a hash of the value and jumps directly to the right bucket: O(1) average time, regardless of set size.
import time
n = 1_000_000
my_list = list(range(n))
my_set = set(my_list)
target = n - 1 # worst case for list (it's at the end)
start = time.time()
_ = target in my_list
print(f"List: {(time.time()-start)*1000:.3f}ms") # ~20ms
start = time.time()
_ = target in my_set
print(f"Set: {(time.time()-start)*1000:.3f}ms") # ~0.001ms
Practical rule: if you're doing membership checks in a loop (e.g., "is this customer ID in the VIP list?"), convert the lookup collection to a set first: vip_set = set(vip_ids).
[Mid-level] What is the difference between dict.get(key) and dict[key]?
Show answer
dict[key]raisesKeyErrorif the key does not existdict.get(key)returnsNoneif the key does not exist (or your specified default)
discounts = {"VIP": 0.15, "Regular": 0.05}
segment = "New"
# WRONG — KeyError
discount = discounts[segment]
# CORRECT — safe
discount = discounts.get(segment, 0) # 0 for unknown segments
In analytics, always use .get() when:
- Mapping codes to labels from a lookup table
- Reading optional fields from a JSON/API response
- Processing data where some categories may not be in the mapping
[Senior] Explain how Python's list is implemented internally. What is the time complexity of append, insert(0, x), and in operations?
Show answer
A Python list is implemented as a dynamic array (similar to C's realloc).
-
append()— O(1) amortised. The array is pre-allocated with extra capacity. Appending fills a slot. When capacity is exhausted, Python allocates a new array (~double the size) and copies all elements. This is rare enough that the average is O(1). -
insert(0, x)— O(n). Inserting at the front requires shifting every existing element one position to the right. On a list with 1 million elements, this copies 1 million elements. Usecollections.dequeif you need efficient front-insertion. -
x in list— O(n). Scans from start to end. For fast membership, use aset.
from collections import deque
import time
n = 100_000
lst = list(range(n))
dq = deque(range(n))
start = time.time()
for _ in range(1000): lst.insert(0, -1)
print(f"List insert(0): {time.time()-start:.3f}s") # ~5s
start = time.time()
for _ in range(1000): dq.appendleft(-1)
print(f"Deque appendleft: {time.time()-start:.3f}s") # ~0.001s
Previous: 02-functions | Next: 04-file-handling