DEV Community

Alex
Alex

Posted on

Build a Dead-Simple File Cache in Python (Under 50 Lines)

Build a Dead-Simple File Cache in Python (Under 50 Lines)

API rate limits killing your development speed? Here's a production-ready file cache you can drop into any Python project in 5 minutes.


The Problem

You're building something that hits an external API. During development, you call it hundreds of times. You hit rate limits. You wait. Your flow is broken.

The fix: cache API responses to disk with automatic expiration.


The Solution (47 Lines)

"""
cache.py — Drop-in file cache for any Python project.
Caches function results to JSON files with configurable TTL.
"""

import json
import time
import hashlib
from pathlib import Path
from functools import wraps

CACHE_DIR = Path(".cache")
DEFAULT_TTL = 300  # 5 minutes


def cached(ttl: int = DEFAULT_TTL, key_prefix: str = ""):
    """
    Decorator that caches function results to disk.

    Usage:
        @cached(ttl=600)
        def fetch_data(url):
            return requests.get(url).json()
    """
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # Generate a unique cache key from function name + arguments
            raw_key = f"{key_prefix or func.__name__}:{args}:{kwargs}"
            cache_key = hashlib.md5(raw_key.encode()).hexdigest()
            cache_file = CACHE_DIR / f"{cache_key}.json"

            # Check cache
            if cache_file.exists():
                data = json.loads(cache_file.read_text())
                age = time.time() - data["ts"]
                if age < ttl:
                    return data["result"]

            # Cache miss — call the function
            result = func(*args, **kwargs)

            # Write to cache
            CACHE_DIR.mkdir(exist_ok=True)
            cache_file.write_text(json.dumps({
                "ts": time.time(),
                "key": raw_key,
                "result": result
            }, default=str))

            return result
        return wrapper
    return decorator


def clear():
    """Clear all cached data."""
    if CACHE_DIR.exists():
        for f in CACHE_DIR.glob("*.json"):
            f.unlink()


def stats():
    """Show cache statistics."""
    if not CACHE_DIR.exists():
        return {"files": 0, "size_bytes": 0}

    files = list(CACHE_DIR.glob("*.json"))
    total_size = sum(f.stat().st_size for f in files)

    valid = 0
    expired = 0
    for f in files:
        data = json.loads(f.read_text())
        if time.time() - data["ts"] < DEFAULT_TTL:
            valid += 1
        else:
            expired += 1

    return {
        "total_files": len(files),
        "valid": valid,
        "expired": expired,
        "size_kb": round(total_size / 1024, 1)
    }
Enter fullscreen mode Exit fullscreen mode

How to Use It

Basic Usage

import requests
from cache import cached

@cached(ttl=300)  # Cache for 5 minutes
def get_weather(city: str):
    resp = requests.get(f"https://api.weather.com/{city}")
    return resp.json()

# First call: hits the API
data = get_weather("london")

# Second call within 5 min: instant, from disk
data = get_weather("london")
Enter fullscreen mode Exit fullscreen mode

Custom TTL Per Function

@cached(ttl=3600)  # Cache for 1 hour
def get_exchange_rates():
    return requests.get("https://api.rates.com/latest").json()

@cached(ttl=60)  # Cache for 1 minute (prices change fast)
def get_stock_price(ticker: str):
    return requests.get(f"https://api.stocks.com/{ticker}").json()
Enter fullscreen mode Exit fullscreen mode

Clear Cache When Needed

from cache import clear, stats

# Check what's cached
print(stats())
# {'total_files': 12, 'valid': 8, 'expired': 4, 'size_kb': 45.2}

# Wipe everything
clear()
Enter fullscreen mode Exit fullscreen mode

Why This Works Better Than In-Memory Caching

Feature In-Memory (dict) File Cache
Survives restart No Yes
Works across processes No Yes
Zero dependencies Yes Yes
Speed Faster Fast enough
Inspectable No Yes (JSON files)

The file cache is slightly slower than a dict, but it survives process restarts and works across multiple scripts — which matters way more during development.


Production Hardening (Optional)

For production use, add these:

import fcntl

def _atomic_write(path: Path, content: str):
    """Write atomically to avoid corruption from concurrent access."""
    tmp = path.with_suffix(".tmp")
    tmp.write_text(content)
    tmp.rename(path)  # Atomic on most filesystems

def _auto_cleanup(max_files: int = 1000):
    """Remove oldest cache files if cache grows too large."""
    files = sorted(CACHE_DIR.glob("*.json"),
                   key=lambda f: f.stat().st_mtime)
    while len(files) > max_files:
        files.pop(0).unlink()
Enter fullscreen mode Exit fullscreen mode

When NOT to Use This

  • High-frequency reads (>1000/sec): Use Redis or memcached instead
  • Large responses (>10MB): Use SQLite or a proper database
  • Shared servers: File locking gets complicated; use Redis
  • Sensitive data: Cache files are plaintext JSON on disk

For everything else — development, prototyping, small production services — this 47-line file cache is all you need.


TL;DR

# Copy cache.py into your project
# Decorate any function with @cached(ttl=300)
# Never hit rate limits during development again
Enter fullscreen mode Exit fullscreen mode

Grab the full code from the snippet above. No pip install required.


Found this useful? Share it with a dev who's tired of rate limits.

Top comments (0)