Before you start

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.

🎯
By the end of this pre-read you will be able to look at any ETL transform loop and immediately understand what it does — and write your own transforms from scratch using the right structure for each job.

What's covered

01
Lists
Ordered, mutable. Your go-to for collecting batches of rows.
02
Tuples
Ordered, immutable. What psycopg2 gives you for each DB row.
03
Sets
Unique values only. O(1) membership checks and deduplication.
04
Dictionaries
Key-value maps. The single most important structure in data work.
05
Strings
Clean, split, join, format. Handling every text column value.
06
Looping patterns
for, enumerate, zip, comprehensions, any/all — all in one place.
💡
How to use this pre-read: Each page has a Before / After toggle. The Before tab shows a common mistake or naive approach with a real problem. The After tab shows the correct pattern. Read both — understanding why the Before is wrong is just as important as knowing the After.
Concept 1 of 6

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

pythonindexing, slicing, aggregates
# 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

pythonappend, insert, pop, remove
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

pythonindex, count, membership
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()

pythonproblem: original order is gone
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
pythonsolution: sorted() returns a new list
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 / FunctionWhat it doesReturns
len(lst)Number of elementsint
sum(lst)Sum of all elementsnumber
min(lst) / max(lst)Smallest / largest elementelement
lst.append(x)Add x to endNone (mutates)
lst.insert(i, x)Insert x at index iNone (mutates)
lst.pop()Remove & return last elementelement
lst.pop(i)Remove & return element at index ielement
lst.remove(x)Remove first occurrence of xNone (mutates)
lst.index(x)Index of first occurrence of xint
lst.count(x)Times x appearsint
lst.sort()Sort in-placeNone (mutates)
sorted(lst)Return new sorted copynew list
lst.reverse()Reverse in-placeNone (mutates)
x in lstMembership checkbool
Concept 2 of 6

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

pythoncreating, accessing, immutability
# 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.

pythontuple unpacking — the core pattern
# 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

pythoncompound keys in lookup dicts
# 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

pythonproblem: silent break if column order changes
# 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
pythonsolution: unpack once, use names everywhere
# 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
ListTuple
Syntax[...](...)
Mutable?YesNo — immutable
Can be dict key?NoYes
fetchall() returnsOne list (all rows)One tuple per row
Use forCollecting rows, building batchesFixed records, compound dict keys
Concept 3 of 6

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

pythoncreating, adding, removing
# 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

pythonwhy set lookup matters at scale
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

pythonunion, intersection, difference
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

pythonproblem: slow and verbose
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
pythonsolution: set() removes duplicates in O(n)
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 / OperatorWhat 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 sMembership check — O(1)
s1 | s2Union — elements in either set
s1 & s2Intersection — elements in both
s1 - s2Difference — in s1 but not s2
s1 ^ s2Symmetric diff — in one but not both
list(set(x))Deduplicate a list
Concept 4 of 6

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

pythonaccess, safe get, default values
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

pythonkeys(), values(), items()
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

pythonupdate, pop, merge
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

pythonbuild a lookup dict in one line
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

pythonproblem: 5,000 DB round-trips
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
pythonsolution: 1 query, then O(1) 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 / OperatorWhat 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 | otherMerge, return new dict (Python 3.9+)
{k: v for ...}Dict comprehension — build from a sequence
Concept 5 of 6

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

pythonstrip, case conversion
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

pythonstartswith, endswith, find, replace
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

pythonparse CSV lines, build SQL placeholders
# 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

pythonnumber formatting, alignment
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

pythonproblem: SQL injection vulnerability
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
pythonsolution: parameterized query — always
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
MethodWhat 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 sSubstring 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
Concept 6 of 6

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

pythonbasic for loop and row-number tracking
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

pythonbatch processing and parallel iteration
# 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

pythonlist, dict, set comprehensions — and any/all
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

python5 lines to do one thing
completed_fares = []
for trip in trips:
    if trip["status"] == "completed":
        completed_fares.append(trip["fare_amount"])
print(completed_fares)
python1 line — same result
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 vs break: 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.
PatternUse when
for x in lstProcessing 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
Reference

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

💡
Lists: len · sum · min · max · append · insert · pop · remove · index · count · sort · sorted() · reverse · x in lst · slicing [i:j]
💡
Tuples: len · count · index · unpacking a, b = tup · use as dict keys · loop unpack for a, b in lst
💡
Sets: add · discard · remove · x in s (O(1)) · | union · & intersection · - difference · list(set(x)) dedup
💡
Dicts: d[k] · d.get(k, default) · keys() · values() · items() · update() · pop() · d | other · {k: v for ...}
💡
Strings: strip · upper/lower/title · startswith/endswith · x in s · find · replace · split · join · isdigit/isalpha · f"{v:,.2f}"
💡
Loops: 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

🎯
You're ready for class when you can explain what each structure is used for without looking at this page, and you can write a list comprehension, a dict comprehension, and an enumerate loop from memory. Everything else you can look up.