Cache Penetration Null Object Mock in Python

Implement a cache that stores a null marker on misses to prevent repeated database hits, reducing cache penetration.

Medium Python 3.9+ Aug 9, 2026 Caching & Redis 15 views 0 copies

Python code

47 lines
Python 3.9+
import time
from collections import defaultdict
from typing import Any, Optional


class Cache:
    def __init__(self):
        self.store: dict[str, Any] = {}
        self.ttl: dict[str, float] = {}
        self.null_marker = object()

    def get(self, key: str, ttl: int = 60, fallback:
            Any = None) -> Any:
        now = time.time()
        if key in self.ttl and now > self.ttl[key]:
            self.store.pop(key, None)
            self.ttl.pop(key, None)

        if key in self.store:
            return self.store[key]

        # Simulate cache penetration: miss returns None
        if fallback is not None:
            # Store null object to avoid repeated DB hits
            self.store[key] = self.null_marker
            self.ttl[key] = now + ttl
            return self.store[key]
        return None

    def set(self, key: str, value: Any, ttl: int = 60) -> None:
        self.store[key] = value
        self.ttl[key] = time.time() + ttl


if __name__ == "__main__":
    cache = Cache()
    # Miss for "user:1", use fallback -> store null object
    result1 = cache.get("user:1", fallback=None)
    # Subsequent hit: matches null object, but we treat as miss
    result2 = cache.get("user:1")
    # Real value set
    cache.set("user:1", {"name": "Alice"})
    result3 = cache.get("user:1")

    print(f"First call: {result1}")
    print(f"Second call (should be null object): {result2 is cache.null_marker}")
    print(f"After set: {result3}")

Output

stdout
First call: None
Second call (should be null object): True
After set: {'name': 'Alice'}

How it works

The Cache class uses a dict to store values and a separate dict for TTL timestamps. When the TTL expires, the key is evicted lazily during get. On a miss, it stores a unique null_marker object to signal a negative result, preventing repeated expensive DB lookups for nonexistent keys. The fallback parameter lets callers distinguish between a genuine miss and a cached negative. A subsequent set overwrites the marker with real data, and get returns it immediately.

Common mistakes

  • Storing `None` directly instead of a unique sentinel, which conflates a cached miss with a nonexistent key.
  • Forgetting to set TTL on the negative entry, causing it to persist forever.
  • Checking `key in self.store` without handling expiration first, returning stale data.

Variations

  1. Use a dedicated `cache.set_null(key, ttl)` method to make the intent explicit.
  2. Store the TTL as a tuple `(value, expiry)` inside one dict instead of two separate structures.

Real-world use cases

  • Preventing repeated database lookups for user IDs that don't exist in a high-traffic service.
  • Avoiding hot-key contention when an external API frequently returns 404 for a specific resource.
  • Short-circuiting expensive ML feature computations for inputs known to be invalid or missing.

Sponsored

Run this sample

Open the browser IDE to tweak the example and see results without installing anything.

Open editor

More from Caching & Redis

Related tutorials and quizzes for this topic.