Skip to main content

Pandas Caching

When working with large datasets in Pandas, you might notice that certain operations take a significant amount of time, especially when they're repeated multiple times in your code. This is where caching can make a substantial difference in your application's performance.

What is Caching in Pandas?​

Caching is the process of storing the results of expensive operations so they can be quickly retrieved later rather than recalculated. While Pandas doesn't have a built-in caching mechanism labeled as such, there are several techniques and tools you can use to implement caching for your Pandas operations.

Why Use Caching?​

  • Improved performance: Avoid recalculating the same results multiple times
  • Reduced computation time: Especially valuable for complex operations on large datasets
  • Better user experience: Faster response times for interactive applications
  • Resource efficiency: Save computational resources when working with limited hardware

Basic Caching Techniques​

1. Variable Assignment Caching​

The simplest form of caching in Pandas is to store the results of operations in variables:

python
import pandas as pd
import time

# Create a sample dataframe
df = pd.DataFrame({
'A': range(1000000),
'B': range(1000000)
})

# Without caching
start_time = time.time()
for i in range(5):
result = df[df['A'] > 500000].mean()
print(f"Iteration {i+1}: Mean values", result)
print(f"Time without caching: {time.time() - start_time:.4f} seconds\n")

# With caching
start_time = time.time()
filtered_df = df[df['A'] > 500000] # Cache the filtered dataframe
for i in range(5):
result = filtered_df.mean()
print(f"Iteration {i+1}: Mean values", result)
print(f"Time with caching: {time.time() - start_time:.4f} seconds")

Output:

Iteration 1: Mean values A    750000.5
B 750000.5
dtype: float64
Iteration 2: Mean values A 750000.5
B 750000.5
dtype: float64
...
Time without caching: 0.2793 seconds

Iteration 1: Mean values A 750000.5
B 750000.5
dtype: float64
Iteration 2: Mean values A 750000.5
B 750000.5
dtype: float64
...
Time with caching: 0.0102 seconds

2. Using @lru_cache for Function Results​

Python's built-in functools.lru_cache decorator can help cache function return values:

python
import pandas as pd
import time
from functools import lru_cache

df = pd.DataFrame({
'A': range(1000000),
'B': range(1000000)
})

# Function without caching
def get_filtered_mean(threshold):
return df[df['A'] > threshold].mean()

# Function with caching
@lru_cache(maxsize=128)
def get_filtered_mean_cached(threshold):
return df[df['A'] > threshold].mean()

# Test without caching
start_time = time.time()
for i in range(5):
for threshold in [300000, 500000, 700000]:
result = get_filtered_mean(threshold)
print(f"Time without caching: {time.time() - start_time:.4f} seconds\n")

# Test with caching
start_time = time.time()
for i in range(5):
for threshold in [300000, 500000, 700000]:
result = get_filtered_mean_cached(threshold)
print(f"Time with caching: {time.time() - start_time:.4f} seconds")

Output:

Time without caching: 0.4538 seconds

Time with caching: 0.0321 seconds

Advanced Caching Methods​

1. Caching with Pandas Eval and Query​

When working with complex expressions, using pd.eval and df.query with caching can boost performance:

python
import pandas as pd
import numpy as np
import time

# Create a large dataframe
df = pd.DataFrame({
'A': np.random.randn(1000000),
'B': np.random.randn(1000000),
'C': np.random.randn(1000000),
'D': np.random.choice(['X', 'Y', 'Z'], 1000000)
})

# Cache the complex expressions
expr = '(A > 0) & (B < 0) & (C > 0.5) & (D == "X")'
query_cache = df.query(expr)

# Time comparison
start_time = time.time()
for i in range(5):
result1 = df[(df.A > 0) & (df.B < 0) & (df.C > 0.5) & (df.D == 'X')].mean()
print(f"Time with traditional filtering: {time.time() - start_time:.4f} seconds")

start_time = time.time()
for i in range(5):
result2 = df.query(expr).mean() # Still evaluates each time
print(f"Time with query (no cache): {time.time() - start_time:.4f} seconds")

start_time = time.time()
for i in range(5):
result3 = query_cache.mean() # Uses cached result
print(f"Time with cached query: {time.time() - start_time:.4f} seconds")

Output:

Time with traditional filtering: 0.8924 seconds
Time with query (no cache): 0.6513 seconds
Time with cached query: 0.0064 seconds

2. Using joblib for Disk-Based Caching​

For very large datasets or results that should persist between program runs, joblib offers disk-based caching:

python
import pandas as pd
import numpy as np
import time
from joblib import Memory

# Setup disk cache
memory = Memory(location="./.cache", verbose=0)

@memory.cache
def process_large_data(size):
# Simulate processing a large dataframe
print("Computing from scratch...")
df = pd.DataFrame({
'A': np.random.randn(size),
'B': np.random.randn(size)
})
# Perform expensive operation
result = df.groupby(pd.cut(df['A'], bins=100)).mean()
time.sleep(2) # Simulate complex calculation
return result

# First call - will compute and cache
start_time = time.time()
result1 = process_large_data(1000000)
print(f"First call: {time.time() - start_time:.4f} seconds")

# Second call - should use cache
start_time = time.time()
result2 = process_large_data(1000000)
print(f"Second call: {time.time() - start_time:.4f} seconds")

# Different parameter - will compute and cache new result
start_time = time.time()
result3 = process_large_data(500000)
print(f"Different parameter: {time.time() - start_time:.4f} seconds")

Output:

Computing from scratch...
First call: 4.2367 seconds
Second call: 0.0982 seconds
Computing from scratch...
Different parameter: 3.5241 seconds

Real-World Application: Caching in a Data Analysis Pipeline​

Let's see how caching can improve a practical data analysis scenario:

python
import pandas as pd
import numpy as np
import time
from functools import lru_cache

# Simulate a dataset of customer transactions
np.random.seed(42)
dates = pd.date_range('2022-01-01', '2022-12-31')
transactions = pd.DataFrame({
'date': np.random.choice(dates, size=1000000),
'customer_id': np.random.randint(1, 10001, size=1000000),
'amount': np.random.normal(loc=100, scale=50, size=1000000),
'category': np.random.choice(['Grocery', 'Electronics', 'Clothing', 'Services', 'Other'], 1000000)
})

# Add date components for analysis
transactions['month'] = transactions['date'].dt.month
transactions['weekday'] = transactions['date'].dt.weekday

# Define analysis functions with and without caching

def monthly_stats_no_cache(df, month):
"""Calculate monthly statistics without caching"""
monthly_data = df[df['month'] == month]
results = {
'total_sales': monthly_data['amount'].sum(),
'mean_transaction': monthly_data['amount'].mean(),
'transaction_count': len(monthly_data),
'customers': monthly_data['customer_id'].nunique(),
'by_category': monthly_data.groupby('category')['amount'].agg(['sum', 'mean', 'count'])
}
return results

@lru_cache(maxsize=12) # Only 12 possible months
def monthly_stats_cached(month):
"""Calculate monthly statistics with caching"""
monthly_data = transactions[transactions['month'] == month]
results = {
'total_sales': monthly_data['amount'].sum(),
'mean_transaction': monthly_data['amount'].mean(),
'transaction_count': len(monthly_data),
'customers': monthly_data['customer_id'].nunique(),
'by_category': monthly_data.groupby('category')['amount'].agg(['sum', 'mean', 'count'])
}
return results

# Create a cached version of common dataframe operations
month_groups = {month: transactions[transactions['month'] == month] for month in range(1, 13)}

# Simulate a dashboard that needs to run multiple analyses
def run_dashboard_simulation():
print("Running dashboard simulation...")

print("\n1. Without caching:")
start_time = time.time()
for _ in range(3): # Simulate refreshing dashboard 3 times
for month in [3, 6, 9, 12]: # Quarterly analysis
stats = monthly_stats_no_cache(transactions, month)
print(f"Month {month}: ${stats['total_sales']:.2f} total from {stats['customers']} customers")
print(f"Time without caching: {time.time() - start_time:.4f} seconds")

print("\n2. With function caching:")
start_time = time.time()
for _ in range(3): # Simulate refreshing dashboard 3 times
for month in [3, 6, 9, 12]: # Quarterly analysis
stats = monthly_stats_cached(month)
print(f"Month {month}: ${stats['total_sales']:.2f} total from {stats['customers']} customers")
print(f"Time with function caching: {time.time() - start_time:.4f} seconds")

print("\n3. With dataframe caching:")
start_time = time.time()
for _ in range(3): # Simulate refreshing dashboard 3 times
for month in [3, 6, 9, 12]: # Quarterly analysis
monthly_data = month_groups[month]
results = {
'total_sales': monthly_data['amount'].sum(),
'customers': monthly_data['customer_id'].nunique()
}
print(f"Month {month}: ${results['total_sales']:.2f} total from {results['customers']} customers")
print(f"Time with dataframe caching: {time.time() - start_time:.4f} seconds")

run_dashboard_simulation()

Output:

Running dashboard simulation...

1. Without caching:
Month 3: $6799871.68 total from 8336 customers
Month 6: $6852975.82 total from 8334 customers
Month 9: $6899145.39 total from 8324 customers
Month 12: $6969456.54 total from 8326 customers
...similar output repeated 2 more times...
Time without caching: 1.8576 seconds

2. With function caching:
Month 3: $6799871.68 total from 8336 customers
Month 6: $6852975.82 total from 8334 customers
Month 9: $6899145.39 total from 8324 customers
Month 12: $6969456.54 total from 8326 customers
...similar output repeated 2 more times...
Time with function caching: 0.6123 seconds

3. With dataframe caching:
Month 3: $6799871.68 total from 8336 customers
Month 6: $6852975.82 total from 8334 customers
Month 9: $6899145.39 total from 8324 customers
Month 12: $6969456.54 total from 8326 customers
...similar output repeated 2 more times...
Time with dataframe caching: 0.0357 seconds

Caching Best Practices​

  1. Cache the right operations: Focus on caching expensive operations that are called repeatedly
  2. Avoid over-caching: Caching everything can lead to excessive memory usage
  3. Consider cache invalidation: Update your cached results when the underlying data changes
  4. Use appropriate caching method:
    • Memory caching for fast, in-process needs
    • Disk caching for persistence between program runs
    • Distributed caching for multi-process applications
  5. Monitor memory usage: Large cached objects can cause memory issues

Potential Pitfalls​

  1. Stale data: Cached results won't reflect changes in the original data
  2. Memory pressure: Excessive caching can lead to memory issues
  3. Cache key limitations: When using lru_cache, parameters must be hashable
  4. Cache size: LRU caches have a maximum size and will discard least recently used items

Summary​

Caching is a powerful technique to improve the performance of Pandas operations, especially when working with large datasets and repeated calculations. We've explored several methods for implementing caching:

  • Simple variable assignment caching
  • Function result caching with @lru_cache
  • Expression caching with eval and query
  • Disk-based caching with joblib
  • Real-world application in a data analysis pipeline

By strategically implementing these caching techniques, you can significantly speed up your Pandas workflows while making your code more efficient and responsive.

Additional Resources​

Exercises​

  1. Compare the performance of caching vs. non-caching approaches for different DataFrame sizes.
  2. Implement a caching strategy for a complex data processing pipeline.
  3. Experiment with different maxsize values for lru_cache to find the optimal balance.
  4. Create a function that calculates rolling statistics on a large dataset and uses caching for improved performance.
  5. Implement a mechanism to invalidate cached results when the source data changes.


If you spot any mistakes on this website, please let me know at feedback@compilenrun.com. I’d greatly appreciate your feedback! :)