crefft Source 2026.10.4

Strawberry GraphQL Style Guide

crefft > Frameworks > Strawberry

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Extends Python style guide with Strawberry GraphQL-specific conventions.

Target Version: Strawberry 0.327.7 on Python 3.14.7

Quick Reference

All Python tooling applies. Additional considerations:

TaskToolCommand
Installuvuv add "strawberry-graphql[fastapi,cli]>=0.327.7,<0.328"
Run devStrawberry CLIuv run strawberry dev schema
Testpytestpytest
DocsBuilt-inGraphiQL at /graphql

Version Baseline

Projects MUST target Strawberry 0.327.71 on Python 3.14.72 and MUST declare a bounded version range instead of an open-ended floor:

# pyproject.toml
[project]
requires-python = ">=3.14"
dependencies = [
    "strawberry-graphql[fastapi,cli]>=0.327.7,<0.328",
]

Projects MUST commit the resolved lock file (uv.lock) alongside pyproject.toml so developer machines, CI, and production install identical versions.

Why: Strawberry publishes patch releases several times a week and five security advisories have been filed against versions that an open-ended 0.287+ floor still admits. A bounded range stops a resolver selecting an affected release, and the committed lock file makes the installed version reproducible and auditable.

Python 3.14 is in bugfix support and reaches end of life in October 20303, so it remains a valid runtime target; 3.14.7 is the audited patch level.

Security Floor

Every advisory below is fixed at or before 0.327.7:

AdvisoryAffected versionsFixed inImpact
GHSA-vpwc-v33q-mq89<= 0.312.20.312.3Authentication bypass via the legacy graphql-ws WebSocket subprotocol
GHSA-hv3w-m4g2-5x77<= 0.312.20.312.3Denial of service via unbounded WebSocket subscriptions
GHSA-x97m-qp5c-w9xj>= 0.288.4, <= 0.315.30.315.4Default GraphiQL may expose HTTP headers in URLs
GHSA-qfwv-87qj-98xq>= 0.71.0, <= 0.315.60.315.7Circular fragment reference denial of service
GHSA-fr49-mhgj-crfc>= 0.172.0, <= 0.315.60.315.7MaxAliasesLimiter bypass via fragment spreads

Projects MUST NOT deploy Strawberry below 0.315.7, which is the lowest release that carries all five advisory fixes. Projects MUST NOT deploy below 0.323.2 where any operation may execute synchronously, because MaskErrors leaked parsing and validation error text during synchronous execution until that release4.

Projects MUST apply Strawberry patch releases within one working week of publication and MUST audit the resolved dependency set in CI so a known-vulnerable version inside the pinned range fails the build:

uv export --frozen --no-emit-project --no-hashes -o requirements.txt
uvx pip-audit==2.10.1 --strict --no-deps --requirement requirements.txt

Why: The advertised support floor is what an operator reads as safe. 0.287+ promises support for releases carrying WebSocket authentication bypass, GraphiQL header leakage, and the alias and fragment denial-of-service defects, while this guide prescribes the very extensions and subscription endpoints those advisories target.

Bootstrapping a Project

The cli extra supplies the strawberry executable’s dependencies; the fastapi extra alone raises MissingOptionalDependenciesError. The server subcommand has been removed, so the dev server is started with strawberry dev:

# Don't: the FastAPI extra omits the CLI, and `server` is no longer a subcommand
uv add "strawberry-graphql[fastapi]"
strawberry server schema

# Do: install both extras at a bounded version and start the dev server
uv add "strawberry-graphql[fastapi,cli]>=0.327.7,<0.328"
uv run strawberry dev schema

strawberry dev schema imports the module named schema from the working directory and serves the schema object defined in it, so the argument MUST match a module that exposes a strawberry.Schema:

# schema.py
import strawberry


@strawberry.type
class Query:
    @strawberry.field
    def hello(self) -> str:
        return "world"


schema = strawberry.Schema(query=Query)

Why: The dev server is the documented entry point for exploring a schema, so a quick start that cannot install or start is worse than no quick start at all. Naming the module explicitly avoids the common failure where strawberry dev cannot import the argument it was given.

Why Strawberry?

Strawberry5 is a modern Python GraphQL library that uses Python’s native type hints and dataclasses to define schemas. Unlike schema-first approaches (like Ariadne) or verbose class-based definitions (like Graphene), Strawberry generates schemas directly from Python code.

Key advantages:

  • Python-native schema definition using decorators and type hints
  • Full async support for high-performance APIs
  • Built-in Mypy plugin for static type checking
  • First-class integrations with FastAPI, Django, and Flask
  • DataLoader support for solving N+1 queries

Use Strawberry when building GraphQL APIs in Python, especially with FastAPI or Django. For REST APIs, see FastAPI or Django REST Framework.

Project Structure

Projects SHOULD organize Strawberry applications by domain:

my_app/
├── src/
│   └── myapp/
│       ├── __init__.py
│       ├── main.py              # FastAPI app with GraphQL router
│       ├── config.py            # Settings
│       ├── database.py          # Database setup
│       ├── graphql/
│       │   ├── __init__.py
│       │   ├── schema.py        # Root schema composition
│       │   ├── context.py       # GraphQL context
│       │   ├── dataloaders.py   # DataLoader definitions
│       │   ├── scalars.py       # Custom scalar types
│       │   └── types/
│       │       ├── __init__.py
│       │       ├── user.py      # User type + resolvers
│       │       └── order.py     # Order type + resolvers
│       ├── models/
│       │   ├── __init__.py
│       │   └── user.py          # SQLAlchemy models
│       └── services/
│           └── user.py          # Business logic
├── tests/
│   ├── conftest.py
│   └── graphql/
│       └── test_users.py
├── pyproject.toml
└── .env

Why: Separating GraphQL types from database models and business logic maintains clean boundaries. The graphql/types/ directory mirrors your domain, making navigation intuitive.

Schema Definition

Types

Projects MUST use @strawberry.type for GraphQL object types:

# src/myapp/graphql/types/user.py
import strawberry
from datetime import datetime

@strawberry.type
class User:
    id: strawberry.ID
    email: str
    full_name: str
    created_at: datetime

    @strawberry.field
    def display_name(self) -> str:
        return self.full_name or self.email.split("@")[0]

Why: Strawberry leverages Python’s type system for schema generation. The decorator pattern is explicit and maintains Python’s readability while generating accurate GraphQL schemas.

Input Types

Projects MUST use @strawberry.input for mutation inputs:

@strawberry.input
class CreateUserInput:
    email: str
    password: str
    full_name: str | None = None

@strawberry.input
class UpdateUserInput:
    email: str | None = None
    full_name: str | None = None

Why: Separate input types from output types. GraphQL requires this distinction, and it enables different validation rules for creation vs. updates.

Enums

Projects MUST use @strawberry.enum for enumeration types:

import strawberry
from enum import Enum

@strawberry.enum
class OrderStatus(Enum):
    PENDING = "pending"
    PROCESSING = "processing"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

Resolvers

Async Resolvers

Projects MUST use async def for resolvers that perform I/O:

# src/myapp/graphql/types/user.py
import strawberry
from strawberry.types import Info
from myapp.graphql.context import Context
from myapp.models.user import User as UserModel

@strawberry.type
class Query:
    @strawberry.field
    async def user(self, info: Info[Context, None], id: strawberry.ID) -> User | None:
        db = info.context.db
        result = await db.execute(select(UserModel).where(UserModel.id == int(id)))
        user_model = result.scalar_one_or_none()
        if user_model:
            return User.from_model(user_model)
        return None

    @strawberry.field
    async def users(self, info: Info[Context, None]) -> list[User]:
        db = info.context.db
        result = await db.execute(select(UserModel))
        return [User.from_model(u) for u in result.scalars()]

Why: Strawberry documentation emphasizes: “It is recommended to use async def for all fields if handling concurrent requests on a single worker.” Synchronous functions block the entire worker despite FastAPI’s threadpool.

Field Arguments

Projects SHOULD use type hints for field arguments:

@strawberry.type
class Query:
    @strawberry.field
    async def users(
        self,
        info: Info[Context, None],
        limit: int = 10,
        offset: int = 0,
        status: OrderStatus | None = None,
    ) -> list[User]:
        query = select(UserModel)
        if status:
            query = query.where(UserModel.status == status.value)
        query = query.limit(limit).offset(offset)
        result = await info.context.db.execute(query)
        return [User.from_model(u) for u in result.scalars()]

Mutations

Projects MUST organize mutations with clear naming and input types:

# src/myapp/graphql/types/user.py
@strawberry.type
class Mutation:
    @strawberry.mutation
    async def create_user(
        self,
        info: Info[Context, None],
        input: CreateUserInput,
    ) -> User:
        service = UserService(info.context.db)
        user_model = await service.create_user(
            email=input.email,
            password=input.password,
            full_name=input.full_name,
        )
        return User.from_model(user_model)

    @strawberry.mutation
    async def update_user(
        self,
        info: Info[Context, None],
        id: strawberry.ID,
        input: UpdateUserInput,
    ) -> User:
        service = UserService(info.context.db)
        user_model = await service.update_user(
            user_id=int(id),
            **{k: v for k, v in input.__dict__.items() if v is not None},
        )
        return User.from_model(user_model)

    @strawberry.mutation
    async def delete_user(self, info: Info[Context, None], id: strawberry.ID) -> bool:
        service = UserService(info.context.db)
        return await service.delete_user(int(id))

Why: Use input types rather than multiple arguments for mutations with more than 2-3 parameters. This improves readability and makes schema evolution easier.

DataLoaders

Projects MUST use DataLoaders to solve the N+1 query problem:

# src/myapp/graphql/dataloaders.py
from collections.abc import Sequence
from strawberry.dataloader import DataLoader
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from myapp.models.user import User as UserModel
from myapp.models.order import Order as OrderModel

async def load_users(keys: Sequence[int], db: AsyncSession) -> list[UserModel | None]:
    result = await db.execute(select(UserModel).where(UserModel.id.in_(keys)))
    users_by_id = {u.id: u for u in result.scalars()}
    return [users_by_id.get(key) for key in keys]

async def load_orders_by_user(
    keys: Sequence[int], db: AsyncSession
) -> list[list[OrderModel]]:
    result = await db.execute(select(OrderModel).where(OrderModel.user_id.in_(keys)))
    orders_by_user: dict[int, list[OrderModel]] = {key: [] for key in keys}
    for order in result.scalars():
        orders_by_user[order.user_id].append(order)
    return [orders_by_user[key] for key in keys]

def create_dataloaders(db: AsyncSession) -> dict[str, DataLoader]:
    return {
        "users": DataLoader(load_fn=lambda keys: load_users(keys, db)),
        "orders_by_user": DataLoader(load_fn=lambda keys: load_orders_by_user(keys, db)),
    }
# Using DataLoaders in resolvers
@strawberry.type
class User:
    id: strawberry.ID
    email: str

    @strawberry.field
    async def orders(self, info: Info[Context, None]) -> list[Order]:
        orders = await info.context.dataloaders["orders_by_user"].load(int(self.id))
        return [Order.from_model(o) for o in orders]

Why: DataLoaders batch multiple individual loads into a single query. Without them, fetching 10 users with their orders would be 11 queries (1 + N). With DataLoaders, it’s 2 queries.

DataLoader Best Practices

  1. Create loaders per-request: Instantiate DataLoaders in context to prevent cross-request cache pollution
  2. Clear cache after mutations: Use loader.clear(key) after modifying data
  3. Prime cache when available: Use loader.prime(key, value) when data is already loaded

Context

Projects MUST use a typed context for dependency injection:

# src/myapp/graphql/context.py
from dataclasses import dataclass
from strawberry.fastapi import BaseContext
from sqlalchemy.ext.asyncio import AsyncSession
from strawberry.dataloader import DataLoader
from myapp.models.user import User

@dataclass
class Context(BaseContext):
    db: AsyncSession
    dataloaders: dict[str, DataLoader]
    current_user: User | None = None
# src/myapp/main.py
from fastapi import FastAPI, Depends
from strawberry.fastapi import GraphQLRouter
from myapp.graphql.schema import schema
from myapp.graphql.context import Context
from myapp.graphql.dataloaders import create_dataloaders
from myapp.database import get_db
from myapp.auth import get_current_user

async def get_context(
    db: AsyncSession = Depends(get_db),
    current_user: User | None = Depends(get_current_user),
) -> Context:
    return Context(
        db=db,
        dataloaders=create_dataloaders(db),
        current_user=current_user,
    )

graphql_app = GraphQLRouter(schema, context_getter=get_context)
app = FastAPI()
app.include_router(graphql_app, prefix="/graphql")

Why: Typed context enables IDE autocompletion and type checking. Creating DataLoaders and database sessions per-request ensures proper resource cleanup and cache isolation.

Permissions

Projects MUST use Strawberry’s permission system for authorization:

# src/myapp/graphql/permissions.py
from strawberry.permission import BasePermission
from strawberry.types import Info
from myapp.graphql.context import Context

class IsAuthenticated(BasePermission):
    message = "User is not authenticated"

    def has_permission(self, source: object, info: Info[Context, None], **kwargs) -> bool:
        return info.context.current_user is not None

class IsAdmin(BasePermission):
    message = "User is not an admin"

    def has_permission(self, source: object, info: Info[Context, None], **kwargs) -> bool:
        user = info.context.current_user
        return user is not None and user.is_admin

class IsOwner(BasePermission):
    message = "User does not own this resource"

    def has_permission(self, source: object, info: Info[Context, None], **kwargs) -> bool:
        user = info.context.current_user
        if user is None:
            return False
        # Check ownership based on the source object
        if hasattr(source, "user_id"):
            return source.user_id == user.id
        return False
# Applying permissions
@strawberry.type
class Mutation:
    @strawberry.mutation(permission_classes=[IsAuthenticated])
    async def create_order(self, info: Info[Context, None], input: CreateOrderInput) -> Order:
        ...

    @strawberry.mutation(permission_classes=[IsAdmin])
    async def delete_user(self, info: Info[Context, None], id: strawberry.ID) -> bool:
        ...

Why: Declarative permissions keep authorization logic separate from business logic. Permission classes are reusable and testable in isolation.

Schema Composition

Projects SHOULD compose schemas from domain-specific types:

# src/myapp/graphql/schema.py
import strawberry
from strawberry.extensions import QueryDepthLimiter, MaxTokensLimiter
from myapp.graphql.types.user import Query as UserQuery, Mutation as UserMutation
from myapp.graphql.types.order import Query as OrderQuery, Mutation as OrderMutation

@strawberry.type
class Query(UserQuery, OrderQuery):
    pass

@strawberry.type
class Mutation(UserMutation, OrderMutation):
    pass

schema = strawberry.Schema(
    query=Query,
    mutation=Mutation,
    extensions=[
        lambda: QueryDepthLimiter(max_depth=10),
        lambda: MaxTokensLimiter(max_token_count=1000),
    ],
)

Why: Multiple inheritance for Query/Mutation types enables domain-driven organization while producing a single coherent schema.

Security Extensions

Projects MUST configure security extensions for production:

from strawberry.extensions import (
    QueryDepthLimiter,
    MaxTokensLimiter,
    MaxAliasesLimiter,
    MaskErrors,
)

schema = strawberry.Schema(
    query=Query,
    mutation=Mutation,
    extensions=[
        lambda: QueryDepthLimiter(max_depth=10),
        lambda: MaxTokensLimiter(max_token_count=1000),
        lambda: MaxAliasesLimiter(max_alias_count=15),
        MaskErrors,  # Hide internal error details in production
    ],
)
ExtensionPurposeRecommended Value
QueryDepthLimiterPrevent deeply nested queries10-15
MaxTokensLimiterLimit query complexity1000-2000
MaxAliasesLimiterPrevent alias abuse10-20
MaskErrorsHide sensitive error infoEnable in production

Why: GraphQL’s flexibility can be exploited for denial-of-service attacks. These extensions prevent abuse while allowing legitimate queries.

Extension Behavior Depends on the Version

These extensions MUST NOT be treated as effective on their own, because three of them were themselves defective in releases the old 0.287+ floor admitted:

  • MaxAliasesLimiter counted aliases statically and so could be bypassed by fragment spread amplification up to 0.315.6 (GHSA-fr49-mhgj-crfc).
  • QueryDepthLimiter recursed without cycle detection, so circular fragment references crashed validation with a RecursionError up to 0.315.6 (GHSA-qfwv-87qj-98xq).
  • MaskErrors leaked parsing and validation error text during synchronous execution up to 0.323.14.

Projects MUST pass extension classes or zero-argument factories to Schema(extensions=...) rather than instances. From 0.316.0 an instance is deprecated, is rejected by the extensions type signature, and is reused for every request, so concurrent requests can observe each other’s ExecutionContext6.

# Don't: one shared instance per schema, and a DeprecationWarning from 0.316.0
extensions=[QueryDepthLimiter(max_depth=10), MaskErrors()]

# Do: a fresh extension per request
extensions=[lambda: QueryDepthLimiter(max_depth=10), MaskErrors]

Projects MUST cover the masking behavior with a test that pushes a malformed and an invalid operation through the synchronous entry point:

# tests/graphql/test_masking.py
import pytest

from myapp.graphql.schema import schema

@pytest.mark.parametrize(
    "query",
    ["{ users ", "{ notAField }"],
    ids=["malformed", "unknown-field"],
)
def test_sync_execution_masks_pre_execution_errors(query: str) -> None:
    result = schema.execute_sync(query)

    assert result.errors is not None
    assert [error.message for error in result.errors] == ["Unexpected error."]

Why: execute_sync takes a different code path from execute, so masking has to be asserted separately. On 0.323.1 the same assertions return Syntax Error: Expected Name, found <EOF>. and Cannot query field 'notAField' on type 'Query'., handing an attacker the schema shape. The test pins the fixed behavior so a downgrade inside the pinned range fails CI.

Disabling Introspection

Projects SHOULD disable introspection in production:

from strawberry.extensions import DisableIntrospection
from myapp.config import settings

extensions = [
    lambda: QueryDepthLimiter(max_depth=10),
]

if not settings.DEBUG:
    extensions.append(DisableIntrospection)

schema = strawberry.Schema(query=Query, mutation=Mutation, extensions=extensions)

FastAPI Integration

Projects using FastAPI MUST follow this integration pattern:

# src/myapp/main.py
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter
from myapp.graphql.schema import schema
from myapp.graphql.context import get_context
from myapp.config import settings

def create_app() -> FastAPI:
    app = FastAPI(title=settings.PROJECT_NAME)

    graphql_app = GraphQLRouter(
        schema,
        context_getter=get_context,
        graphql_ide="graphiql" if settings.DEBUG else None,
        allow_queries_via_get=False,  # Disable GET queries for security
    )

    app.include_router(graphql_app, prefix="/graphql")

    return app

app = create_app()

Django Integration

Projects using Django SHOULD use Strawberry’s Django integration:

# myapp/schema.py
import strawberry
from strawberry_django import type as django_type
from myapp.models import User as UserModel

@django_type(UserModel)
class User:
    id: strawberry.auto
    email: strawberry.auto
    full_name: strawberry.auto
# myapp/urls.py
from django.urls import path
from strawberry.django.views import AsyncGraphQLView
from myapp.schema import schema

urlpatterns = [
    path("graphql/", AsyncGraphQLView.as_view(schema=schema)),
]

Type Checking with Mypy

Projects MUST enable Strawberry’s Mypy plugin:

# pyproject.toml
[tool.mypy]
plugins = ["strawberry.ext.mypy_plugin"]

Why: The Mypy plugin validates that GraphQL types match their Python definitions, catching type mismatches before runtime.

Testing

Projects MUST test GraphQL operations using the schema directly:

# tests/conftest.py
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from myapp.graphql.schema import schema
from myapp.graphql.context import Context
from myapp.graphql.dataloaders import create_dataloaders
from myapp.database import Base

@pytest.fixture
async def db_session():
    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    async_session = async_sessionmaker(engine, expire_on_commit=False)
    async with async_session() as session:
        yield session

@pytest.fixture
def context(db_session: AsyncSession) -> Context:
    return Context(
        db=db_session,
        dataloaders=create_dataloaders(db_session),
        current_user=None,
    )
# tests/graphql/test_users.py
import pytest
from myapp.graphql.schema import schema

@pytest.mark.asyncio
async def test_create_user(context) -> None:
    query = """
        mutation CreateUser($input: CreateUserInput!) {
            createUser(input: $input) {
                id
                email
                fullName
            }
        }
    """
    result = await schema.execute(
        query,
        variable_values={
            "input": {
                "email": "test@example.com",
                "password": "password123",
                "fullName": "Test User",
            }
        },
        context_value=context,
    )
    assert result.errors is None
    assert result.data["createUser"]["email"] == "test@example.com"

@pytest.mark.asyncio
async def test_get_users(context, user_factory) -> None:
    await user_factory.create_batch(3)

    query = """
        query GetUsers {
            users {
                id
                email
            }
        }
    """
    result = await schema.execute(query, context_value=context)
    assert result.errors is None
    assert len(result.data["users"]) == 3

Why: Testing against the schema directly (rather than HTTP) isolates GraphQL logic from transport concerns. Pass variables dynamically rather than hardcoding them in query strings.

Testing Permissions

@pytest.mark.asyncio
async def test_create_order_requires_auth(context) -> None:
    query = """
        mutation CreateOrder($input: CreateOrderInput!) {
            createOrder(input: $input) {
                id
            }
        }
    """
    result = await schema.execute(
        query,
        variable_values={"input": {"items": []}},
        context_value=context,  # No current_user
    )
    assert result.errors is not None
    assert "not authenticated" in result.errors[0].message.lower()

Error Handling

Projects SHOULD use custom error types for domain errors:

# src/myapp/graphql/errors.py
from typing import Annotated

import strawberry

from myapp.graphql.types.user import User

@strawberry.type
class ValidationError:
    field: str
    message: str

@strawberry.type
class NotFoundError:
    message: str = "Resource not found"

# Union type for mutation responses
UserResult = Annotated[
    User | ValidationError | NotFoundError,
    strawberry.union("UserResult"),
]

Projects MUST declare unions with typing.Annotated. The types keyword argument to strawberry.union() was deprecated in 0.191.0 and removed in 0.298.0, where it now raises TypeError: union() got an unexpected keyword argument 'types' at import time7.

# Don't: removed in 0.298.0, fails when the module is imported
UserResult = strawberry.union("UserResult", types=[User, ValidationError, NotFoundError])

# Do: declare the members in the annotation
UserResult = Annotated[User | ValidationError | NotFoundError, strawberry.union("UserResult")]

Projects migrating from the removed form SHOULD run Strawberry’s codemod rather than editing declarations by hand:

uv run strawberry upgrade annotated-union src/myapp

Why: The union alias is evaluated while the module is imported, so a removed argument breaks the whole schema rather than a single field. The codemod rewrites every call site and adds the Annotated import, which avoids missed declarations in large schemas.

@strawberry.type
class Mutation:
    @strawberry.mutation
    async def create_user(
        self, info: Info[Context, None], input: CreateUserInput
    ) -> UserResult:
        # Check for existing user
        existing = await info.context.db.execute(
            select(UserModel).where(UserModel.email == input.email)
        )
        if existing.scalar_one_or_none():
            return ValidationError(field="email", message="Email already registered")

        user = await create_user_service(info.context.db, input)
        return User.from_model(user)

Why: Union types for results enable type-safe error handling in clients. This follows the “errors as data” pattern preferred in GraphQL over throwing exceptions.

Subscriptions

Projects MAY implement subscriptions for real-time updates:

import asyncio
from collections.abc import AsyncGenerator
import strawberry
from strawberry.types import Info
from myapp.graphql.context import Context

@strawberry.type
class Subscription:
    @strawberry.subscription
    async def order_status_changed(
        self, info: Info[Context, None], order_id: strawberry.ID
    ) -> AsyncGenerator[Order, None]:
        pubsub = info.context.pubsub
        async for message in pubsub.subscribe(f"order:{order_id}"):
            yield Order.from_dict(message)

For production subscriptions, use Redis pub/sub or similar message brokers.

Projects exposing subscriptions MUST run at least 0.312.3, which fixes an authentication bypass via the legacy graphql-ws WebSocket subprotocol (GHSA-vpwc-v33q-mq89) and a denial of service from unbounded WebSocket subscriptions (GHSA-hv3w-m4g2-5x77). The 0.327.7 baseline carries both fixes.

Why: The legacy graphql-ws handler did not require a completed connection_init handshake before processing subscription messages, so a client could skip the on_ws_connect authentication hook entirely. Both subprotocols are enabled by default in every integration that supports WebSockets and the client selects one through the Sec-WebSocket-Protocol header, so the endpoint is only as safe as the installed version.

Pre-commit Configuration

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.16.6
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v2.3.1
    hooks:
      - id: mypy
        additional_dependencies: [strawberry-graphql]
        args: [--strict]

See Also

References

Footnotes

  1. strawberry-graphql 0.327.7 on PyPI - Audited baseline release ↩

  2. Python 3.14.7 - Current 3.14 maintenance release ↩

  3. Status of Python versions - Python 3.14 support window ↩

  4. Strawberry 0.323.2 release notes - Synchronous MaskErrors fix ↩ ↩2

  5. Strawberry GraphQL - Python GraphQL library ↩

  6. Strawberry 0.316.0 release notes - Per-request extension construction ↩

  7. Strawberry 0.298.0 release notes - Removal of the types union argument ↩