skip to content

In Django 5.2 and later, what does preserve_request=True change on HttpResponseRedirect and HttpResponsePermanentRedirect, and when do you need it?

level: middleimportance: nice to knowfreq 24%

answer

  1. 302 versus 307, 301 versus 308
  2. browsers may turn POST into GET
  3. method and body kept
  4. new constructor argument in 5.2

basics

~20 s

preserve_request=True switches HttpResponseRedirect from 302 to 307 and HttpResponsePermanentRedirect from 301 to 308, telling the client to repeat the same method and body. Use it when redirecting a non-GET request, such as a moved POST endpoint.

solid answer

~40 s

`HttpResponseRedirect` sends **302** and `HttpResponsePermanentRedirect` sends **301**. For historical reasons clients may follow a 301 or 302 with a `GET`, dropping a `POST` body. Since Django 5.2 both constructors accept `preserve_request=True`, which switches the status to **307** or **308**; those codes require the client to repeat the original method and body at the new `Location`. You need it when a non-GET request is redirected and must still arrive intact, for example an API endpoint that moved from `/api/v1/stock-moves/` to `/api/v2/stock-moves/`. The `redirect()` shortcut takes the same argument (5.2) and `RedirectView` gained a `preserve_request` attribute in 6.1. For the normal post/redirect/get pattern after a form, keep the default 302.

code

python · 12 lines
python
from django.http import HttpResponsePermanentRedirect, HttpResponseRedirect


def legacy_stock_moves(request):
    # Old write endpoint: forward POST and its body unchanged (308)
    return HttpResponsePermanentRedirect("/api/v2/stock-moves/", preserve_request=True)


def receive_goods(request):
    # After a form POST: post/redirect/get, default 302 so the browser switches to GET
    ...
    return HttpResponseRedirect("/receiving/done/")

go deeper

for a junior

Recall the pairs: HttpResponseRedirect is 302, HttpResponsePermanentRedirect is 301, and preserve_request=True turns them into 307 and 308.

for a middle

Explain why clients may turn POST into GET on 301/302 and why post/redirect/get depends on that while a moved API endpoint does not.

for a senior

Use 308 deliberately when retiring write endpoints, keep post/redirect/get on 302, and still plan for clients to move to the new URL.

for a principal

Treat redirects as a migration tool with a sunset date, balancing old-client support against the ambiguity of silently replayed writes.

## The two redirect classes Django ships two redirect responses in `django.http`, both built on `HttpResponseRedirectBase`, which is itself an `HttpResponse`: | Class | Default status | With `preserve_request=True` | |---|---|---| | `HttpResponseRedirect` | 302 Found | 307 Temporary Redirect | | `HttpResponsePermanentRedirect` | 301 Moved Permanently | 308 Permanent Redirect | The first positional argument is the target URL, which may be absolute, host-relative (`/stock/`) or relative (`stock/`). It is converted with `iri_to_uri()` and stored in the `Location` header; the read-only `response.url` attribute returns it. ## Why the method can change The HTTP specification allows a client that receives 301 or 302 in response to a `POST` to repeat the request as a `GET`, and browsers do exactly that. That behaviour is what makes the classic **post/redirect/get** pattern work: a form posts, the view saves and redirects with 302, and the browser loads the result page with a harmless `GET`, so a refresh does not resubmit. It is the wrong behaviour when the redirect exists to move the request itself somewhere else. 307 and 308 were defined so that the client must re-send the **same method and the same body**. ## What preserve_request does Django 5.2 added the `preserve_request` keyword to both classes and to the `redirect()` shortcut. Inside the constructor the logic is a single swap: - each class carries a `status_code` (302 or 301) and a `status_code_preserve_request` (307 or 308); - when `preserve_request` is true, the instance status becomes the second value. Nothing else changes: the body stays empty, the `Location` header is the same, and the scheme check still runs. Django 6.1 added the same switch to `RedirectView` as a `preserve_request` class attribute. Before 5.2 you had to build a 307 or 308 yourself, either by subclassing `HttpResponseRedirectBase` with a different `status_code` or with a plain `HttpResponse` and a hand-set `Location` header, which also skipped the redirect safety checks. ## When to use it 1. **A moved write endpoint.** A client still posts stock movements to `/api/v1/stock-moves/`; a permanent redirect with `preserve_request=True` (308) forwards the `POST` and its JSON body to `/api/v2/stock-moves/`. 2. **Host or scheme canonicalisation for non-GET traffic.** A `PUT` sent to the old hostname must land on the new one with its body. 3. **Temporary maintenance routing.** A 307 sends a `DELETE` to a fallback URL while one path is unavailable. When **not** to use it: - after handling an HTML form, where you *want* the browser to switch to `GET`; - for plain page moves reached by `GET`, where 301/302 and 308/307 behave the same and the defaults are fine. For a public API, a redirect is a migration aid, not a substitute for moving clients to the new URL. ## Testing the behaviour Django's test client mirrors the rule. With `follow=True`, it re-issues the request with the **same method and data** when it receives a 307 or 308, and with a `GET` for other redirect codes. `assertRedirects(response, expected_url, status_code=302, ...)` defaults to 302, so a test for a preserving redirect must pass `status_code=307` or `308` explicitly, which doubles as documentation that the choice was deliberate. A compact summary of the four codes Django can produce: 1. **302** from `HttpResponseRedirect`: temporary, method may become `GET`. 2. **301** from `HttpResponsePermanentRedirect`: permanent and cacheable by clients, method may become `GET`. 3. **307** from `HttpResponseRedirect(..., preserve_request=True)`: temporary, method and body kept. 4. **308** from `HttpResponsePermanentRedirect(..., preserve_request=True)`: permanent, method and body kept. Because 301 and 308 may be cached by browsers, use the permanent variants only when the move really is permanent. ## Safety checks every redirect gets `HttpResponseRedirectBase` also guards the target: - the scheme must be one of `allowed_schemes` (`http`, `https`, `ftp`); a `javascript:` or `data:` URL raises `DisallowedRedirect`; - the `Location` length is capped at 16,384 characters by default, and Django 6.1 added a `max_length` argument to override that limit (or `None` to disable it); - `DisallowedRedirect` is a `SuspiciousOperation`, which Django turns into a 400 response. These checks do not stop an **open redirect** to another https host built from user input; validating a `next` parameter is a separate job.

  • Why would you not use preserve_request=True after a successful form submission?
    Post/redirect/get relies on the browser switching to `GET` after a 302, so reloading the result page cannot resubmit the form. A 307 would make the browser re-send the `POST` and its body to the target URL, which defeats the pattern and can duplicate the write.
  • What does Django do if a redirect target uses the javascript: scheme?
    `HttpResponseRedirectBase` checks the parsed scheme against `allowed_schemes` (`http`, `https`, `ftp`) and raises `DisallowedRedirect`, a `SuspiciousOperation`, which the request handler converts to a 400 response. It does not check the host, so open-redirect validation of user input is still your job.

saying these in an interview costs you the question

  • preserve_request=True makes Django forward the request server-side
  • HttpResponseRedirect sends 301 by default
  • Browsers always keep the POST body on a 302 redirect
  • preserve_request also protects against open redirects to other hosts
  • A 307 or 308 needs a custom HttpResponse in every Django version