Python for Data Engineering
File I/O at scale, error handling, structured logging, generators, config management, and writing testable pipeline code — built around one running pipeline.
What Python for Data Engineering Actually Looks Like
Python for data engineering is not the same as Python for web development, data science, or automation scripting. The patterns, the error-handling discipline, the memory constraints, and the testing approach are all different. A data scientist’s notebook that works perfectly for exploring a sample becomes a 3 AM production disaster when it runs unattended against the full 50 GB dataset.
This module is built around one running example: FreshCart, the same 40-store grocery chain from the Linux and Working with APIs modules. Every night, each store’s point-of-sale system drops an orders export into blob storage — anywhere from 50 MB on a slow Tuesday to 6 GB on the Saturday before Thanksgiving. Your job is to build the Python that reads those files, cleans them, and loads them into the warehouse — reliably, every single night, without anyone watching it run.
File I/O at Scale — Reading FreshCart’s Store Exports
The first thing most beginners do when they need to read a file in Python is load the entire thing into memory. For a 1 KB config file, that is fine. For a 6 GB CSV of Saturday’s orders across 40 stores, it crashes the process — or worse, doesn’t crash, and instead slows the whole machine to a crawl as the OS starts swapping memory to disk.
The naive read — and why it breaks on the big nights
import pandas as pd
def load_orders_wrong(filepath: str) -> pd.DataFrame:
df = pd.read_csv(filepath) # reads the ENTIRE file into RAM before returning
return df
load_orders_wrong('/data/freshcart/store_014_2026-03-21.csv')Traceback (most recent call last):
...
MemoryError: Unable to allocate 5.8 GiB for an array with shape (48_200_000,) and data type objectThis works fine on a quiet Tuesday when the file is 50 MB. It fails the exact night it matters most — the Saturday before a holiday, when order volume (and file size) is highest and the business most needs the data on time. The fix is always the same: read in chunks, process chunk by chunk, and never hold more than one chunk in memory at once.
Chunked reading — constant memory regardless of file size
import pandas as pd
def process_orders_in_chunks(filepath: str, chunk_size: int = 100_000) -> None:
"""Process a large CSV file in memory-efficient chunks."""
# chunksize turns read_csv into a lazy TextFileReader iterator —
# nothing is read from disk until you iterate over it.
chunk_iter = pd.read_csv(
filepath,
chunksize=chunk_size,
dtype={'order_id': 'int64', 'store_id': 'int64', 'amount': 'float64', 'status': 'string'},
parse_dates=['created_at'],
na_values=['', 'NULL', 'N/A', '-'],
on_bad_lines='warn', # log malformed rows instead of crashing the whole run
)
rows_seen = 0
for i, chunk in enumerate(chunk_iter, start=1):
rows_seen += len(chunk)
print(f"chunk {i}: {len(chunk):,} rows (running total: {rows_seen:,})")
# ... clean, validate, write this chunk before the next one loads ...
process_orders_in_chunks('/data/freshcart/store_014_2026-03-21.csv')chunk 1: 100,000 rows (running total: 100,000)
chunk 2: 100,000 rows (running total: 200,000)
chunk 3: 100,000 rows (running total: 300,000)
...
chunk 482: 40,120 rows (running total: 48,200,000)At any point in that loop, memory usage is proportional to one 100,000-row chunk — roughly 40–80 MB depending on column width — never the full 5.8 GB file. The same code handles a 50 MB file and a 6 GB file identically.
Parquet — when a store’s export is too big for CSV to be practical
CSV chunking solves the memory problem, but it still means scanning every byte of the file even if you only need three of its twenty columns. Parquet is a columnar format: it stores each column separately with its own statistics, so a reader can skip whole sections of the file it doesn’t need.
import pyarrow as pa
import pyarrow.parquet as pq
import pyarrow.dataset as ds
# Write partitioned by store and date — this is what makes later reads fast
table = pa.Table.from_pandas(orders_df)
pq.write_to_dataset(
table,
root_path='/data/freshcart/orders_parquet',
partition_cols=['store_id', 'order_date'],
)
# Read back with COLUMN PROJECTION + PREDICATE PUSHDOWN:
# only 'order_id' and 'amount' are read off disk, and only for store 014
dataset = ds.dataset('/data/freshcart/orders_parquet', format='parquet', partitioning='hive')
table = dataset.to_table(
columns=['order_id', 'amount'],
filter=(ds.field('store_id') == 14) & (ds.field('order_date') == '2026-03-21'),
)
print(table.to_pandas().head()) order_id amount
0 9284751 380.00
1 9284752 45.50
2 9284753 112.75
3 9284754 28.00
4 9284755 205.30
# Read 2.1 MB off disk instead of the full 5.8 GB file —
# partition pruning skipped 39 stores, column projection skipped 18 columns.Reading straight from cloud storage
FreshCart’s store exports don’t land on a local disk — they land in an S3 bucket (or ADLS Gen2 container). Both boto3 and the higher-level fsspec/s3fs libraries let you stream a remote file the same way you’d stream a local one, without downloading the whole thing first.
import boto3
import pandas as pd
s3 = boto3.client('s3')
def stream_orders_from_s3(bucket: str, key: str, chunk_size: int = 100_000):
"""Stream a CSV directly from S3 without downloading it to local disk first."""
response = s3.get_object(Bucket=bucket, Key=key)
body = response['Body'] # a botocore StreamingBody — file-like, reads lazily
for chunk in pd.read_csv(body, chunksize=chunk_size):
yield chunk
# Same pattern with fsspec — works across S3, ADLS, and GCS with one API:
import pandas as pd
df_iter = pd.read_csv('s3://freshcart-orders/store_014_2026-03-21.csv', chunksize=100_000)pd.read_csv(path, chunksize=1000) in a loop, printing len(chunk) each time. Then remove chunksize entirely and compare — for a small file there’s no visible difference, which is exactly why this bug doesn’t show up until the file that matters is big enough to break it.Calling APIs From a Pipeline — The Short Version
FreshCart’s orders file tells you what was sold. It doesn’t tell you which of those orders were later refunded — that lives in a separate payments API your pipeline needs to call for enrichment. This section covers just enough to make that call correctly. Auth, pagination, and rate limiting each get a full, much deeper treatment in Module 18 — Working with APIs — this is the light version you need before you get there.
Authenticating the request
import os
import requests
def fetch_refund(order_id: int) -> dict:
response = requests.get(
f'https://payments.freshcart.internal/v1/refunds/{order_id}',
headers={'Authorization': f'Bearer {os.environ["PAYMENTS_API_TOKEN"]}'},
timeout=15,
)
response.raise_for_status()
return response.json()>>> fetch_refund(9284751)
{'order_id': 9284751, 'refunded': False, 'refund_amount': None}Paging through results
def fetch_all_refunds(store_id: int) -> list[dict]:
"""Follow cursor-based pagination until the API returns no next cursor."""
refunds, cursor = [], None
while True:
params = {'store_id': store_id, 'limit': 200}
if cursor:
params['cursor'] = cursor
resp = requests.get(
'https://payments.freshcart.internal/v1/refunds',
headers={'Authorization': f'Bearer {os.environ["PAYMENTS_API_TOKEN"]}'},
params=params, timeout=15,
)
resp.raise_for_status()
page = resp.json()
refunds.extend(page['items'])
cursor = page.get('next_cursor')
if not cursor:
break
return refunds>>> len(fetch_all_refunds(store_id=14))
340
# fetched across 2 pages of 200 + 1 page of 140 — the loop above followed
# next_cursor automatically until the API returned NoneRespecting rate limits
import time
def fetch_refund_with_retry(order_id: int, max_attempts: int = 4) -> dict:
for attempt in range(1, max_attempts + 1):
resp = requests.get(f'https://payments.freshcart.internal/v1/refunds/{order_id}',
headers={'Authorization': f'Bearer {os.environ["PAYMENTS_API_TOKEN"]}'},
timeout=15)
if resp.status_code == 429:
wait = int(resp.headers.get('Retry-After', 2 ** attempt))
time.sleep(wait)
continue
resp.raise_for_status()
return resp.json()
raise RuntimeError(f"Gave up fetching refund {order_id} after {max_attempts} attempts")Error Handling and Retries — Not Every Failure Deserves a Retry
The refunds API call from Part 03 will fail sometimes — a timeout, a dropped connection, a 503 while the payments team deploys. The instinct is to wrap every API call in a retry loop. That instinct is half right: some failures should be retried, and some should never be retried at all.
Transient vs permanent — the classification that matters
# Transient: the same request will probably succeed if you just try again
TRANSIENT_ERRORS = {
'ConnectionError', 'Timeout', 'ChunkedEncodingError',
}
TRANSIENT_STATUS_CODES = {429, 500, 502, 503, 504}
# Permanent: retrying the identical request produces the identical failure
PERMANENT_STATUS_CODES = {400, 401, 403, 404, 422}
def is_retryable(exc: Exception | None, status_code: int | None) -> bool:
if status_code in PERMANENT_STATUS_CODES:
return False
if status_code in TRANSIENT_STATUS_CODES:
return True
return type(exc).__name__ in TRANSIENT_ERRORS>>> is_retryable(None, 401) # bad token — retrying changes nothing
False
>>> is_retryable(None, 503) # payments API mid-deploy — will recover
TrueExponential backoff with jitter
A retry that fires immediately after a failure usually hits the same overloaded system and fails again. Waiting progressively longer between attempts — and adding a small random offset (jitter) — gives the system time to recover and stops every failing pipeline from retrying at exactly the same instant.
import time
import random
import functools
import logging
logger = logging.getLogger('freshcart_pipeline')
def with_retry(max_attempts: int = 5, base_delay: float = 1.0, max_delay: float = 60.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
logger.error("Giving up after %d attempts: %s", max_attempts, e)
raise
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
delay += random.uniform(0, delay * 0.25) # jitter: up to +25%
logger.warning("Attempt %d/%d failed (%s), retrying in %.1fs",
attempt, max_attempts, e, delay)
time.sleep(delay)
return wrapper
return decorator
@with_retry(max_attempts=4)
def fetch_refund(order_id: int) -> dict:
resp = requests.get(f'https://payments.freshcart.internal/v1/refunds/{order_id}', timeout=15)
resp.raise_for_status()
return resp.json()WARNING attempt 1/4 failed (HTTPError 503), retrying in 1.2s
WARNING attempt 2/4 failed (HTTPError 503), retrying in 2.3s
INFO refund 9284751 fetched successfully on attempt 3Dead letter queue — what happens after the last retry
A record that still fails after every retry cannot be allowed to crash the whole pipeline — 39 stores’ worth of good data shouldn’t be lost because one store’s file has one bad row. Instead, write the failed record and the reason it failed to a dead letter queue, and keep going.
import json
from datetime import datetime, timezone
from pathlib import Path
class DeadLetterQueue:
def __init__(self, path: str):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
def write(self, record: dict, error: Exception) -> None:
entry = {
'ts': datetime.now(timezone.utc).isoformat(),
'error': str(error),
'error_type': type(error).__name__,
'record': record,
}
with open(self.path, 'a') as f:
f.write(json.dumps(entry) + '\n') # NDJSON — one failure per line
dlq = DeadLetterQueue('/data/dlq/freshcart_orders.ndjson')
for order in orders:
try:
process_order(order)
except Exception as e:
dlq.write(order, e)
continue # the rest of the batch still gets processedValueError and wrap it with @with_retry(max_attempts=3). Watch the delays it prints — then change base_delay and see how the wait times scale.Structured Logging — Writing Logs You Can Actually Search at 3 AM
print() statements disappear the moment the terminal closes. When FreshCart’s nightly pipeline runs unattended on a schedule, the only record of what happened is whatever got logged — and a wall of unstructured text is nearly useless when you’re trying to find one failed store among forty at 3 AM.
Structured JSON logging — one setup, used everywhere
import logging
import json
import uuid
from datetime import datetime, timezone
class StructuredFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
'ts': datetime.now(timezone.utc).isoformat(),
'level': record.levelname,
'logger': record.name,
'msg': record.getMessage(),
'run_id': getattr(record, 'run_id', None),
}
if record.exc_info:
payload['exception'] = self.formatException(record.exc_info)
return json.dumps(payload)
def setup_pipeline_logging(run_id: str) -> logging.Logger:
logger = logging.getLogger('freshcart_pipeline')
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setFormatter(StructuredFormatter())
logger.addHandler(handler)
# Bind run_id onto every record automatically
old_factory = logging.getLogRecordFactory()
def factory(*args, **kwargs):
record = old_factory(*args, **kwargs)
record.run_id = run_id
return record
logging.setLogRecordFactory(factory)
return logger
RUN_ID = str(uuid.uuid4())
logger = setup_pipeline_logging(RUN_ID)
logger.info("Pipeline started"){"ts": "2026-08-22T03:00:04.112Z", "level": "INFO", "logger": "freshcart_pipeline", "msg": "Pipeline started", "run_id": "a1f9-..."}
{"ts": "2026-08-22T03:00:06.884Z", "level": "INFO", "logger": "freshcart_pipeline", "msg": "store_014: 48,200,000 rows read", "run_id": "a1f9-..."}
{"ts": "2026-08-22T03:00:41.203Z", "level": "WARNING", "logger": "freshcart_pipeline", "msg": "store_027: 12 rows sent to DLQ (invalid status)", "run_id": "a1f9-..."}Every line is now one JSON object — filterable by run_id in any log platform, greppable with jq on the command line, and alertable on (page someone whenever level == "ERROR" appears). A wall of plain text can’t do any of that.
What to log at each level
| Level | When to use it | FreshCart example |
|---|---|---|
| DEBUG | Verbose internal state, disabled in production | "Row 40219: raw amount field = '$12.50'" |
| INFO | Normal operation, confirms progress | "store_014: batch 3 of 12 loaded, 300,000 rows" |
| WARNING | Recovered automatically, but worth a look | "store_027: 12 rows sent to DLQ" |
| ERROR | Requires a human — pipeline failed or aborted | "store_009: connection to warehouse lost after 5 retries" |
PAYMENTS_API_TOKEN itself — even at DEBUG level. Logs get shipped to third-party platforms and kept for months; treat them as no more private than a public support ticket.StructuredFormatter above with a deliberately raised exception inside a try/except, calling logger.error("failed", exc_info=True). Confirm the full traceback shows up inside the JSON exception field, not as separate unstructured lines.Generators — Chaining the Whole Pipeline With Constant Memory
Part 02 solved memory for reading one file. But a real pipeline is read → validate → transform → write, and if any one of those steps builds a full list before passing it to the next, you’re back to holding the entire file in memory — just one step later than before. Generators fix this for the whole chain, not just the read.
List vs generator — the memory difference, made visible
import sys
def orders_as_list(n: int) -> list[dict]:
return [{'order_id': i, 'amount': i * 1.5} for i in range(n)]
def orders_as_generator(n: int):
for i in range(n):
yield {'order_id': i, 'amount': i * 1.5}
big_list = orders_as_list(1_000_000)
big_gen = orders_as_generator(1_000_000)
print(f"list: {sys.getsizeof(big_list):,} bytes")
print(f"generator: {sys.getsizeof(big_gen):,} bytes")list: 8,448,728 bytes
generator: 200 bytesThe generator is 200 bytes regardless of whether n is a thousand or a billion — it holds only its current position, not the data. The list holds every item, all at once, for as long as it exists.
Chaining generators into one lazy pipeline
def read_ndjson(filepath: str):
with open(filepath) as f:
for line in f:
yield json.loads(line)
def validate_orders(records):
for r in records:
if r.get('amount', 0) > 0 and r.get('status') in VALID_STATUSES:
yield r
else:
dlq.write(r, ValueError('failed validation'))
def transform_orders(records):
for r in records:
r['amount'] = round(float(r['amount']), 2)
r['status'] = r['status'].strip().lower()
yield r
def batch_records(records, batch_size: int = 5_000):
batch = []
for r in records:
batch.append(r)
if len(batch) >= batch_size:
yield batch
batch = []
if batch:
yield batch
def run_pipeline(filepath: str):
raw = read_ndjson(filepath)
valid = validate_orders(raw)
clean = transform_orders(valid)
for batch in batch_records(clean):
write_batch_to_warehouse(batch)
logger.info("Batch written: %d rows", len(batch))INFO Batch written: 5000 rows
INFO Batch written: 5000 rows
INFO Batch written: 3120 rows
# at no point did the process hold more than one 5,000-row batch in memory —
# not the raw file, not the validated set, not the transformed setNothing runs until write_batch_to_warehouse actually pulls a batch — at that point, one record flows through read_ndjson → validate_orders → transform_orders → into the current batch, then the next record does the same. The chain is lazy end to end.
Generator expressions — the same idea, inline
# List comprehension — builds the whole list immediately
amounts_list = [o['amount'] for o in orders]
# Generator expression — identical syntax, but lazy (note: no brackets)
amounts_gen = (o['amount'] for o in orders)
total = sum(o['amount'] for o in orders if o['status'] == 'delivered')
# sum() pulls one amount at a time — the filtered sequence never fully materialisesrun_pipeline function above and add a print() inside validate_orders right before each yield. Run it against a small file and watch the print statements interleave with the "Batch written" logs — proof that validation, transformation, and writing are all happening one record at a time, not in separate complete passes.Configuration — Never Hardcode a Secret Into a Pipeline File
The PAYMENTS_API_TOKEN used back in Part 03 has to come from somewhere. Hardcoding it directly in the script means it ends up in Git history the moment the file is committed — recoverable forever, even after you delete the line. Every credential and every environment-specific value belongs outside the code.
The manual way — and why it fails quietly
import os
db_url = os.environ.get('DB_URL') # returns None if missing — no error!
batch_size = int(os.environ.get('BATCH_SIZE', '5000'))
# A typo like DB_URl instead of DB_URL doesn't raise anything —
# db_url is just None, and the failure shows up much later, confusingly,
# wherever db_url is first used.>>> db_url
None
# no error here — the crash happens minutes later inside psycopg2.connect(None),
# far from where the actual mistake was madePydantic settings — fail loudly, at startup, with a clear message
from pydantic_settings import BaseSettings
class Config(BaseSettings):
db_url: str
payments_api_token: str
batch_size: int = 5_000
max_retries: int = 5
dlq_path: str = '/data/dlq/freshcart_orders.ndjson'
class Config:
env_file = '.env'
config = Config() # raises immediately if a required field is missingpydantic_core._pydantic_core.ValidationError: 1 validation error for Config
db_url
field required (type=value_error.missing)
# fails in the first line of the script, with the exact missing field named —
# not three functions deep at 3 AMSecrets managers — one step further for production
Environment variables are a good default, but they still mean the secret is sitting in plaintext somewhere (a .env file, a CI variable). Cloud secret managers store it encrypted and log every access:
import boto3
import json
def get_secret_aws(secret_name: str) -> dict:
client = boto3.client('secretsmanager')
response = client.get_secret_value(SecretId=secret_name)
return json.loads(response['SecretString'])
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
def get_secret_azure(vault_url: str, secret_name: str) -> str:
credential = DefaultAzureCredential()
client = SecretClient(vault_url=vault_url, credential=credential)
return client.get_secret(secret_name).valueTestable Code — Separating Business Logic From I/O
Pipeline code that can’t be unit tested is pipeline code that gets deployed with bugs. The usual reason it can’t be tested is that the business logic — the actual rules about what makes an order valid — is entangled with the I/O: the database connection, the file read, the API call.
Untestable vs testable — the same logic, restructured
def process_orders(): # no inputs — depends on external state
conn = psycopg2.connect(os.environ['DB']) # I/O
df = pd.read_csv('orders.csv') # I/O
df = df[df['amount'] > 0]
df['status'] = df['status'].str.lower()
df.to_sql('orders', conn, if_exists='append') # I/O
# Cannot test this without: a real database, a file on disk, env vars set.
# Cannot test edge cases without touching all three.def clean_orders(df: pd.DataFrame) -> pd.DataFrame:
"""Pure function: DataFrame in, DataFrame out. No I/O, no side effects."""
df = df.copy()
df = df[df['amount'] > 0]
df['status'] = df['status'].str.lower().str.strip()
valid_statuses = {'placed', 'confirmed', 'delivered', 'cancelled'}
df = df[df['status'].isin(valid_statuses)]
return df
def load_orders_from_csv(filepath: str) -> pd.DataFrame: # I/O only
return pd.read_csv(filepath)
def write_orders_to_db(df: pd.DataFrame, conn) -> None: # I/O only
df.to_sql('silver_orders', conn, if_exists='append', index=False)
def run_orders_pipeline(filepath: str, conn) -> None: # orchestration only
raw = load_orders_from_csv(filepath)
clean = clean_orders(raw) # the one line that matters, and it's testable
write_orders_to_db(clean, conn)Unit tests — no database, no file, just Python
import pandas as pd
def test_clean_orders_removes_negative_amounts():
input_df = pd.DataFrame({
'order_id': [1, 2, 3], 'amount': [380.0, -50.0, 0.0],
'status': ['delivered', 'placed', 'cancelled'],
})
result = clean_orders(input_df)
assert len(result) == 1
assert result.iloc[0]['order_id'] == 1
def test_clean_orders_removes_invalid_status():
input_df = pd.DataFrame({
'order_id': [1, 2], 'amount': [380.0, 220.0],
'status': ['delivered', 'deliverd'], # typo in second row
})
result = clean_orders(input_df)
assert len(result) == 1$ pytest test_orders_clean.py -v
test_orders_clean.py::test_clean_orders_removes_negative_amounts PASSED
test_orders_clean.py::test_clean_orders_removes_invalid_status PASSED
============================== 2 passed in 0.04s ==============================Mocking — testing the I/O layer without a real API or database
from unittest.mock import patch, MagicMock
@patch('requests.get')
def test_fetch_refund_success(mock_get):
mock_response = MagicMock()
mock_response.json.return_value = {'order_id': 9284751, 'refunded': False}
mock_get.return_value = mock_response
result = fetch_refund(9284751)
assert result['order_id'] == 9284751
mock_get.assert_called_once()
@patch('requests.get')
def test_fetch_refund_handles_timeout(mock_get):
mock_get.side_effect = requests.exceptions.Timeout("Connection timed out")
with pytest.raises(requests.exceptions.Timeout):
fetch_refund(9284751)process_orders() above and split it into a pure function plus two thin I/O wrappers yourself, before scrolling back up to see how this module did it. The exercise is in noticing which lines are “business rule” and which are “talks to something external”.Type Hints and Pydantic — Catching Bad Data at the Boundary
Python is dynamically typed — a function that expects an integer and receives a string does not fail immediately, it fails later, wherever that string first gets used in a way integers behave differently. Type hints document intent; Pydantic actually enforces it, at the exact point data enters your pipeline.
Type hints — documentation a reader (and a linter) can check
from typing import Iterator
def process_batch(records: list[dict], batch_size: int = 10_000) -> Iterator[list[dict]]:
...
def fetch_page(cursor: str | None, start_date: str) -> tuple[list[dict], str | None]:
...Pydantic — validated models that reject bad data on the way in
from decimal import Decimal
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, validator, Field
class OrderStatus(str, Enum):
PLACED = 'placed'; CONFIRMED = 'confirmed'
DELIVERED = 'delivered'; CANCELLED = 'cancelled'
class Order(BaseModel):
order_id: int = Field(..., gt=0)
store_id: int = Field(..., gt=0)
amount: Decimal = Field(..., gt=0, decimal_places=2)
status: OrderStatus
created_at: datetime
@validator('amount', pre=True)
def coerce_amount(cls, v):
if isinstance(v, str):
v = v.replace('$', '').replace(',', '').strip()
return Decimal(str(v))>>> Order(order_id=9284751, store_id=14, amount='$380.00',
... status='delivered', created_at='2026-03-21T20:14:32-04:00')
Order(order_id=9284751, store_id=14, amount=Decimal('380.00'),
status=<OrderStatus.DELIVERED: 'delivered'>, created_at=datetime(...))
>>> Order(order_id=-5, store_id=14, amount='380.00', status='delivered', created_at='2026-03-21')
pydantic.error_wrappers.ValidationError: 1 validation error for Order
order_id
ensure this value is greater than 0 (type=value_error.number.not_gt)def parse_orders(raw_records: list[dict]) -> tuple[list[Order], list[dict]]:
valid, failed = [], []
for raw in raw_records:
try:
valid.append(Order(**raw))
except ValueError as e:
failed.append({'record': raw, 'error': str(e)})
return valid, failedFive Misconceptions About Python for Data Engineering
Assembling the Complete FreshCart Nightly Orders Pipeline
Every part of this module built one piece. Here is what it looks like assembled into the pipeline that actually runs at 2 AM against all 40 stores — config and logging from Parts 05 and 07, the chunked reader from Part 02, the generator chain from Part 06, validation from Part 09, and retry-protected DLQ handling from Part 04.
import os, uuid, logging
from pydantic_settings import BaseSettings
class Config(BaseSettings):
db_url: str
batch_size: int = 5_000
max_retries: int = 5
dlq_path: str = '/data/dlq/freshcart_orders.ndjson'
class Config:
env_file = '.env'
config = Config() # fails loudly here if anything required is missing
RUN_ID = str(uuid.uuid4())
logger = setup_pipeline_logging(RUN_ID) # from Part 05dlq = DeadLetterQueue(config.dlq_path) # from Part 04
def read_store_export(filepath: str):
for chunk in pd.read_csv(filepath, chunksize=100_000):
for record in chunk.to_dict('records'):
yield record
def validate_and_parse(records):
for r in records:
try:
yield Order(**r) # Pydantic model from Part 09
except ValueError as e:
dlq.write(r, e)@with_retry(max_attempts=config.max_retries)
def write_batch(batch: list[Order], conn) -> None:
rows = [(o.order_id, o.store_id, float(o.amount), o.status.value, o.created_at) for o in batch]
with conn.cursor() as cur:
execute_values(cur, """
INSERT INTO silver.orders (order_id, store_id, amount, status, created_at)
VALUES %s ON CONFLICT (order_id) DO UPDATE SET status = EXCLUDED.status
""", rows)
conn.commit()
def batch_and_load(orders, conn) -> int:
batch, loaded = [], 0
for order in orders:
batch.append(order)
if len(batch) >= config.batch_size:
write_batch(batch, conn)
loaded += len(batch)
logger.info("Batch loaded: %d total rows written", loaded)
batch = []
if batch:
write_batch(batch, conn)
loaded += len(batch)
return loadeddef run(store_files: list[str]) -> None:
logger.info("Pipeline started for %d stores", len(store_files))
start = time.monotonic()
total_loaded = 0
with psycopg2.connect(config.db_url) as conn:
for filepath in store_files:
raw = read_store_export(filepath)
valid = validate_and_parse(raw)
loaded = batch_and_load(valid, conn)
total_loaded += loaded
logger.info("%s: %d rows loaded", filepath, loaded)
duration = time.monotonic() - start
logger.info("Pipeline complete | total_loaded=%d duration=%.1fs", total_loaded, duration)
if __name__ == '__main__':
run(store_files=glob.glob('/data/freshcart/store_*.csv')){"level": "INFO", "msg": "Pipeline started for 40 stores", "run_id": "a1f9-..."}
{"level": "INFO", "msg": "/data/freshcart/store_001.csv: 812,400 rows loaded"}
{"level": "WARNING", "msg": "store_027: 12 rows sent to DLQ (invalid status)"}
...
{"level": "INFO", "msg": "Pipeline complete | total_loaded=31,840,220 duration=642.8s"}5 Interview Questions — With Complete Answers
Mistakes Beginners Make Constantly
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓Never load large files entirely into memory. Use pd.read_csv(chunksize=100_000) to process in chunks, or use PyArrow datasets for columnar projection and predicate pushdown. Memory usage should be constant regardless of file size.
- ✓API calls need three things beyond a simple GET: authentication read from environment variables (never hardcoded), pagination that follows the API’s cursor or next-URL, and rate-limit handling that respects 429 responses. This module covers the minimum version — Module 18 covers all three in much more depth.
- ✓Distinguish transient from permanent errors before deciding whether to retry. Transient errors (timeouts, 503, connection reset) should be retried with exponential backoff and jitter. Permanent errors (validation failures, 401, 404) should fail immediately — retrying wastes time and can cause harm.
- ✓Exponential backoff with jitter prevents thundering herds: multiple pipeline instances that fail simultaneously retry at slightly different times, spreading load instead of all hitting the recovered system at once.
- ✓Structured logging (JSON output with defined fields) makes logs searchable and alertable in log management tools. Every log entry should include a run_id and relevant metrics. Never log PII or secrets. Never use print() in pipeline code.
- ✓Generators (functions using yield) process arbitrarily large data with constant memory. Chain multiple generators together to build a lazy pipeline where data flows one record at a time from source to sink — this matters for maintainability, not just for huge files.
- ✓Read secrets from environment variables or cloud secret managers, validated through a settings class like Pydantic’s BaseSettings. Fail loudly on missing required config at startup, rather than failing mysteriously deep inside the pipeline later.
- ✓Separate business logic from I/O. Pure transformation functions take data in, return data out, with no file reads or database connections. These are trivially unit-testable. I/O functions are thin wrappers. Orchestration wires them together.
- ✓Pydantic models validate and parse data at the boundary between external systems and your pipeline. Type hints alone document intent but do not enforce it at runtime — Pydantic (or an equivalent) is what actually rejects bad data on the way in.
- ✓Dead letter queues are essential for production pipelines. When a record fails all retries or validation, write it to a DLQ file with the error context and keep processing the rest. Never silently discard failed records and never halt an entire pipeline because one record is bad.
What comes next
Module 15 covers SQL at the data engineering level — window functions, complex CTEs, deduplication patterns, and the advanced queries that every real DE interview actually tests.
Module 15 → SQL for Data Engineers — Beyond the BasicsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.