File uploads¶
Veloce parses multipart/form-data and application/x-www-form-urlencoded
bodies on demand. Uploaded files arrive as UploadFile
objects with an async read/write interface; text fields and files together
live in a FormData multi-value mapping.
The multipart parser ships with Veloce. There is no python-multipart
dependency to install — form and file handling works out of the box.
from veloce import Request, Veloce
app = Veloce()
@app.post("/upload")
async def upload(request: Request):
form = await request.form()
upload = form.get_upload("file")
if upload is None:
return {"error": "no file field named 'file'"}, 400
data = await upload.read()
return {"filename": upload.filename, "size": len(data)}
request.form() is an async method — always await it. It returns a
FormData containing both text fields and uploads. get_upload("file")
returns the first value for file if it is an UploadFile, otherwise
None.
Reading the form¶
request.form() parses the body once and caches the result, so calling it
more than once in the same handler is cheap. The mapping is a
multidict.MultiDict subclass: single-value access returns the first
value, and getlist returns every value for a repeated field.
from veloce import Request, Veloce
app = Veloce()
@app.post("/profile")
async def profile(request: Request):
form = await request.form()
name = form.get("name") # first value, or None
tags = form.getlist("tag") # every value for repeated `tag`
return {"name": name, "tags": tags}
For a multipart/form-data body, file parts become UploadFile values
and text parts become plain strings. For an
application/x-www-form-urlencoded body, every value is a string. For any
other content type, request.form() returns an empty FormData.
Note
request.form() and request.files() are coroutines. Forgetting the
await gives you the coroutine object, not the data — a common
mistake when porting from synchronous frameworks.
Files only¶
When a handler only cares about uploads, request.files() returns a
FormData containing just the entries whose value is an UploadFile.
Text fields are excluded. Like request.form(), it is async and cached.
from veloce import Request, Veloce
app = Veloce()
@app.post("/attachments")
async def attachments(request: Request):
files = await request.files()
return {"count": len(files), "names": [f.filename for f in files.values()]}
Debug-mode diagnostics
The most common upload mistake is submitting the form without
enctype="multipart/form-data", so the field arrives as plain text and is
absent from request.files(). When the application runs with debug=True,
a missing-key lookup such as files["avatar"] raises
FilesKeyError — a KeyError
subclass whose message names the cause (missing enctype, a JSON body, or
no multipart body at all). In production the lookup raises a plain
KeyError. Use files.get("avatar") or files.get_upload("avatar") to
avoid the exception entirely.
The UploadFile object¶
UploadFile wraps the uploaded
bytes behind an async interface. Small uploads stay in memory; larger
ones are spooled to a temporary file by the multipart parser, and the
read/write methods transparently hop to a thread when the spool has
rolled over to disk.
The object exposes:
filename— the client-supplied file name.content_type— the part's declared content type, defaulting to"application/octet-stream".size— the part size in bytes.headers— the part headers as a case-insensitiveHeadersobject.read(size=-1)— async; read up tosizebytes, or the whole file whensizeis-1.write(data)— async; append bytes.seek(offset)— async; move the read cursor.close()— async; close the underlying spool.save(destination, buffer_size=16384)— sync; stream the upload to a path or open binary file object.content— sync property returning the full content as bytes.
UploadFile is also an async context manager — leaving the async with
block closes the underlying file:
from veloce import Request, Veloce
app = Veloce()
@app.post("/echo")
async def echo(request: Request):
form = await request.form()
upload = form.get_upload("file")
if upload is None:
return {"error": "missing file"}, 400
async with upload:
head = await upload.read(16)
return {"filename": upload.filename, "head": head.decode("latin-1")}
Saving an upload to disk¶
save streams the upload in fixed-size chunks so memory stays bounded
for large files. Pass a filesystem path (opened in "wb" mode and closed
for you) or an already-open binary file object (which you remain
responsible for closing). The read cursor is reset to the start before
streaming and restored afterwards, so the upload stays readable.
from pathlib import Path
from veloce import Request, Veloce, secure_filename
app = Veloce()
UPLOAD_DIR = Path("uploads")
@app.post("/save")
async def save(request: Request):
form = await request.form()
upload = form.get_upload("file")
if upload is None:
return {"error": "missing file"}, 400
UPLOAD_DIR.mkdir(exist_ok=True)
name = secure_filename(upload.filename)
upload.save(str(UPLOAD_DIR / name))
return {"saved": name}
Sanitise file names
Never trust upload.filename as a path component. A malicious client
can send ../../etc/passwd. Run it through
secure_filename (or build the
destination from a server-controlled name) before writing to disk.
See the OWASP file upload guidance.
Note
save and the content property are synchronous. For an upload that
has been spooled to disk, content performs blocking I/O on the
event loop; prefer await upload.read() in that case. save streams
in chunks and is fine for large files, but it still runs to
completion inline — offload it with run_in_executor if you need the
loop free during a very large write.
Files as handler parameters¶
Instead of reaching into request.form() by hand, declare the upload as
a parameter. Annotating a parameter as UploadFile binds it from the
multipart form by parameter name. Use File
for an explicit marker and Form for text
fields submitted alongside it.
from veloce import File, Form, UploadFile, Veloce
app = Veloce()
@app.post("/items")
async def create_item(
name: str = Form(),
file: UploadFile = File(),
):
data = await file.read()
return {"name": name, "filename": file.filename, "size": len(data)}
A bare UploadFile annotation works too — the parameter is bound from
the form by name. Make it optional with UploadFile | None = None so a
request without that part still resolves:
from veloce import UploadFile, Veloce
app = Veloce()
@app.post("/avatar")
async def avatar(avatar: UploadFile | None = None):
if avatar is None:
return {"uploaded": False}
return {"uploaded": True, "filename": avatar.filename}
See Parameters for the full set of declarative markers and their shared validation options.
Form and a body model cannot share one handler
A Form()/File()/UploadFile parameter reads the body as
multipart/form-data or application/x-www-form-urlencoded; a Pydantic
(or msgspec) body model reads it as JSON. A single request body has one
content type, so the two are mutually exclusive on the same handler.
Mixing them does not error at registration — at runtime the form parser
returns an empty FormData for a JSON body (and JSON parsing fails on a
form body), so one of the two parameters resolves as missing. Split the
endpoint, or send the structured fields as individual Form() fields.
Shapes Veloce does not bind¶
Two FastAPI-style shapes are accepted by the type system but are not wired to multipart binding in Veloce. They are listed here so the divergence is explicit.
| Shape | What FastAPI does | What Veloce does |
|---|---|---|
data: bytes = File() |
Reads the part's raw bytes into data. |
Binds the UploadFile object, then tries to coerce it to bytes — it does not read the content. Use UploadFile and await upload.read(). |
files: list[UploadFile] |
Binds every part sharing the field name. | Falls through to query-list binding, not multipart. Read repeated uploads with (await request.files()).getlist("field"). |
Note
To accept many files under one field name, reach into the form directly:
Limits¶
The multipart parser caps the number of parts and the size of each part
to bound memory and CPU under malicious input. The defaults are 1000
parts and 10 MiB per part. Override them per application through
app.config:
from veloce import Veloce
app = Veloce()
app.config["MAX_FORM_PARTS"] = 200
app.config["MAX_FORM_PART_SIZE"] = 2 * 1024 * 1024 # 2 MiB per part
File and text parts can be limited independently. This is the common "small fields, large files" policy: cap each text field tightly while allowing larger uploads, and bound the file and field counts separately.
app.config["MAX_FORM_FIELD_SIZE"] = 64 * 1024 # 64 KiB per text field
app.config["MAX_FORM_FILE_SIZE"] = 50 * 1024 * 1024 # 50 MiB per file
app.config["MAX_FORM_FIELDS"] = 500 # at most 500 text fields
app.config["MAX_FORM_FILES"] = 10 # at most 10 files
app.config["MAX_FORM_FIELD_MEMORY"] = 1024 * 1024 # 1 MiB of text in total
MAX_FORM_FIELD_MEMORY caps the combined resident size of every text
field (value bytes plus field-name bytes), a ceiling that the per-field
limit alone cannot express.
When a request exceeds any of these limits, parsing fails with
413 Request Entity Too Large before the whole body is buffered.
A multipart request with a missing or malformed boundary is rejected
with 400 Bad Request rather than parsed to an empty form.
A text field that declares its own Content-Type charset (one of
ascii, us-ascii, utf-8, or iso-8859-1) is decoded with that
charset.
See Configuration for how app.config is loaded and
overridden.
Testing uploads¶
The in-memory TestClient sends multipart bodies through the
files argument. Each entry may be raw bytes, a str, a file-like object,
a (filename, content) tuple, or a (filename, content, content_type)
tuple — the same shapes requests and httpx accept. Pass plain text fields
through data.
from veloce import File, Form, TestClient, UploadFile, Veloce
app = Veloce()
@app.post("/items")
async def create_item(name: str = Form(), file: UploadFile = File()):
data = await file.read()
return {"name": name, "filename": file.filename, "size": len(data)}
client = TestClient(app)
response = client.post(
"/items",
data={"name": "report"},
files={"file": ("report.txt", b"hello world")},
)
assert response.status_code == 200
assert response.json() == {
"name": "report",
"filename": "report.txt",
"size": 11,
}
Next steps¶
- Parameters —
Form,File, and the other declarative markers. - Requests and responses — the rest of the
RequestAPI and the response shapes a handler can return. - Testing — driving the app with
TestClient. - API reference:
UploadFile,FormData.