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.
The right choice depends on whether you are using standard views (v_) or projection tables (tv_):
| Deployment | Cache recommended? | Reasoning |
|---|---|---|
fraiseql-v (views) | Yes, for read-heavy workloads | Every request runs SQL joins and on-the-fly JSONB construction. A cache hit saves 10–20ms of real database work — a meaningful gain at scale. |
fraiseql-tv (projection tables) | No (or short write-off periods only) | TV tables pre-compute the JSONB at write time. The remaining cost per read (one indexed row fetch) is already very cheap, so cache hits add less than 3% throughput. Cache enabled on a TV-table deployment adds a full-flush overhead on every mutation, which can cost 3× the write throughput under concurrent load. |
FraiseQL supports two caching backends:
| Backend | Use Case | Persistence |
|---|---|---|
redis | Production, multi-instance | Survives restarts, shared across instances |
memory | Development, single-instance | Per-process, lost on restart |
Start a Redis instance
docker run -d --name redis -p 6379:6379 redis:7-alpineConfigure the [caching] block
[caching]enabled = truebackend = "redis"redis_url = "redis://localhost:6379"Start FraiseQL
fraiseql runOn startup, FraiseQL connects to Redis and logs confirmation that caching is active.
For local development or single-instance deployments:
[caching]enabled = truebackend = "memory"The 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_views decorator parameter:
@fraiseql.mutation( sql_source="fn_create_post", operation="CREATE", invalidates_views=["v_post"] # Purge cache entries for queries backed by v_post)def create_post(input: PostInput) -> PostResult: passWhen createPost executes:
A mutation can invalidate several views at once when it affects multiple read paths:
@fraiseql.mutation( sql_source="fn_create_comment", operation="CREATE", invalidates_views=["v_comment", "v_post"] # Both comment list and post comment count)def create_comment(post_id: fraiseql.ID, body: str) -> Comment: passAll 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 | Counter | Total cache hits across all queries |
fraiseql_cache_misses | Counter | Total cache misses |
fraiseql_cache_hit_ratio | Gauge | Cache hit ratio (0–1) |
Cache hit ratio (direct gauge):
fraiseql_cache_hit_ratioOr computed from counters over a 5-minute window:
rate(fraiseql_cache_hits[5m]) /( rate(fraiseql_cache_hits[5m]) + rate(fraiseql_cache_misses[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_misses[5m]) /rate(fraiseql_cache_hits[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 '\[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_views list on the mutation includes that exact view name (case-sensitive)“Out of memory with in-memory cache”
The in-memory backend uses a 64-shard LRU cache with automatic least-recently-used eviction — it will not grow without bound. However, the default capacity may be too large for memory-constrained environments. Tune max_capacity to match your available memory, or switch to Redis for shared caching across replicas:
[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(sql_source="fn_create_post", operation="CREATE", invalidates_views=["v_post"])
# Avoid: Invalidating unrelated views@fraiseql.mutation(sql_source="fn_create_post", operation="CREATE", invalidates_views=["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:
entity_id in the mutation_response use entity-aware eviction — only entries for the mutated entity are purged. Write-heavy workloads dominated by CREATE mutations still see a view-wide flush on every write.X-Cache-Status headers to responses.Response caching applies to GraphQL and REST transports (both return JSON from the same JSON-shaped views). gRPC responses are not cached by FraiseQL’s internal cache layer — the cache serialization is JSON-based and doesn’t match protobuf wire format. HTTP-level caching (CDN, reverse proxy) works with REST GET endpoints as standard HTTP cache semantics apply.
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