File and image fields
FileField and ImageField store a Storage key in a TEXT column and resolve it to a public URL through the ambient storage backend.
FileField and ImageField are TEXT-backed model fields that hold a storage key - the opaque identifier a Storage backend returns when it persists a file. The column stores the bare key; FileField::url() resolves it to a public URL at render time, so a template can show an uploaded file without the model author threading a storage handle through every call.
ImageField is a FileField in everything but its default form widget: the admin renders an image preview for an ImageField and a plain file input for a FileField. At the storage, serialisation, and SQL layers they behave identically.
A model that declares a FileField / ImageField needs a Storage backend registered, or the build fails the field.storage_backend system check at boot. Add StoragePlugin with a media side (it registers a filesystem backend) or call umbral::storage::set_storage(...) before App::build().
Example
use umbral::prelude::*; #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, Model)]pub struct Post { pub id: i64, pub title: String, pub cover: ImageField, // widget = "image" pub attachment: FileField, // widget = "file" pub thumbnail: Option<FileField>, // nullable} // Store the key returned by your upload handler:let post = Post { cover: ImageField::from("ab12-hero.png"), /* ... */ }; // Resolve a public URL in a handler or template:let src = post.cover.url(); // e.g. "/media/ab12-hero.png"let key = post.cover.key(); // "ab12-hero.png" (the persisted value)In a template, {{ post.cover.url }} turns the stored key into a usable src. When no backend is registered, url() falls back to the raw key rather than panicking - the boot check is the loud guard.
Both fields serialise as the bare key string, so a REST API round-trips "ab12-hero.png", the same shape a plain String column would have.
In the admin
The admin change / add form renders a native file input for a FileField and an image thumbnail plus an upload input for an ImageField. When the form carries any file/image field it switches to enctype="multipart/form-data"; plain forms stay urlencoded so an admin without a Storage backend keeps working.
On submit, the admin stores each uploaded file through the ambient Storage and writes the returned key to the column - you never wire an upload handler yourself. Editing a row without choosing a new file leaves the existing key untouched (the empty file part is skipped), so a "save" that only changes other fields never clears the attachment.
File and image columns aren't editable inline from the changelist table (a single-cell text edit can't carry an upload); use the row's change form / sheet to replace a file.
Separately, a String/Text field rendered with the markdown widget lets a staff user paste, drop, or pick an image directly in the editor; the bytes upload through the same ambient Storage and the URL is inlined as markdown. See Markdown image upload in the admin docs.
Streaming large files
The Storage backend behind these fields persists bytes through store (buffered) or store_stream (true-streaming, chunk-by-chunk, no full buffering), and reads them back through retrieve / retrieve_stream. For large uploads, StoragePlugin::save_stream writes a streamed body to the backend and records the actual byte count in media_file.size - and when a max_size cap is configured it is enforced mid-stream, rejecting an oversize body the instant its real bytes cross the cap (never trusting Content-Length). The read/serve path already streams via tower-http::ServeDir. The streaming and buffered paths apply the same filename guards, so a FileField value never loses its stored-XSS or path-traversal defence by going through the streaming API.
Cleanup on delete
By default a deleted row's underlying file is left in storage - the key column goes away but the blob stays. Opt a model into orphan cleanup with StoragePlugin::cleanup_on_delete::<M>(): when a row is deleted, the framework removes the file behind each of its FileField / ImageField columns.
App::builder() .plugin(SignalsPlugin) // cleanup hangs off a pre_delete signal .plugin(StoragePlugin::new().media("/media", "./media").cleanup_on_delete::<Post>()) .build()?;The file columns are auto-detected from M's metadata (any column the macro tagged with the file / image widget). If you overrode the widget on a file column, name the columns explicitly with StoragePlugin::cleanup_files::<M>(&["..."]) instead.
Cleanup is best-effort (a storage-delete error is logged, never failing the row delete) and is wired to the per-row pre_delete signal. A bulk QuerySet::delete() fires only bulk_post_delete (PKs, no instances), so it does not trigger file cleanup - call delete_instance per row when the file must go too. A bulk delete intentionally skips per-row cleanup because it never loads the instances.
Replace cleanup
Opting in to cleanup_on_delete / cleanup_files also reclaims the old blob when a file field is changed to a new key. Saving a row whose FileField moves from one key to another via M::objects().save(row) deletes the previous blob (a same-key save, or any non-file-column save, deletes nothing). This rides the per-row post_update signal; the bulk update_values() path doesn't trigger it. See Storage (Static + Media) for the full contract.
See also
- Storage (Static + Media) - the
Storagetrait andStoragePlugin. arch.mdanddocs/decisions/2026-06-02-media-and-s3.mdfor the design rationale.