Skip to content

Caching

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_):

DeploymentCache recommended?Reasoning
fraiseql-v (views)Yes, for read-heavy workloadsEvery 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:

BackendUse CasePersistence
redisProduction, multi-instanceSurvives restarts, shared across instances
memoryDevelopment, single-instancePer-process, lost on restart
  1. Start a Redis instance

    Terminal window
    docker run -d --name redis -p 6379:6379 redis:7-alpine
  2. Configure the [caching] block

    [caching]
    enabled = true
    backend = "redis"
    redis_url = "redis://localhost:6379"
  3. Start FraiseQL

    Terminal window
    fraiseql run

    On startup, FraiseQL connects to Redis and logs confirmation that caching is active.

For local development or single-instance deployments:

[caching]
enabled = true
backend = "memory"

The in-memory backend requires no external services but:

  • Cache is lost when FraiseQL restarts
  • Each instance has independent cache in multi-instance setups
  • Limited by available heap memory

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:
pass

A cache_ttl_seconds of 0 disables caching for that query entirely (useful to opt out when the global default is set).

Data typeRecommended TTLRationale
Public static content (site config, feature flags)3600 s (1 hour)Changes rarely, safe to cache long
Product listings, post feeds300 s (5 min)Moderate freshness requirement
User-specific data60 s (1 min)May change across sessions
Real-time or financial dataNo cachingStaleness 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:
pass

When createPost executes:

  1. FraiseQL runs the mutation function call
  2. Purges all cache entries for queries backed by the listed views
  3. Subsequent queries fetch fresh data from the database

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:
pass

All caching settings can be overridden via environment variables:

VariableOverrides
FRAISEQL_CACHING_ENABLEDfraiseql.caching.enabled
FRAISEQL_CACHING_BACKENDfraiseql.caching.backend
FRAISEQL_REDIS_URLfraiseql.caching.redis_url

Example:

Terminal window
export FRAISEQL_CACHING_ENABLED=true
export FRAISEQL_REDIS_URL=redis://prod-redis:6379
fraiseql run

FraiseQL exposes Prometheus metrics for cache performance at the /metrics endpoint:

MetricTypeDescription
fraiseql_cache_hitsCounterTotal cache hits across all queries
fraiseql_cache_missesCounterTotal cache misses
fraiseql_cache_hit_ratioGaugeCache hit ratio (0–1)

Cache hit ratio (direct gauge):

fraiseql_cache_hit_ratio

Or 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:

  • TTLs are too short relative to request frequency
  • Mutations are invalidating entries too aggressively
  • The query patterns don’t match the cache rules

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:

Terminal window
# Check caching is enabled
grep -A5 '\[caching\]' fraiseql.toml
# Verify your queries have cache_ttl_seconds set in the Python schema
grep cache_ttl_seconds schema/*.py

“Cache invalidation not working”

  1. Check that the mutation returns the correct entity type name
  2. Verify the invalidates_views list on the mutation includes that exact view name (case-sensitive)
  3. Ensure the entity name in the rule matches the name in your schema

“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:

Terminal window
redis-cli -h localhost -p 6379 ping
# Should return: PONG

Check the connection URL format:

# Standard format
redis_url = "redis://localhost:6379"
# With password
redis_url = "redis://:password@localhost:6379"
# With database number
redis_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:

  1. Executing a cached query (should hit cache)
  2. Running a mutation that should invalidate it
  3. Executing the same query again (should hit database, not cache)

Current caching implementation has these limitations:

  1. CREATE mutations flush the full view: When a CREATE mutation runs, all cache entries for the listed views are flushed. UPDATE and DELETE mutations that return an 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.
  2. No cache key customization: Cache keys are automatically derived from query name and arguments. You cannot customize the key pattern.
  3. No per-query cache headers: Unlike some GraphQL servers, FraiseQL does not add X-Cache-Status headers to responses.
  4. No cache warming: There is no built-in mechanism to pre-populate the cache on startup.

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.

Observers

Observers — Trigger cache invalidation via database events

Performance

Performance Guide — N+1 elimination, projection tables, and query optimization