DEV Community

Cover image for AI Wrote It. Your Database Paid for It: How get_object() in DRF Actions Quietly Kills Backend Performance
Artem
Artem

Posted on

AI Wrote It. Your Database Paid for It: How get_object() in DRF Actions Quietly Kills Backend Performance

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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):
Enter fullscreen mode Exit fullscreen mode

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(...)
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
noureldien44 profile image
SOLTAN NOURELDIEN

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 :)

Collapse
 
artemooon profile image
Artem • Edited

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 :)