A pattern that looks clean, “idiomatic,” and AI-approved:
@action(detail=True, methods=["put"])
def close_ticket(self, request, pk=None):
ticket = self.get_object()
workflow_service.close_ticket(ticket=ticket, actor=request.user)
return Response(TicketDetailSerializer(ticket).data)
It works. It ships. Everyone is happy.
Until you profile it.
The trap — the black horse hiding in plain sight — is self.get_object().
In this case self.get_object() does not fetch “a ticket.” It evaluates the entire get_queryset() for that view.
If get_queryset() is built for rich list/retrieve responses, this action may trigger heavy joins/prefetches for data it never uses.
Let's look at this code:
class TicketViewSet(ModelViewSet):
serializer_class = TicketSerializer
def get_queryset(self):
qs = (
Ticket.objects
.select_related("requester", "assignee", "project", "project__customer")
.prefetch_related(
"labels",
"watchers",
"attachments",
Prefetch("comments", queryset=Comment.objects.select_related("author")),
Prefetch("events", queryset=TicketEvent.objects.select_related("actor")),
)
.annotate(
comments_count=Count("comments", distinct=True),
watchers_count=Count("watchers", distinct=True),
last_activity=Max("events__created_at"),
)
)
# lots of list filters, permissions, tenant scoping, etc.
return qs
One status update endpoint can suddenly act like a mini-report query.
This is exactly the kind of thing AI-generated backend code often gets wrong: correct behavior, terrible query shape.
How AI Quietly Wrecks DB Performance
AI tools tend to optimize for:
- familiar framework patterns
- fewer lines
- “it works” correctness
They do not reliably optimize for:
- action-specific query plans
- serializer/query coupling
- P95 latency under load
- DB memory/IO overhead
So you get “smart-looking” code with hidden query bloat.
The Core Design Trap
One global get_queryset() for all actions:
- list needs broad graph
- retrieve needs nested graph
- close/reopen need maybe 5 columns total
Then @action calls self.get_object(), inheriting the same heavy graph.
Result: a tiny mutation endpoint executes a heavyweight read path.
Why This Is Bad
Over-fetching by default
Loading comments, subscribers, nested relations for a status flip is waste.
Invisible cost
get_object() reads like O(1) logic; actual SQL can be huge.
Wrong serializer for the job
Returning full serializer after a transition forces extra DB reads and serialization time.
Performance regression at scale
These endpoints are often frequent. Query bloat multiplies quickly.
Better Architecture: Explicit Query Methods
Create dedicated query methods for each use case (repository/query-service).
class HelpdeskTicketRepository:
def list_queryset(self, user, filters):
return (
HelpdeskTicket.objects
.filter(...)
.select_related("reporter", "assignee", "department")
.prefetch_related("subscribers", "comments__author")
)
def transition_queryset(self, user):
return (
HelpdeskTicket.objects
.filter(...)
.only("id", "status", "closed_by_id", "closed_at", "updated_at")
)
class HelpdeskTicketViewSet(ModelViewSet):
def get_queryset(self):
if self.action in {"close_ticket", "reopen_ticket"}:
return repo.transition_queryset(self.request.user)
return repo.list_queryset(self.request.user, self.request.query_params)
@action(detail=True, methods=["put"])
def close_ticket(self, request, pk=None):
ticket = self.get_object() # now lean query
workflow_service.close_ticket(ticket, request.user)
return Response(TicketStatusSerializer(ticket).data) # lean response
Cons of the “Just Use get_object() in @action” Habit
- Couples action performance to unrelated list/retrieve serializer needs
- Encourages accidental N+1 or unnecessary prefetches
- Makes optimization reactive instead of intentional
- Hides query intent from reviewers
- Gives a false sense of simplicity while increasing infrastructure cost
AI + Backend Rule You Should Adopt
Use AI to speed up scaffolding.
Never let AI decide your query strategy without review.
Before optimizing @action, ask a more uncomfortable question:
Should this be an @action at all?
In many codebases, @action becomes a dumping ground for “extra endpoints.” It feels convenient. It keeps everything in one place. It looks tidy.
But structurally, it introduces long-term cost.
Why avoiding @action often leads to better architecture
URL surface becomes implicit and harder to navigate
Routes are derived from method names.
There is no explicit URL declaration.
Finding endpoints requires scanning class methods.
That does not scale.
ViewSets grow uncontrollably
A resource ViewSet starts clean: list, retrieve, create, update, destroy
Then comes: close_ticket, reopen_ticket, assign_ticket, approve_ticket, archive_ticket
Now your “resource controller” is a workflow engine.
It violates Single Responsibility Principle
A TicketViewSet should manage CRUD semantics of a Ticket resource.
Workflow transitions are not CRUD.
They are domain commands.
Query shape leakage
As discussed earlier, @action inherits get_queryset() unless you explicitly override behavior.
That means workflow endpoints silently inherit list/retrieve query weight.
A Cleaner Alternative
Instead of:
@action(detail=True, methods=["put"])
def close_ticket(self, request, pk=None):
Prefer:
class CloseTicketView(APIView):
def put(self, request, pk):
ticket = Ticket.objects.only("id", "status").get(pk=pk)
workflow_service.close_ticket(ticket=ticket, actor=request.user)
return Response(...)
Now:
URL is explicit
Query is explicit
Responsibility is isolated
Class size stays small
Performance behavior is predictable
One class. One action. One purpose.
Final Takeaway
AI won’t save your database from lazy query design.
If your action updates one status and fetches half your model graph, you’ve already lost.
get_object() is not bad. Blind get_object() on heavyweight querysets is.
Design query paths per endpoint. Your DB, latency charts, and cloud bill will all improve.
If you’ve run into this pattern in production, I’d genuinely like to hear about it.
Have you discovered hidden query explosions behind “clean” DRF code?
Share your experience in the comments.
Top comments (2)
Thank you for this great article. Once it clicks, it feels very natural.
I just used it right now in my current system while building a sub-URL to retrieve just the ID and filter on that one :)
Thank you for reading! Glad it helped you. This concept can be a bit difficult to understand at first, but once it clicks, it becomes much easier to build effective and clean API endpoints :)