Skip to content

ViewSet Lifecycle ​

Controls how a viewset class becomes the actual instance that handles a request, and whether/how that instance's own state persists across requests.

Where this fits

Lifecycle is orthogonal to the request pipeline described in Architecture: context (built by Context Processors) is about who's calling, this page is about which viewset instance handles the call and what it remembers between calls. Don't confuse the two - a viewset's own state (e.g. a cache, a counter, an open connection) is not the same thing as per-request context.


The lifecycle parameter ​

Both route_viewset and celery_viewset (see Routers & Decorators) accept a lifecycle: LifecycleType = "singleton" parameter with identical semantics on both the FastAPI and the worker side:

ModeInstance creationState hooks
"singleton"One instance is created when the decorator runs and reused for every request.load_state() / save_state() are called on every request, so state can be shared across multiple processes (e.g. via Redis). No locking — concurrent requests may race when writing state.
"per-request"A new instance is created for every incoming request.Not called. Useful when the viewset needs per-request data (e.g. the current user - see Context Processors) with no shared state.
"instance-key"A new instance is created per request.load_state() is called before the endpoint and save_state() after it, so state is loaded fresh and persisted on every request. Same race-condition caveat as "singleton".

State hooks: load_state / save_state ​

"singleton" and "instance-key" both call two optional async methods on the viewset around each request:

MethodWhen calledPurpose
async def load_state(self)Before the endpoint handlerRestore state from an external store
async def save_state(self)After the endpoint handler (in a finally block)Persist state back to the external store

Neither method is required — if absent, it is simply skipped. save_state is called even if the endpoint raises an exception, and for a celery_viewset action interrupted by a soft time limit. A hard time limit, a termination or a lost worker process ends the process executing the action, and save_state runs only if the process unwinds that far before it is killed (see Outcomes of a call).

A worker process that needs setting up before its first execution (a connection a state hook uses) does it in Middleware.executor_started(), not in the viewset's constructor: a "singleton" instance is created when the decorator runs, in the process that imports the viewset.

Note: There is currently no distributed locking around load_state / save_state. In deployments with multiple worker processes, concurrent requests can interleave their load/save cycles and overwrite each other's changes. Add external locking (e.g. a Redis lock) in your load_state / save_state implementation if you need consistency.

python
from fastapi_viewsets.context import Context

@route_viewset(router, base_path="/session", lifecycle="instance-key")
class SessionViewSet(ListMixin[str]):
    def __init__(self):
        self.items: list[str] = []

    async def load_state(self):
        self.items = await redis.lrange("session:items", 0, -1)

    async def save_state(self):
        await redis.delete("session:items")
        if self.items:
            await redis.rpush("session:items", *self.items)

    async def perform_list(self, context: Context) -> list[str]:
        return self.items

Where this runs relative to the rest of the pipeline ​

load_state() runs before context is built and before the command middleware chain starts; save_state() runs after command middleware unwinds, in a finally block, so it always runs even if the endpoint or a middleware raises. See the full ordering in Architecture.

Built-in Redis-backed state: SaveStateRedis ​

fastapi_viewsets.save_state ships a ready-made load_state/save_state implementation backed by Redis, if you don't want to write your own:

python
import json

from redis.asyncio import Redis

from fastapi_viewsets.save_state.save_state import SaveStateRedis

redis = Redis()


@route_viewset(router, base_path="/session", lifecycle="instance-key")
class SessionViewSet(SaveStateRedis, ListMixin[str]):
    save_state_redis_key = "session:items"  # required class variable

    def __init__(self):
        super().__init__(instance_id="session", redis=redis)
        self.items: list[str] = []

    async def serialize_state(self) -> str:
        return json.dumps(self.items)

    async def deserialize_state(self, state: str | None):
        if state is not None:
            self.items = json.loads(state)

    async def perform_list(self, context: Context) -> list[str]:
        return self.items

route_viewset constructs the viewset with no arguments, so the Redis client comes from the module rather than the constructor.

SaveStateRedis(instance_id, redis) reads/writes save_state_redis_key via redis.get/redis.set, serializing through serialize_state()/deserialize_state() (from the SerializeState base it also inherits), which the viewset implements; deserialize_state() receives None while the key holds nothing. SerializeStateSlots is a ready-made serialize_state/deserialize_state pair for classes that declare __slots__: it JSON-dumps/loads exactly those slots, optionally through a custom_json_encoder/custom_json_decoder pair (the same pattern used by settings.viewsets_context_json_encoder/decoder - see Context Processors).

Known limitations ​

Released under the MIT License.