skip to content

On a Django news site, a TemplateView's extra_context shows the same headlines and copyright year until the server restarts; why, and how do you fix it?

level: seniorimportance: should knowfreq 38%

answer

  1. evaluated when the module loads
  2. one dict for every request
  3. a QuerySet caches its rows
  4. workers started at different times
  5. move work into get_context_data

basics

~20 s

extra_context is evaluated once, when the class body or URLconf loads, and the same dict is merged into every request's context. A computed year freezes, and a QuerySet stored there caches its rows on first render. Compute such values in get_context_data().

solid answer

~40 s

`extra_context={"year": date.today().year, "headlines": Story.objects.order_by("-published")[:5]}` is a Python expression evaluated once per process: when the class body runs or when the URLconf calls `as_view()`. `ContextMixin.get_context_data()` then merges that same dict into every request's context. The year is a frozen integer. The QuerySet is lazy, so the first render runs the query, and the QuerySet object caches its rows; because every request gets the same object, later requests reuse those rows and never query again. Each worker process freezes its own copy at its own start time, so after a deploy different users can see different headlines. Fix it by overriding `get_context_data()` and building the QuerySet and date there, or use the `{% now "Y" %}` template tag for the year. Keep `extra_context` for true constants.

code

python · 11 lines
python
from django.test import TestCase

from news.models import Story


class HomePageFreshnessTests(TestCase):
    def test_new_story_appears_without_restart(self):
        self.client.get("/")
        Story.objects.create(title="Breaking", slug="breaking")
        response = self.client.get("/")
        self.assertContains(response, "Breaking")

go deeper

for a junior

Recall that anything you write inside extra_context is computed once, so dates, queries and function calls there do not update per request.

for a middle

Explain that the same dict and the same QuerySet object are merged into every request's context, so the QuerySet's cached rows are reused.

for a senior

Diagnose from the symptoms: fixed until restart, different per worker. Move the work into get_context_data() and add a two-request regression test.

for a principal

Turn it into a review rule for all class-level view configuration: constants only, and caching added deliberately with explicit invalidation.

## The symptom The home page of a news site is a `TemplateView` configured in the URLconf: ```python from datetime import date from django.urls import path from django.views.generic import TemplateView from news.models import Story urlpatterns = [ path( "", TemplateView.as_view( template_name="home.html", extra_context={ "year": date.today().year, "headlines": Story.objects.order_by("-published")[:5], }, ), ), ] ``` Editors publish a breaking story and it never appears on the home page. On 1 January the footer still says last year. A restart fixes both, until the next story. Users on different workers sometimes see different headlines. ## Why: extra_context is built once **`extra_context`** is an ordinary Python dict. The expressions inside it run when the dict literal is evaluated: - In a URLconf, that is when `urls.py` is imported and `as_view()` is called — once per worker process. - As a class attribute (`extra_context = {...}` in the class body), it is when the module defining the view is imported — also once per process. `ContextMixin.get_context_data()` does `kwargs.update(self.extra_context)` on every request, but it merges the **same dict**, holding the **same objects**, every time. ## Why the year freezes `date.today().year` ran once and produced an integer. Every request copies that integer into its context. Nothing re-runs the expression. ## Why the headlines freeze The QuerySet case is subtler, because QuerySets are lazy: 1. At import, `Story.objects.order_by("-published")[:5]` builds a QuerySet but runs **no** query. 2. The first request's template iterates it, which runs the SQL and stores the rows in the QuerySet's **result cache**. 3. Every later request receives the **same QuerySet object** from `extra_context`. Iterating it again reads the cache. No new query is run, so new stories never appear. (The general rules for when a QuerySet is evaluated and cached belong to the QuerySet API; the Django-specific trap here is that `extra_context` shares one QuerySet object across requests.) ## Why workers disagree | Worker | Started | Cached headlines | |---|---|---| | Process A | Before the breaking story | Old five stories | | Process B | Restarted after it | Includes the breaking story | Each process has its own copy of the URLconf, its own `extra_context` dict and its own cached QuerySet. Which version a reader sees depends on which process serves the request. ## The fix Build anything time-dependent inside **`get_context_data()`**, which runs per request: ```python from django.views.generic import TemplateView from news.models import Story class HomeView(TemplateView): template_name = "home.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["headlines"] = Story.objects.order_by("-published")[:5] return context ``` - A new QuerySet is created on every call, so every request queries fresh rows. - For the year, the template can use **`{% now "Y" %}`**, which is evaluated at render time. - Keep **`extra_context`** for true constants: a section name, a page heading, a feature label. - If fresh-per-request is too expensive, add caching deliberately, with a timeout and invalidation, rather than getting it by accident from a frozen object. ## Other values that freeze the same way The home-page case is the common one, but any expression inside `extra_context`, or in any class attribute of a view, has the same lifetime: - **Settings-derived values that are later changed at runtime**, for example a feature flag read from the database once at import. - **Translated strings** built with `gettext()` rather than `gettext_lazy()`: the language active at import time is frozen for every visitor. - **Model instances**, such as `Section.objects.get(slug="home")`, which run a query at import time. That can even break `migrate` on a fresh database, because the table does not exist yet when the URLconf is imported. - **Counts and aggregates**, such as a subscriber total, which never move. The rule is the same for all of them: `extra_context` and class attributes are **configuration**, fixed per process; anything that describes the current state of the world belongs in a method that runs per request. ## How to catch it - **Test with two requests**: render the page, create a new `Story`, render again, and assert it appears. A frozen QuerySet fails this within one test process. - **Review rule**: no function calls, QuerySets or `now()`-style expressions inside `extra_context` literals.

  • Would storing Story.objects.all() without a slice in extra_context avoid the stale rows?
    No. Slicing is irrelevant; the problem is that one QuerySet object is shared. Once any request iterates it, its result cache is filled and every later request reads those cached rows. Only building a new QuerySet per request, in `get_context_data()` or a handler, gets fresh data.
  • Why does the stale page differ between users after a deploy?
    Every worker process imports the URLconf itself and freezes its own `extra_context` values at its own start time. Processes started, or first rendering the page, at different moments hold different cached rows. Which headlines a reader sees depends on which process handles the request.

saying these in an interview costs you the question

  • Believing extra_context is re-evaluated on every request
  • Assuming a QuerySet in extra_context runs a fresh query each time it is rendered
  • Blaming the database or a cache backend before checking where the QuerySet is built
  • Fixing staleness by scheduling periodic server restarts
  • Thinking removing the slice from the QuerySet makes it refresh