Django

Common Django bugs and gotchas — a field reference

Every bug in this lesson has already been explained mechanically somewhere earlier in this domain — this is the field-reference version, the shape each one actually takes in real code, so it's recognizable on sight instead of requiring the mechanism to be re-derived from scratch every time.

Advanced

4 min read

Bug 1: the N+1 query, hiding behind a completely innocent-looking loop

for book in Book.objects.all():        # 1 query
    print(book.author.name)              # 1 MORE query — for EVERY single book

Covered mechanically in the select_related/prefetch_related lesson: accessing a lazy foreign key inside a loop fires one additional query per row, because Django doesn't fetch related objects until the attribute is actually touched. The fix: Book.objects.select_related("author") for a one-to-one/foreign-key relationship, .prefetch_related(...) for many-to-many or reverse foreign keys — either collapses N+1 queries down to 1 or 2.

Bug 2: mutating a value read into Python instead of using F(), under concurrency

article.views = article.views + 1
article.save()   # two concurrent requests can both read the same starting value,
                    # both write the same "incremented" result — one increment is lost

Covered mechanically in the Q objects and F expressions lesson: reading a value into Python, incrementing it, and writing it back has a race-condition window between the read and the write. The fix: Article.objects.filter(id=1).update(views=F("views") + 1) — the increment runs entirely inside the database as one atomic operation, with no window for a concurrent read to see a stale value.

Bug 3: a form that "just doesn't save the file," with no error at all

def upload_view(request):
    form = ProfileForm(request.POST)   # request.FILES is MISSING here
    if form.is_valid():
        form.save()   # every OTHER field saves fine — the file field is just silently empty

Covered mechanically in the file uploads lesson: uploaded files never arrive in request.POST — they arrive in the separate request.FILES dict, and a form handling a file field needs both passed in explicitly. The fix: ProfileForm(request.POST, request.FILES), and enctype="multipart/form-data" on the <form> tag in the template — without the enctype, the browser never sends the file's bytes in the first place, regardless of what the view does.

Bug 4: a queryset that's slow in production but fast in every local test

User.objects.filter(email=email).first()   # a full table scan once the table has millions of rows,
                                              # if `email` has no index — invisible on a dev DB of 50 rows

A query that filters or orders by a column with no database index degrades from instant to genuinely slow specifically as table size grows — which is exactly why it's invisible during local development against a small dataset, and only shows up once production data has scaled up. The fix: email = models.EmailField(db_index=True) on the field, or a Meta.indexes entry for a multi-column index — decided by looking at what columns are actually filtered or ordered by in real queries, not added preemptively to every field.

Bug 5: an atomic() block that doesn't actually stop a race condition

@transaction.atomic
def redeem_ticket(ticket_id):
    ticket = Ticket.objects.get(id=ticket_id)   # no lock — a concurrent request can read
    if ticket.redeemed:                            # the SAME stale, not-yet-redeemed state
        raise TicketAlreadyRedeemed()
    ticket.redeemed = True
    ticket.save()

Covered mechanically in the transactions lesson: atomic() guarantees a set of writes is all-or-nothing, but it does not, by itself, stop two concurrent requests from both reading the same row's state before either one writes — two simultaneous redemption requests can both pass the if ticket.redeemed check. The fix: Ticket.objects.select_for_update().get(id=ticket_id) — the row lock makes a second concurrent transaction block until the first one commits, closing the read-then-write race window entirely.

The actual throughline across all five

Every one of these traces back to the same handful of things this domain already covered in depth: the ORM's laziness (queries only fire when data is actually touched, which is exactly what makes N+1 easy to miss), the gap between "value read into Python" and "value inside the database" (which is what both the F() bug and the select_for_update bug are really about), and the fact that a form's convenience hides real plumbing (request.FILES, enctype) that still has to be wired up correctly by hand. Recognizing a bug's shape on sight — "this smells like N+1," "this smells like a missing lock" — is what separates fixing a Django bug quickly from re-deriving the ORM's behavior from first principles every single time one shows up.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What's the fix for an N+1 query problem caused by accessing book.author.name inside a loop over Book.objects.all()?

2. What race condition does `article.views = article.views + 1; article.save()` risk under concurrent requests?

3. A file upload form validates every field fine but the uploaded file is always empty. What's the most likely cause?

4. Why might select_for_update() be necessary even inside an @transaction.atomic block?