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)Local scopes
Section titled “Local scopes”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.
@classmethoddef 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.
Global scopes
Section titled “Global scopes”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 rowsawait Post.filter(author_id=7) # also excludes themawait Post.without_global_scopes().all() # everythingThis 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.
Multi-tenancy
Section titled “Multi-tenancy”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.
Registering them
Section titled “Registering them”Global scopes are usually added once, where the models are imported:
from .post import Postfrom .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.
Escaping them
Section titled “Escaping them”Post.without_global_scopes() # a queryset with none appliedNecessary 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.
How it fits together
Section titled “How it fits together”| Piece | Role |
|---|---|
HasScopes | The mixin on the base model, providing add_global_scope and without_global_scopes |
ScopeRegistry | Holds the global scopes for a model, and applies them |
RecordQuerySet | Tortoise’s QuerySet with the scope_* methods attached |
RecordManager | The 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 RecordManagerScopes versus managers versus properties
Section titled “Scopes versus managers versus properties”- 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.