Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

cacheql

codecov PyPI - Version PyPI - Downloads PyPI - Python Version

Server-side caching framework for GraphQL APIs in Python.

Compatible with Apollo Server's @cacheControl directive semantics.

Caching Strategies

CacheQL implements the core caching strategies recommended by the GraphQL community, as documented in GraphQL.js Caching Strategies:

Strategy Status Description
Resolver-level caching Cache results of specific fields via @cached decorator
Operation result caching Cache entire query responses (query + variables)
Cache invalidation TTL-based expiration and tag-based manual purging

Additional features following Apollo Server's caching semantics:

Feature Status Description
@cacheControl directive Declarative cache hints in schema
HTTP Cache-Control headers Automatic header generation for CDN/proxy integration
Scope control PUBLIC/PRIVATE cache partitioning

Features

  • Apollo-style Cache Control: Full support for @cacheControl directives
  • Query-level caching: Cache entire GraphQL query responses
  • Field-level caching: Fine-grained cache control per field and type
  • Dynamic cache hints: Set cache policies from within resolvers
  • HTTP Cache-Control headers: Automatic header generation
  • Multiple backends: In-memory (LRU) and Redis support
  • Framework adapters: Built-in support for Ariadne and Strawberry
  • Tag-based invalidation: Invalidate cache entries by tags
  • Async-first: Fully async API for modern Python applications

Installation

# Core package with in-memory backend
pip install cacheql

# With Ariadne support
pip install cacheql[ariadne]

# With Strawberry support
pip install cacheql[strawberry]

# With Redis backend
pip install cacheql[redis]

# All optional dependencies
pip install cacheql[all]

Quick Start with @cacheControl Directives

Following Apollo Server's caching documentation, cacheql supports the @cacheControl directive for declarative cache configuration.

Schema Setup

# Add the directive definition to your schema
directive @cacheControl(
  maxAge: Int
  scope: CacheControlScope
  inheritMaxAge: Boolean
) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION

enum CacheControlScope {
  PUBLIC
  PRIVATE
}

# Apply directives to types and fields
type Query {
  # Cache for 5 minutes, shared across all users
  users: [User!]! @cacheControl(maxAge: 300)

  # Cache for 1 minute, per-user only
  me: User @cacheControl(maxAge: 60, scope: PRIVATE)
}

type User @cacheControl(maxAge: 600) {
  id: ID!
  name: String!
  # Private data - makes entire response private
  email: String! @cacheControl(scope: PRIVATE)
}

type Post @cacheControl(maxAge: 300) {
  id: ID!
  title: String!
  # Inherit maxAge from parent (Post's 300s)
  author: User! @cacheControl(inheritMaxAge: true)
}

Python Setup with Ariadne

from ariadne import QueryType, make_executable_schema
from fastapi import FastAPI

from cacheql import (
    CacheService,
    CacheConfig,
    InMemoryCacheBackend,
    DefaultKeyBuilder,
    JsonSerializer,
    get_cache_control_directive_sdl,
)
from cacheql.adapters.ariadne import CachingGraphQL

# Include directive definition in your schema
type_defs = get_cache_control_directive_sdl() + """
    type Query {
        users: [User!]! @cacheControl(maxAge: 300)
        me: User @cacheControl(maxAge: 60, scope: PRIVATE)
    }

    type User @cacheControl(maxAge: 600) {
        id: ID!
        name: String!
        email: String! @cacheControl(scope: PRIVATE)
    }
"""

query = QueryType()

@query.field("users")
async def resolve_users(*_):
    return [{"id": "1", "name": "Alice", "email": "alice@example.com"}]

@query.field("me")
async def resolve_me(*_):
    return {"id": "1", "name": "Alice", "email": "alice@example.com"}

schema = make_executable_schema(type_defs, query)

# Create cache service
config = CacheConfig(
    enabled=True,
    use_cache_control=True,
    default_max_age=0,  # No cache by default (conservative)
    calculate_http_headers=True,
    cache_queries=True,
    cache_mutations=False,
)
cache_service = CacheService(
    backend=InMemoryCacheBackend(maxsize=1000),
    key_builder=DefaultKeyBuilder(),
    serializer=JsonSerializer(),
    config=config,
)

# Create the GraphQL app with caching
graphql_app = CachingGraphQL(
    schema,
    cache_service=cache_service,
    debug=True,
)

# Mount on FastAPI
app = FastAPI()
app.mount("/graphql", graphql_app)

Cache Control Semantics

Following Apollo Server's rules:

Response Policy Calculation

The overall cache policy is determined by the most restrictive values:

  • maxAge: Uses the lowest value across all fields
  • scope: Uses PRIVATE if any field specifies PRIVATE

Default Behavior

  • Root fields (Query, Mutation): Default maxAge: 0 (no caching)
  • Object/Interface/Union fields: Default maxAge: 0
  • Scalar fields: Inherit from parent

This conservative approach ensures only explicitly cacheable data gets cached.

HTTP Headers

cacheql automatically generates Cache-Control headers:

Cache-Control: max-age=300, public
Cache-Control: max-age=60, private
Cache-Control: no-store  (when maxAge is 0)

Dynamic Cache Hints in Resolvers

Adapter support: currently implemented for the Ariadne adapter (CachingGraphQL/CachingGraphQLHTTPHandler). The Strawberry CacheExtension does not yet calculate a policy from @cacheControl directives or dynamic hints - it caches whole responses using default_ttl regardless of scope. Type-aware caching for Strawberry is tracked as follow-up work.

Set cache hints dynamically based on runtime conditions:

from cacheql.hints import set_cache_hint, private_cache, no_cache

@query.field("user")
async def resolve_user(_, info, id: str):
    user = await get_user(id)

    # Set cache hint based on user data
    if user.is_public_profile:
        set_cache_hint(info, max_age=3600, scope="PUBLIC")
    else:
        set_cache_hint(info, max_age=60, scope="PRIVATE")

    return user

@query.field("sensitive_data")
async def resolve_sensitive(_, info):
    # Disable caching entirely
    no_cache(info)
    return get_sensitive_data()

@query.field("my_profile")
async def resolve_my_profile(_, info):
    # Shorthand for private cache
    private_cache(info, max_age=300)
    return get_current_user_profile(info)

Legacy Mode (Simple TTL-based Caching)

For simpler use cases without directive parsing:

from datetime import timedelta
from cacheql import CacheConfig

config = CacheConfig(
    use_cache_control=False,  # Disable directive parsing
    default_ttl=timedelta(minutes=5),
)

# All queries are cached with the default TTL

Field-Level Caching with Decorators

For fine-grained control without schema directives:

from cacheql import cached, invalidates, configure

configure(cache_service)

@cached(ttl=timedelta(minutes=10), tags=["User", "User:{id}"])
async def get_user(id: str) -> dict:
    return await db.get_user(id)

@invalidates(tags=["User", "User:{id}"])
async def update_user(id: str, data: dict) -> dict:
    return await db.update_user(id, data)

Redis Backend

For distributed deployments:

from cacheql_redis import RedisCacheBackend

backend = RedisCacheBackend(
    redis_url="redis://localhost:6379",
    key_prefix="myapp",
)

cache_service = CacheService(
    backend=backend,
    key_builder=DefaultKeyBuilder(),
    serializer=JsonSerializer(),
    config=config,
)

Configuration

from datetime import timedelta
from cacheql import CacheConfig

config = CacheConfig(
    enabled=True,                           # Enable/disable caching
    default_ttl=timedelta(minutes=5),       # Default TTL (legacy mode)
    max_size=1000,                          # Max entries for LRU backends
    key_prefix="cacheql",                   # Prefix for cache keys

    # Cache control settings (Apollo-style)
    use_cache_control=True,                 # Enable directive parsing
    default_max_age=0,                      # Default maxAge in seconds
    calculate_http_headers=True,            # Generate Cache-Control headers

    # Query behavior
    cache_queries=True,                     # Cache query responses
    cache_mutations=False,                  # Don't cache mutations
    auto_invalidate_on_mutation=True,       # Auto-invalidate on mutations
)

Accessing Cache Statistics

You can access cache statistics through the GraphQL app:

graphql_app = CachingGraphQL(schema, cache_service=cache_service)

# Access statistics
stats = graphql_app.cache_stats
print(f"Hits: {stats['hits']}")
print(f"Misses: {stats['misses']}")

# Or directly from the cache service
stats = cache_service.stats

Cache Invalidation

By Tags

await cache_service.invalidate(["User"])
await cache_service.invalidate(["User:123"])

CachingGraphQL/CachingGraphQLHTTPHandler (Ariadne) and the Strawberry CacheExtension automatically tag cached responses with TypeName and TypeName:id for every object that has an id field, derived from the schema for Ariadne and from __typename in the response for Strawberry (request __typename in your queries if you rely on this with Strawberry). Calling @cached(tags=[...]) or invalidate([...]) with those same type names invalidates the matching entries - no need to pass tags= explicitly when caching whole GraphQL responses.

Mutations also auto-invalidate: when auto_invalidate_on_mutation is enabled (the default), a successful mutation response has its tags extracted the same way and used to invalidate affected entries.

Clear All

await cache_service.clear()

HTTP Headers

When using CachingGraphQL, cache control headers are automatically set on responses:

  • Cache-Control: max-age=300, public - for cacheable responses
  • Cache-Control: max-age=60, private - for private responses
  • Cache-Control: no-store - when maxAge is 0
  • X-Cache: HIT - indicates response was served from cache

To read these headers in middleware (e.g., with FastAPI):

from starlette.middleware.base import BaseHTTPMiddleware

class CacheHeaderMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)

        # Read headers set by CachingGraphQL
        cache_header = getattr(request.state, "cache_control_header", None)
        if cache_header:
            response.headers["Cache-Control"] = cache_header

        if getattr(request.state, "cache_hit", False):
            response.headers["X-Cache"] = "HIT"

        return response

app.add_middleware(CacheHeaderMiddleware)

Architecture

cacheql follows Domain-Driven Design principles:

┌─────────────────────────────────────────────┐
│         Adapters (Ariadne/Strawberry)       │
├─────────────────────────────────────────────┤
│         Application Services                │
├─────────────────────────────────────────────┤
│         Domain (Core)                       │
├─────────────────────────────────────────────┤
│         Infrastructure                      │
└─────────────────────────────────────────────┘

Core Components

  • CacheHint: Represents cache control settings (maxAge, scope)
  • CacheScope: Enum for PUBLIC/PRIVATE scope
  • ResponseCachePolicy: Calculated policy for entire response
  • CacheControlCalculator: Calculates policy from hints
  • DirectiveParser: Parses @cacheControl from schema

Development

# Install dev dependencies
pip install -e ".[dev]"

# Run tests
pytest tests/ -v

# Run tests with coverage
pytest tests/ --cov=cacheql --cov-report=html

# Type checking
mypy src/cacheql

# Linting
ruff check src/cacheql

License

MIT License - see LICENSE file for details.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cacheql-0.0.1a3.tar.gz (107.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cacheql-0.0.1a3-py3-none-any.whl (44.8 kB view details)

Uploaded Python 3

File details

Details for the file cacheql-0.0.1a3.tar.gz.

File metadata

  • Download URL: cacheql-0.0.1a3.tar.gz
  • Upload date:
  • Size: 107.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for cacheql-0.0.1a3.tar.gz
Algorithm Hash digest
SHA256 3d29b86c67986be1be21fca66c5b8ffb0dc337e296be26a2f97af4cecc39702a
MD5 fed84ead9ea538b3bd5ae151ca0c5454
BLAKE2b-256 f1121013fe9c998e8371d0bfb861570606d1d0e51773c2ea0d8af966ea87032b

See more details on using hashes here.

Provenance

The following attestation bundles were made for cacheql-0.0.1a3.tar.gz:

Publisher: publish.yml on nogueira-raphael/cacheql

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file cacheql-0.0.1a3-py3-none-any.whl.

File metadata

  • Download URL: cacheql-0.0.1a3-py3-none-any.whl
  • Upload date:
  • Size: 44.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for cacheql-0.0.1a3-py3-none-any.whl
Algorithm Hash digest
SHA256 3cc1f45a6a2de975e1dbacc0396601351e39c5ad10c7aa50226d6062a2aacbf7
MD5 2de0d76b2fb10620a7a71a97eff12090
BLAKE2b-256 513ae764470e8b2f07c2a7b01d6d0a76568d043adfe662e5c189ce51760f1dbb

See more details on using hashes here.

Provenance

The following attestation bundles were made for cacheql-0.0.1a3-py3-none-any.whl:

Publisher: publish.yml on nogueira-raphael/cacheql

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.0.1a3 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page