User uploads and storage

StaticAssets and AssetManifest are for build-time assets you ship with the app—immutable, fingerprinted, safe to cache aggressively. User uploads are different: visitor-supplied bytes that need storage, naming, metadata, and access control.

Stario does not ship a standard upload class or storage abstraction; the shape depends on your product (object storage vs disk, streaming vs buffered bodies, auth, retention, …). This page sketches handler-level read and write patterns; validation, auth, and storage layout stay in your code.

Prefer object storage for larger files

If uploads are large or frequent, routing every byte through your Python process is usually the wrong bottleneck. The common pattern is: your app issues a signed URL (or short-lived upload credential), the browser puts the object straight to S3 or another bucket, and your backend only stores the object key (and metadata) after the fact—or the client tells you when the upload finished. That keeps memory, timeouts, and worker occupancy predictable; see also Deployment for timeouts and edge behavior.

Typical flow (provider-specific): issue a short-lived, scoped upload URL or credential; the client puts the object in the bucket (PUT/POST per API); your app stores the key and metadata after you know the upload finished—HEAD/size check, provider callback, or an idempotent “finalize” step if the client retries. Browser uploads to another origin usually need CORS, HTTPS-only URLs, and tight TTLs.

If you store files locally

You still need a policy only your app can define:

  • Names — Using the client’s filename as the on-disk name collides the moment two users upload photo.jpg. Anything that maps “logical id → path” without collisions usually means your own naming scheme (UUIDs, content hashes, per-user directories) and often a database row for metadata and authorization.

  • Serving back — A download handler reads the bytes you stored, sets an accurate Content-Type (for example with mimetypes.guess_type in the standard library), and responds. Set Content-Disposition: inline when the browser should render the file in the page, and attachment (with a safe filename when you expose one) when you want a download prompt; see RFC 6266 for filename encoding.

Server limits on request bodies

Uploads are subject to Stario’s maximum body size and read behavior whether you use Request.body() or Request.stream(): both paths count bytes against the same cap and the same slow-read rules (Request).

await c.req.body() buffers the entire request body in memory (up to the configured maximum). Use Request.stream() when you want to read the upload in chunks without holding the full payload in RAM.

Raise the body and header caps with STARIO_REQUESTS_MAX_BODY_BYTES and STARIO_REQUESTS_MAX_HEADER_BYTES; defaults and examples are in Stario-side limits. Tune STARIO_REQUESTS_BODY_TIMEOUT when uploads need a different slow-read policy. Match your reverse proxy so limits are predictable end-to-end.

WhatDefaultTypical response if exceeded
Request body size10 MiB413 Payload Too Large
Stall between body chunks30 s408 Request Timeout (slow upload)

Per-route tighter caps apply only to buffered reads: await c.req.body(max_size=65536). Streaming uploads rely on the server-wide STARIO_REQUESTS_MAX_BODY_BYTES unless you count bytes in the loop and stop early.

If the client disconnects while you are reading the body, body() and stream() raise ClientDisconnected. Delete partial files or make finalize steps idempotent.

Minimal upload and download handlers

The framework gives you Context, Request.body() or Request.stream(), stario.responses, and Writer when you need streaming or a precise Content-Type on bytes.

python
import mimetypes
from pathlib import Path
 
import stario.responses as responses
from stario import Context, Writer
 
 
UPLOAD_DIR = Path("var/uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
 
 
async def upload_raw_body(c: Context, w: Writer) -> None:
    data = await c.req.body()
    dest = UPLOAD_DIR / "myfile.bin"
    dest.write_bytes(data)
    responses.text(w, "ok")
 
 
async def download_stored_file(c: Context, w: Writer) -> None:
    path = UPLOAD_DIR / "myfile.bin"
    if not path.is_file():
        responses.text(w, "Not found", status=404)
        return
    mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
    w.respond(path.read_bytes(), mime.encode("ascii"))

Register these handlers from your app bootstrap like any other route.

Streaming upload and download

If the upload is large enough that you want to avoid holding the whole body in memory, read the request with Request.stream() and write each chunk to disk instead of calling Request.body(). For large downloads, open the stored file and write chunks through Writer after write_headers. Set Content-Length from the file size before write_headers when you know the size.

python
import mimetypes
from pathlib import Path
 
import stario.responses as responses
from stario import Context, Writer
 
 
UPLOAD_DIR = Path("var/uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
 
 
async def upload_stream_to_file(c: Context, w: Writer) -> None:
    dest = UPLOAD_DIR / "myfile.bin"
    with dest.open("wb") as f:
        async for chunk in c.req.stream():
            f.write(chunk)
    responses.text(w, "ok")
 
 
async def download_stream_file(c: Context, w: Writer) -> None:
    path = UPLOAD_DIR / "myfile.bin"
    if not path.is_file():
        responses.text(w, "Not found", status=404)
        return
    mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
    size = path.stat().st_size
    w.headers.set("Content-Type", mime)
    w.headers.set("Content-Length", str(size))
    w.write_headers(200)
    with path.open("rb") as f:
        while True:
            chunk = f.read(65_536)
            if not chunk:
                break
            w.write(chunk)
    w.end()

Start with either a full read or a stream for a given request—do not call body() after you have begun iterating stream(), or the other way around (Request).

The examples use a fixed filename and paths for clarity—they are not safe for concurrent users or production routing: real code uses opaque ids, one upload root, auth, and collision-proof destinations.

Multipart forms and Datastar file signals

Wire format: classic HTML forms and many APIs use multipart/form-data or a raw body (the sections above); Stario gives you the bytes either way. Datastar-driven UIs often send files as signals instead—see Reading and writing signals — file uploads.

If the client sends multipart/form-data, Stario exposes the raw request—you parse parts with a library you choose (for example python-multipart) and enforce limits yourself (Request).