Skip to content

Reusable query fragments: local scope_ methods that become chainable, global scopes applied to every query, and how to escape them when you need everything.

A scope is a named piece of a query you would otherwise repeat.

class Post(Model):
status = fields.CharField(max_length=20)
published_at = fields.DatetimeField(null=True)
@classmethod
def scope_published(cls, queryset):
return queryset.filter(status="published", published_at__isnull=False)
@classmethod
def scope_by_author(cls, queryset, author_id):
return queryset.filter(author_id=author_id)
await Post.published()
await Post.published().by_author(7).order_by("-published_at").limit(10)

Any classmethod named scope_<name> becomes available two ways:

  • On the model, as Model.<name>(...): starting a new query.
  • On a queryset, as .<name>(...): continuing one.

The chaining is what makes them worth having. A scope returns a queryset, so it composes with filter, order_by, limit, other scopes, and everything else Tortoise offers.

@classmethod
def scope_search(cls, queryset, term):
return queryset.filter(
Q(title__icontains=term) | Q(body__icontains=term)
)
await Post.published().search("async").limit(20)

The first argument after cls is always the queryset. Everything after it is yours.

A global scope applies to every query on the model.

Post.add_global_scope(lambda qs: qs.filter(deleted_at__isnull=True))
await Post.all() # excludes soft-deleted rows
await Post.filter(author_id=7) # also excludes them
await Post.without_global_scopes().all() # everything

This is the idiomatic way to make soft deletes the default, which the base model deliberately does not do on its own. See the caution in Models.

The other common use, and the one to be careful with:

Invoice.add_global_scope(lambda qs: qs.filter(tenant_id=current_tenant()))

It works, and it is genuinely useful for defence in depth. It is not a security boundary on its own:

  • without_global_scopes() bypasses it, and one call site eventually will;
  • raw SQL bypasses it;
  • a related-model traversal from an unfiltered model can reach the rows anyway;
  • current_tenant() has to be correct in every context the model is used from, including console commands and background jobs where no request set it.

Enforce tenancy in the query you write and treat the global scope as the safety net, not the other way round.

Global scopes are usually added once, where the models are imported:

database/models/__init__.py
from .post import Post
from .invoice import Invoice
Post.add_global_scope(lambda qs: qs.filter(deleted_at__isnull=True))

add_global_scope on a base class applies to its subclasses, which is how you would make soft deletes default across a whole project.

To remove one you need the same callable object:

active_only = lambda qs: qs.filter(deleted_at__isnull=True)
Post.add_global_scope(active_only)
Post._scope_registry.remove(active_only)

Which is a reason to define them as named functions rather than inline lambdas if you ever expect to remove one.

Post.without_global_scopes() # a queryset with none applied

Necessary for an admin view that has to show trashed rows, a repair script, or a report over everything. The framework uses it itself. upsert Re-fetches the row through without_global_scopes() so that upserting a soft-deleted row still returns it.

PieceRole
HasScopesThe mixin on the base model, providing add_global_scope and without_global_scopes
ScopeRegistryHolds the global scopes for a model, and applies them
RecordQuerySetTortoise’s QuerySet with the scope_* methods attached
RecordManagerThe default manager, which applies global scopes to every queryset

The manager is the load-bearing one. It is set on Model.Meta, so replacing manager with something that does not subclass RecordManager silently switches global scopes off:

class Meta:
manager = MyManager() # must subclass RecordManager
  • A scope when the result is a queryset that should keep chaining. Most cases.
  • A classmethod returning a value when it is a terminal question: await Post.published_count().
  • A property when it is about one loaded instance and needs no query: post.is_published.

The mistake to avoid is a “scope” that awaits internally and returns a list. It looks like a scope at the call site and then refuses to chain.