Seven Python concepts.
One real pipeline script.
In Week 3 you will write a Python function that connects to PostgreSQL, fetches trip data, processes it, and writes results to a file — all without crashing silently when something goes wrong. This pre-read covers the seven Python patterns that script depends on. Work through each one before class so the syntax is familiar before you see it in context.
The script you are building toward
Every concept in this pre-read appears in this function. You will build it piece by piece.
What's covered
Lists & tuples
When your script fetches rows from a database, each row comes back as a tuple. Multiple rows come back as a list of tuples. Understanding the difference between these two tells you what you can and cannot do with your data.
What is a list?
A list is an ordered, changeable collection. You can add, remove, or update items after it's created. Use square brackets.
# Create a list of driver names drivers = ["Alice", "Bob", "Carol"] # Access by index (starts at 0) print(drivers[0]) # "Alice" print(drivers[-1]) # "Carol" (last item) # Lists are mutable — you can change them drivers.append("David") # add to end drivers[1] = "Ben" # update item print(len(drivers)) # 4
What is a tuple?
A tuple is an ordered, fixed collection. Once created, you cannot change it. Database rows come back as tuples because the database won't let you accidentally mutate them. Use round brackets.
# One database row comes back as a tuple row = (1, "Alice", 850.50, "completed") # ↑ trip_id ↑ driver ↑ fare ↑ status # Access by index — same as list print(row[0]) # 1 (trip_id) print(row[3]) # "completed" # Tuples are immutable — this crashes: # row[0] = 99 → TypeError: 'tuple' object does not support item assignment # Unpack a tuple into named variables trip_id, driver, fare, status = row print(driver) # "Alice"
How the database returns data
When you run a query, cursor.fetchall() returns a list of tuples — one tuple per row.
# After: cur.execute("SELECT trip_id, driver_id, fare_amount, status FROM trips") rows = cur.fetchall() # rows is a list of tuples: # [ # (1, 3, 850.50, "completed"), # (2, 1, 420.00, "cancelled"), # (3, 3, 1200.00, "completed"), # ... up to 1,000,000 rows # ] # Access the first row first_row = rows[0] # (1, 3, 850.50, "completed") print(first_row[2]) # 850.50 (fare_amount) # How many rows came back? print(len(rows)) # 1000000
row[2] works but breaks silently if the column order changes. In Week 3 you will use cursor.description or named columns to access values by name instead of position.| List | Tuple | |
|---|---|---|
| Syntax | [1, 2, 3] | (1, 2, 3) |
| Changeable? | Yes — mutable | No — immutable |
| Use for | A collection you build up or modify | A fixed record (one DB row) |
| DB returns | List of rows | Each individual row |
Loops
Your script will receive thousands of rows from the database. You cannot write code for each row individually. A for loop lets you write the logic once and apply it to every row automatically — whether the database returns 10 rows or 1,000,000.
Basic for loop
# rows is the list of tuples from fetchall() rows = [ (1, "Alice", 850.50, "completed"), (2, "Bob", 420.00, "cancelled"), (3, "Alice", 1200.00, "completed"), ] for row in rows: trip_id, driver, fare, status = row # unpack the tuple print(f"Trip {trip_id}: {driver} — NPR {fare}") # Output: # Trip 1: Alice — NPR 850.5 # Trip 2: Bob — NPR 420.0 # Trip 3: Alice — NPR 1200.0
enumerate() — know which row you're on
When something goes wrong, you need to know which row caused the problem. enumerate() gives you both the row number and the row itself.
for row_num, row in enumerate(rows, start=1): trip_id, driver, fare, status = row print(f"Row {row_num}/{len(rows)}: {driver}") # Output: # Row 1/3: Alice # Row 2/3: Bob # Row 3/3: Alice # Why this matters in a pipeline: # "Batch failed at row 2847/5000" is a useful error message. # "Batch failed" tells you nothing.
Before / After — the real pipeline pattern
for row in rows: trip_id, driver, fare, status = row cur.execute(INSERT_SQL, row) # If row 2847 has an invalid rating value: # → you get an error but have no idea which row failed # → you have to manually count or add debugging later # → at 1,000,000 rows, this wastes hours
row_num = 0 try: for row_num, row in enumerate(rows, start=1): trip_id, driver, fare, status = row cur.execute(INSERT_SQL, row) except Exception as e: # Now you know exactly where it broke: logger.error(f"Failed at row {row_num}/{len(rows)}: {e}") # "Failed at row 2847/1000000: invalid rating value 99"
start=1? Python indexes from 0, but humans count from 1. enumerate(rows, start=1) makes error messages say "row 2847" not "row 2846" — much easier to explain to someone debugging at 2am.Conditions
Not every row from the database should be processed the same way. Conditions let
your script make decisions — skip cancelled trips, flag high fares, write different
files for different statuses. An if statement is how you encode
business logic in Python.
Basic if / elif / else
trip_id, driver, fare, status = (1, "Alice", 850.50, "completed") if status == "completed": print(f"Write to report: {driver} — NPR {fare}") elif status == "cancelled": print(f"Skip: {driver} cancelled") else: print(f"In progress — ignore for now")
Combining conditions
# Flag high-value completed trips if status == "completed" and fare > 1000: print(f"High-value trip: NPR {fare}") # Skip if missing fare OR missing status if fare is None or status is None: print("Incomplete row — skipping") continue # jump to next loop iteration # Check if status is one of several values if status in ("completed", "in_progress"): valid_rows.append(row)
Before / After — filtering inside a loop
completed_rows = [] for row in rows: completed_rows.append(row) # appends ALL rows # Your "completed trips" report now contains cancelled trips. # The analyst will get wrong numbers. Nobody will notice for weeks.
completed_rows = [] for row_num, row in enumerate(rows, start=1): trip_id, driver_id, fare, status = row if status != "completed": continue # skip this row, go to next if fare is None: logger.warning(f"Row {row_num}: null fare — skipping") continue completed_rows.append(row) # only completed, valid rows print(f"{len(completed_rows)} completed trips ready for report")
continue vs break: continue skips to the next loop iteration — use it to skip bad rows. break stops the loop entirely — use it when you've found what you need or something is critically wrong.Exception handling
This is the single most important pattern for pipeline engineering. A pipeline that crashes and tells you is recoverable. A pipeline that swallows errors and appears to succeed — while writing nothing to the database — is a disaster waiting to be discovered by an analyst weeks later.
The basic pattern
try: # Code that might fail goes here result = risky_operation() except ValueError as e: # Runs only if a ValueError was raised print(f"Value error: {e}") except Exception as e: # Catches ANY other exception print(f"Unexpected error: {e}") else: # Runs ONLY if try completed with no exception # Perfect place for conn.commit() print("Success") finally: # Always runs — even if an exception occurred # Perfect place for conn.close() or file.close() cleanup()
The most critical rule: always raise after rollback
After catching an error and rolling back the database transaction, you must re-raise the exception. If you don't, the caller thinks the function succeeded.
def load_batch(conn, rows): try: for row in rows: cur.execute(INSERT_SQL, row) conn.commit() return len(rows) except Exception as e: conn.rollback() print(f"Error: {e}") return 0 # ← WRONG: caller sees 0 and thinks it's fine # The caller sees return value 0 and moves on. # 0 rows were loaded. Nobody knows. The pipeline "succeeded". loaded = load_batch(conn, rows) print(f"Loaded {loaded} rows") # prints "Loaded 0 rows" — no alarm
def load_batch(conn, rows): conn.autocommit = False row_num = 0 try: with conn.cursor() as cur: for row_num, row in enumerate(rows, start=1): cur.execute(INSERT_SQL, row) except Exception as e: conn.rollback() logger.error(f"Failed at row {row_num}: {e}") raise # ← re-raise so the caller knows it failed else: conn.commit() # only if NO exception occurred return len(rows)
return 0 after a failure looks like success. raise makes the failure visible. In Week 6 you will use Airflow — it marks a task as FAILED only if an exception propagates. A silent return 0 will make Airflow report success while loading nothing.Common exception types you will see
| Exception | When it happens | What to do |
|---|---|---|
psycopg2.OperationalError | Cannot connect to the database | Log error, raise — no point retrying immediately |
psycopg2.IntegrityError | FK violation, CHECK constraint, duplicate key | Rollback the whole batch, log which row failed |
FileNotFoundError | Output directory doesn't exist | Create the directory or log and raise |
KeyError | Accessing a dict key that doesn't exist | Log the row data, skip or raise |
Logging
print() is for development. logging is for production.
Your pipeline will run at 2am while you sleep. The only way to know what happened
is to read the log file the next morning. A properly logged pipeline tells you
exactly how many rows loaded, where it failed, and why.
Setting up the logger
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S", handlers=[ logging.FileHandler("pipeline.log"), # write to file logging.StreamHandler() # also print to terminal ] ) logger = logging.getLogger(__name__)
The five log levels
| Level | Use for | Example |
|---|---|---|
DEBUG | Detailed dev info — off in production | logger.debug("Processing row 42") |
INFO | Normal progress milestones | logger.info("Loaded 5000 rows") |
WARNING | Something unexpected but recoverable | logger.warning("Null fare on row 88 — skipped") |
ERROR | A failure that stopped part of the work | logger.error("Batch failed at row 2847") |
CRITICAL | The whole pipeline must stop now | logger.critical("Cannot connect to DB") |
Before / After — print vs logging
print("Connecting to database") rows = fetch_trips(conn) print(f"Got {len(rows)} rows") print("Done") # Output: just text, no timestamp # Connecting to database # Got 1000000 rows # Done # If this ran at 2am: you have no idea when it ran, # how long it took, or if anything went wrong.
logger.info("Connecting to database") rows = fetch_trips(conn) logger.info(f"Fetched {len(rows):,} rows from trips") logger.info("Export complete") # Output in pipeline.log: # 2025-01-15 02:00:01 INFO Connecting to database # 2025-01-15 02:00:03 INFO Fetched 1,000,000 rows from trips # 2025-01-15 02:01:47 INFO Export complete # You can see: it started at 2am, fetched 1M rows, # and finished 1 min 47 secs later. Everything you need.
{value:,} in f-strings to format large numbers with commas: f"{1000000:,}" prints 1,000,000. Your logs become much easier to read at scale.File handling
After fetching and filtering rows, your script needs to write the results somewhere.
The safest way to work with files in Python is the with statement —
it guarantees the file is closed properly even if an exception occurs halfway through writing.
Opening and writing a file
import csv output_path = "completed_trips.csv" with open(output_path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) # Write the header row first writer.writerow(["trip_id", "driver_id", "fare_amount", "status"]) # Write each data row for row in completed_rows: writer.writerow(row) # File is automatically closed here — even if an exception happened logger.info(f"Wrote {len(completed_rows):,} rows to {output_path}")
File modes
| Mode | Meaning | Use when |
|---|---|---|
"w" | Write — creates or overwrites | Daily export that replaces yesterday's file |
"a" | Append — adds to end of file | Log file that grows over time |
"r" | Read — file must exist | Reading a config or input file |
"x" | Create — fails if file exists | Preventing accidental overwrites |
Before / After — safe file writing
f = open("output.csv", "w") writer = csv.writer(f) for row in rows: writer.writerow(row) # what if this crashes? f.close() # ← never reached on exception # If an exception happens mid-loop: # - f.close() is never called # - The file buffer may not be flushed to disk # - The file handle leaks — can cause issues on some systems
with open("output.csv", "w", newline="") as f: writer = csv.writer(f) for row in rows: writer.writerow(row) # File is ALWAYS closed here — exception or not # Python calls f.close() automatically when the with block exits # This is called a "context manager" # The database connection works exactly the same way: with conn.cursor() as cur: cur.execute(QUERY) rows = cur.fetchall() # cursor is automatically closed here
newline="" when writing CSV: Without this, Python adds an extra blank line between every row on Windows. Always include it when opening a file for CSV writing.Putting it all together
Every concept from this pre-read appears in the script below. Read it top to bottom and find each pattern: the list that collects rows, the loop that processes them, the conditions that filter, the exception handling that protects the transaction, the logging that records everything, and the file handler that writes the result safely.
""" fetch_and_export.py ──────────────────── Connects to PostgreSQL, fetches completed trips, writes them to a CSV file. Logs every step. """ import csv import logging import psycopg2 # ── 1. Logging setup ───────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S", handlers=[ logging.FileHandler("pipeline.log"), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # ── 2. Database config ─────────────────────────────────────────── DB_CONFIG = dict( host="localhost", port=5432, dbname="ridedb", user="postgres", password="postgres" ) QUERY = """ SELECT trip_id, driver_id, rider_id, fare_amount, distance_km, status, requested_at FROM trips ORDER BY requested_at """ OUTPUT_FILE = "completed_trips.csv" HEADERS = ["trip_id", "driver_id", "rider_id", "fare_amount", "distance_km", "status", "requested_at"] # ── 3. Main function ───────────────────────────────────────────── def fetch_and_export(): # ── Connect ────────────────────────────────────────────────── logger.info("Connecting to database…") try: conn = psycopg2.connect(**DB_CONFIG) # ** unpacks the dict except psycopg2.OperationalError as e: logger.critical(f"Cannot connect: {e}") raise # stop — nothing to do # ── Fetch all rows ─────────────────────────────────────────── logger.info("Running query…") try: with conn.cursor() as cur: # cursor auto-closes cur.execute(QUERY) all_rows = cur.fetchall() # list of tuples except Exception as e: logger.error(f"Query failed: {e}") conn.close() raise logger.info(f"Fetched {len(all_rows):,} rows total") # ── Filter and collect ─────────────────────────────────────── completed_rows = [] # list to collect completed trips skipped = 0 for row_num, row in enumerate(all_rows, start=1): # loop + enumerate trip_id, driver_id, rider_id, fare, dist, status, req_at = row # Condition 1: only keep completed trips if status != "completed": skipped += 1 continue # Condition 2: skip rows with null fare if fare is None: logger.warning(f"Row {row_num}: null fare — skipping") skipped += 1 continue completed_rows.append(row) # add to list logger.info( f"{len(completed_rows):,} completed rows | {skipped:,} skipped" ) # ── Write to CSV ───────────────────────────────────────────── logger.info(f"Writing to {OUTPUT_FILE}…") try: with open(OUTPUT_FILE, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(HEADERS) # header row first for row in completed_rows: writer.writerow(row) # each data row (tuple) except IOError as e: logger.error(f"Could not write file: {e}") raise logger.info(f"Done. {len(completed_rows):,} rows written to {OUTPUT_FILE}") conn.close() # ── 4. Entry point ─────────────────────────────────────────────── if __name__ == "__main__": fetch_and_export()
Where each concept lives in this script
- Lists:
completed_rows = []— collects the filtered rows as the loop runs - Tuples: every row from
fetchall()is a tuple — unpacked withtrip_id, driver_id, ... = row - Loops:
for row_num, row in enumerate(all_rows, start=1)— processes every row with a row counter - Conditions:
if status != "completed": continueandif fare is None: continue - Exception handling: three
try/except/raiseblocks — connect, fetch, write — each one raises after logging - Logging:
logger.info,logger.warning,logger.error,logger.critical— all levels used - File handling:
with open(OUTPUT_FILE, "w") as f— file always closes, even on exception