File Uploads¶
HyperDjango supports three upload modes that the framework selects automatically based on file size. All modes use the same API — handlers don't need to know which mode is active.
Three Upload Modes¶
Mode 1: Memory (default for small files)¶
Files smaller than FILE_UPLOAD_MAX_MEMORY_SIZE (default 2.5 MB) are buffered
in memory. This is the fastest path — zero disk I/O.
@app.post("/upload")
async def upload(request):
files = await request.files()
photo = files["photo"]
photo.data # bytes — in memory
photo.size # int
photo.in_memory # True
Mode 2: Disk Spill (for medium/large files)¶
Files larger than the memory threshold are written to a temporary file during
multipart parsing. .data reads from disk lazily. .path gives the temp file
path for zero-copy operations.
@app.post("/upload-large")
async def upload_large(request):
files = await request.files()
video = files["video"]
video.in_memory # False
video.path # "/tmp/hyper_upload_abc123" — temp file
video.size # int — known without reading
# Stream from disk in chunks (bounded memory)
async for chunk in video.chunks():
await process(chunk)
Mode 3: Pass-Through Streaming (for proxying to S3/CDN)¶
For multi-GB uploads proxied to external services, the body is never buffered at all. Bytes flow directly from the TCP socket to your handler in chunks.
@app.post("/proxy-to-s3")
async def proxy_to_s3(request):
async for chunk in request.stream():
await s3_client.upload_part(chunk)
This mode activates automatically when Content-Length exceeds MAX_BODY_SIZE
(default 10 MB). The Zig server reads chunks from the TCP socket on demand —
bounded memory regardless of upload size.
UploadedFile API¶
Instances are returned by request.files() — not user-instantiated.
class UploadedFile:
filename: str # Original filename from Content-Disposition
content_type: str # MIME type from Content-Type header
@property
def data(self) -> bytes:
"""Full file content. Reads from disk if spilled."""
@property
def size(self) -> int:
"""File size in bytes."""
@property
def path(self) -> str | None:
"""Temp file path (disk mode), or None."""
@property
def in_memory(self) -> bool:
"""True if file data is in RAM."""
async def chunks(self, chunk_size: int = 0) -> AsyncIterator[bytes]:
"""Stream file content in chunks. Works for all modes."""
request.stream()¶
For pass-through proxying without multipart parsing:
async def stream(self, chunk_size: int = 0) -> AsyncIterator[bytes]:
"""Stream raw body bytes directly from the TCP socket."""
When the body exceeds MAX_BODY_SIZE, chunks are pulled from the Zig server's
TCP socket reader via FFI — true streaming with bounded memory. For small bodies
or TestClient, falls back to yielding the buffered body in chunks.
Settings¶
| Setting | Type | Default | Description |
|---|---|---|---|
MAX_BODY_SIZE |
int | 10 MB | Threshold: bodies smaller are buffered in memory, larger use streaming |
FILE_UPLOAD_MAX_MEMORY_SIZE |
int | 2.5 MB | Per-file threshold: smaller in memory, larger spill to disk |
FILE_UPLOAD_MAX_SIZE |
int | 0 | Per-file max size during parsing (0 = unlimited) |
FILE_UPLOAD_TEMP_DIR |
str | "" |
Temp directory for spilled files (empty = system default) |
STREAM_BODY_CHUNK_SIZE |
int | 256 KB | Chunk size for request.stream() and UploadedFile.chunks() |
Architecture¶
Small body (< MAX_BODY_SIZE):
Zig reads full body → PyBytes → request.body → parse multipart → UploadedFile(memory)
Large body (>= MAX_BODY_SIZE):
Zig stores socket + Content-Length in thread-local
Python handler calls request.stream() → Zig reads chunk from socket → yields to Python
OR: multipart parts with files > FILE_UPLOAD_MAX_MEMORY_SIZE spill to temp files
The streaming path uses the same Zig worker thread for both the socket read and the Python handler — no cross-thread coordination, no GIL (Python 3.14t free-threaded).