File uploads¶
There are two separate mechanisms for a route to receive an upload, depending on whether the function needs the bytes themselves or only needs to decide where they land on disk.
Receiving raw bytes¶
A route that needs raw bytes — not just JSON — declares a second argument, beyond its own
json/jsonb request argument, matched by type, never by name:
create function hotel.upload_property_photo(req jsonb, files bytea[])
returns jsonb language plpgsql as $$ ... $$;
comment on function hotel.upload_property_photo(jsonb, bytea[]) is 'route:: path: "/hotel/photos", method: "POST"';
bytea[]only ever receives bytes via amultipart/form-databody —files[i]pairs withreq.parts[i]'s metadata (name,filename,content_type,sniffed_content_type,headers). A non-multipart body sent to such a route is a415. A plain (non-file) form field alongside a file input lands infilestoo, as its raw UTF-8 bytes, withparts[i].filenamestayingnull— check that field to tell the two apart.bytea(singular) only ever receives bytes via a raw, non-multipart body — the whole request body becomes the single value. Amultipart/form-databody sent to such a route is also a415, since there'd be nowhere to route the individual parts.- A route declaring neither still receives
partsin the request for a multipart body, even though it can't retrieve their bytes.
A mismatch between what the route declares and what the request actually sends is always a
415 Unsupported Media Type, never a silent fallback. A bytea[]-declaring route called with
no body genuinely gets an empty files array, though — that's not a mismatch, just an
upload-less call; likewise a bytea-declaring route called with an empty body gets an empty
byte string.
Uploads received this way are bounded by http.max_body_size (10 MiB by default) and
http.max_part_count (100) — see Configuration reference to raise either.
Both bytes and parts must be fully received and held in memory before the route function
runs; there is no streaming mechanism for genuinely large uploads (video, multi-GB archives)
through this path. stream_upload below streams straight to disk instead, and is bounded by
the separate, independently-configurable http.upload.max_size.
Choosing a destination without routing bytes through Postgres¶
stream_upload requires http.upload.dir to be configured — a subpath of the static write
directory (the first entry of http.static.path) that every upload is written under. Leaving
it unset disables stream_upload entirely (a request to such a route is a 500). Uploaded
files remain servable as ordinary static content through the static fallback, but no file
under http.upload.dir is ever eligible for jet execution — a deliberate
separation between "files the site serves" and "files a template can run."
If a function only needs to decide where an upload lands on disk, without the bytes ever
passing through Postgres, declare it stream_upload — rel calls it twice:
create function hotel.upload_lease_pdf(req jsonb, out resp jsonb, out content jsonb)
returns record language plpgsql as $$
begin
if req->'upload'->'size' is null then
-- First call : no bytes received yet — decide the destination from the
-- request's own metadata (a path param, req.query, req.jwt, ...).
if (req->'query'->>'reject') = 'true' then
raise exception 'not accepting uploads right now' using errcode = 'RS400';
end if;
resp := jsonb_build_object('upload', jsonb_build_object(
'path', 'leases/' || (req->'upload'->'part'->>'filename'),
'mkdir', true,
'overwrite', 'disallow'
));
content := null;
else
-- Second call : bytes already landed on disk at the path decided above.
resp := jsonb_build_object('status', 200);
content := jsonb_build_object('size', req->'upload'->'size');
end if;
end;
$$;
comment on function hotel.upload_lease_pdf(jsonb) is 'route:: path: "/hotel/leases", method: "POST", stream_upload: true';
A stream_upload function must use the full-control (two-OUT-column) shape — it's the only
way to return the upload key on the first call — and must not declare a bytea/bytea[]
argument at all; the whole point is that the file's bytes never enter Postgres.
- First call, before any bytes are received:
req.uploadis{part}— the upload's metadata (filename, claimed/sniffed content type is absent here, nosizeyet). The function reads whatever else it needs offreq(path params,req.query,req.jwt, ...) and responds withupload: {path?, mkdir?, overwrite?, max_size?}— binding, not a hint. Onlyuploadis read from this response; any other side-effecting field (status,cookies,jwt,template, ...) is ignored, and the secondOUTcolumn is discarded. It may reject outright (RSxxx) — the earliest possible rejection point, before spending time receiving bytes for an upload that was always going to be refused. It may also returnmax_sizeto tightenhttp.upload.max_sizefor this one request (a per-user quota, say) — since it runs before any payload byte is read, this still applies before a single byte streams to disk. It can only lower the cap, never raise it. - Second call, after the bytes are fully written to a temporary location on disk:
req.uploadis{part, size}—partnow also carryingsniffed_content_type, detected from the file's own head. This call's response is the one actually sent to the client, exactly like any other full-control route; it cannot change the destination.
Both calls happen inside the same request; there is no separate registration step and no
__prepare/mandatory-function pairing to declare — one function, called twice, distinguished
by whether req.upload.size is present.
Middleware covering a stream_upload route's path runs once, ahead of
the first call only — never repeated before the second — though any cookies/headers/jwt it set
still apply to the second call's response, the one actually sent to the client.
upload.path, when set, is validated against the same traversal rules static file
serving applies — no .., nothing resolving outside http.upload.dir.
Leaving path unset is a deliberate discard: bytes are still received (so
the second call still gets an accurate size), but nothing is kept on disk.
overwrite: 'disallow' (the default) rejects with 409 Conflict, checked before any bytes
are read, if a file already exists at path; overwrite: 'allow' replaces it. The file is
only actually swapped into place after the second call's own transaction commits — if it
raises instead, nothing is written to disk.
A route accepting stream_upload is scoped to exactly one file per request — a client
uploading several independently-destined files makes several requests. A JSON/text/form body
sent to a stream_upload route (nothing to stream) is a 415.