On a Django forum, a class-based view's list class attribute starts showing one member's unread notices to other members; why does it leak, and how do you fix it?
answer
- new instance, same class
- in-place mutation vs rebinding
- list += is in place
- initkwargs objects are shared
- per process, so intermittent
basics
~20 sDjango builds a new view instance per request, but the class lives for the whole process. Appending to a list defined on the class mutates one shared list that later requests in that process see. Build such state per request.
solid answer
~50 s`as_view()` gives every request a fresh instance, but `self.notices.append(...)` does not create an instance attribute: attribute lookup falls through to the class, and the append mutates the one list on the class object. That object lives as long as the worker process, and all threads in it share it, so later requests see earlier users' notices, and the list grows without bound. It looks random because each worker process has its own copy. The same applies to a mutable object passed through `as_view(...)`: `cls(**initkwargs)` puts that same object on every instance. Fix it by creating per-request state in `setup()`, a handler or `get_context_data()` (`self.notices = []`), using immutable class defaults such as tuples or `None`, and copying before mutating. Add a test that makes two requests as different users and asserts the second sees nothing from the first.
code
python · 17 linesfrom django.test import TestCase
from django.urls import reverse
from .factories import make_member_with_notice
class NoticeBoardIsolationTests(TestCase):
def test_second_member_sees_no_notice_from_the_first(self):
alice = make_member_with_notice("alice", "reply from bob")
carol = make_member_with_notice("carol", "welcome")
self.client.force_login(alice)
self.client.get(reverse("notices"))
self.client.force_login(carol)
response = self.client.get(reverse("notices"))
self.assertNotContains(response, "reply from bob")go deeper
Recall that Django makes a new view object per request but the class is shared, so never mutate a list or dict defined in the class body.
Explain attribute lookup falling back to the class, and why append and += on a list mutate the shared object while plain assignment does not.
Diagnose the intermittent, per-process pattern, cover the initkwargs variant, and back the fix with a two-user regression test and a review rule.
Treat it as a class of bug: a lint or review checklist for mutable class attributes on views is cheaper than hunting the next privacy incident.
## The symptom A forum shows each member their unread notices. The view looks harmless: ```python from django.views.generic import TemplateView class NoticeBoardView(TemplateView): template_name = "forum/notices.html" notices = [] # shared by every request in this process def get_context_data(self, **kwargs): for notice in self.request.user.notices.filter(read=False): self.notices.append(notice.text) return super().get_context_data(notices=self.notices, **kwargs) ``` In development it seems fine until two people log in. In production, members report seeing strangers' notices, the list grows on every page load, and the bug comes and goes. ## Why a per-request instance does not protect you Django's `View.as_view()` returns a function that runs `cls(**initkwargs)` on **every** request, so each request has its own instance. But the instance is not where `notices` lives: - `notices = []` in the class body creates **one list, owned by the class object**. - `self.notices` on a new instance finds no instance attribute and falls back to the class attribute. - `.append()` **mutates that one list in place**. No instance attribute is ever created. - The class object is created once when the module is imported and lives for the whole **worker process**. Every thread in that process, and every later request it serves, sees the same list. So "new instance per request" is true and irrelevant: the state is on the class. (The general Python rule about class versus instance attributes is its own topic; what Django adds is the false sense of safety that the per-request instance gives.) ## Why it looks intermittent | Deployment | What you observe | |---|---| | Development server, one process with threads | Every request shares the list; easy to reproduce | | Several worker processes | Each process has its own class and list; a user sees leaks only from others who hit the same process | | Restart or redeploy | The list empties, and the bug seems to disappear for a while | The list also **grows forever** in each process, so the leak is a memory leak as well as a privacy bug. ## Variants that bite the same way - **`+=` on a list** — `self.tags += [tag]` calls `list.__iadd__`, which extends the class's list in place and then binds an instance attribute to that same object. The class list is still corrupted. With a tuple, `+=` builds a new tuple and is safe. - **Dicts and sets** — `self.cache[key] = value` or `self.seen.add(x)` on a class-level dict or set. - **Mutable initkwargs** — `NoticeBoardView.as_view(filters={"read": False})` stores one dict in `view_initkwargs`, and `cls(**initkwargs)` assigns that same dict to every instance. Django's reference documentation warns against passing a list, dict or other mutable object for exactly this reason. - **Objects that cache results** — a class-level `QuerySet` caches its rows once evaluated; Django's generic display views defend against this by cloning `queryset` with `.all()` inside `get_queryset()`. ## How to fix it 1. **Create per-request state per request.** Assign a new object in `setup()`, in the handler, or in `get_context_data()`. 2. **Keep class attributes immutable.** Use tuples, `frozenset`, strings, numbers or `None` as defaults, and build a list from them when you need to mutate. 3. **Copy before mutating.** `tags = list(self.default_tags)` gives the request its own list. 4. **Never pass mutable objects to `as_view()`**, or copy them in `setup()` if you must. Applied to the forum view, the first fix is a local list built on every call: ```python def get_context_data(self, **kwargs): notices = [n.text for n in self.request.user.notices.filter(read=False)] return super().get_context_data(notices=notices, **kwargs) ``` ## How to catch it early - **A regression test** with Django's test `Client` or `RequestFactory`: request the page as member A, then as member B, and assert B's response contains nothing of A's. Both requests run in one test process, so a class-level leak reproduces deterministically. - **Code review rule**: flag any `[]`, `{}` or `set()` literal assigned in a view's class body, and any `.append`, `.update`, `.add` or `+=` on `self.<attr>` where `<attr>` is defined on the class. - **Grep for** `as_view(` calls with list or dict literals in URLconfs. ## Why a lock is the wrong fix Wrapping the `append()` in a `threading.Lock` stops two threads corrupting the list at the same instant, but the list is still shared: it still carries every earlier member's notices and still grows. A lock fixes a race; this bug is **sharing**, and the only fix is to stop sharing the object. The same reasoning rules out "clearing the list at the start of `get()`": concurrent requests in the same process would still see each other's entries between the clear and the render. ## Takeaway Django guarantees a fresh **instance** per request, never a fresh **class**. Anything mutable reachable through the class, or through initkwargs, is process-wide shared state.
- A colleague changes self.notices.append(x) to self.notices += [x]. Is the leak fixed?No. For a list, `+=` calls `list.__iadd__`, which extends the existing class-level list in place and returns it; Python then binds an instance attribute to that same object. The shared list is still mutated. It would only be safe if the class default were a tuple, where `+=` creates a new object, or if the method assigned a fresh list first.
- Why can passing a dict to as_view() leak state even when the class body looks clean?`as_view(filters={...})` stores the dict once in `view_initkwargs`, and every request runs `cls(**initkwargs)`, which assigns that same dict to the new instance. Mutating `self.filters` in one request changes it for all later requests in the process. Pass immutable values, or copy the dict in `setup()` before changing it.
- Why did the bug never appear in the single-developer staging environment?Staging had one tester, so every leaked notice was their own and looked correct; the list only grew. The leak needs a second user served by the same process. Multiple worker processes in production add randomness, since only users sharing a process see each other's data.
A hotel gives each guest a fresh room, but the lobby noticeboard belongs to the building: if every guest pins their messages to the lobby board instead of their own room's desk, everyone reads everyone's notes.
saying these in an interview costs you the question
- Saying a new view instance per request makes class attributes safe to mutate
- Believing self.items += [x] creates a private per-instance list
- Blaming a caching layer or session mix-up before checking view class state
- Fixing it with a lock around the append instead of removing shared state
- Assuming each worker thread gets its own copy of the view class