Skip to content

strawberry-graphql/strawberry-orm

strawberry-orm

Backend-agnostic schema generation for Strawberry GraphQL on top of Django ORM, SQLAlchemy, and Tortoise ORM.

WARNING

🟧🟨 strawberry-orm is still in alpha. Expect breaking changes, incomplete APIs, and release-to-release churn while the package stabilizes. 🟨🟧

strawberry-orm helps you keep one Strawberry schema style across multiple ORMs. It focuses on:

  • model-backed Strawberry types
  • generated input, filter, and order types
  • list fields that expose filtering and ordering automatically
  • query optimization hooks to reduce N+1 queries
  • helpers for related-list mutation inputs

Installation

# Base package
uv add strawberry-orm

# With a backend
uv add strawberry-orm[django]
uv add strawberry-orm[sqlalchemy]
uv add strawberry-orm[tortoise]

You can do the same with pip:

pip install "strawberry-orm[sqlalchemy]"

Requirements:

  • Python >=3.12
  • strawberry-graphql>=0.311.0

Quick Start

  1. Create a backend instance.
  2. Generate Strawberry types from ORM models with @orm.type(...).
  3. Generate filter/order types from the same models.
  4. Expose list fields with orm.field().
  5. Add orm.optimizer_extension() to the schema.
import strawberry

from strawberry_orm import StrawberryORM, auto

orm = StrawberryORM(
    "sqlalchemy",
    dialect="postgresql",
    session_getter=lambda info: info.context["session"],
)

UserFilter = orm.filter(User)
UserOrder = orm.order(User)


@orm.type(User, filters=UserFilter, order=UserOrder)
class UserType:
    id: auto
    name: auto
    email: auto


@strawberry.type
class Query:
    users: list[UserType] = orm.field()


schema = strawberry.Schema(
    query=Query,
    extensions=[orm.optimizer_extension()],
)

That single users field will:

  • start from the backend's default queryset for User
  • accept filter and order arguments automatically because UserType carries them
  • let the optimizer eagerly load related data based on the GraphQL selection set

Backends

strawberry-orm follows Strawberry's mixed execution model:

  • sync and async schema execution are both supported
  • a resolver can return plain values, backend query objects, or awaitables
  • the optimizer extension handles both sync and async execution paths
  • direct helper APIs such as apply_ref_list(...) may be sync or awaitable depending on the backend/session in use

As a rule of thumb:

  • Django works in both sync and async Strawberry execution, but custom async resolvers still need sync_to_async(...) around direct Django ORM access
  • SQLAlchemy supports both sync Session and AsyncSession
  • Tortoise is async-first; use async Strawberry execution there
Backend Constructor Notes
Django StrawberryORM("django") Uses Django querysets directly; async execution is supported via Strawberry's mixed sync/async model.
SQLAlchemy StrawberryORM("sqlalchemy", dialect="postgresql", session_getter=...) Requires a SQLAlchemy Session or AsyncSession at resolve time.
Tortoise StrawberryORM("tortoise") Async ORM; use Strawberry's async execution path.

Django

orm = StrawberryORM("django")

When executing the schema asynchronously, custom resolvers that touch Django models directly should still wrap those ORM calls with sync_to_async(...), following the same guidance as strawberry-django.

SQLAlchemy

orm = StrawberryORM(
    "sqlalchemy",
    dialect="postgresql",
    session_getter=lambda info: info.context["session"],
)

SQLAlchemy needs a session when a query is executed. strawberry-orm can obtain it from either:

  • session_getter=...
  • info.context["session"]
  • info.context.session
  • info.context.get_session()

If your context stores a callable session factory, pass a session_getter instead of putting the callable directly on info.context.

Both sync and async sessions are supported:

# Sync session
orm = StrawberryORM(
    "sqlalchemy",
    dialect="postgresql",
    session_getter=lambda info: info.context["session"],
)

# Async session
orm = StrawberryORM(
    "sqlalchemy",
    dialect="postgresql",
    session_getter=lambda info: info.context["session"],
)

@strawberry.type
class Query:
    @strawberry.field
    def users(self) -> list[UserType]:
        return select(User)

# context["session"] can be either Session or AsyncSession

Tortoise

orm = StrawberryORM("tortoise")

Tortoise resolvers, mutations, and related-list helpers should be used from async Strawberry execution:

@strawberry.type
class Query:
    @strawberry.field
    async def users(self) -> list[UserType]:
        return await User.all()

Backend Options

Shared options:

Option Default Meaning
default_query_limit None Adds a default limit to list queries created from the backend default queryset.
exclude_sensitive_fields True Excludes sensitive-looking fields from generated input/filter/order types.
warn_sensitive True Emits warnings when sensitive-looking fields are exposed on generated output types.
hard_delete_refs False Makes apply_ref_list(..., delete=...) delete related rows instead of only unlinking them.
max_filter_depth 10 Caps recursive filter nesting.
max_filter_branches 50 Caps the total number of all / any / oneOf branches.
max_in_list_size 500 Caps inList / notInList filter size.
enable_regex_filters False Enables regex and iRegex string lookups.

SQLAlchemy-only options:

Option Default Meaning
dialect "postgresql" Chooses SQLAlchemy dialect-specific behavior.
session_getter None Returns the session for the current request.
filter_overrides {} Maps Python types to custom lookup input types.

Defining Types

@orm.type(Model)

Use @orm.type(Model) to turn an ORM model into a Strawberry object type.

from strawberry_orm import auto


@orm.type(User)
class UserType:
    id: auto
    name: auto
    email: auto

auto is an alias for strawberry.auto. The backend inspects the model and fills in the Python type for each field.

Keyword arguments:

  • include=[...]
  • exclude=[...]
  • name="CustomGraphQLTypeName"
  • filters=UserFilter
  • order=UserOrder
@orm.type(User, include=["id", "name"], name="PublicUser")
class PublicUserType:
    id: auto
    name: auto


@orm.type(User, exclude=["password_hash", "api_key"])
class SafeUserType:
    id: auto
    name: auto
    email: auto

Relations

Reference other generated types directly. The backend auto-generates resolvers for relationship fields:

@orm.type(Tag)
class TagType:
    id: auto
    name: auto


@orm.type(Post)
class PostType:
    id: auto
    title: auto
    tags: list[TagType]

If the nested type carries filters and/or order, list relations expose those arguments too.

Custom Strawberry Fields

You can mix generated fields with plain Strawberry fields:

@orm.type(User)
class UserType:
    id: auto
    name: auto
    email: auto

    @strawberry.field
    def display_name(self) -> str:
        return f"{self.name} <{self.email}>"

Type-Level Queryset Scoping

Define a get_queryset classmethod on a type to scope the model query centrally. When the optimizer extension is installed and a resolver returns a backend query object, get_queryset is applied automatically.

@orm.type(Post)
class PublishedPostType:
    id: auto
    title: auto
    is_published: auto

    @classmethod
    def get_queryset(cls, qs, info):
        return qs.filter(is_published=True)  # Django / Tortoise style
        # return qs.where(Post.is_published == True)        # SQLAlchemy style

This is useful for soft-delete filtering, multi-tenant scoping, "published only" content types, and reusable authorization-aware model filters.

orm.input(Model)

Generates a Strawberry input type from model metadata.

CreateUserInput = orm.input(User, include=["name", "email"])

Generated input fields are optional (defaulting to strawberry.UNSET), skip relations, exclude primary keys by default, and exclude sensitive-looking fields unless explicitly included.

Keyword arguments: include, exclude, exclude_pk=False, name.

orm.partial(Model)

UpdateUserInput = orm.partial(User, include=["name", "email"])

Same logic as input() with a default name like UserPartialInput. Useful for patch-style update payloads.


Filters and Ordering

Filters

Generate a filter input and attach it to a type:

UserFilter = orm.filter(User)

@orm.type(User, filters=UserFilter)
class UserType:
    id: auto
    name: auto
    email: auto

List fields returning UserType then accept a filter argument:

{
  users(filter: { field: { name: { exact: "Alice" } } }) {
    id
    name
  }
}

Filter Shape

Filters are recursive @oneOf trees supporting field, all, any, not, and oneOf:

# OR condition
{
  users(filter: {
    any: [
      { field: { name: { exact: "Alice" } } }
      { field: { name: { exact: "Bob" } } }
    ]
  }) { name }
}

# AND condition
{
  posts(filter: {
    all: [
      { field: { authorId: { exact: 1 } } }
      { field: { isPublished: { exact: true } } }
    ]
  }) { title }
}

# NOT condition
{
  users(filter: {
    not: { field: { email: { contains: "example.com" } } }
  }) { name }
}

Built-in Lookup Types

The package exports reusable lookup inputs:

StringLookup, BooleanLookup, IDLookup, IntComparisonLookup, FloatComparisonLookup, DateComparisonLookup, TimeComparisonLookup, DateTimeComparisonLookup

Typical string lookups: exact, neq, contains, iContains, startsWith, iStartsWith, endsWith, iEndsWith, inList, notInList, isNull.

Regex lookups (regex, iRegex) are disabled by default. Enable with enable_regex_filters=True.

Ordering

Generate an order input:

UserOrder = orm.order(User)

The generated type is a @oneOf input — each entry specifies exactly one column. The order argument is a list, where position determines tie-break priority:

{
  users(order: [{ name: ASC }, { email: DESC }]) {
    name
    email
  }
}

This sorts by name ascending first, then breaks ties by email descending.

Supported values from the Ordering enum: ASC, ASC_NULLS_FIRST, ASC_NULLS_LAST, DESC, DESC_NULLS_FIRST, DESC_NULLS_LAST.

Filters and ordering can be combined:

{
  posts(
    filter: { field: { isPublished: { exact: true } } }
    order: [{ title: DESC }]
  ) {
    title
  }
}

Queries

Automatic List Fields

If a field returns list[SomeType], orm.field() builds the resolver from the model attached to that type:

@orm.type(User, filters=UserFilter, order=UserOrder)
class UserType:
    id: auto
    name: auto
    email: auto


@strawberry.type
class Query:
    users: list[UserType] = orm.field()

You can also supply filter and order types explicitly:

@strawberry.type
class Query:
    users: list[UserType] = orm.field(filters=UserFilter, order=UserOrder)

Explicit Resolvers

For custom scoping, join logic, or backend-specific behavior, return a backend query object from a normal Strawberry resolver:

@strawberry.type
class Query:
    @strawberry.field
    def active_users(self, info: strawberry.types.Info) -> list[UserType]:
        return select(User).where(User.is_active.is_(True))  # SQLAlchemy
        # return User.objects.filter(is_active=True)         # Django
        # return User.filter(is_active=True)                 # Tortoise

This works with the optimizer extension and with type-level get_queryset hooks.

If you execute your schema asynchronously, the same pattern works with async resolvers too:

@strawberry.type
class Query:
    @strawberry.field
    async def active_users(self, info: strawberry.types.Info) -> list[UserType]:
        return await User.filter(is_active=True)  # Tortoise
        # return await sync_to_async(list)(User.objects.filter(is_active=True))  # Django

Mutations

Write plain @strawberry.mutation resolvers and use strawberry-orm for the generated input types:

CreatePostInput = orm.input(Post, include=["title", "body", "author_id"])


@strawberry.input
class UpdatePostInput:
    id: int
    title: str | None = strawberry.UNSET
    body: str | None = strawberry.UNSET


@strawberry.type
class Mutation:
    @strawberry.mutation
    def create_post(self, info: strawberry.types.Info, input: CreatePostInput) -> PostType:
        post = Post(title=input.title, body=input.body, author_id=input.author_id)
        ...
        return post

    @strawberry.mutation
    def update_post(self, info: strawberry.types.Info, input: UpdatePostInput) -> PostType | None:
        ...

Async mutations work too. Use async ORM calls in the resolver body, and await backend helpers when the active backend/session requires it:

@strawberry.type
class Mutation:
    @strawberry.mutation
    async def create_post(
        self,
        info: strawberry.types.Info,
        input: CreatePostInput,
    ) -> PostType:
        return await Post.create(
            title=input.title,
            body=input.body,
            author_id=input.author_id,
        )

Related List Inputs (orm.ref)

orm.ref(...) generates a @oneOf input for managing related lists:

CreateTagInput = orm.input(Tag, include=["name"])


@strawberry.input
class UpdateTagInput:
    id: strawberry.ID
    name: str


TagRef = orm.ref(Tag, create=CreateTagInput, update=UpdateTagInput, delete=True)

Each ref in the list can be one of:

  • { id: "1" } — link an existing row
  • { create: { ... } } — create a related row inline
  • { update: { id: "...", ... } } — update an existing related row
  • { delete: { id: "..." } } — unlink (or delete if hard_delete_refs=True)

Apply ref operations in a mutation:

@strawberry.mutation
def set_post_tags(self, info: strawberry.types.Info, post_id: int, tags: list[TagRef]) -> PostType | None:
    post = ...
    orm.apply_ref_list(post, "tags", tags, info)
    return post

Async backends can use the same helper from async mutations:

@strawberry.mutation
async def set_post_tags(
    self,
    info: strawberry.types.Info,
    post_id: int,
    tags: list[TagRef],
) -> PostType | None:
    post = await Post.get_or_none(pk=post_id)
    if post is None:
        return None

    await orm.apply_ref_list(post, "tags", tags, info)
    return post
mutation {
  setPostTags(postId: 1, tags: [
    { id: "2" }
    { update: { id: "1", name: "python3" } }
    { create: { name: "new-tag" } }
    { delete: { id: "3" } }
  ]) {
    id
    tags { id name }
  }
}

apply_ref_list supports mode="replace" (default, replaces the entire list) and mode="patch" (only touches mentioned items). An optional authorize callback receives (action, model, obj_id, info) and returns bool.

In practice:

  • use it directly in sync Django / sync SQLAlchemy mutations
  • await it for Tortoise
  • await it for SQLAlchemy when your request context carries an AsyncSession
  • in custom async Django resolvers, prefer the same async-safe pattern you already use for direct ORM calls

Query Optimization

Add the optimizer extension to your schema:

schema = strawberry.Schema(
    query=Query,
    mutation=Mutation,
    extensions=[orm.optimizer_extension()],
)

The optimizer:

  • executes backend query objects returned by your resolvers
  • eager-loads relations based on the GraphQL selection set
  • applies field-level hints registered through orm.field(...)
  • honors type-level get_queryset hooks

Field Hints

Inside @orm.type(...), orm.field(...) attaches optimizer metadata:

@orm.type(Post)
class PostType:
    id: auto
    title: auto
    tags: list[TagType] = orm.field(load=["author"])
    body: auto = orm.field(only=["id", "title", "body"])
Argument Meaning
load=[...] Extra eager-load paths to apply.
load=callable A callable that customises the queryset for a related field (see below).
only=[...] Restrict loaded columns.
compute={...} Register computed-column hints for the optimizer store.
disable_optimization=True Skip optimization for that field.
description="..." Forward a field description to Strawberry.

Custom Querysets on Related Fields (load=callable)

When load is a callable instead of a list, it receives the default queryset for the related model and returns a modified queryset. This lets you filter, reorder, or limit related objects from the parent level:

@orm.type(User)
class UserType:
    id: auto
    name: auto
    posts: list[PostType] = orm.field(
        load=lambda qs: qs.filter(is_published=True)
    )

How each backend applies the callable:

  • Django — wraps the relation in a Prefetch object with the custom queryset.
  • SQLAlchemy — extracts WHERE criteria from the modified select() and applies them via relationship.and_(...).
  • Tortoise — performs a separate batch query filtered by parent IDs and assigns results back to each parent instance.

This composes with type-level get_queryset. If the related type defines get_queryset and the field has a load callable, both are applied (type-level first, then the field-level callable):

@orm.type(Post)
class PublishedPostType:
    id: auto
    title: auto

    @classmethod
    def get_queryset(cls, qs, info):
        return qs.filter(is_published=True)


@orm.type(User)
class UserType:
    id: auto
    name: auto
    posts: list[PublishedPostType] = orm.field(
        load=lambda qs: qs.order_by("-created_at")
    )

The optimizer handles batching, so this avoids N+1 queries even with custom filtering.

Field Permissions

Use make_field(...) to attach Strawberry permission classes to a generated field:

from strawberry_orm import make_field


@orm.type(User)
class UserType:
    id: auto
    name: auto
    email: auto = make_field(permission_classes=[IsAuthenticated])

Security

strawberry-orm has safety-focused defaults, but you still need to make deliberate schema choices.

Defaults:

  • orm.input(), orm.filter(), and orm.order() exclude sensitive-looking fields such as password_hash, api_key, role, and is_admin by default
  • String regex filters are disabled by default
  • Filter depth, branch count, and inList size are capped by default
  • orm.ref(..., delete=True) unlinks by default; hard deletes require hard_delete_refs=True

Caveats:

  • orm.type() does not auto-hide sensitive output fields. It warns by default, but you must still use exclude=[...] or permission classes to protect them.
  • List queries are unbounded unless you set default_query_limit=...
  • apply_ref_list() only enforces authorization if you provide an authorize callback
  • GraphQL introspection, auth, and query-complexity limits are still your application's responsibility

A production-oriented configuration:

orm = StrawberryORM(
    "sqlalchemy",
    dialect="postgresql",
    session_getter=lambda info: info.context["session"],
    default_query_limit=100,
    max_filter_depth=8,
    max_filter_branches=25,
    max_in_list_size=200,
)

Public Exports

Top-level exports from strawberry_orm:

StrawberryORM, auto, make_field, make_ref_type, Ordering, FieldDefinition, FieldHints, OptimizerExtension, OptimizerStore, UNSET, and the built-in lookup input classes from strawberry_orm.filters.

License

MIT

About

No description, website, or topics provided.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages