APQ
Automatic Persisted Queries — Reduce query payload size with client-side query hashing
FraiseQL supports response caching for GraphQL queries. Caching can reduce database load dramatically for read-heavy workloads.
FraiseQL supports three caching backends:
| Backend | Use Case | Persistence |
|---|---|---|
redis | Production, multi-instance | Survives restarts, shared across instances |
memory | Development, single-instance | Per-process, lost on restart |
postgresql | Fallback when Redis unavailable | Stored in database table |
Start a Redis instance
docker run -d --name redis -p 6379:6379 redis:7-alpineConfigure the [fraiseql.caching] block
[fraiseql.caching]enabled = truebackend = "redis"redis_url = "redis://localhost:6379"max_memory_entries = 1000Start FraiseQL
fraiseql runOn startup, FraiseQL connects to Redis and logs confirmation that caching is active.
For local development or single-instance deployments:
[fraiseql.caching]enabled = truebackend = "memory"max_memory_entries = 1000The in-memory backend requires no external services but:
Cache TTL is configured per query in your Python schema using the cache_ttl_seconds parameter:
import fraiseql
@fraiseql.query( sql_source="v_post", cache_ttl_seconds=300 # Cache this query for 5 minutes)def posts(category: str | None = None) -> list[Post]: """List posts, optionally filtered by category.""" pass
@fraiseql.query( sql_source="v_user", cache_ttl_seconds=60 # Short TTL — user data changes frequently)def user_profile(user_id: fraiseql.ID) -> User | None: pass
@fraiseql.query( sql_source="v_config", cache_ttl_seconds=3600 # 1-hour TTL — config rarely changes)def site_config() -> SiteConfig: passA cache_ttl_seconds of 0 disables caching for that query entirely (useful to opt out when the global default is set).
| Data type | Recommended TTL | Rationale |
|---|---|---|
| Public static content (site config, feature flags) | 3600 s (1 hour) | Changes rarely, safe to cache long |
| Product listings, post feeds | 300 s (5 min) | Moderate freshness requirement |
| User-specific data | 60 s (1 min) | May change across sessions |
| Real-time or financial data | No caching | Staleness is not acceptable |
FraiseQL invalidates cache entries automatically when mutations change data. When a mutation runs, FraiseQL detects which SQL views the mutation affects and purges cached responses for queries that read from those views.
Declare which views a mutation invalidates using the invalidates decorator parameter:
@fraiseql.mutation( fn_name="create_post", invalidates=["v_post"] # Purge cache entries for queries backed by v_post)def create_post(input: PostInput) -> PostResult: passWhen createPost executes:
In some cases, you may need to invalidate cache manually (e.g., administrative changes):
from fraiseql import cache
# Invalidate all entries matching a patternawait cache.invalidate_pattern("posts:*")
# Invalidate specific entity cacheawait cache.invalidate_entity("Post", post_id="123")import { cache } from 'fraiseql';
// Invalidate all entries matching a patternawait cache.invalidatePattern('posts:*');
// Invalidate specific entity cacheawait cache.invalidateEntity('Post', { postId: '123' });All caching settings can be overridden via environment variables:
| Variable | Overrides |
|---|---|
FRAISEQL_CACHING_ENABLED | fraiseql.caching.enabled |
FRAISEQL_CACHING_BACKEND | fraiseql.caching.backend |
FRAISEQL_REDIS_URL | fraiseql.caching.redis_url |
Example:
export FRAISEQL_CACHING_ENABLED=trueexport FRAISEQL_REDIS_URL=redis://prod-redis:6379fraiseql runFraiseQL exposes Prometheus metrics for cache performance at the /metrics endpoint:
| Metric | Type | Description |
|---|---|---|
fraiseql_cache_hits_total | Counter | Total cache hits across all queries |
fraiseql_cache_misses_total | Counter | Total cache misses |
fraiseql_cache_invalidations_total | Counter | Total cache entries invalidated |
Cache hit rate over a 5-minute window:
sum(rate(fraiseql_cache_hits_total[5m])) /( sum(rate(fraiseql_cache_hits_total[5m])) + sum(rate(fraiseql_cache_misses_total[5m])))A healthy read-heavy API typically achieves a hit rate above 80%. Values below 50% indicate that:
Invalidation rate relative to hits:
rate(fraiseql_cache_invalidations_total[5m]) /rate(fraiseql_cache_hits_total[5m])If this ratio exceeds 0.1 (10%), review your invalidation triggers — they may be too broad.
“Caching enabled but no cache hits”
Verify caching is active and rules match your queries:
# Check caching is enabledgrep -A5 '\[fraiseql.caching\]' fraiseql.toml
# Verify your queries have cache_ttl_seconds set in the Python schemagrep cache_ttl_seconds schema/*.py“Cache invalidation not working”
invalidates list on the mutation includes that exact view name (case-sensitive)“Out of memory with in-memory cache”
The in-memory backend has no automatic eviction. For production use cases, switch to Redis:
[fraiseql.caching]backend = "redis"redis_url = "redis://localhost:6379"“Redis connection errors”
Verify Redis is accessible:
redis-cli -h localhost -p 6379 ping# Should return: PONGCheck the connection URL format:
# Standard formatredis_url = "redis://localhost:6379"
# With passwordredis_url = "redis://:password@localhost:6379"
# With database numberredis_url = "redis://localhost:6379/0"Use specific invalidation views. Broad invalidation reduces cache effectiveness:
# Good: Only invalidate the view this mutation affects@fraiseql.mutation(fn_name="create_post", invalidates=["v_post"])
# Avoid: Invalidating unrelated views@fraiseql.mutation(fn_name="create_post", invalidates=["v_post", "v_user", "v_config"])Choose TTLs based on data change frequency. Static data can cache longer; frequently changing data needs shorter TTLs or no caching.
Use Redis for production. The in-memory backend is convenient for development but unsuitable for production multi-instance deployments.
Monitor hit rates. Alert when cache hit rate drops below your threshold (typically 70-80%).
Test invalidation. After implementing caching, verify that mutations correctly invalidate cache entries by:
Current caching implementation has these limitations:
X-Cache-Status headers to responses.APQ
Automatic Persisted Queries — Reduce query payload size with client-side query hashing
Observers
Observers — Trigger cache invalidation via database events
Performance
Performance Guide — N+1 elimination, projection tables, and query optimization