Skip to content

App Cache

self.cache stores values that survive app restarts — counters, timestamps, API responses, user preferences. Every App instance gets one automatically, backed by its own SQLite database file. No setup required.

For real-time Home Assistant entity state, self.states is the right tool. self.cache is for app data, not entity state.

Basic Usage

Every data method on self.cache is async and must be awaited:

from hassette import App, AppConfig


class MyApp(App[AppConfig]):
    async def on_initialize(self):
        # Store data
        await self.cache.set("last_run", self.now())
        await self.cache.set(
            "user_preferences", {"theme": "dark", "notifications": True}
        )

        # Retrieve data
        last_run = await self.cache.get("last_run")
        if last_run is not None:
            self.logger.info("Last run: %s", last_run)

        # Get with default value
        count = await self.cache.get("run_count", default=0) or 0
        await self.cache.set("run_count", count + 1)

        # Delete data
        await self.cache.delete("old_key")

get returns None when a key is missing, or the value passed as default when one is given. set stores a value indefinitely unless a ttl is given (see TTL and Expiration below). delete removes a key; deleting a missing key is a no-op.

Instance-Scoped Directories

Each app instance gets its own cache directory and its own SQLite file. Two instances of the same WeatherApp class — say, one per city — do not share cache data, so a key like "last_forecast" in instance 0 never collides with instance 1's copy.

The default directory is {data_dir}/{app_key}/{index}/cache/cache.db. Set cache_key on an app's section in hassette.toml to override this — the most common reason is preserving cache data across an app rename:

[hassette.apps.weather]
cache_key = "weather_v1/0"  # keep the cache from before the app was renamed

When cache_key is set, Hassette uses it as-is with no index appended. Two apps that intentionally share a cache_key share one cache file; Hassette logs a warning at startup if two different app_key values resolve to the same cache_key unintentionally.

Lazy Population with get_or_set

get_or_set reads a key, and on a miss, calls an async function to compute the value, stores it, and returns it:

data = await self.cache.get_or_set("weather", fetch_weather, ttl=3600)

fetch_weather only runs when the cache misses or the entry expired. Subsequent calls within the TTL window return the stored value without calling fetch_weather again.

TTL and Expiration

set accepts a ttl parameter in seconds:

await self.cache.set("weather_data", payload, ttl=3600)  # expires in 1 hour

The entry expires after the given number of seconds; get returns None for an expired key and removes it from storage. Three levels resolve the default when ttl is omitted, in order:

  1. The per-call ttl argument to set
  2. The app subclass's default_cache_ttl class attribute
  3. default_cache_ttl in hassette.toml (a global fallback under [hassette])

When none of these are set, entries persist indefinitely. An app that sets a class-level default looks like this:

from hassette import App, AppConfig


class DataCacheApp(App[AppConfig]):
    async def get_cached_data(self, key: str, ttl_minutes: int = 60):
        """Get data from cache if not expired, or None if expired or absent."""
        cache_key = f"data:{key}"

        entry = await self.cache.get(cache_key)
        if entry is not None:
            timestamp, value = entry

            # Return cached data if still within TTL
            if timestamp > self.now().subtract(minutes=ttl_minutes):
                return value

        # Data expired or not found
        return None

    async def set_cached_data(self, key: str, value) -> None:
        """Store data alongside a timestamp for TTL tracking."""
        cache_key = f"data:{key}"
        await self.cache.set(cache_key, (self.now(), value))

ttl=0 deletes, it doesn't store

set(key, value, ttl=0) deletes any existing entry at key and does not store the new value. Use delete(key) if that's the intent — ttl=0 exists to let a computed TTL of zero (e.g., "already expired") behave the same way.

Synchronous Access

AppSync apps run their lifecycle hooks and handlers in a thread pool, without async/await. self.cache.sync exposes the same methods as plain synchronous calls:

self.cache.sync.set("temp", reading, ttl=300)
val = self.cache.sync.get("temp")

self.cache.sync opens its own connection to the same SQLite file — it works from AppSync handlers, but calling it from inside a running event loop raises RuntimeError, matching the safety contract of self.bus.sync, self.scheduler.sync, and self.api.sync.

Test Isolation with DummyCache

DummyCache is an in-memory implementation of the same interface. Pass it to App.__init__ via the cache parameter — or use the dummy_cache pytest fixture from hassette.test_utils — to exercise cache-using code without touching disk. DummyCache supports the full API — get, set, delete, get_or_set, clear, invalidate, and .sync — with the same TTL semantics as the real cache.

What Can Be Cached

The cache stores any Python object that supports pickling, Python's built-in serialization format:

  • Primitives: str, int, float, bool, None
  • Collections: list, dict, tuple, set
  • Timestamps from the whenever library: Instant, ZonedDateTime, PlainDateTime, TimeDelta
  • Pydantic models and dataclasses (if picklable)

Storing timestamps

self.now() — a built-in App method returning the current time as a timezone-aware ZonedDateTime — and all whenever types are picklable. Store them directly without conversion.

Verify It Works

Check that cache data persists across restarts with hassette log:

hassette log --app my_app --since 1h

Corruption and deserialization failures are logged as warnings. Normal cache reads and writes do not produce log output.

See Also