Skip to content

Customise serializer context

DRF's get_serializer_context() lives on every GenericAPIView and returns {"request", "view", "format"} by default. When the standard viewset shape uses one serializer per action it's enough — but service- backed views run two serializers per request (input and output), and sometimes those two need different context. The library plumbs that without forcing you to override get_serializer_context() and branch on direction.

Four layers, later wins on overlap:

  1. DRF defaultview.get_serializer_context(). Always applied.
  2. Directional fallbackget_input_serializer_context() / get_output_serializer_context() on the view. Skipped when the method is absent, so plain DRF viewsets work unchanged.
  3. Per-action overrideget_<action>_input_serializer_context() / get_<action>_output_serializer_context(). Viewset-only; skipped on standalone single-purpose views.
  4. Per-spec callableServiceSpec.input_serializer_context / ServiceSpec.output_selector_spec.output_serializer_context / SelectorSpec.output_serializer_context. Co-located with the spec that backs the action. Has the final say.

The resolver is wired into service-backed views, viewset mixins, @service_action, and @selector_action. The standalone SelectorListView / SelectorRetrieveView override get_serializer_context() directly; the SelectorListMixin / SelectorRetrieveMixin viewset mixins inherit the same override from _ActionSpecsMixin. Either way, a SelectorSpec.output_serializer_context flows into DRF's ListModelMixin / RetrieveModelMixin dispatch.

Different context for input vs. output

A common case: the input serializer needs a "current tenant" for cross-field validation, but the output serializer doesn't.

class AuthorViewSet(ServiceViewSet):
    queryset = Author.objects.all()
    action_specs = {
        "create": ServiceSpec(
            service=create_author,
            input_serializer=CreateAuthorInput,
            output_selector_spec=SelectorSpec(
                kind=SelectorKind.RETRIEVE,
                output_serializer=AuthorOutputSerializer,
            ),
        ),
    }

    def get_input_serializer_context(self):
        return {"tenant": self.request.tenant}

Inside the input serializer:

class CreateAuthorInput(serializers.Serializer):
    name = serializers.CharField()

    def validate_name(self, value):
        tenant = self.context["tenant"]
        if Author.objects.filter(tenant=tenant, name=value).exists():
            raise serializers.ValidationError("name already taken in this tenant")
        return value

The output serializer keeps the default DRF context.

Per-action override

When only one action needs the extra key, name the hook after the action — no branching on self.action:

_author_out = SelectorSpec(
    kind=SelectorKind.RETRIEVE,
    output_serializer=AuthorOutputSerializer,
)


class AuthorViewSet(ServiceViewSet):
    action_specs = {
        "create": ServiceSpec(
            service=create_author,
            input_serializer=CreateAuthorInput,
            output_selector_spec=_author_out,
        ),
        "update": ServiceSpec(
            service=update_author,
            input_serializer=UpdateAuthorInput,
            output_selector_spec=_author_out,
        ),
    }

    def get_create_input_serializer_context(self):
        return {"signup_token": self.request.headers.get("X-Signup-Token")}

update keeps the directional / DRF default; create adds the token on top.

Per-spec context (the fourth layer)

When context belongs with the spec rather than the view, put it on the spec. This pairs the input/output context with the service/selector it feeds — no second method on the view, no if self.action == ....

class AuthorViewSet(ServiceViewSet):
    queryset = Author.objects.all()
    action_specs = {
        "create": ServiceSpec(
            service=create_author,
            input_serializer=CreateAuthorInput,
            input_serializer_context=lambda view, request: {
                "tenant": request.tenant,
            },
            output_selector_spec=SelectorSpec(
                kind=SelectorKind.RETRIEVE,
                output_serializer=AuthorOutputSerializer,
                output_serializer_context=lambda view, request: {
                    "include_links": "links" in request.query_params,
                },
            ),
        ),
        "list": SelectorSpec(
            kind=SelectorKind.LIST,
            selector=list_authors,
            output_serializer=AuthorOutputSerializer,
            output_serializer_context=lambda view, request: {
                "include_links": True,
            },
        ),
    }

SelectorSpec carries only output_serializer_context — selectors don't validate input, so there's no symmetrical input hook. On ServiceSpec, the output hook lives on the nested output_selector_spec; the input hook stays at the top level. The callable receives the calling view (typed as ServiceView) and the DRF Request.

Output context that depends on the resolved data

Sometimes the output serializer needs data derived from the very objects it's about to render — and you want that derived data fetched in a single batched query rather than one query per row. The output context provider can receive the resolved data and run that one query.

An output context provider (the directional get_output_serializer_context hook, the per-action get_<action>_output_serializer_context hook, or the spec-level output_serializer_context) may declare an extra keyword parameter naming the data about to be serialized:

Action Keyword Value
mutation output result the final (post-output_selector_spec) instance
retrieve instance the resolved object
list page the paginated object list (the full queryset when pagination is off)

The extra is passed only when the provider declares it (or accepts **kwargs), so existing (view, request) providers keep working unchanged — view and request are always positional. The provider always runs after the data is resolved, so the value is real, not a placeholder.

from django.db.models import Count

class PostViewSet(SelectorViewSet):
    queryset = Post.objects.all()
    action_specs = {
        "list": SelectorSpec(
            kind=SelectorKind.LIST,
            selector=list_posts,
            output_serializer=PostOutputSerializer,
            # One query for the whole page, keyed on the page's ids.
            output_serializer_context=lambda view, request, *, page: {
                "comment_counts": dict(
                    Comment.objects.filter(post__in=page)
                    .values_list("post")
                    .annotate(n=Count("id"))
                ),
            },
        ),
    }

The serializer reads self.context["comment_counts"][obj.id] per row, with no extra query. The same shape works for a mutation (*, result) or a retrieve (*, instance), and on the view-method hooks too — e.g. def get_list_output_serializer_context(self, *, page): ....

For an unpaginated list the page value is the full queryset; reading ids off it ([p.id for p in page]) reuses the same evaluated queryset DRF serializes, so it still costs one batched query, not two.

The input context provider has no such extra — there is no resolved output before the service runs.

On standalone views

Standalone mutation views (ServiceCreateView, ServiceUpdateView, ServiceDeleteView) and @service_action honour the directional hooks (get_input_serializer_context / get_output_serializer_context) plus the spec layer (ServiceSpec.input_serializer_context and ServiceSpec.output_selector_spec.output_serializer_context). The per-action hooks only apply where there is an action — i.e. inside viewset mixins and @service_action / @selector_action.

Standalone SelectorListView / SelectorRetrieveView honor SelectorSpec.output_serializer_context through their get_serializer_context() override. They do not honor the directional get_output_serializer_context hook there — that hook is reserved for the mutation flow's input/output split. If you want a shared "always add X to the context" on a selector view, override get_serializer_context() directly.