In Django, why is request.POST empty when a client posts a JSON body, and how should the view read it?
answer
- form data, not the body
- two content types only
- bytes you decode yourself
- the stream reads once
basics
~10 srequest.POST only holds form data: Django parses it for POST requests with form-urlencoded or multipart bodies. A JSON body lands in request.body as bytes, which the view parses with json.loads() after checking request.content_type.
solid answer
~30 s`request.POST` is a lazily built `QueryDict` of **form** data: Django fills it only when the method is `POST` and the content type is `application/x-www-form-urlencoded` or `multipart/form-data`. Anything else, `application/json` included, gives an empty `QueryDict`, and so does a form-encoded `PUT`. The raw payload is `request.body`, a `bytes` object, so the view checks `request.content_type`, calls `json.loads(request.body)` and answers 400 on `ValueError`. For a signed webhook, verify the HMAC over `request.body` exactly as received before parsing. Reading the body is capped by `DATA_UPLOAD_MAX_MEMORY_SIZE` (2.5 MB by default), and reading `request.body` after the multipart parser consumed the stream raises `RawPostDataException`.
code
python · 26 linesimport hashlib
import hmac
import json
from django.conf import settings
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def payment_webhook(request):
if request.content_type != "application/json":
return HttpResponse(status=415)
expected = hmac.new(
settings.PAYMENT_WEBHOOK_SECRET.encode(), request.body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(request.headers.get("X-Signature", ""), expected):
return HttpResponse(status=403)
try:
event = json.loads(request.body)
except ValueError:
return HttpResponseBadRequest("Body is not valid JSON.")
handle_payment_event(event)
return HttpResponse(status=204)go deeper
Recall that request.POST is only for form submissions and that JSON must be read from request.body with json.loads().
Explain the parsing rules by method and content type, the bytes type of request.body, the size cap, and the read-once stream behind RawPostDataException.
Build webhook endpoints that check content type, verify signatures over raw bytes, parse after verification, and fail with clear 4xx responses.
Decide where JSON parsing belongs across a codebase: hand-written views with json.loads, a shared helper, or an API toolkit that parses bodies for every endpoint.
## What `request.POST` actually is `request.POST` is a `QueryDict` that Django fills **lazily**, the first time something reads it. `HttpRequest._load_post_and_files()` decides what goes in, and the rules are narrow: | Request | `request.POST` | |---|---| | method is not `POST` (for example `PUT` or `PATCH`) | always an empty `QueryDict` | | `POST` with `application/x-www-form-urlencoded` | parsed form fields | | `POST` with `multipart/form-data` | parsed form fields; files go to `request.FILES` | | `POST` with any other content type, `application/json` included | an empty `QueryDict` | So `request.POST` means "**form data sent with the POST method**", not "the body of a POST request". A JSON payload from a payment provider's webhook, or from a browser's `fetch()` call, never appears there. Neither does a form-encoded `PUT`. ## Reading a JSON body The raw payload is `request.body`, a **`bytes`** object. Decoding it is your job: ```python import json from django.http import HttpResponse, HttpResponseBadRequest def payment_webhook(request): if request.content_type != "application/json": return HttpResponse(status=415) try: event = json.loads(request.body) except ValueError: return HttpResponseBadRequest("Body is not valid JSON.") ... ``` A few details make this robust: - **`request.content_type`** holds the media type from the `Content-Type` header without its parameters; the parameters, such as `charset`, are in `request.content_params`. - **`json.loads()` accepts bytes** and detects UTF-8, UTF-16 and UTF-32, so there is no need to decode first. Catching `ValueError` covers both malformed JSON (`json.JSONDecodeError` is a subclass) and undecodable bytes. - **Size is capped.** Reading `request.body` checks `DATA_UPLOAD_MAX_MEMORY_SIZE`, 2.5 MB (2621440 bytes) by default, and raises `RequestDataTooBig` when the body is larger. That is a `SuspiciousOperation`, so the client gets a 400. ## Why webhooks care about raw bytes Payment providers usually sign each webhook: they compute an HMAC over the exact bytes they sent and put it in a header. The verification must use **`request.body` as received**: - re-serializing with `json.dumps(json.loads(request.body))` changes whitespace and key order, so the digest no longer matches; - comparing digests with `hmac.compare_digest()` rather than `==` avoids leaking timing information; - parsing should happen **after** verification, so unauthenticated input is never acted on. ## The read-once stream and `RawPostDataException` The body arrives as a stream that can only be read once. Django keeps a copy in memory when you access `request.body`, but not when the multipart parser consumes the stream directly. That produces a classic error: 1. Some code, perhaps a middleware, reads `request.POST` on a `multipart/form-data` request. The parser reads the stream. 2. A view then reads `request.body`. 3. Django raises `RawPostDataException`: "You cannot access body after reading from request's data stream". The reverse order works: once `request.body` has been read, `request.POST` parses the in-memory copy. For form-encoded requests there is no conflict, because Django parses them from `request.body` anyway. ## Putting it together A webhook endpoint in Django therefore: 1. accepts only `POST` and checks `request.content_type`; 2. verifies the signature header against `request.body`; 3. parses with `json.loads(request.body)` and answers 400 on failure; 4. is exempted from CSRF protection, because the provider cannot send a CSRF token, which is exactly why the signature check is mandatory; 5. returns quickly with a 2xx and hands slow work to a background job. ## Testing a JSON endpoint The test client can send the body the way the provider does: ```python from django.test import TestCase class PaymentWebhookJsonTests(TestCase): def test_non_json_is_rejected(self): response = self.client.post( "/payments/webhook/", data="amount=10", content_type="text/plain" ) self.assertEqual(response.status_code, 415) ``` - With `content_type="application/json"` and a `dict` or `list` as `data`, the client JSON-encodes it for you; pass `bytes` when the exact bytes matter, as they do for a signature test. - Without a `content_type`, `post()` sends `multipart/form-data`, which is why a test can pass while the real client's JSON request finds `request.POST` empty. ## Common wrong answers - "Add `request.POST.get('amount')` and it will work once the client sets the right header." It will not, unless the client switches to form encoding. - "Use `request.data`." That attribute belongs to Django REST framework's `Request`, not to Django's `HttpRequest`. - "Read `request.body` as a string." It is bytes; string operations on it fail or need an explicit decode.
- In Django, when does reading request.body raise RawPostDataException?When the stream was already consumed without Django keeping a copy, typically because something read `request.POST` on a `multipart/form-data` request, so the multipart parser read the stream directly. Reading `request.body` first and `request.POST` afterwards works, because the parser then uses the in-memory copy.
- What happens in Django when a JSON body exceeds DATA_UPLOAD_MAX_MEMORY_SIZE?Accessing `request.body` raises `RequestDataTooBig`, a `SuspiciousOperation`, and the client gets a 400. The default limit is 2621440 bytes (2.5 MB). Raise it for endpoints that legitimately receive large payloads, or set it to `None` to disable the check, accepting the memory risk.
request.POST is a clerk who only processes the office's own paper forms; hand over a letter in any other format and the clerk's tray stays empty, while the letter itself is still in the envelope, request.body, for you to open and read.
saying these in an interview costs you the question
- request.POST holds the body of any POST request, whatever its content type.
- Django's HttpRequest has a request.data attribute with the parsed JSON.
- request.body is a str that can be passed to string methods directly.
- A webhook signature can be checked against json.dumps of the parsed body.
- A form-encoded PUT request fills request.POST like a POST does.