Unified, backend-agnostic ORM abstraction for Strawberry GraphQL
Project description
strawberry-orm
Backend-agnostic schema generation for Strawberry GraphQL on top of Django ORM, SQLAlchemy, and Tortoise ORM.
Warning —
strawberry-ormis still in alpha. Expect breaking changes and incomplete APIs while the package stabilizes.
Contents
New to strawberry-orm: Installation → Quick Start → Backends → Scoping, resolution, and the optimizer
Going to production: Security → Scoping, resolution, and the optimizer → Production baseline
Feature reference: Guide — types, querying, mutations, relay, async
Part 1 — Get started
Part 2 — Scoping and the optimizer
Part 3 — Security
Part 4 — Guide
- Defining Types
- Filters and Ordering
- Custom Filters and Ordering
- Grouping and Aggregation
- Mutations
- Relay Integration
- Async Usage
Reference
Installation
uv add "strawberry-orm[sqlalchemy]" # or [django] or [tortoise]
Or with pip:
pip install "strawberry-orm[sqlalchemy]"
Requires Python >=3.12 and strawberry-graphql>=0.311.0.
Quick Start
Minimal blog API: users with published posts only. Assumes SQLAlchemy models User and Post where Post.is_published is a boolean. See Backends to wire session context.
import strawberry
from strawberry_orm import StrawberryORM, auto
orm = StrawberryORM.for_sqlalchemy(
dialect="postgresql",
session_getter=lambda info: info.context["session"],
)
@orm.type(Post)
class PostType:
id: auto
title: auto
@classmethod
def get_queryset(cls, qs, info):
return qs.filter(is_published=True) # hide drafts everywhere Post loads
@orm.type(User)
class UserType:
id: auto
name: auto
posts: list[PostType]
@strawberry.type
class Query:
users: list[UserType] = orm.field()
schema = orm.schema(query=Query)
QUERY = "{ users { name posts { title } } }"
# result = schema.execute_sync(QUERY, context_value={"session": session})
# → drafts excluded; only published post titles under each user
Next: Scoping, resolution, and the optimizer explains why drafts are hidden and what you must configure yourself.
Production: Security
Full tour: Guide · Appendix: Full example
Backends
Session and context requirements differ by backend. Tortoise users should also read Async Usage.
| Backend | Constructor | Notes |
|---|---|---|
| Django | StrawberryORM.for_django(...) |
Uses Django querysets directly. |
| SQLAlchemy | StrawberryORM.for_sqlalchemy(dialect="...", session_getter=...) |
Requires a Session or AsyncSession at resolve time. |
| Tortoise | StrawberryORM.for_tortoise(...) |
Async ORM; use async Strawberry execution. |
- Django — sync and async schema execution both work. Custom async resolvers that touch the ORM directly still need
sync_to_async(...). - SQLAlchemy — the session is resolved from
session_getter,info.context["session"],info.context.session, orinfo.context.get_session(). Both sync and async sessions are supported. - Tortoise — async-first. Use
awaitin resolvers and mutations.
Backend options reference
Shared options:
| Option | Default | Meaning |
|---|---|---|
default_query_limit |
None |
Default limit for auto-generated list queries. |
exclude_sensitive_fields |
True |
Excludes sensitive-looking fields from generated input/filter/order types. |
warn_sensitive |
True |
Warns when sensitive-looking fields are exposed on output types. |
warn_missing_queryset |
True |
Warns when an @orm.type class has no get_queryset classmethod. |
lazy_resolution |
"warn" |
"off", "warn", or "error" when a GraphQL relation field has no explicit resolver. Use orm.schema() for eager loading. |
enable_optimizer |
True |
When using orm.schema(), mount the query optimizer extension automatically. |
max_filter_depth |
10 |
Caps recursive filter nesting. |
max_filter_branches |
50 |
Caps all / any / oneOf branch count. |
max_in_list_size |
500 |
Caps inList / notInList size. |
enable_regex_filters |
False |
Enables regex and iRegex string lookups. |
Django-only:
| Option | Default | Meaning |
|---|---|---|
django_async_safe |
True |
Offloads sync ORM resolvers with sync_to_async(thread_sensitive=True) under async GraphQL. |
SQLAlchemy-only:
| Option | Default | Meaning |
|---|---|---|
dialect |
"postgresql" |
SQLAlchemy dialect. |
session_getter |
None |
Callable returning the session for the current request. |
filter_overrides |
{} |
Maps Python types to custom lookup input types. |
Scoping, resolution, and the optimizer
Resolution paths drive scoping and N+1 behavior. The Quick Start example shows the minimal schema; this chapter explains what happens when it runs.
Three layers
| Layer | Role |
|---|---|
| GraphQL schema | Strawberry types, field resolvers, selection set |
Optimizer (orm.schema()) |
Executes query objects, prefetches relations, applies get_queryset and field hints |
| ORM backend | Django queryset, SQLAlchemy select, Tortoise QuerySet |
Resolution flow
For { users { name posts { title } } }:
Query.usersreturns a User queryset/select;UserType.get_querysetmay filter it (including joins on related tables — that only affects which users match).- The optimizer walks the selection set, sees
postsunderusers. - For prefetch criteria it calls
PostType.get_queryset(and anyload=callableonUserType.posts) — a separate scoping step for Post rows, not a re-run ofUserType.get_queryset. - One batched query loads users and scoped posts.
- GraphQL reads
user.postsfrom prefetched data — no second scoping pass on SQLAlchemy/Django for plain annotation relations.
flowchart TD
Q["Query.users"] --> R["Root resolver returns User select/queryset"]
R --> O["Optimizer: apply UserType.get_queryset"]
O --> W["Walk selection set → sees posts"]
W --> P["Build prefetch: PostType.get_queryset + load=callable"]
P --> SQL["Execute batched SQL"]
SQL --> G["GraphQL serializes instances"]
get_queryset — one model, one type
define get_queryset on the @orm.type class for the model being loaded. A query with nested fields runs one hook per model loaded — not one hook for the whole path:
| Query path | When loading root rows | When loading nested relation |
|---|---|---|
posts { … } |
PostType.get_queryset |
— |
users { posts { … } } |
UserType.get_queryset |
PostType.get_queryset on posts |
posts { author { … } } |
PostType.get_queryset |
UserType.get_queryset on author |
The important rule: UserType.get_queryset on the root does not scope Post rows when posts is in the selection — that requires PostType.get_queryset (or UserType.posts with load=callable). Both hooks run, but each only filters its own model's query.
Parent scoping does not flow to children
In GraphQL, users { posts { … } } looks like one tree — but ORM loading is per model, in separate steps. UserType.get_queryset runs when User rows are fetched for the root field; PostType.get_queryset runs when Post rows are fetched for the nested posts field. Scoping on the parent type does not substitute for scoping on the child type — even if the parent's get_queryset joins or filters on related tables.
What parent get_queryset can do: restrict which parent rows are returned, including via nested criteria:
@classmethod
def get_queryset(cls, qs, info):
# ✓ valid — return only users who have at least one published post
return qs.filter(posts__is_published=True).distinct()
For { users { name } } (no posts in the selection), this correctly hides users with no published work. The filter runs on the User query — it does not configure how Post rows load later.
What it cannot do: scope the nested posts field when clients request child data. The join above checks existence of a matching post; it does not limit which posts appear under each user when the relation is loaded:
@orm.type(User)
class UserType:
id: auto
name: auto
posts: list[PostType]
@classmethod
def get_queryset(cls, qs, info):
return qs.filter(posts__is_published=True).distinct()
@orm.type(Post)
class PostType:
id: auto
title: auto
# no get_queryset
Database:
| User | Post | is_published | author |
|---|---|---|---|
| Alice | "Hello world" | true | Alice |
| Alice | "Secret draft" | false | Alice |
| Bob | "Bob's only post" | false | Bob |
Query: { users { name posts { title } } }
| Step | Hook | Effect |
|---|---|---|
| Load users | UserType.get_queryset |
Alice included (has a published post); Bob excluded |
| Load posts | PostType.get_queryset |
not defined — all of Alice's posts load |
{
"users": [
{
"name": "Alice",
"posts": [
{ "title": "Hello world" },
{ "title": "Secret draft" }
]
}
]
}
Alice passed the parent filter because she has a published post, but the draft still appears — the nested load never ran UserType.get_queryset again and never applied a publish filter to Post rows.
Rule of thumb:
| Goal | Where to scope |
|---|---|
| Hide users with no published posts | UserType.get_queryset with a join/exists on posts |
| Hide draft posts under each user | PostType.get_queryset (or UserType.posts with load=callable) |
| Both | both — parent join for user list, child scope for post rows |
Example — active users, unscoped posts (no nested join):
@orm.type(User)
class UserType:
id: auto
name: auto
posts: list[PostType]
@classmethod
def get_queryset(cls, qs, info):
return qs.filter(is_active=True) # scope User rows only
@orm.type(Post)
class PostType:
id: auto
title: auto
# no get_queryset — Post rows are unscoped
Same leak: Bob (inactive) is excluded, but Alice's draft still appears under posts because UserType.get_queryset only filters the users table, not the posts loaded via the relation.
Fix — scope Post rows wherever they load:
@orm.type(Post)
class PostType:
id: auto
title: auto
@classmethod
def get_queryset(cls, qs, info):
return qs.filter(is_published=True) # scope Post rows everywhere they load
Now { users { posts { title } } } and { posts { title } } both hide drafts. The same rule applies for multi-tenant IDs, soft deletes, or auth: scope every model type clients can reach, not just the root — and don't assume a parent join replaces child scoping.
UserType.postswithload=callableadds extra criteria on that relation edge only — it still composes on top ofPostType.get_queryset, and does not replace it. See Tracing scoping hooks.
Resolver kinds
| Kind | Example | Scoping |
|---|---|---|
| Root list | users: list[UserType] = orm.field() |
Optimizer + UserType.get_queryset |
| Root custom queryset | @orm.field def active_users(): return select(User).where(...) |
Optimizer + UserType.get_queryset on root; nested fields use child types' hooks — see Root custom queryset |
| Annotation relation | posts: list[PostType] |
Related type's get_queryset; optimizer prefetch |
Field load=callable |
orm.field(load=lambda qs: …) |
Composes after related type's get_queryset |
@orm.field override |
def author(self): return self.author |
Resolver as written; scoping via prefetch only |
@strawberry.field override |
fully custom | You own scoping and auth |
| Root returns instances | return session.scalars(select(User)).all() |
Optimizer skipped; nested relations may be unscoped |
Scoping hooks run at prefetch time
get_queryset and load=callable run before SQL executes, during optimizer prefetch setup. They compose in order: type-level first, then field-level.
| Mechanism | Purpose | Composes with get_queryset |
|---|---|---|
get_queryset on related type |
Default row scope for that model | — (base layer) |
load=callable on parent field |
Extra scope for this relation edge | Yes, second |
load=["rel"] list |
Extra eager-load paths only | No filtering |
When scoping does not run: strawberry.Schema(query=Query) without orm.schema() on SQLAlchemy/Django skips prefetch scoping hooks. Custom root resolvers that return materialized instances skip the optimizer entirely.
Tortoise note: annotation-only list relations also apply _apply_nested_queryset at resolve time when prefetch did not run. The get_queryset then load=callable order is the same inside that helper.
Tracing scoping hooks
Add print(..., flush=True) to see hook order. The repo tests this by patching print — see tests/backends/*/test_query_scoping_hook_order.py.
@classmethod
def get_queryset(cls, qs, info):
print("SCOPE:PostType.get_queryset", flush=True)
return qs.filter(is_published=True)
posts: list[PostType] = orm.field(
load=lambda qs: (
print("SCOPE:UserType.posts.load", flush=True) or qs.filter(title != "GraphQL Guide")
)
)
For { users { name posts { title } } }, stdout is always:
SCOPE:PostType.get_queryset
SCOPE:UserType.posts.load
With plain posts: list[PostType] (no load=), only the first line appears. Neither hook runs again when GraphQL reads each user.posts.
Root custom queryset
A root Query field (e.g. active_users) is not a nested relation — it returns User rows directly. With orm.schema(), returning an unexecuted select/queryset still triggers the optimizer: UserType.get_queryset composes on your filter, and nested fields like posts get PostType.get_queryset during prefetch.
@strawberry.type
class Query:
@orm.field
def active_users(self, info) -> list[UserType]:
return select(User).where(User.is_active.is_(True)) # ✓ query object
# users: list[UserType] = orm.field() # alternative: get_default_queryset + UserType.get_queryset
Return a query object, not session.scalars(...).all(). Prefer UserType.get_queryset when the same scope applies everywhere User loads; use a custom root resolver for entry-point-specific criteria. See List Fields for a comparison table.
orm.schema()
Build schemas with orm.schema() — the query optimizer is enabled by default. It executes backend query objects, eager-loads relations from the selection set, applies field hints, and honors get_queryset. On SQLAlchemy and Django, nested scoping depends on this — not optional for correct row access.
schema = orm.schema(query=Query, mutation=Mutation)
schema = orm.schema(query=Query, extensions=[MyCustomExtension()])
# Opt out per schema or globally:
schema = orm.schema(query=Query, optimizer=False)
orm = StrawberryORM.for_sqlalchemy(enable_optimizer=False, ...)
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"])
@orm.type(User)
class UserType:
posts: list[PostType] = orm.field(load=lambda qs: qs.filter(is_published=True))
| Argument | Meaning |
|---|---|
load=[...] |
Extra eager-load paths (no filtering). |
load=callable |
Extra scope on this relation edge; composes after get_queryset. |
only=[...] |
Restrict loaded columns. |
compute={...} |
Computed-column hints for the optimizer store. |
disable_optimization=True |
Skip optimization for that field. |
description="..." |
Forward a field description to Strawberry. |
Tangible example: root queryset vs load=
Clients often use a custom root resolver that already returns a filtered queryset. That scopes User rows for that field only. Nested posts still load in a separate optimizer step — there is no resolver on UserType.posts that returns Post.objects…, so you cannot “fix drafts” by returning a queryset higher in the tree.
Schema (Django):
from typing import Annotated
import strawberry
from strawberry_orm import StrawberryORM
from strawberry_orm.types import auto
orm = StrawberryORM.for_django()
from myapp.models import Post, User # Django models
@orm.type(Post)
class PostType:
id: auto
title: auto
is_published: auto
@orm.type(User)
class UserType:
id: auto
name: auto
# No custom resolver — optimizer prefetches posts when the query asks for them.
posts: list[PostType] = orm.field(
load=lambda qs: qs.filter(is_published=True) # scope this edge only
)
@strawberry.type
class Query:
@orm.field
def active_users(self, info) -> list[UserType]:
# ✓ Scopes which *users* are returned from this field
return User.objects.filter(is_active=True)
What goes wrong without load= (or PostType.get_queryset):
query {
activeUsers {
name
posts {
title
isPublished
}
}
}
| Row | User | Post title | is_published |
|---|---|---|---|
| 1 | Alice (active) | Hello world | true |
| 2 | Alice (active) | Secret draft | false |
| 3 | Bob (inactive) | — | — |
active_users excludes Bob, but Alice’s draft still appears under posts — the root queryset never filtered Post rows.
What each mechanism does here:
| Mechanism | Runs when | Effect in this example |
|---|---|---|
active_users returns User.objects.filter(is_active=True) |
Root field resolve | Hides inactive users |
Optimizer walks posts { title isPublished } |
Before SQL on users | Adds prefetch_related for posts |
load=lambda qs: qs.filter(is_published=True) on UserType.posts |
Building that prefetch | Drops draft posts under each user |
PostType.get_queryset (alternative) |
Same prefetch step | Same publish filter for all Post loads |
You do not need load=["posts"] when the client already selects posts { … } — the optimizer follows the selection set (including ... on SomeType inline fragments and named fragment spreads). Use load=[...] when you want extra joins beyond what the query selected, or load=callable / get_queryset when nested rows must be filtered.
Union inline fragments — common in production APIs:
query {
activeUsers {
posts {
... on PostType {
title
isPublished
}
}
}
}
With orm.schema(), the optimizer walks fields inside each inline branch so posts and nested relations are still prefetched (no AttributeError on fragment nodes).
Field permissions via make_field(permission_classes=[...]) — see Defining Types.
If nested rows are unscoped, verify
orm.schema(), query objects at the root, andget_queryseton every exposed type. See Security.
Security
strawberry-orm has safety-focused defaults, but schema design determines what clients can read and write.
What the library does by default
orm.input(),orm.filter(), andorm.order()exclude sensitive-looking fields (password_hash,api_key,role,is_admin, etc.)- String regex filters are disabled by default
- Filter depth, branch count, and
inListsize are capped orm.ref()provides explicitunlinkanddeleteoperations — both opt-in viaunlink=Trueanddelete=True- When you use
orm.schema()(optimizer enabled by default), nested relation loads honor each type'sget_queryset— but only for types where you define it (the ORM warns at type registration whenget_querysetis missing)
Your responsibility
| Concern | Library | You |
|---|---|---|
| Authentication | — | middleware, info.context, permission classes |
| Row access | get_queryset per exposed type |
define on every model type clients can reach — parent scoping does not flow to children |
| Column exposure | — | exclude=[...] on @orm.type, or make_field(permission_classes=…) |
| Query size | — | default_query_limit |
| Mutations | — | auth in resolvers; authorize callback on apply_ref_list |
| Custom resolvers | — | same as hand-written DB access — you own scoping |
Common mistakes
Each item links to the scoping chapter for mechanics. Expand for full examples.
- Parent
get_querysetdoes not scope nestedposts— including joins likeposts__is_published=Trueon User. → Parent scoping does not flow to children - Custom
@strawberry.fieldon relations — e.g.return self.posts.all()skipsPostType.get_queryset. → Resolver kinds strawberry.Schemainstead oform.schema()— nested scoping hooks skipped on SQLAlchemy/Django. →orm.schema()- Root resolver returns materialized instances — optimizer cannot prefetch or scope nested relations. → Root custom queryset
get_querysetignoresinfo.context— hardcoded tenant/user filters leak cross-request. →get_queryset@orm.typeexposes sensitive columns viaauto— output types do not auto-hide secrets. → Defining Types
Full mistake examples
UserType.get_queryset does not filter users { posts }
@classmethod
def get_queryset(cls, qs, info):
return qs.filter(posts__is_published=True).distinct()
# ✓ { users { name } } ✗ drafts still under { users { posts { title } } }
Custom @strawberry.field resolvers bypass auto scoping
@strawberry.field
def posts(self) -> list[PostType]:
return self.posts.all() # PostType.get_queryset never applied
strawberry.Schema(...) instead of orm.schema(...)
schema = strawberry.Schema(query=Query) # ✗ drafts visible via lazy loads
Root resolver returns materialized instances
return session.scalars(select(User)).all() # ✗
return orm.get_default_queryset(User) # ✓
get_queryset ignores info.context
return qs.filter(tenant_id=info.context["tenant_id"]) # ✓ not hardcoded
@orm.type exposes sensitive columns via auto
@orm.type(User, exclude=["password_hash", "api_key"])
class UserType:
id: auto
name: auto
Production baseline
orm = StrawberryORM.for_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,
)
schema = orm.schema(query=Query) # optimizer on by default
Guide
Feature documentation below assumes Scoping, resolution, and the optimizer. Each section covers one capability — types, querying, writes, relay, async.
Defining Types
Relation fields load the related model; scoping is per type — see Scoping, resolution, and the optimizer.
@orm.type(Model)
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 resolves the Python type for each field.
Keyword arguments: include, exclude, name, filters, order.
@orm.type(User, exclude=["password_hash", "api_key"], name="PublicUser")
class PublicUserType:
id: auto
name: auto
email: auto
Relations
Reference other generated types directly. The backend auto-generates resolvers for relationship fields:
@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 automatically.
@orm.field Decorator
Overrides auto-generated resolvers — see the resolver kinds table. Use @orm.field (bare, without parentheses) as a decorator on resolver methods. It works for related models, computed fields, and querysets:
@orm.type(Post)
class PostType:
id: auto
title: auto
# Forward FK — resolves a single related model
@orm.field
def author(self) -> UserType:
return self.author
# Computed scalar
@orm.field
def title_upper(self) -> str:
return self.title.upper()
When the return type is a list[T] where T has filters/ordering, @orm.field() (with parentheses) auto-adds filter and order arguments — just like the assignment form. Bare @orm.field without parentheses does not.
@orm.field() with parentheses also accepts keyword arguments (filters, order, load, only, etc.).
List Fields
orm.field() builds a list resolver from the model attached to the return type:
@strawberry.type
class Query:
users: list[UserType] = orm.field()
For a root field with custom criteria, return an unexecuted select/queryset from @orm.field — still optimized by orm.schema(). See Root custom queryset.
Define row scope on the type with get_queryset — see get_queryset and the Quick Start.
Custom Fields
Mix generated fields with custom resolvers. Use @orm.field for resolvers that return ORM data, or @strawberry.field for purely computed values:
@orm.type(User)
class UserType:
id: auto
name: auto
email: auto = make_field(permission_classes=[IsAuthenticated]) # from strawberry_orm
@orm.field
def display_name(self) -> str:
return f"{self.name} <{self.email}>"
orm.input(Model) and orm.partial(Model)
Generate input types from model metadata:
CreateUserInput = orm.input(User, include=["name", "email"])
UpdateUserInput = orm.partial(User, include=["name", "email"])
input() and partial() share the same signature: include, exclude, exclude_pk (default True), name. Fields are optional (defaulting to strawberry.UNSET), skip relations, exclude primary keys by default, and exclude sensitive-looking fields unless explicitly included.
Filters and Ordering
Filters narrow rows within a type's queryset; they do not replace get_queryset — see Security and Scoping, resolution, and the optimizer.
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
{ users(filter: { any: [
{ field: { name: { exact: "Alice" } } }
{ field: { name: { exact: "Bob" } } }
] }) { name } }
# AND
{ posts(filter: { all: [
{ object: { author: { field: { id: { exact: 1 } } } } }
{ field: { isPublished: { exact: true } } }
] }) { title } }
# NOT
{ users(filter: {
not: { field: { email: { contains: "example.com" } } }
}) { name } }
Built-in lookup types
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.
Object Traversal
When filters are registered for related models, the generated filter gains an object key for filtering by conditions on related objects:
UserFilter = orm.filter(User)
PostFilter = orm.filter(Post) # Post has an "author" relation to User
{
posts(filter: {
object: { author: { field: { name: { exact: "Alice" } } } }
}) { title }
}
Object traversal composes with boolean operators and supports multi-level nesting when intermediate models also have registered filters:
# Comments on posts written by Alice
{
comments(filter: {
object: { post: {
object: { author: { field: { name: { exact: "Alice" } } } }
} }
}) { body }
}
The object type is @oneOf. Relations only appear in object if their target model already has a registered filter at the time orm.filter() is called -- register leaf models first.
Filter Projection
Pass project={...} to control which relations appear in object and how deep traversal can go:
UserFilter = orm.filter(User)
TagFilter = orm.filter(Tag)
CommentFilter = orm.filter(Comment)
PostFilter = orm.filter(Post, project={"author": {}}) # only author, not tags/comments
Sub-project dicts control nested traversal. {} means "include as a leaf" (no further object traversal). A non-empty dict lists reachable relations:
CommentFilter = orm.filter(Comment, project={
"post": {"author": {}}, # Comment -> post -> author (but not post -> tags)
})
project value |
Behavior |
|---|---|
None (default) |
Auto-include all relations with registered filters |
{} |
No object type (scalar lookups only) |
{"rel": {}} |
Include rel as a leaf |
{"rel": {"nested": {}}} |
Include rel, allow traversal to nested from it |
Projected filters are cached internally and do not overwrite the global filter registry.
Ordering
UserOrder = orm.order(User)
Each order entry is a @oneOf input with a field key (for scalar columns) or an object key (for related models). Position in the list determines tie-break priority:
{
users(order: [{ field: { name: ASC } }, { field: { email: DESC } }]) {
name
email
}
}
Supported values: ASC, ASC_NULLS_FIRST, ASC_NULLS_LAST, DESC, DESC_NULLS_FIRST, DESC_NULLS_LAST.
Order by Related Object
When order types are registered for related models, the generated order gains an object key that lets you sort by fields on related objects — mirroring the filter object traversal structure:
{
posts(order: [
{ object: { author: { field: { name: ASC } } } }
{ field: { title: DESC } }
]) {
title
}
}
Registration order matters: define related orders before the parent (e.g. orm.order(User) before orm.order(Post)).
Custom Filters and Ordering
orm.filter() and orm.order() auto-generate types from model introspection. When you need filter logic that goes beyond column lookups — full-text search across multiple fields, subquery-based conditions, or ordering by computed values — use orm.filter_type() and orm.order_type() with the @filter_field and @order_field decorators.
Custom Filter Types
orm.filter_type(Model) is a class decorator. Annotate fields with auto for standard lookups (identical to what orm.filter() generates). Add methods decorated with @filter_field for custom logic:
from strawberry_orm import StrawberryORM, filter_field, auto
orm = StrawberryORM.for_sqlalchemy(dialect="postgresql", session_getter=...)
@orm.filter_type(User)
class UserFilter:
name: auto # standard StringLookup
email: auto # standard StringLookup
@filter_field
def search(self, value: str, query):
"""Full-text search across name and email."""
from sqlalchemy import or_
return query.where(
or_(User.name.ilike(f"%{value}%"), User.email.ilike(f"%{value}%"))
)
@filter_field
def has_posts(self, value: bool, query):
"""Filter users who have (or lack) any posts."""
from sqlalchemy import func, select
subq = (
select(func.count(Post.id))
.where(Post.author_id == User.id)
.correlate(User)
.scalar_subquery()
)
if value:
return query.where(subq > 0)
return query.where(subq == 0)
Each @filter_field method must:
- Have a
valueparameter with a type annotation — this becomes the GraphQL input type for the field. - Have a
queryparameter — receives the backend's native query object (DjangoQuerySet, SQLAlchemySelect, or TortoiseQuerySet). - Return the modified query.
- Optionally accept an
infoparameter to receive the StrawberryInfocontext.
The generated GraphQL input places custom fields as top-level keys alongside field, object, all, any, not, and oneOf:
input UserFilter @oneOf {
field: UserField # auto-generated scalar lookups
object: UserFilterObject # auto-generated relation lookups (if any)
search: String # custom
hasPosts: Boolean # custom
all: [UserFilter!]
any: [UserFilter!]
not: UserFilter
oneOf: [UserFilter!]
}
Since filters are @oneOf, combine custom filters with standard lookups using all or any:
{
users(filter: { all: [
{ search: "john" },
{ field: { email: { contains: "example.com" } } }
] }) {
name
email
}
}
Custom Order Types
orm.order_type(Model) works the same way. auto fields get the standard Ordering enum. Methods decorated with @order_field receive a value of type Ordering (ASC, DESC, etc.) and return the modified query:
from strawberry_orm import order_field
from strawberry_orm.types import Ordering
@orm.order_type(User)
class UserOrder:
name: auto # standard Ordering (ASC/DESC/...)
@order_field
def post_count(self, value: Ordering, query):
"""Order users by how many posts they have."""
from sqlalchemy import func
query = query.outerjoin(Post, Post.author_id == User.id).group_by(User.id)
col = func.count(Post.id)
if "DESC" in value.value:
return query.order_by(col.desc())
return query.order_by(col.asc())
The generated GraphQL input:
input UserOrder @oneOf {
field: UserOrderField # auto-generated
object: UserOrderObject # auto-generated (if relations exist)
postCount: Ordering # custom
}
Custom and standard orders compose naturally in the order list:
{
users(order: [
{ postCount: DESC },
{ field: { name: ASC } }
]) {
name
}
}
Using Custom Types
Custom filter and order types are used exactly like auto-generated ones:
@orm.type(User, filters=UserFilter, order=UserOrder)
class UserType:
id: auto
name: auto
email: auto
@strawberry.type
class Query:
@orm.field
def users(self) -> list[UserType]:
return orm.get_default_queryset(User)
They also work with Relay connections and orm.connection().
Backend-Specific Examples
The query manipulation inside @filter_field and @order_field methods is backend-specific since it operates on native query objects. Here are equivalent examples for each backend:
Django
from django.db.models import Q, Count, F
@orm.filter_type(User)
class UserFilter:
name: auto
@filter_field
def search(self, value: str, query):
return query.filter(Q(name__icontains=value) | Q(email__icontains=value))
@orm.order_type(User)
class UserOrder:
name: auto
@order_field
def post_count(self, value: Ordering, query):
query = query.annotate(_post_count=Count("posts"))
dir_value = value.value
if dir_value.startswith("DESC"):
return query.order_by(F("_post_count").desc())
return query.order_by(F("_post_count").asc())
Tortoise
from tortoise.queryset import Q
from tortoise.functions import Count
@orm.filter_type(User)
class UserFilter:
name: auto
@filter_field
def search(self, value: str, query):
return query.filter(Q(name__icontains=value) | Q(email__icontains=value))
@orm.order_type(User)
class UserOrder:
name: auto
@order_field
def post_count(self, value: Ordering, query):
query = query.annotate(_post_count=Count("posts"))
if value.value.startswith("DESC"):
return query.order_by("-_post_count")
return query.order_by("_post_count")
Custom Group-By Types
orm.group_type(Model) works like orm.filter_type() and orm.order_type(). auto fields get the standard group-by type (Boolean or DateGroupByOption). Methods decorated with @group_field add custom grouping logic:
from strawberry_orm import group_field
@orm.group_type(Order)
class OrderGroupBy:
status: auto # standard Boolean group-by
created_at: auto # DateGroupByOption with interval
@group_field
def by_customer_tier(self, value: bool, query):
"""Group by a computed customer tier."""
from sqlalchemy import case
return case(
(Order.amount >= 100, "premium"),
else_="standard",
).label("customer_tier")
Combining with orm.filter() / orm.order()
orm.filter(), orm.order(), and orm.group() remain available for fully auto-generated types. Use orm.filter_type(), orm.order_type(), and orm.group_type() only when you need custom logic. The types produced by both APIs are interchangeable in all contexts — orm.type(Model, filters=..., order=..., group=...), orm.field(filters=..., order=...), and orm.connection().
Grouping and Aggregation
Group-by and aggregation are available on Relay connection fields. Register a group-by type for a model and pass it to orm.type():
from strawberry import relay
from strawberry_orm import StrawberryORM, auto
from strawberry_orm.relay import ORMListConnection
orm = StrawberryORM.for_sqlalchemy(dialect="postgresql", session_getter=...)
OrderFilter = orm.filter(Order)
OrderOrder = orm.order(Order)
OrderGroupBy = orm.group(Order)
@orm.type(Order, filters=OrderFilter, order=OrderOrder, group=OrderGroupBy)
class OrderNode(relay.Node):
id: relay.NodeID[int]
status: auto
amount: auto
quantity: auto
created_at: auto
@strawberry.type
class Query:
orders: ORMListConnection[OrderNode] = orm.connection()
schema = orm.schema(query=Query)
When group is set, the generated connection type automatically includes aggregates, groups, and an extended pageInfo with aggregate data.
Querying Aggregates
{
orders(first: 100) {
pageInfo {
hasNextPage
aggregates {
count
sum { amount }
avg { amount }
}
}
edges {
node { status amount }
}
}
}
Aggregates are computed over the full filtered result set (before pagination). Page-level aggregates in pageInfo cover only the current page.
Auto-generated aggregate types include count, sum, avg, min, and max — scoped to the numeric and comparable fields on the model.
Querying Groups
{
orders(
groupBy: [{ field: { status: true } }]
first: 100
) {
groups {
key { status }
aggregates {
count
sum { amount }
avg { amount }
}
edgeIndices
items(first: 5) {
edges {
node { status amount quantity }
}
}
}
edges {
node { status amount }
}
}
}
Each group includes:
key— the group-by column valuesaggregates— per-group aggregate values (count, sum, avg, min, max)edgeIndices— indices into the parent connection'sedgesarrayitems— a nested cursor-paginated connection of items in that group
Date/datetime fields support interval-based grouping:
{
orders(
groupBy: [{ field: { createdAt: { interval: MONTH } } }]
) {
groups {
key { createdAt }
aggregates { count }
}
}
}
Supported intervals: DAY, WEEK, MONTH, QUARTER, YEAR.
Custom Aggregates
Use @aggregate_field to define computed aggregate expressions:
from strawberry_orm import aggregate_field
@orm.aggregate_type(Order)
class OrderAggregation:
amount: auto
quantity: auto
@aggregate_field
def total_revenue(self, columns) -> float:
from sqlalchemy import func
return func.sum(columns.amount * columns.quantity)
Mutations
Write plain @strawberry.mutation resolvers and use strawberry-orm for generated input types. Authorization and row-level checks are your responsibility — see Security.
CreatePostInput = orm.input(Post, include=["title", "body", "author_id"])
@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
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 | None = strawberry.UNSET
TagRef = orm.ref(Tag, create=CreateTagInput, update=UpdateTagInput, unlink=True, delete=True)
Each ref is a @oneOf with these keys:
update— link an existing object by ID, or update its fields. Always present (an ID-only input is auto-generated if no customupdatetype is provided).create— create a new related object (present whencreate=is provided).unlink— remove the object from the relation without deleting it (present whenunlink=True).delete— hard-delete the related row (present whendelete=True).
All list mutations use patch semantics: only the items you mention are affected; existing related objects not listed are left untouched.
Apply ref operations with orm.apply_ref_list(parent, "relation_name", refs, info). An optional authorize callback (action, model, obj_id, info) -> bool can be provided for per-operation authorization.
mutation {
setPostTags(postId: 1, tags: [
{ update: { id: "2" } }
{ update: { id: "1", name: "python3" } }
{ create: { name: "new-tag" } }
{ unlink: { id: "3" } }
{ delete: { id: "4" } }
]) {
tags { id name }
}
}
Note: Whether the order of items in the list affects the final ordering of the relation is an implementation detail that each backend must maintain.
Recursive Node Mutations
orm.mutations.create_node() and orm.mutations.update_node() generate catch-all Relay Node mutations with recursive nested inputs:
@orm.type(Post)
class PostNode(relay.Node):
id: relay.NodeID[int]
title: auto
body: auto
@strawberry.type
class Mutation:
create_node = orm.mutations.create_node()
update_node = orm.mutations.update_node()
mutation {
createNode(input: {
post: {
title: "Hello"
body: "World"
author: { create: { name: "Alice", email: "alice@example.com" } }
tags: [{ create: { name: "python" } }]
}
}) { __typename }
}
List relations are flat arrays of ref operations (same @oneOf shape as orm.ref). Patch semantics apply — only mentioned items are affected.
Generate only the input types (without the resolver) via orm.mutations.create_node_input() and orm.mutations.update_node_input().
Mutation projection and policy config
Pass project={...} to restrict recursion depth and configure relation semantics:
project = {
"post": {
"author": {
"_meta": {"onReplace": ["DISCONNECT", "DELETE"]},
},
"comments": {
"author": {"_meta": {"onReplace": ["DISCONNECT", "DELETE"]}},
},
"tags": {},
},
"comment": {
"author": {"_meta": {"onReplace": ["DISCONNECT", "DELETE"]}},
},
}
@strawberry.type
class Mutation:
create_node = orm.mutations.create_node(project=project)
update_node = orm.mutations.update_node(project=project)
Rules:
- Root keys are model names (
post,comment, ...). - Nested keys are relation names on that model.
_metaconfigures behavior for that relation subtree.- Omitted relations still appear as shallow inputs (one more level, then stop).
_meta supports:
onReplace—"DISCONNECT"or"DELETE", or an array of both to expose a choice. Controls what happens to the previous object when replacing a singular (FK) relation. Default:DISCONNECT.
Values can be a single string (fixes behavior, omits the GraphQL field) or an array of strings (exposes a choice to the caller).
Relay Integration
strawberry-orm works with Strawberry's Relay support for cursor-based pagination and global node identification.
Relay Node Types
Extend relay.Node instead of a plain Strawberry type. Use relay.NodeID for the id field:
from strawberry import relay
from strawberry_orm import StrawberryORM, auto
orm = StrawberryORM.for_sqlalchemy(dialect="postgresql", session_getter=...)
UserFilter = orm.filter(User)
UserOrder = orm.order(User)
@orm.type(User, filters=UserFilter, order=UserOrder)
class UserNode(relay.Node):
id: relay.NodeID[int]
name: auto
email: auto
Connection Fields
Use orm.connection() with ORMListConnection to create paginated connection fields. Filters and ordering from the node type are automatically wired in:
from collections.abc import Iterable
from strawberry_orm.relay import ORMListConnection
@strawberry.type
class Query:
@orm.connection(ORMListConnection[UserNode])
def users_connection(self) -> Iterable[UserNode]:
return orm.get_default_queryset(User)
This gives you:
{
usersConnection(
filter: { field: { email: { contains: "example.com" } } }
order: [{ field: { name: DESC } }]
first: 10
after: "YXJyYXljb25uZWN0aW9uOjk="
) {
edges {
cursor
node { name email }
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
Filters and ordering are applied before pagination, so the connection always slices from a correctly filtered and sorted result set.
orm.connection() accepts the same keyword arguments as relay.connection() — name, description, deprecation_reason, extensions, and max_results.
Node Mutations
orm.mutations.create_node() and orm.mutations.update_node() generate catch-all Relay Node mutations with recursive nested inputs. See Recursive Node Mutations for full documentation.
Async Usage
strawberry-orm supports both sync and async execution (schema.execute / schema.execute_sync, Django AsyncGraphQLView, etc.).
| Backend | Pattern |
|---|---|
| Django | django_async_safe=True (default) wraps generated and @orm.type resolvers with sync_to_async when the event loop is running. Use orm.schema() for eager loads (enabled by default). |
| SQLAlchemy | Pass a sync Session or AsyncSession via session_getter. Both work transparently. |
| Tortoise | Async-first. Use async def resolvers and await ORM calls. |
orm = StrawberryORM.for_django() # django_async_safe=True, lazy_resolution="warn"
schema = orm.schema(query=Query)
Custom sync resolvers passed to orm.field(my_resolver) are async-safe automatically. Automatic filter and order arguments are wired on generated list and connection fields; pass filters/order explicitly on bare orm.field(my_resolver) resolvers if you need them.
Sync @orm.connection resolvers on @orm.type work under async execution, including when the method name matches a Django reverse relation (e.g. def comments(self, info) returning a queryset).
Optional runtime FK checks: extensions=[orm.lazy_resolution_extension()].
# Tortoise example
@strawberry.type
class Query:
@strawberry.field
async def users(self) -> list[UserType]:
return await User.all()
apply_ref_list is sync for Django/sync-SQLAlchemy and awaitable for Tortoise/async-SQLAlchemy.
Migrating from a custom Django async integration layer
If you previously monkey-patched StrawberryORM for AsyncGraphQLView, you can remove that module and rely on:
| Old workaround | Built-in replacement |
|---|---|
_patch_orm_filter_extension_for_async |
_AutoFilterOrderExtension async/sync paths |
@orm.type + _ensure_async_resolver |
django_async_safe + @orm.type post-processing |
Custom orm.field without filter extension |
orm.field(callable) (no _AutoFilterOrderExtension) |
_materialize_django_result |
materialize_query / extension materialization |
Manual is_type_of |
Automatic on @orm.type(Model) |
Public Exports
StrawberryORM, auto, make_field, make_ref_type, Ordering, DateGroupByInterval, DateGroupByOption, FieldDefinition, FieldHints, OptimizerExtension, OptimizerStore, UNSET, filter_field, order_field, group_field, aggregate_field, and the built-in lookup input classes from strawberry_orm.filters.
Appendix: Full example
A blog API with users, posts, tags, and comments — covering types, relations, queryset scoping, optimizer hints, filters, ordering, object traversal, grouping, aggregation, mutations, ref lists, recursive node mutations, and the query optimizer:
import strawberry
from strawberry_orm import StrawberryORM, auto
orm = StrawberryORM.for_sqlalchemy(
dialect="postgresql",
session_getter=lambda info: info.context["session"],
)
# -- Filters, ordering, and grouping (register leaf models first) ------------
UserFilter = orm.filter(User)
UserOrder = orm.order(User)
TagFilter = orm.filter(Tag)
TagOrder = orm.order(Tag)
CommentFilter = orm.filter(Comment)
PostFilter = orm.filter(Post) # picks up author/tags/comments relations
PostOrder = orm.order(Post)
PostGroupBy = orm.group(Post) # group-by support for aggregation
# -- Types -------------------------------------------------------------------
@orm.type(User, filters=UserFilter, order=UserOrder)
class UserType:
id: auto
name: auto
email: auto
posts: list["PostType"]
@orm.type(Tag, filters=TagFilter, order=TagOrder)
class TagType:
id: auto
name: auto
@orm.type(Comment, filters=CommentFilter)
class CommentType:
id: auto
body: auto
@orm.type(Post, filters=PostFilter, order=PostOrder, group=PostGroupBy)
class PostType:
id: auto
title: auto
body: auto
is_published: auto
tags: list[TagType] = orm.field(load=lambda qs: qs.order_by("name"))
comments: list[CommentType]
@orm.field
def author(self) -> UserType:
return self.author
@classmethod
def get_queryset(cls, qs, info):
return qs.filter(is_published=True) # works on all backends
# -- Mutations ---------------------------------------------------------------
CreatePostInput = orm.input(Post, include=["title", "body", "author_id"])
CreateTagInput = orm.input(Tag, include=["name"])
TagRef = orm.ref(Tag, create=CreateTagInput, unlink=True, delete=True)
@strawberry.type
class Mutation:
@strawberry.mutation
def create_post(self, input: CreatePostInput) -> PostType:
post = Post(title=input.title, body=input.body, author_id=input.author_id)
...
return post
@strawberry.mutation
def set_post_tags(self, post_id: int, tags: list[TagRef]) -> PostType:
post = ...
orm.apply_ref_list(post, "tags", tags)
return post
create_node = orm.mutations.create_node()
update_node = orm.mutations.update_node()
@strawberry.type
class Query:
users: list[UserType] = orm.field()
posts: list[PostType] = orm.field()
schema = orm.schema(query=Query, mutation=Mutation)
# Filter posts by a related author's name, ordered by title
{
posts(
filter: {
all: [
{ field: { isPublished: { exact: true } } }
{ object: { author: { field: { name: { exact: "Alice" } } } } }
]
}
order: [{ field: { title: ASC } }]
) {
title
author { name }
tags { name }
}
}
mutation {
setPostTags(postId: 1, tags: [
{ update: { id: "2" } }
{ create: { name: "new-tag" } }
{ unlink: { id: "3" } }
{ delete: { id: "4" } }
]) {
tags { id name }
}
}
mutation {
createNode(input: {
post: {
title: "Hello"
body: "World"
author: { create: { name: "Alice", email: "alice@example.com" } }
tags: [{ create: { name: "python" } }]
}
}) { __typename }
}
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file strawberry_orm-0.13.0.tar.gz.
File metadata
- Download URL: strawberry_orm-0.13.0.tar.gz
- Upload date:
- Size: 105.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e0d00f35f4a6e183e138d027ff9ae183457a97c62452c47da9f826bbd124344d
|
|
| MD5 |
04743816aacc4d0a2ed611b6e63522ab
|
|
| BLAKE2b-256 |
60224a14276c874f84611f47ad7e439f65932b9e7c142149f861d6f3f8739b50
|
Provenance
The following attestation bundles were made for strawberry_orm-0.13.0.tar.gz:
Publisher:
release.yml on strawberry-graphql/strawberry-orm
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
strawberry_orm-0.13.0.tar.gz -
Subject digest:
e0d00f35f4a6e183e138d027ff9ae183457a97c62452c47da9f826bbd124344d - Sigstore transparency entry: 1661197173
- Sigstore integration time:
-
Permalink:
strawberry-graphql/strawberry-orm@6a24bfea196964d6c958adc5d115fef8156ccf4a -
Branch / Tag:
refs/heads/main - Owner: https://github.com/strawberry-graphql
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@6a24bfea196964d6c958adc5d115fef8156ccf4a -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file strawberry_orm-0.13.0-py3-none-any.whl.
File metadata
- Download URL: strawberry_orm-0.13.0-py3-none-any.whl
- Upload date:
- Size: 100.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e8ea63a67ef9303826ddbfe177c157246086be93962ded2036ab0bb18d04e2c2
|
|
| MD5 |
e369c3413d0c686726fe4736235d0aad
|
|
| BLAKE2b-256 |
c7e7f39c9f060aa444797d559540cc77161dd9b8cc933db3ee06fef26e9d2f23
|
Provenance
The following attestation bundles were made for strawberry_orm-0.13.0-py3-none-any.whl:
Publisher:
release.yml on strawberry-graphql/strawberry-orm
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
strawberry_orm-0.13.0-py3-none-any.whl -
Subject digest:
e8ea63a67ef9303826ddbfe177c157246086be93962ded2036ab0bb18d04e2c2 - Sigstore transparency entry: 1661197330
- Sigstore integration time:
-
Permalink:
strawberry-graphql/strawberry-orm@6a24bfea196964d6c958adc5d115fef8156ccf4a -
Branch / Tag:
refs/heads/main - Owner: https://github.com/strawberry-graphql
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@6a24bfea196964d6c958adc5d115fef8156ccf4a -
Trigger Event:
workflow_dispatch
-
Statement type: