
Imagine you're running a data pipeline that processes customer transaction records every hour. The pipeline reads from a database, joins the transactions against a product catalog, applies some business logic, and writes the results to a reporting table. The product catalog has 50,000 rows and barely changes — maybe once a week. Yet your pipeline is re-reading that entire catalog from the database, transferring it across the network, and loading it into memory every single hour. Across a full day, that's 24 identical fetches of the same data. You're burning compute time, network bandwidth, and money on work that doesn't need to happen.
This is the problem that caching solves. A cache is a layer of storage that holds a copy of data or a computation result so it can be retrieved faster the next time it's needed, without repeating the original work. In data pipelines, caching shows up in several forms — cached datasets, cached transformation results, cached API responses, and even cached database query plans. Understanding when and how to cache is one of the most impactful skills you can develop as a data engineer, because it directly reduces both latency (how long each run takes) and cost (how much compute you consume).
By the end of this lesson, you'll understand the core mechanics of pipeline caching, be able to identify where caching provides genuine value in a pipeline, and know how to implement several practical caching patterns. You'll also understand the most dangerous caching failure mode — serving stale data — and how to guard against it.
What you'll learn:
You should be comfortable reading Python code and understand what an ETL (Extract, Transform, Load) pipeline does at a high level. You don't need to be a Python expert, but you should know what a function, a dictionary, and a file are. If you've ever written a script that reads data from one place and writes it somewhere else, you're ready.
Before we talk about solutions, let's be precise about the problem. Data pipelines are composed of stages — discrete steps that each perform some operation on data. A typical pipeline might look like this:
[Extract from DB] → [Join with Reference Data] → [Apply Business Rules] → [Load to Warehouse]
Each stage has a cost: latency (time), compute (CPU/memory), and sometimes money (API calls with per-request pricing). Now consider that most pipelines run on a schedule — hourly, daily, whatever the use case demands. And here's the uncomfortable truth: in most pipelines, a significant portion of the data being processed hasn't changed since the last run.
This creates two categories of redundant work:
Caching is the technique of breaking that cycle. Instead of repeating expensive operations, you store the result and check the stored version first. The first run pays the full cost; subsequent runs pay only the cost of checking the cache.
Not all caching is the same. Let's walk through the three patterns you'll use most often, with a clear explanation of what each one does and when it applies.
Result caching means storing the complete output of a pipeline stage so you can skip the entire stage on the next run if the input hasn't changed. This is the most powerful form of caching but also the most coarse-grained.
Think of it like a chef who preps a large batch of sauce every Sunday. Instead of making sauce from scratch every night, the kitchen grabs the already-prepared batch from the fridge. The work was done once; the benefit is reaped all week.
In a pipeline, you might result-cache an entire dataset after a heavy aggregation step:
import os
import json
import hashlib
import pickle
from datetime import datetime, timedelta
def load_sales_aggregates(raw_data_path, cache_path="cache/sales_aggregates.pkl"):
"""
Compute daily sales aggregates from raw transaction data.
Uses result caching to avoid re-aggregating if the source file hasn't changed.
"""
# Generate a fingerprint of the source data
source_checksum = compute_file_checksum(raw_data_path)
# Check if a valid cache exists for this input
if cache_is_valid(cache_path, source_checksum):
print("Cache hit: loading pre-computed sales aggregates")
with open(cache_path, "rb") as f:
return pickle.load(f)
# Cache miss: do the real work
print("Cache miss: computing sales aggregates from raw data")
raw_data = read_large_csv(raw_data_path)
aggregates = compute_aggregates(raw_data) # expensive operation
# Save result to cache for next time
save_to_cache(cache_path, aggregates, source_checksum)
return aggregates
def compute_file_checksum(filepath):
"""Compute MD5 hash of file contents to detect changes."""
hasher = hashlib.md5()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
hasher.update(chunk)
return hasher.hexdigest()
The key insight here is the checksum: you're not just caching blindly. You're computing a fingerprint of the input and storing it alongside the cached result. When you come back later, you check whether the fingerprint still matches. If it does, nothing has changed and the cache is safe to use. If it doesn't, the source data changed and you need to recompute.
Fragment caching means caching specific sub-components of your pipeline, rather than entire stage outputs. This is useful when part of your input changes frequently but another part stays stable.
Returning to our transaction pipeline: the transaction data changes with every run, but the product catalog is nearly static. Fragment caching lets you cache just the product catalog independently.
import time
import requests
import json
CATALOG_CACHE_FILE = "cache/product_catalog.json"
CATALOG_TTL_SECONDS = 3600 * 6 # Cache is valid for 6 hours
def get_product_catalog():
"""
Fetch the product catalog from the internal API.
Uses TTL-based fragment caching to avoid hammering the API every pipeline run.
"""
# Check if we have a fresh cached version
if os.path.exists(CATALOG_CACHE_FILE):
cache_age = time.time() - os.path.getmtime(CATALOG_CACHE_FILE)
if cache_age < CATALOG_TTL_SECONDS:
print(f"Catalog cache is {cache_age:.0f}s old — using cached version")
with open(CATALOG_CACHE_FILE, "r") as f:
return json.load(f)
else:
print(f"Catalog cache is {cache_age:.0f}s old — expired, fetching fresh copy")
# Cache is missing or expired — fetch from API
response = requests.get("https://internal-api.company.com/v2/products")
response.raise_for_status()
catalog = response.json()
# Write fresh data to cache
with open(CATALOG_CACHE_FILE, "w") as f:
json.dump(catalog, f)
print(f"Fetched {len(catalog)} products from API and cached result")
return catalog
This example introduces the TTL (Time-To-Live) pattern. Rather than checking whether the source has changed (which would require querying the API anyway), you decide that data older than 6 hours is "stale enough" that you should refresh it. TTL is simpler to implement than checksum-based validation but is less precise — you might serve slightly outdated data for up to 6 hours if the catalog changes during that window.
Memoization is caching at the function level — you store the output of a pure function keyed by its inputs, and skip re-execution if you've seen those inputs before in the same run. This is more of an in-memory, within-run optimization rather than a cross-run persistence strategy.
from functools import lru_cache
@lru_cache(maxsize=1024)
def apply_discount_rules(product_category, customer_tier, purchase_month):
"""
Compute the discount rate for a given product/customer combination.
This function runs business logic that's pure (same inputs = same output)
but moderately expensive. With millions of transactions, the same combinations
repeat frequently. lru_cache avoids redundant computation within a pipeline run.
"""
# Simulate complex business rule evaluation
base_rate = lookup_base_discount(product_category)
tier_multiplier = get_tier_multiplier(customer_tier)
seasonal_adjustment = get_seasonal_factor(purchase_month)
return base_rate * tier_multiplier * seasonal_adjustment
Python's built-in functools.lru_cache decorator handles this automatically. The maxsize=1024 means the cache holds the 1,024 most recent unique input combinations. When it's full, the least-recently-used entry is evicted to make room.
Tip: Memoization only works on pure functions — functions whose output depends solely on their inputs, with no side effects (no database writes, no API calls, no modifying global state). If your function has side effects, memoizing it will cause those effects to silently stop happening after the first call.
There's a famous saying in computer science: "There are only two hard things in computer science: cache invalidation and naming things." Cache invalidation means deciding when a cached value is no longer trustworthy and must be discarded.
Get this wrong in one direction and you serve stale data — your reports show last week's numbers as if they're current. Get it wrong in the other direction and you invalidate your cache constantly, eliminating any performance benefit.
The three main approaches form a spectrum from "simple but imprecise" to "precise but complex":
1. TTL (Time-To-Live): Expire the cache after a fixed duration. Simple. Works well for data that updates on a known schedule.
2. Event-based invalidation: Invalidate the cache when a specific event occurs (e.g., a new file lands in S3, a database table is updated). More precise than TTL but requires your pipeline to receive or check for those events.
3. Checksum/fingerprint-based invalidation: Hash the source data and compare it to the hash stored with the cache. Only invalidate if the hash differs. The most precise approach, but requires reading the source to compute the hash (which has its own cost).
Let's build a small helper that implements all three in a reusable way:
import hashlib
import json
import os
import time
from typing import Any, Optional
class PipelineCache:
"""
A simple file-based cache with support for TTL and checksum validation.
Suitable for development and small-scale pipelines.
"""
def __init__(self, cache_dir: str = "pipeline_cache"):
self.cache_dir = cache_dir
os.makedirs(cache_dir, exist_ok=True)
def _cache_path(self, key: str) -> str:
safe_key = hashlib.md5(key.encode()).hexdigest()
return os.path.join(self.cache_dir, f"{safe_key}.cache")
def get(self, key: str, ttl_seconds: Optional[int] = None,
source_checksum: Optional[str] = None) -> Optional[Any]:
"""
Retrieve a cached value.
Returns None if the cache is missing, expired, or invalidated by checksum.
"""
path = self._cache_path(key)
if not os.path.exists(path):
return None # Cache miss
with open(path, "r") as f:
entry = json.load(f)
# TTL check
if ttl_seconds is not None:
age = time.time() - entry["timestamp"]
if age > ttl_seconds:
print(f" Cache expired for '{key}' (age: {age:.0f}s > TTL: {ttl_seconds}s)")
return None
# Checksum check
if source_checksum is not None:
if entry.get("source_checksum") != source_checksum:
print(f" Cache invalidated for '{key}' — source data changed")
return None
print(f" Cache hit for '{key}'")
return entry["value"]
def set(self, key: str, value: Any, source_checksum: Optional[str] = None):
"""Store a value in the cache."""
path = self._cache_path(key)
entry = {
"timestamp": time.time(),
"source_checksum": source_checksum,
"value": value
}
with open(path, "w") as f:
json.dump(entry, f)
print(f" Cached result for '{key}'")
Warning: This file-based cache stores values as JSON, which means it won't work with non-serializable Python objects like pandas DataFrames or numpy arrays out of the box. For those, use
pickleinstead ofjson, but be aware that pickle files can execute arbitrary code when loaded — only unpickle data you produced yourself.
In production pipelines, you rarely rely on a single caching mechanism. Instead, you use layers that match the access pattern and cost of each piece of data:
| Layer | Technology | Use Case | Typical TTL |
|---|---|---|---|
| In-process memory | Python dict / lru_cache | Within-run deduplication | Duration of a single run |
| Local disk | Files, SQLite | Moderate-size datasets, dev/testing | Hours to days |
| Distributed cache | Redis, Memcached | Cross-worker shared state in distributed pipelines | Minutes to hours |
| Object storage | S3, GCS | Large datasets, long-lived intermediate results | Days to weeks |
The logic is simple: check the fastest layer first. If you miss there, check the next layer. If you miss everywhere, compute the result and populate all layers on the way out.
def get_region_revenue_summary(region: str, month: str,
local_cache: PipelineCache,
db_conn) -> dict:
"""
Retrieve regional revenue summary, checking cache layers before querying the DB.
Layers:
1. Local file cache (fast, free)
2. Live database query (slow, costs money)
"""
cache_key = f"region_revenue_{region}_{month}"
# Layer 1: Check local cache (6-hour TTL — reports refresh twice daily)
result = local_cache.get(cache_key, ttl_seconds=21600)
if result is not None:
return result
# Layer 2: Database query (expensive)
print(f" Querying database for {region} revenue in {month}...")
query = """
SELECT
region,
SUM(transaction_amount) as total_revenue,
COUNT(DISTINCT customer_id) as unique_customers,
AVG(transaction_amount) as avg_order_value
FROM transactions
WHERE region = %s
AND DATE_TRUNC('month', transaction_date) = %s
GROUP BY region
"""
cursor = db_conn.cursor()
cursor.execute(query, (region, month))
row = cursor.fetchone()
result = {
"region": row[0],
"total_revenue": float(row[1]),
"unique_customers": int(row[2]),
"avg_order_value": float(row[3])
}
# Populate the local cache before returning
local_cache.set(cache_key, result)
return result
Let's build something end-to-end. Your task is to extend the PipelineCache class to handle a real scenario.
Scenario: You're building a pipeline that processes daily sales data for 50 regional offices. The pipeline needs a currency exchange rate table that updates once per day. Currently, it fetches the exchange rates from a third-party API on every single pipeline execution (which runs every 15 minutes). The API charges per request.
Your task:
Write a function get_exchange_rates(api_url, cache) that:
Write a function convert_regional_sales(sales_records, target_currency, cache) that:
get_exchange_rates to get rates (cache will handle deduplication)lru_cache on the actual conversion math to avoid redundant calculations per currency pairTest your implementation by simulating 10 pipeline runs (use a loop) and tracking how many API calls are actually made. You should see exactly 1 API call across all 10 runs.
Starter code:
from functools import lru_cache
import time
# Use the PipelineCache class from earlier in this lesson
@lru_cache(maxsize=256)
def apply_exchange_rate(amount_usd, rate_to_target):
"""
Convert a USD amount to target currency.
Pure function — safe to memoize.
"""
return round(amount_usd * rate_to_target, 2)
def get_exchange_rates(api_url: str, cache: PipelineCache) -> dict:
# Your code here
pass
def convert_regional_sales(sales_records: list,
target_currency: str,
cache: PipelineCache) -> list:
# Your code here
pass
# Simulate 10 pipeline runs
api_call_count = 0
cache = PipelineCache("exercise_cache")
for run in range(10):
print(f"\n--- Pipeline Run {run + 1} ---")
rates = get_exchange_rates("https://api.exchangerates.example.com/latest", cache)
# sales_records would come from your data source
# converted = convert_regional_sales(sample_sales, "EUR", cache)
print(f"\nTotal API calls made: {api_call_count}")
print(f"Expected: 1")
Mistake 1: Caching mutable objects and modifying them
If you cache a Python dictionary and then modify it elsewhere in your code, you've silently corrupted your cache — the cached object and the object you modified are the same object in memory. Always return a copy from your cache:
# Dangerous
return self._memory_cache[key]
# Safe
import copy
return copy.deepcopy(self._memory_cache[key])
Mistake 2: TTL that's too long for the data's actual change frequency
If your product catalog updates every 4 hours and your TTL is 6 hours, you'll sometimes serve data that's 2 hours out of date. Know your data's update cadence and set your TTL to something shorter than that, not longer.
Mistake 3: Not accounting for cache storage growth
File-based and disk caches grow indefinitely unless you clean them up. Implement a cache eviction policy or use a tool like Redis that has built-in eviction. At minimum, add a scheduled job that deletes cache files older than a certain age.
Mistake 4: Caching failures
Never cache an error result. If your API call fails or your database query throws an exception, do not write that failure to the cache. The next pipeline run should retry the real operation, not load a cached failure.
try:
result = fetch_from_api(url)
cache.set(key, result) # Only cache on success
return result
except Exception as e:
print(f"API call failed: {e} — not caching this failure")
raise
Mistake 5: Using wall-clock time for cache keys in test environments
If you write tests that run quickly, TTL-based caches will never expire during your test suite. Inject your time source as a parameter rather than calling time.time() directly inside the cache logic. This makes your code testable.
Let's consolidate what you've built up in this lesson.
Caching in data pipelines solves the problem of redundant work — re-reading, re-fetching, or re-computing data that hasn't changed. The cost of redundant work compounds across every scheduled run, making caching one of the highest-leverage optimizations in data engineering.
The three core patterns are:
The most important discipline is cache invalidation: knowing when your cached data is no longer trustworthy. Use TTL for data with predictable update schedules. Use checksums/fingerprints for data where exact freshness matters. Use event-based invalidation when you have a reliable signal that the source has changed.
A layered architecture — memory → disk → distributed cache → object storage — gives you the best of all worlds: speed, capacity, and shareability.
Where to go next:
Learning Path: Data Pipeline Fundamentals