Before you start

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.

🎯
By the end of this pre-read you will be able to read and write the complete script at the bottom of page 7 — a production-style database loader that uses every concept here.

The script you are building toward

Every concept in this pre-read appears in this function. You will build it piece by piece.

fetch_and_export.py
Connects to PostgreSQL → fetches trip rows → filters by status → writes results to a CSV file → logs every step → handles errors without crashing

What's covered

01
Lists & tuples
Store query results. Know when to use each one.
02
Loops
Process every row returned from the database.
03
Conditions
Filter rows. Decide what to write to file.
04
Exception handling
The single most important pattern for pipelines.
05
Logging
Know what happened when the script ran at 2am.
06
File handling
Write query results safely to a CSV.
💡
How to use this pre-read: Each page has a Before / After toggle. The Before tab shows a 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 7

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.

pythonlist basics
# 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.

pythontuple basics
# 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.

pythonwhat fetchall() returns
# 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
⚠️
Watch out: 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.
ListTuple
Syntax[1, 2, 3](1, 2, 3)
Changeable?Yes — mutableNo — immutable
Use forA collection you build up or modifyA fixed record (one DB row)
DB returnsList of rowsEach individual row
Concept 2 of 7

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

pythonlooping over rows
# 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.

pythonenumerate — critical for error logging
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

pythonproblem: no row context on error
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
pythonsolution: enumerate from 1
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"
💡
Why 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.
Concept 3 of 7

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

pythonfiltering rows by status
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

pythonand / or / not
# 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

pythonproblem: writes cancelled trips to report
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.
pythonsolution: check status before appending
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.
Concept 4 of 7

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

pythontry / except / else / finally
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.

pythonproblem: swallowed exception looks like success
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
pythonsolution: rollback AND raise
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)
🔴
Never swallow exceptions in a pipeline. 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

ExceptionWhen it happensWhat to do
psycopg2.OperationalErrorCannot connect to the databaseLog error, raise — no point retrying immediately
psycopg2.IntegrityErrorFK violation, CHECK constraint, duplicate keyRollback the whole batch, log which row failed
FileNotFoundErrorOutput directory doesn't existCreate the directory or log and raise
KeyErrorAccessing a dict key that doesn't existLog the row data, skip or raise
Concept 5 of 7

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

pythonstandard logger setup — copy this exactly
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

LevelUse forExample
DEBUGDetailed dev info — off in productionlogger.debug("Processing row 42")
INFONormal progress milestoneslogger.info("Loaded 5000 rows")
WARNINGSomething unexpected but recoverablelogger.warning("Null fare on row 88 — skipped")
ERRORA failure that stopped part of the worklogger.error("Batch failed at row 2847")
CRITICALThe whole pipeline must stop nowlogger.critical("Cannot connect to DB")

Before / After — print vs logging

pythonproblem: no timestamp, no level, no file
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.
pythonsolution: timestamped, levelled, persisted
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.
💡
Use {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.
Concept 6 of 7

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

pythonalways use with — it closes automatically
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

ModeMeaningUse when
"w"Write — creates or overwritesDaily export that replaces yesterday's file
"a"Append — adds to end of fileLog file that grows over time
"r"Read — file must existReading a config or input file
"x"Create — fails if file existsPreventing accidental overwrites

Before / After — safe file writing

pythonproblem: file left open if exception occurs
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
pythonsolution: with closes the file automatically
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.
Concept 7 of 7

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.

🎯
Before Week 3 class: read this script until you can describe what each section does in plain English. You don't need to memorise it — you need to understand the shape.
pythonfetch_and_export.py — the complete script
"""
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

🎯
You're ready for Week 3 when you can read this script top to bottom and explain what each section does without looking at the notes. In class, you will extend this pattern to write directly to the database — using transactions to make it safe.