Multipart Forms — Complete Guide
In this tutorial, you will learn about Multipart Forms. We cover key concepts, practical examples, and best practices to help you master this topic.
Multipart/form-data encoding splits an HTTP request body into multiple parts separated by a unique boundary string, each part carrying its own headers and binary content.
What You'll Learn
By the end of this lesson, you will understand how multipart encoding structures file uploads, how to parse multipart requests, and how to stream large files without buffering everything in memory.
Why It Matters
Every file upload on the web uses multipart encoding. Understanding how it works helps you debug upload issues, choose the right Parsing library, and optimize memory usage for large files.
Real-World Use
A medical imaging platform receives DICOM files and patient metadata in a single multipart request. The server must parse the form fields and the binary image data, validate each part, and store them separately.
Multipart Structure
flowchart TB
Request[HTTP Request Body] --> Boundary[Boundary Delimiter]
Boundary --> Part1[Part 1: Text Field]
Boundary --> Part2[Part 2: File Field]
Boundary --> Part3[Part 3: Second File]
Part1 --> H1[Headers: Content-Disposition]
Part2 --> H2[Headers: Content-Disposition + Content-Type]
Part3 --> H3[Headers + Binary Data]
Understanding the Boundary
The boundary is a unique string that separates parts. The client generates it and includes it in the Content-Type header. Each part starts with --boundary and the body ends with --boundary--.
# multipart_parser.py
# Simulating a multipart request structure
import uuid
from typing import List, Dict, Tuple
class MultipartPart:
def __init__(self, headers: Dict[str, str], body: bytes):
self.headers = headers
self.body = body
self.name = self._parse_name()
self.filename = self._parse_filename()
def _parse_name(self):
disp = self.headers.get("Content-Disposition", "")
for part in disp.split(";"):
part = part.strip()
if part.startswith("name="):
return part[5:].strip('"')
return ""
def _parse_filename(self):
disp = self.headers.get("Content-Disposition", "")
for part in disp.split(";"):
part = part.strip()
if part.startswith("filename="):
return part[9:].strip('"')
return None
def __repr__(self):
return f"Part(name={self.name}, filename={self.filename}, size={len(self.body)})"
class MultipartRequest:
def __init__(self, boundary: str):
self.boundary = boundary
self.parts: List[MultipartPart] = []
def parse(self, body: bytes):
raw_boundary = f"--{self.boundary}".encode()
closing_boundary = f"--{self.boundary}--".encode()
raw_parts = body.split(raw_boundary)
for raw in raw_parts:
raw = raw.strip(b"\r\n")
if not raw or raw == closing_boundary.strip(b"--"):
continue
if raw.startswith(b"Content-Disposition"):
header_end = raw.find(b"\r\n\r\n")
header_section = raw[:header_end].decode()
body_section = raw[header_end + 4:]
headers = {}
for line in header_section.split("\r\n"):
if ":" in line:
key, val = line.split(":", 1)
headers[key.strip()] = val.strip()
self.parts.append(MultipartPart(headers, body_section))
boundary = "----FormBoundary" + uuid.uuid4().hex[:8]
req = MultipartRequest(boundary)
body_parts = []
body_parts.append(f"--{boundary}\r\n".encode())
body_parts.append(b'Content-Disposition: form-data; name="username"\r\n\r\n')
body_parts.append(b"alice\r\n")
body_parts.append(f"--{boundary}\r\n".encode())
body_parts.append(b'Content-Disposition: form-data; name="avatar"; filename="photo.jpg"\r\n')
body_parts.append(b"Content-Type: image/jpeg\r\n\r\n")
body_parts.append(b"\xff\xd8\xff\xe0" + b"fakejpegdata" * 10 + b"\r\n")
body_parts.append(f"--{boundary}--\r\n".encode())
req.parse(b"".join(body_parts))
for p in req.parts:
print(p)
Expected output:
Part(name=username, filename=None, size=5)
Part(name=avatar, filename=photo.jpg, size=123)
Fields vs Files
In a multipart form, regular fields and file fields are structurally identical. The difference is that a file part includes a filename parameter in its Content-Disposition header and a Content-Type header. A text field has neither.
# field_vs_file.py
from typing import Dict
def classify_part(headers: Dict[str, str], body: bytes) -> str:
disp = headers.get("Content-Disposition", "")
content_type = headers.get("Content-Type", "")
has_filename = "filename=" in disp
is_text = content_type.startswith("text/") or not content_type
if has_filename:
return "file"
elif is_text and len(body) < 1024:
return "text_field"
else:
return "unknown"
samples = [
({"Content-Disposition": 'form-data; name="email"'}, b"a@b.com"),
({"Content-Disposition": 'form-data; name="file"; filename="doc.pdf"',
"Content-Type": "application/pdf"}, b"%PDF-1.4"),
({"Content-Disposition": 'form-data; name="desc"',
"Content-Type": "text/plain"}, b"hello world"),
]
for headers, body in samples:
print(f" {classify_part(headers, body)}")
Expected output:
text_field
file
text_field
Streaming Multipart Parsing
Reading the entire request body into memory does not scale for large files. Streaming parsers Process parts as they arrive, writing files to disk without buffering the whole body.
# streaming_multipart.py
# Concept: processing parts as boundaries are encountered
import io
from typing import Callable
class StreamingMultipartParser:
def __init__(self, boundary: str, on_part: Callable):
self.boundary = boundary.encode()
self.on_part = on_part
self.buffer = b""
self.current_headers = {}
self.in_headers = True
def feed(self, chunk: bytes):
self.buffer += chunk
if self.in_headers:
self._parse_headers()
def _parse_headers(self):
end = self.buffer.find(b"\r\n\r\n")
if end == -1:
return
header_bytes = self.buffer[:end]
for line in header_bytes.decode().split("\r\n"):
if ":" in line:
k, v = line.split(":", 1)
self.current_headers[k.strip()] = v.strip()
body_start = end + 4
self.in_headers = False
self.buffer = self.buffer[body_start:]
parser = StreamingMultipartParser(
"----Boundary7MA4YWxkTrZu0gW",
lambda headers, data: print(f"Received: {headers.get('Content-Disposition', '')[:40]}...")
)
chunk1 = b'------Boundary7MA4YWxkTrZu0gW\r\n'
chunk1 += b'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n\r\n'
chunk1 += b'hello world\r\n'
parser.feed(chunk1)
Expected output:
Received: form-data; name="file"; filename="test.txt"...
Common Mistakes
1. Hardcoding Boundary Values
Each request uses a unique boundary. Never hardcode it; always extract it from the Content-Type header.
2. Not Handling Missing Boundary
If the boundary is missing or malformed, parsing fails. Always validate the Content-Type before parsing.
3. Loading Entire Body Into Memory
For large files, reading the whole body into memory can crash the server. Use streaming parsers.
4. Ignoring Trailing Boundary
The final --boundary-- is the proper end marker. Some parsers stop at the first boundary, leaving unread data in the stream.
5. Confusing Content-Type with File Extension
The Content-Type in a multipart part is advisory. A JPEG file might be declared as text/plain. Always verify actual content.
Practice Questions
1. What character sequence separates parts in a multipart request?
--boundary at the start of each part, --boundary-- at the end.
2. How does the server know a part contains a file?
The Content-Disposition header includes a filename parameter.
3. Why is streaming parsing important for file uploads?
It prevents loading the entire request body into memory, allowing large file uploads without exhausting server RAM.
4. What happens if the boundary value appears inside uploaded file data?
Multipart encoding requires the boundary to be unique. Multipart clients generate random boundaries to avoid collision.
Challenge
Build a multipart parser that writes each file part to a separate temp file while keeping text fields in memory, then returns both when parsing completes.
FAQ
Mini Project: Boundary Generator
# boundary_generator.py
import secrets
import re
class BoundaryGenerator:
SAFE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@classmethod
def generate(cls, prefix: str = "----FormBoundary") -> str:
suffix = "".join(secrets.choice(cls.SAFE_CHARS) for _ in range(24))
boundary = f"{prefix}{suffix}"
return boundary
@classmethod
def extract_from_header(cls, content_type: str) -> str:
match = re.search(r'boundary=([^;\s]+)', content_type, re.IGNORECASE)
if not match:
raise ValueError("No boundary found in Content-Type")
return match.group(1)
b1 = BoundaryGenerator.generate()
print(f"Boundary: {b1}")
ct = f"multipart/form-data; boundary={b1}"
print(f"Header: {ct}")
extracted = BoundaryGenerator.extract_from_header(ct)
print(f"Extracted: {extracted}")
print(f"Match: {b1 == extracted}")
Expected output:
Boundary: ----FormBoundarya3Bf8kLm9xQw2Rz7Yp5VnC1j
Header: multipart/form-data; boundary=----FormBoundarya3Bf8kLm9xQw2Rz7Yp5VnC1j
Extracted: ----FormBoundarya3Bf8kLm9xQw2Rz7Yp5VnC1j
Match: True
What's Next
You understand multipart form encoding. Next, learn how to enforce file size limits at the server level, then explore restricting allowed file types.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro