After more than 20 years of building for the web, this topic somehow kept slipping past me. It always felt obvious, so I never looked deeper. Recently I finally took the time to explore it properly, did some quick research, and now I’m sharing the results. Here’s a simple walkthrough of the different content types you can send in POST requests.
Standard Form Data
When you submit a basic HTML form like <form method="post" action=""></form>, for example a login form, the browser sends the data using the application/x-www-form-urlencoded content type. The body of the request looks like a URL-encoded query string, the same format typically used in GET requests.
Example: username=john_doe&password=pass123.
A POST request with this content type using the fetch API looks like this:
async function sendURLEncoded() {
const params = new URLSearchParams();
params.append('username', 'john_doe');
params.append('email', 'john@example.com');
params.append('password', 'Secret123');
params.append('bio', 'This is a multi-line\nbio text.\nIt supports newlines!');
const response = await fetch('/api/urlencoded/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCSRFToken()
},
body: params
});
const result = await response.json();
console.log(result);
}
On the Django side, validation with a Django form is the usual approach:
from django import forms
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from .forms import URLEncodedForm
class URLEncodedForm(forms.Form):
username = forms.CharField(
max_length=150,
required=True,
min_length=3,
)
email = forms.EmailField(
required=True,
error_messages={
"required": "Email is required",
"invalid": "Please enter a valid email address",
},
)
password = forms.CharField(
required=True,
min_length=8,
)
bio = forms.CharField(
required=False,
max_length=500,
widget=forms.Textarea,
error_messages={"max_length": "Bio cannot exceed 500 characters"},
)
@require_http_methods(["POST"])
def handle_urlencoded(request):
"""Handle application/x-www-form-urlencoded requests"""
form = URLEncodedForm(request.POST)
if not form.is_valid():
return JsonResponse(
{
"status": "error",
"errors": form.errors,
"content_type": request.content_type,
},
status=400,
)
return JsonResponse(
{
"status": "success",
"form_data": form.cleaned_data,
"content_type": request.content_type,
}
)
Form Data with Files
If your form includes file uploads, the browser switches to multipart/form-data. You enable it in HTML like this:
<form method="post" action="" enctype="multipart/form-data">
A fetch-based upload looks like:
async function sendMultipart() {
const formData = new FormData();
formData.append('username', 'john_doe');
formData.append('email', 'john@example.com');
const fileInput = document.getElementById('file-input');
if (fileInput.files[0]) {
formData.append('avatar', fileInput.files[0]);
}
const response = await fetch('/api/multipart/', {
method: 'POST',
headers: {
'X-CSRFToken': getCSRFToken()
},
body: formData
});
const result = await response.json();
console.log(result);
}
The Django view works the same way, just with request.FILES added.
from django import forms
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
class MultipartForm(forms.Form):
username = forms.CharField(
max_length=150,
required=True,
min_length=3,
)
email = forms.EmailField(
required=True,
)
avatar = forms.FileField(
required=False,
)
def clean_avatar(self):
avatar = self.cleaned_data.get("avatar")
if avatar:
if avatar.size > 5 * 1024 * 1024:
raise forms.ValidationError("File size cannot exceed 5MB")
allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"]
if avatar.content_type not in allowed_types:
raise forms.ValidationError(
f'Invalid file type. Allowed types: {", ".join(allowed_types)}'
)
return avatar
@require_http_methods(["POST"])
def handle_multipart(request):
"""Handle multipart/form-data requests (forms with files)"""
form = MultipartForm(request.POST, request.FILES)
if not form.is_valid():
return JsonResponse(
{
"status": "error",
"errors": form.errors,
"content_type": request.content_type,
},
status=400,
)
form_data = {}
for key, value in form.cleaned_data.items():
if hasattr(value, "read"):
form_data[key] = {
"name": value.name,
"size": value.size,
"content_type": value.content_type,
}
else:
form_data[key] = value
return JsonResponse(
{
"status": "success",
"form_data": form_data,
"content_type": request.content_type,
}
)
JSON String
A very common modern content type is application/json. Single-Page Applications and most JavaScript-heavy frontends rely on it.
Frontend example:
async function sendJSON() {
const data = {
name: "John Doe",
email: "john@example.com",
age: 30
};
const response = await fetch('/api/json/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify(data)
});
const result = await response.json();
console.log(result)
}
For validation, you can use Pydantic, which is a nice alternative to Django-REST-Framework serializers.
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from pydantic import BaseModel, EmailStr, Field, ConfigDict, ValidationError
class JSONDataSchema(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "John Doe",
"email": "john@example.com",
"age": 30,
}
}
)
name: str = Field(min_length=2, max_length=100, description="User's full name")
email: EmailStr = Field(description="Valid email address")
age: int = Field(ge=0, le=150, description="User's age")
@require_http_methods(["POST"])
def handle_json(request):
"""Handle application/json requests"""
try:
data = json.loads(request.body)
validated = JSONDataSchema(**data)
return JsonResponse(
{
"status": "success",
"received": validated.model_dump(),
"content_type": request.content_type,
}
)
except json.JSONDecodeError:
return JsonResponse(
{
"status": "error",
"error": "Invalid JSON",
"content_type": request.content_type,
},
status=400,
)
except ValidationError as e:
return JsonResponse(
{
"status": "error",
"errors": e.errors(),
"content_type": request.content_type,
},
status=400,
)
Newline-Delimited JSON
application/x-ndjson (or simply NDJSON) is an experimental but handy format where each line is a separate JSON object. It’s useful for bulk imports – logs, analytics, and other large datasets.
async function sendNDJSON() {
const ndjsonData = (
`{"name":"John","age":30}
{"name":"Jane","age":25}
{"name":"Bob","age":35}`
);
const response = await fetch('/api/ndjson/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-ndjson',
'X-CSRFToken': getCSRFToken()
},
body: ndjsonData
});
const result = await response.json();
console.log(result);
}
The processing logic is standard: split, parse, validate.
import json
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
@require_http_methods(["POST"])
def handle_ndjson(request):
"""Handle application/x-ndjson requests"""
try:
ndjson_content = request.body.decode("utf-8")
lines = [
json.loads(line) for line in ndjson_content.strip().split("\n") if line
]
return JsonResponse(
{
"status": "success",
"lines_count": len(lines),
"data": lines,
"content_type": request.content_type,
}
)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid NDJSON"}, status=400)
Plain Text
Some systems accept plain text via text/plain, especially object storage services or endpoints meant for raw logs or unstructured content.
async function sendTextPlain() {
const textData = `This is plain text content.
It can span multiple lines.
Line 3 of the content.`;
const response = await fetch('/api/text/', {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'X-CSRFToken': getCSRFToken()
},
body: textData
});
const result = await response.json();
console.log(result);
}
The Django view is minimal and functional:
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
@require_http_methods(["POST"])
def handle_text_plain(request):
"""Handle text/plain requests"""
text_content = request.body.decode("utf-8")
return JsonResponse(
{
"status": "success",
"received": text_content,
"length": len(text_content),
"content_type": request.content_type,
}
)
HTML Text
You can also POST HTML content using text/html, which can be useful for wiki-style pages or HTML editors that save full documents.
async function sendHTML() {
const htmlData = `<!DOCTYPE html>
<html>
<head><title>Example</title></head>
<body><h1>Hello World!</h1></body>
</html>`;
const response = await fetch('/api/html/', {
method: 'POST',
headers: {
'Content-Type': 'text/html',
'X-CSRFToken': getCSRFToken()
},
body: htmlData
});
const result = await response.json();
console.log(result);
}
The Django view would accept the data like this (add your own validation):
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
@require_http_methods(["POST"])
def handle_html(request):
"""Handle text/html requests"""
html_content = request.body.decode("utf-8")
return JsonResponse(
{
"status": "success",
"received": html_content,
"length": len(html_content),
"content_type": request.content_type,
}
)
XML Data
Older or legacy integrations (especially SOAP-based ones) still rely on application/xml.
Here's a JavaScript example:
async function sendXML() {
const xmlData = `<?xml version="1.0" encoding="UTF-8"?>
<user>
<name>John Doe</name>
<email>john@example.com</email>
<age>30</age>
</user>`;
const response = await fetch('/api/xml/', {
method: 'POST',
headers: {
'Content-Type': 'application/xml',
'X-CSRFToken': getCSRFToken()
},
body: xmlData
});
const result = await response.json();
console.log(result);
}
And the Django view can parse the XML data into a dictionary like this:
import xml.etree.ElementTree as ET
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
@require_http_methods(["POST"])
def handle_xml(request):
"""Handle application/xml requests"""
try:
xml_content = request.body.decode("utf-8")
root = ET.fromstring(xml_content)
data = {
child.tag: child.text
for child in root
}
return JsonResponse(
{
"status": "success",
"root_tag": root.tag,
"data": data,
"content_type": request.content_type,
}
)
except ET.ParseError:
return JsonResponse({"error": "Invalid XML"}, status=400)
SVG Image
SVG graphics can be sent as image/svg+xml, since they are XML-based.
async function sendSVG() {
const svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
</svg>`;
const response = await fetch('/api/svg/', {
method: 'POST',
headers: {
'Content-Type': 'image/svg+xml',
'X-CSRFToken': getCSRFToken()
},
body: svgData
});
const result = await response.json();
console.log(result);
}
The Django view (add your own validation):
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
@require_http_methods(["POST"])
def handle_svg(request):
"""Handle image/svg+xml requests"""
svg_content = request.body.decode("utf-8")
return JsonResponse(
{
"status": "success",
"received": svg_content,
"length": len(svg_content),
"content_type": request.content_type,
}
)
Binary Data
For raw binary streams – images, audio, video, PDF, or any unknown byte sequence – the fallback is application/octet-stream.
Here is a JavaScript example of how to post it:
async function sendBinary() {
const binaryData = new Uint8Array([72, 101, 108, 108, 111]);
const response = await fetch('/api/binary/', {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'X-CSRFToken': getCSRFToken()
},
body: binaryData
});
const result = await response.json();
console.log(result);
}
The Django view would be the most straightforward, because request.body is already coming as bytestring:
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
@require_http_methods(["POST"])
def handle_binary(request):
"""Handle application/octet-stream requests (raw binary data)"""
binary_data = request.body
return JsonResponse(
{
"status": "success",
"size": len(binary_data),
"content_type": request.content_type,
"first_bytes": list(binary_data[:10]),
}
)
Protocol Buffers
Protocol Buffers use application/x-protobuf, and compared to JSON they’re usually around 50% smaller and faster to parse. I won't cover the code in this article, but do your research if you need speed.
Code to Play Around with
The rough distribution of content-type usage in real-world APIs is this:
- JSON: ~85–90%
- Multipart: ~10–15%
- Everything else: <5%
If you want to experiment with all the content types, and see the code in context, here's a repo as a great playground:
https://github.com/archatas/django-post-content-types
Top comments (0)