Five data structures.
Every loop pattern you need.
Before you can read or write an ETL script, you need to recognise five structures on sight — lists, tuples, sets, dictionaries, and strings — and know which built-in methods apply to each. This pre-read covers all five with data engineering examples, then collects every looping pattern you will actually use in class.
What's covered
Lists
A list is Python's most common sequence type. In data engineering, lists appear everywhere:
a batch of rows from fetchall(), a list of column names, a collection of IDs
to process. Lists are ordered, mutable, and allow duplicates.
Creating and reading
# Lists hold ordered, changeable collections fares = [450.0, 320.5, 210.0, 580.0, 275.0] # Access by index — zero-based print(fares[0]) # 450.0 — first element print(fares[-1]) # 275.0 — last element (negative counts from end) print(fares[1:3]) # [320.5, 210.0] — slice: index 1 up to (not including) 3 print(fares[:3]) # [450.0, 320.5, 210.0] — first 3 (like SQL LIMIT 3) print(fares[-2:]) # [580.0, 275.0] — last 2 # Four aggregate functions you will use every single day print(len(fares)) # 5 — number of elements print(sum(fares)) # 1835.5 — total fare revenue print(min(fares)) # 210.0 — cheapest trip print(max(fares)) # 580.0 — most expensive trip
Adding and removing
fares = [450.0, 320.5, 210.0] fares.append(580.0) # add one item to END → [..., 580.0] fares.insert(0, 999.0) # insert at specific index 0 → [999.0, 450.0, ...] print(fares) # [999.0, 450.0, 320.5, 210.0, 580.0] fares.pop() # remove & return LAST element → 580.0 fares.pop(0) # remove & return at index 0 → 999.0 fares.remove(210.0) # remove FIRST occurrence of this value print(fares) # [450.0, 320.5]
Searching
fares = [450.0, 320.5, 210.0, 320.5] print(fares.index(320.5)) # 1 — index of first occurrence print(fares.count(320.5)) # 2 — how many times this value appears print(320.5 in fares) # True — membership check print(999.0 in fares) # False
Before / After — sort() vs sorted()
fares = [450.0, 320.5, 210.0, 580.0] fares.sort() # sorts IN-PLACE — original list is permanently changed print(fares) # [210.0, 320.5, 450.0, 580.0] # Now you also need the original order for a log message: # print(f"Original order was: {fares}") → too late, it's sorted now
fares = [450.0, 320.5, 210.0, 580.0] sorted_fares = sorted(fares) # new list, ascending sorted_desc = sorted(fares, reverse=True) # new list, descending print(fares) # [450.0, 320.5, 210.0, 580.0] ← unchanged print(sorted_fares) # [210.0, 320.5, 450.0, 580.0] ← new copy # Rule of thumb: # fares.sort() → mutates in-place (fast, no copy — use when you don't need original) # sorted(fares) → returns new copy (safe — use when original still matters)
| Method / Function | What it does | Returns |
|---|---|---|
len(lst) | Number of elements | int |
sum(lst) | Sum of all elements | number |
min(lst) / max(lst) | Smallest / largest element | element |
lst.append(x) | Add x to end | None (mutates) |
lst.insert(i, x) | Insert x at index i | None (mutates) |
lst.pop() | Remove & return last element | element |
lst.pop(i) | Remove & return element at index i | element |
lst.remove(x) | Remove first occurrence of x | None (mutates) |
lst.index(x) | Index of first occurrence of x | int |
lst.count(x) | Times x appears | int |
lst.sort() | Sort in-place | None (mutates) |
sorted(lst) | Return new sorted copy | new list |
lst.reverse() | Reverse in-place | None (mutates) |
x in lst | Membership check | bool |
Tuples
A tuple is like a list but immutable — you cannot change it after creation. The psycopg2 default cursor returns each database row as a tuple. Understanding tuples tells you why you can't accidentally edit a row you just fetched, and how to unpack its values safely.
Basics
# Round brackets — type is 'tuple', not 'list' pickup = (27.7172, 85.3240) # (latitude, longitude) print(type(pickup)) # <class 'tuple'> # Access by index — identical to lists print(pickup[0]) # 27.7172 print(pickup[1]) # 85.3240 # len, count, index work the same as lists print(len(pickup)) # 2 print(pickup.count(27.7172)) # 1 print(pickup.index(85.3240)) # 1 # Tuples are immutable — modifying them raises an error: # pickup[0] = 99 → TypeError: 'tuple' object does not support item assignment
Unpacking
Unpacking assigns each element to a named variable in one line. This is the right way to work with tuple rows — you get readable names instead of mysterious index numbers.
# Unpack into named variables lat, lon = (27.7172, 85.3240) print(f"lat={lat}, lon={lon}") # psycopg2 default cursor returns each row as a tuple db_row = (1, "Sita Rai", "Kathmandu", 450.0) trip_id, driver_name, city, fare = db_row print(f"Trip {trip_id} by {driver_name} in {city}: NPR {fare}") # Unpack inside a for loop — the most common ETL pattern rows = [(1, "Alice", 450.0), (2, "Bob", 320.5)] for trip_id, driver, fare in rows: print(f" {trip_id}: {driver} — NPR {fare}")
Tuples as dictionary keys
# Lists CANNOT be dict keys — they're mutable, so their hash can change # Tuples CAN be dict keys — they're immutable, so their hash is stable city_stats = {} city_stats[("Kathmandu", "completed")] = 142 city_stats[("Pokhara", "completed")] = 88 print(city_stats[("Kathmandu", "completed")]) # 142 # Useful for grouping by multiple columns at once — # the same idea as GROUP BY city, status in SQL
Before / After — index vs named unpacking
# SELECT trip_id, driver_id, fare_amount, status FROM trips for row in rows: if row[3] == "completed": # what is index 3? total += row[2] # what is index 2? # If someone reorders the SELECT columns: # SELECT trip_id, STATUS, fare_amount, driver_id ... ← status now at [1] # row[3] is now driver_id — wrong field, no error, wrong numbers silently
# SELECT trip_id, driver_id, fare_amount, status FROM trips for row in rows: trip_id, driver_id, fare, status = row # unpack once at the top if status == "completed": # clear intent total += fare # clear intent # If column order changes: you fix ONE unpacking line, not every access # Self-documenting: any reader can see what each field is
| List | Tuple | |
|---|---|---|
| Syntax | [...] | (...) |
| Mutable? | Yes | No — immutable |
| Can be dict key? | No | Yes |
fetchall() returns | One list (all rows) | One tuple per row |
| Use for | Collecting rows, building batches | Fixed records, compound dict keys |
Sets
A set holds unique values in no guaranteed order. Use sets for two things: deduplication (removing duplicates from a list) and O(1) membership checks (testing whether a value is in a large collection without looping through it).
Basics
# Curly braces with no key:value pairs (those would be a dict) driver_ids = {101, 102, 103, 101, 102} # duplicates removed automatically print(driver_ids) # {101, 102, 103} — order is NOT guaranteed print(type(driver_ids)) # <class 'set'> # Adding driver_ids.add(104) print(driver_ids) # {101, 102, 103, 104} # Removing driver_ids.discard(999) # safe — no error if 999 is not in the set driver_ids.remove(104) # raises KeyError if 104 is not found print(driver_ids) # {101, 102, 103}
Membership check — O(1) speed
active_ids = {101, 102, 103, 104, 105}
print(101 in active_ids) # True — same syntax as list, but O(1) speed
print(999 in active_ids) # False
# Why it matters in a 1,000,000-row ETL loop:
#
# list with 10,000 IDs → up to 10,000 comparisons per row check
# set with 10,000 IDs → ~1 comparison regardless of set size
#
# At 1,000,000 rows: list = ~10 billion comparisons
# set = ~1 million comparisons
Set operations
active = {101, 102, 103}
completed = {101, 103, 105}
print(active | completed) # UNION: {101, 102, 103, 105} — in either
print(active & completed) # INTERSECTION: {101, 103} — in both
print(active - completed) # DIFFERENCE: {102} — active but no completed trip
print(active ^ completed) # SYMMETRIC Δ: {102, 105} — in one but not both
# Think of | as SQL UNION, & as SQL INNER JOIN on IDs, - as LEFT ANTI JOIN
Before / After — deduplication
raw_ids = [101, 102, 101, 103, 102, 104] unique_ids = [] for driver_id in raw_ids: if driver_id not in unique_ids: # O(n) check for EACH item unique_ids.append(driver_id) # At 100,000 rows: up to 10 billion comparisons
raw_ids = [101, 102, 101, 103, 102, 104] unique_ids = list(set(raw_ids)) # one pass — O(n) print(unique_ids) # [101, 102, 103, 104] (order may vary) # From a list of dicts — unique driver IDs from trip rows: unique_drivers = list({t["driver_id"] for t in trips}) # set comprehension
| Method / Operator | What it does |
|---|---|
s.add(x) | Add x to the set |
s.discard(x) | Remove x — no error if missing |
s.remove(x) | Remove x — KeyError if missing |
x in s | Membership check — O(1) |
s1 | s2 | Union — elements in either set |
s1 & s2 | Intersection — elements in both |
s1 - s2 | Difference — in s1 but not s2 |
s1 ^ s2 | Symmetric diff — in one but not both |
list(set(x)) | Deduplicate a list |
Dictionaries
Dictionaries map keys to values. They are the single most important Python structure for
data engineering: a single DB row as a dict, a lookup table built before a
transform loop, the output of a dict comprehension. If you understand one
structure deeply, make it this one.
Creating and reading
trip = {
"trip_id": 1,
"driver_id": 101,
"fare_amount": 450.0,
"status": "completed",
}
# Access by key
print(trip["trip_id"]) # 1
print(trip["fare_amount"]) # 450.0
# trip["rating"] → KeyError: 'rating' — crashes if key doesn't exist
# Safe access with .get()
print(trip.get("rating")) # None — no crash
print(trip.get("rating", 0)) # 0 — with a default value
Viewing keys, values, and pairs
trip = {"trip_id": 1, "driver_id": 101, "fare_amount": 450.0}
print(list(trip.keys())) # ['trip_id', 'driver_id', 'fare_amount']
print(list(trip.values())) # [1, 101, 450.0]
print(list(trip.items())) # [('trip_id', 1), ('driver_id', 101), ...]
# Loop over key-value pairs — the most useful pattern
for key, value in trip.items():
print(f" {key}: {value}")
Adding, updating, and removing
trip = {"trip_id": 1, "fare_amount": 450.0}
trip["status"] = "completed" # add a new key
trip["fare_amount"] = 500.0 # overwrite an existing key
trip.update({"tip": 50.0, "rating": 4.8}) # merge another dict in
removed = trip.pop("tip") # remove and return the value
print(removed) # 50.0
# Python 3.9+: merge operator — returns a new dict, originals unchanged
extras = {"city": "Kathmandu"}
merged = trip | extras
print(merged)
Dict comprehension — the lookup pattern
drivers = [
{"driver_id": 101, "driver_key": 1, "name": "Sita Rai"},
{"driver_id": 102, "driver_key": 2, "name": "Ram Thapa"},
{"driver_id": 103, "driver_key": 3, "name": "Gita Magar"},
]
id_to_key = {d["driver_id"]: d["driver_key"] for d in drivers}
id_to_name = {d["driver_id"]: d["name"] for d in drivers}
print(id_to_key) # {101: 1, 102: 2, 103: 3}
print(id_to_name) # {101: 'Sita Rai', 102: 'Ram Thapa', 103: 'Gita Magar'}
# Now resolve a driver_id in O(1) — no database call:
print(id_to_name.get(101, "Unknown")) # "Sita Rai"
Before / After — N+1 queries vs dict lookup
for trip in trips: # 5,000 rows cur.execute( "SELECT name FROM drivers WHERE driver_id = %s", (trip["driver_id"],) # ← one DB query per row ) trip["driver_name"] = cur.fetchone()[0] # 5,000 trips = 5,000 separate database queries # At 1 ms per query: 5 seconds minimum # At 1,000,000 trips: 16+ minutes just for lookups
# One query — fetch ALL drivers at once cur.execute("SELECT driver_id, name FROM drivers") driver_lookup = {row[0]: row[1] for row in cur.fetchall()} # Now loop — no DB calls at all for trip in trips: trip["driver_name"] = driver_lookup.get(trip["driver_id"], "Unknown") # 1 DB query total instead of 5,000 # In-memory dict lookup: O(1) per row # This one pattern is worth the entire session.
| Method / Operator | What it does |
|---|---|
d[key] | Get value — KeyError if key missing |
d.get(key) | Get value — None if key missing |
d.get(key, default) | Get value — default if key missing |
d.keys() | View of all keys |
d.values() | View of all values |
d.items() | View of (key, value) pairs — use in for loops |
d.update(other) | Merge another dict in (mutates d) |
d.pop(key) | Remove key and return its value |
d | other | Merge, return new dict (Python 3.9+) |
{k: v for ...} | Dict comprehension — build from a sequence |
Strings
Every value from a database column, CSV file, or API response is — at some point — a string. Knowing how to clean, search, split, join, and format strings is a core ETL skill. It also includes the one rule you must never break: never put a string directly inside SQL.
Cleaning
raw = " Kathmandu " print(raw.strip()) # "Kathmandu" — remove both sides print(raw.lstrip()) # "Kathmandu " — left side only print(raw.rstrip()) # " Kathmandu" — right side only city = raw.strip() # clean first, then transform print(city.upper()) # "KATHMANDU" print(city.lower()) # "kathmandu" print(city.title()) # "Kathmandu" — first letter of each word capitalised # In ETL: strip().lower() normalises values before comparison cities = ["Kathmandu", "kathmandu", " KATHMANDU "] normalised = [c.strip().lower() for c in cities] print(normalised) # ['kathmandu', 'kathmandu', 'kathmandu']
Checking content
status = "trip_completed_ok" city = "Kathmandu" print(city.startswith("Kath")) # True print(city.endswith("andu")) # True print("ath" in city) # True — substring membership print(city.isdigit()) # False print("12345".isdigit()) # True print(status.find("completed")) # 5 — index of first match; -1 if not found print(status.replace("_", " ")) # "trip completed ok"
Splitting and joining
# Parsing a CSV line csv_line = "1,Sita Rai,Kathmandu,450.0,completed" parts = csv_line.split(",") print(parts) # ['1', 'Sita Rai', 'Kathmandu', '450.0', 'completed'] # zip() headers with values to get a dict headers = ["trip_id", "driver_name", "city", "fare_amount", "status"] row_dict = dict(zip(headers, parts)) print(row_dict) # {'trip_id': '1', 'driver_name': 'Sita Rai', 'city': 'Kathmandu', ...} # join() to build SQL INSERT placeholders placeholders = ", ".join(["%s"] * len(headers)) print(placeholders) # %s, %s, %s, %s, %s
f-string formatting
fare = 1000450.75 count = 1500000 driver = "Sita Rai" print(f"Fare: {fare:.2f}") # "Fare: 1000450.75" — 2 decimal places print(f"Fare: {fare:,.2f}") # "Fare: 1,000,450.75" — with comma separators print(f"Rows: {count:,}") # "Rows: 1,500,000" — whole number with commas # Column alignment — useful in log messages and text reports print(f"{'Driver':<15}{'Fare':>12}") # headers print(f"{driver:<15}{fare:>12,.2f}") # data row # Driver 1,000,450.75
Before / After — SQL injection
status = "completed" # NEVER do this cur.execute(f"SELECT * FROM trips WHERE status = '{status}'") # What if status was: completed' OR '1'='1 # The query becomes: WHERE status = 'completed' OR '1'='1' # Which returns EVERY row regardless of status — a data breach
status = "completed" cur.execute( "SELECT * FROM trips WHERE status = %s", (status,) # ← tuple — note the trailing comma for single-element tuples ) # psycopg2 safely escapes the value regardless of what it contains # This is the ONLY safe way to include a variable value in SQL # No exceptions — even if the value comes from your own code
| Method | What it does |
|---|---|
s.strip() | Remove leading/trailing whitespace |
s.upper() / s.lower() / s.title() | Case conversion |
s.startswith(x) / s.endswith(x) | Check prefix or suffix |
x in s | Substring membership check |
s.find(x) | Index of first match; -1 if not found |
s.replace(old, new) | Replace all occurrences |
s.split(delim) | Split string into a list on delimiter |
delim.join(lst) | Join a list of strings with delimiter |
s.isdigit() / s.isalpha() | Check character type |
f"{v:.2f}" | Format float to 2 decimal places |
f"{v:,}" | Format number with comma separators |
Looping patterns
Python gives you many ways to iterate over data. Each pattern suits a different scenario. Knowing which one to reach for makes the difference between clear, readable ETL code and code that works but no-one wants to touch.
for and enumerate
fares = [450.0, 320.5, 210.0, 580.0] # Basic for — when you just need the values for fare in fares: print(fare) # enumerate — when you also need the position (start=1 for human-readable row numbers) for i, fare in enumerate(fares, start=1): print(f" [{i}] {fare}") # enumerate over a list of dicts — the core ETL loop trips = [ {"trip_id": 1, "fare_amount": 450.0, "status": "completed"}, {"trip_id": 2, "fare_amount": 320.5, "status": "cancelled"}, ] for row_num, trip in enumerate(trips, start=1): if trip["status"] != "completed": continue # skip to next row print(f"Row {row_num}: trip {trip['trip_id']} = {trip['fare_amount']}")
while and zip
# while — consuming a queue or processing rows in batches rows = list(range(100)) batch_size = 20 while rows: batch = rows[:batch_size] rows = rows[batch_size:] print(f" Processing {len(batch)} rows, {len(rows)} remaining") # zip — walk two lists in parallel (headers + values from a cursor row) headers = ["trip_id", "fare_amount", "status"] values = [1, 450.0, "completed"] for key, val in zip(headers, values): print(f" {key}: {val}") # One-liner to build a row dict from headers + values: row_dict = dict(zip(headers, values)) # {'trip_id': 1, 'fare_amount': 450.0, ...}
Comprehensions
trips = [
{"trip_id": 1, "fare_amount": 450.0, "status": "completed"},
{"trip_id": 2, "fare_amount": 320.5, "status": "cancelled"},
{"trip_id": 3, "fare_amount": 580.0, "status": "completed"},
]
# List comprehension — filter + transform in one line
completed_fares = [t["fare_amount"] for t in trips if t["status"] == "completed"]
print(completed_fares) # [450.0, 580.0]
# Dict comprehension — build a lookup in one line
id_to_fare = {t["trip_id"]: t["fare_amount"] for t in trips}
print(id_to_fare) # {1: 450.0, 2: 320.5, 3: 580.0}
# Set comprehension — unique values from a column
statuses = {t["status"] for t in trips}
print(statuses) # {'completed', 'cancelled'}
# any() / all() — aggregate boolean checks
print(any(t["status"] == "cancelled" for t in trips)) # True
print(all(t["fare_amount"] > 100 for t in trips)) # True
Before / After — for + append vs comprehension
completed_fares = [] for trip in trips: if trip["status"] == "completed": completed_fares.append(trip["fare_amount"]) print(completed_fares)
completed_fares = [t["fare_amount"] for t in trips if t["status"] == "completed"] print(completed_fares) # When NOT to use a comprehension: # - Body has side effects: logging, DB writes, print statements # - More than one condition that needs a comment to explain # - Result would be longer than ~80 characters # For those cases, use an explicit for loop instead
continue skips to the next iteration — use it to skip bad rows. break exits the loop entirely — use it when you've found what you need or hit a fatal condition. In ETL loops, continue is far more common.| Pattern | Use when |
|---|---|
for x in lst | Processing every item; index not needed |
for i, x in enumerate(lst, start=1) | Need row number for error logging or reports |
while lst: | Consuming a queue or processing in batches |
for a, b in zip(lst1, lst2) | Walking two lists in parallel |
[expr for x in lst if cond] | Filter + transform; result needed as a list |
{k: v for ...} | Build a dict from a sequence |
{expr for x in lst} | Unique values from a column |
any(cond for x in lst) | Check if at least one item matches |
all(cond for x in lst) | Check if every item matches |
Quick reference
Everything from this pre-read on one page. Use it during class when you need to remember a method name or decide which structure to use.
Which structure should I use?
| List | Tuple | Set | Dict | |
|---|---|---|---|---|
| Syntax | [...] |
(...) |
{...} |
{k: v} |
| Mutable? | Yes | No | Yes | Yes |
| Ordered? | Yes | Yes | No | Yes (3.7+) |
| Duplicates? | Yes | Yes | No | Keys: No |
| Best for | Collecting rows | One DB row | Dedup / fast in |
Lookups / records |
All methods at a glance
len · sum · min · max ·
append · insert · pop · remove ·
index · count · sort · sorted() ·
reverse · x in lst · slicing [i:j]
len · count · index ·
unpacking a, b = tup · use as dict keys · loop unpack for a, b in lst
add · discard · remove ·
x in s (O(1)) ·
| union · & intersection · - difference ·
list(set(x)) dedup
d[k] · d.get(k, default) ·
keys() · values() · items() ·
update() · pop() · d | other ·
{k: v for ...}
strip · upper/lower/title ·
startswith/endswith · x in s ·
find · replace · split · join ·
isdigit/isalpha · f"{v:,.2f}"
for x in lst · enumerate(lst, start=1) ·
while lst: · zip(a, b) ·
[expr for x in lst if cond] ·
{k: v for ...} · {expr for ...} ·
any() · all()
The three patterns that appear in every ETL script
- Build a lookup dict once, look up in O(1). Never query the database inside a loop. Fetch all dimension rows once, build
{id: key}, then resolve with.get(). - Unpack tuples by name, not by index.
trip_id, driver_id, fare, status = rowat the top of the loop. If the column order ever changes, you fix one line. - Use list comprehensions for pure filter + transform. For anything with logging, DB writes, or complex conditions, keep an explicit
forloop — it's easier to read and easier to add error handling to.
enumerate loop from memory. Everything else you can look up.