File uploads in Pages
Use htmx for a form that returns HTML, or the generated JS function for structured results or per-file progress. Both deliver the same file dictionary to Python. Do not manually implement the old RPC multipart format.
The picker is the design system's ab-upload: drag and drop, keyboard, type and size validation and a file list. It is a native form field, so a <form> (and htmx) submits its files under its name.
Upload form with htmx
First install the Jinja helper and htmx 4.0.0 base template, with its endpoint/auth hook. Save the HTML blocks below as templates/upload.html and templates/upload_result.html.
POST _fn/upload_file calls post_upload_file. The submit ab-button shows its loading state while the file uploads, and accept/max-size reject wrong files in the browser before any request (validate again in Python). Do not repeat post_ in the URL. Return an HTML string for htmx, not a dict (which gives 406). User-supplied filenames are escaped by Jinja. The unique subdirectory prevents unrelated uploads with the same name from overwriting one another.
File contract in Python
A file arrives as a dict, not a Path or file-like object:
| Key | Type | Example |
|---|---|---|
filename | str | "report.pdf" |
content_type | str | "application/pdf" |
content | bytes | Raw file bytes |
The whole file is in memory before the function runs. If an API requires a filesystem path, save it with Path.write_bytes(file["content"]). Always sanitize the filename before using it in a path. Do not call file.name, file.read() or shutil.copy2(file, ...). Use dict or no annotation; the runtime does not convert annotations.
Other multipart fields
Ordinary fields pass through json.loads when possible: id=7 becomes an integer, true becomes a boolean, and numeric postal codes may lose leading zeroes. Handle those types explicitly; urlencoded forms remain text, but multipart does not promise that. Use JS RPC/JSON when preserving structured types matters.
Multipart query parameters stay available via get_query_params(), not as function arguments. Do not supply __function__ yourself for /_fn requests; cloud-api adds the resolved name at the end. Malformed multipart delimiters return 400.
Multiple files
Repeated file parts under one name produce a list of dictionaries, but one file produces a single dictionary. Normalize both cases:
Generated JavaScript RPC remains supported
When a File or Blob is passed to a generated function, the wrapper switches to multipart automatically, supplies __function__, and JSON-encodes non-file arguments. Otherwise it sends JSON. This path POSTs to the page endpoint, not /_fn, and keeps its literal function names.
Pass the actual File/Blob, not just its name or path. Mixed structured arguments work if they are declared in the Python signature. For per-file status, call the function once per file and report each one back to ab-upload, which shows a progress bar, a check or the error under each file:
A structured return value such as {"name": str, "path": str} remains useful with RPC (the same convention as Forms' FileResponse). It is not an HTML response for htmx. Returning raw bytes is not supported: use a URL, base64 plus a browser Blob, or register_static(path) for downloads.
Request size and troubleshooting
Uploads use the regular execution pipeline. Large files can hit transport limits and are fully loaded into memory. For very large files, upload directly to object storage and pass its URL/key to the function instead. There is no streaming/chunked file parameter API.
| Symptom | Check |
|---|---|
404 on _fn/upload_file | The POST function must be named post_upload_file and registered. |
| 406 with htmx | Return an HTML string, not a structured object. |
| Missing file | Set name="file" on the ab-upload (without name it submits nothing), hx-encoding="multipart/form-data", and match the Python parameter. |
| Unexpected integer field | Multipart fields may be JSON-decoded; convert deliberately. |
Function 'None' not found with old RPC | Do not hand-roll its multipart body; call the generated wrapper. |
'dict' object has no attribute 'name' | Use file["filename"] and file["content"]. |
| Upload appears to hang | Check the request size and memory requirements. |