File Storage
This guide covers how to attach files to your peewee models and how Proper stores, validates, and serves them.
After reading this guide, you will know:
- How to install the storage addon and configure one or more services.
- How to attach a single file or many files to a record.
- How to link to and serve attachments in development and in production.
- How to validate uploaded files by size and content type.
- How to transform images and generate variants on demand.
- How to preview non-image files like PDFs and videos.
- How to implement support for additional storage services.
The companion AttachmentField section in the Forms guide covers the field that handles uploads inside a form in depth. Use this guide as the reference for storage but jump there for form mechanics.
1. What is File Storage?
File Storage in Proper handles uploading files to a cloud service, like Amazon S3, or to your local disk for testing and development. It provides a single Attachment model so the rest of your application can treat any uploaded file the same way regardless of where its bytes live.
The subsystem has two cooperating pieces:
- Storage services. A service knows how to put bytes somewhere and get them back:
Diskwrites to a folder on the local filesystem,S3talks to Amazon S3 (and any service that speaks the S3 protocol). Each service is named in your config, and one service is active per environment. - The
Attachmentmodel. Every uploaded file gets a row in theattachmenttable that records its filename, content type, byte size, which service holds the bytes, and a few other flags. Your own peewee models reference attachments through a regularForeignKeyField.
Because the application code talks to Attachment and never to a service directly, swapping Disk for S3 between development and production is one config change. A user's avatar is stored in storage/ on a developer's laptop, in temp/storage/ during the test run, and in an S3 bucket in production. None of that ripples into model or controller code.
1.1 Requirements
Some features of File Storage depend on third-party software which Proper will not install, and must be installed separately:
- libvips v8.6+ for making thumbnails and image transformations.
- ffmpeg v3.4+ for video previews.
- poppler for PDF previews.
libvips
On macOS install the system library with brew install vips; on Debian or Ubuntu, apt install libvips-dev. For other systems, see the libvips installation page.
2. Setup
The storage addon is installed on demand; a freshly generated application doesn't carry it by default. From the project root:
$ proper install storage
$ proper db migrate
This writes a few files into your application:
- An
Attachmentmodel that lives in your application and inherits its powers from the one in Proper, so you can extend it with extra fields or methods. It uses an UUID as primary key. - Drop-in Jx components for file inputs with image previews, paired with Stimulus controllers and CSS.
- Several controllers at
storage_controller.py:
| Controller | Purpose |
|---|---|
StorageRedirectController |
Redirect to the storage service's native URL (e.g. a presigned S3 link) for signed URLs. Fallback to streaming if the service doesn't implement them. |
StorageProxyController |
Streams the attachment bytes through the app. |
DirectUploadController |
To upload files directly to remote services plus a fallback for the Disk service. |
2.1 Services Configuration
Storage services are declared in config/storage.py as a dictionary. Each entry has a name (chosen by you) and a type plus any options the service needs:
# config/storage.py
import os
STORAGES = {
"local": {
"type": "Disk",
"root": "storage/",
},
"test": {
"type": "Disk",
"root": "temp/storage",
},
"amazon": {
"type": "S3",
"bucket": "my-app-uploads",
"region": "us-east-1",
"access_key_id": os.getenv("AWS_ACCESS_KEY_ID"),
"secret_access_key": os.getenv("AWS_SECRET_ACCESS_KEY"),
},
}
STORAGE = "local"
if env == "prod":
STORAGE = "amazon"
elif env == "test":
STORAGE = "test"
STORAGES names tall he available services, STORAGE names the default service. You can declare more services than you actively use: a service is instantiated only the first time someone or something writes to it.
2.2 Disk Service
The Disk service writes files under a single root folder. Configuration takes one option, root, interpreted relative to the project root (one level above app.root_path):
"local": {
"type": "Disk",
"root": "storage/",
}
If it doesn't already exist, the folder is created the first time the service is used.
Tip
storage/ is in your .gitignore (the new-app generator already does it). Uploaded files are user data, not source, and they don't belong in version control.
2.3 S3 Service
The S3 service uploads to Amazon S3 or any service that speaks the S3 protocol (DigitalOcean Spaces, Cloudflare R2, MinIO, Wasabi, Backblaze B2, ...).
Required: bucket. Everything else is optional.
"amazon": {
"type": "S3",
"bucket": "my-app-uploads",
"region": "us-east-1",
"access_key_id": os.getenv("AWS_ACCESS_KEY_ID"),
"secret_access_key": os.getenv("AWS_SECRET_ACCESS_KEY"),
}
If you omit the credentials, boto3 falls back to its default credential chain (environment variables, IAM instance profile, shared AWS config files). On a properly-configured EC2 instance or container, you don't need to put credentials in your config at all.
For non-AWS services, set endpoint:
"spaces": {
"type": "S3",
"bucket": "my-bucket",
"region": "nyc3",
"endpoint": "https://nyc3.digitaloceanspaces.com",
"access_key_id": os.getenv("DO_SPACES_KEY"),
"secret_access_key": os.getenv("DO_SPACES_SECRET"),
}
The same shape covers Cloudflare R2, MinIO, Wasabi, and similar; check the provider's docs for the right endpoint value.
Note
The S3 service requires boto3. Add it to your dependencies:
$ uv add boto3
2.4 Public Access
Public access is decided at the service level. Each entry in STORAGES can carry a public: True flag; every attachment stored in that service is reachable through a stable, unsigned URL. Attachments in services without the flag (the default) are reachable only through a signed URL with an expiration.
STORAGES = {
"local": {
"type": "Disk",
"root": "storage/",
},
# A second service for files that are genuinely world-readable:
"public": {
"type": "Disk",
"root": "storage/public",
"public": True,
},
"amazon": {
"type": "S3",
"bucket": "private-uploads",
},
"amazon_public": {
"type": "S3",
"bucket": "public-assets",
"public": True,
},
}
The benefit of routing public files through their own service is operational clarity: the bucket policies, CDN configuration, and access logs all align with the access mode. There's no risk of accidentally flipping a single attachment's public flag and exposing data, because the access mode is a property of where the bytes live.
To put a new attachment in a public service, pass service_name= either when constructing it manually or when declaring the form field:
# Manually:
att = Attachment(upload, service_name="public")
att.save()
# Through the form field:
avatar = f.AttachmentField(Attachment, service_name="public")
2.5 Baseline Configuration
Three more keys, all populated by the installer, control how files are served and how variants are encoded:
STORAGE_ALLOWED_INLINE = [
"image/*",
"video/*",
"application/pdf",
]
STORAGE_ALLOWED_VARIANTS = [
"image/png",
"image/jpeg",
"image/gif",
]
STORAGE_FALLBACK_FORMAT = "png"
STORAGE_ALLOWED_INLINE is a list of glob patterns (matched with fnmatch) for content types that should be served inline in the browser. <img> shows the image, <video> plays the video, the PDF opens in the browser viewer. Anything not matched is served with Content-Disposition: attachment, triggering a download dialog.
STORAGE_ALLOWED_VARIANTS is a list of glob patterns for source content types whose format should be preserved when generating a variant. A source PNG produces a PNG variant; a source JPEG produces a JPEG variant. Add image/webp or image/avif if your application produces those.
STORAGE_FALLBACK_FORMAT is the format used for variants whose source content type is not in STORAGE_ALLOWED_VARIANTS. The default is "png" (lossless, supports transparency). Set it to "jpg" or "webp" if smaller files matter more than fidelity. A caller-supplied save={"format": "..."} always overrides both rules - see Format Conversion.
3. Attaching Files to Records
The recommended way to attach a file is to give your model a ForeignKeyField that points at Attachment. There is no magic attached_as declaration: a regular foreign key shows up in migrations, plays well with normal queries, and lets two records share the same blob if you ever need to copy a record.
3.1 Single Attachment
For "one file per record" relationships - e.g. an avatar on a user, a cover on a book, a logo on an organization - declare a nullable foreign key:
# models/user.py
import peewee as pw
from .attachment import Attachment
from .base import BaseModel
class User(BaseModel):
name = pw.CharField()
email = pw.CharField(unique=True)
avatar = pw.ForeignKeyField(Attachment, null=True)
That's the entire wire-up. user.avatar is either None or an Attachment instance. Reading attributes works as you'd expect:
user.avatar.url
user.avatar.filename
user.avatar.byte_size
user.avatar.content_type
To assign an attachment, you build it and save it before pointing the FK at it:
att = Attachment(upload, filename="avatar.jpg")
att.save()
user.avatar = att
user.save()
The order matters because the Attachment.id column has no default=uuid4. The id is generated when you call save(), not when you call Attachment(...). That's intentional: it makes attachment.id is None truthfully signal "not in the database yet" so you can't accidentally set a foreign key to a UUID that points at no row.
If you want to skip the ordering dance, use AttachmentField on a form (Forms and Attachments) - the field handles the build, save, and assign in one form.save() call.
3.2 Multiple Attachments
For "many files per record" - e.g. photos in a gallery, documents on a project, supporting evidence on a claim - use a normal through-model:
class Photo(BaseModel):
gallery = pw.ForeignKeyField(Gallery, backref="photos")
attachment = pw.ForeignKeyField(Attachment)
caption = pw.CharField(default="")
position = pw.IntegerField(default=0)
class Gallery(BaseModel):
name = pw.CharField()
# gallery.photos is provided by the backref above
gallery.photos is now an iterable of Photo rows, each carrying its own attachment, caption, and position. The through-model is also the natural place for any per-attachment data: ordering, alt text, captions, who uploaded it, when, in what context.
If you don't need any extra columns, you can still use a through-model with just two foreign keys. It's a tiny amount of boilerplate that pays off the first time you want to add ordering or labels.
3.3 Attaching File or IO Objects
The Attachment constructor accepts anything with a read() method, like an open file, an in-memory BytesIO, or a request body. It also accepts the MultipartPart objects produced by the form parser, which is what AttachmentField uses under the hood.
from io import BytesIO
from .attachment import Attachment
# From in-memory bytes:
data = generate_report_pdf(...)
att = Attachment(
BytesIO(data),
filename="report.pdf",
content_type="application/pdf",
)
att.save()
# From an open file:
with open("/tmp/photo.jpg", "rb") as fp:
att = Attachment(fp, filename="photo.jpg")
att.save()
# From a remote URL via httpx:
import httpx
resp = httpx.get("https://example.com/avatar.png")
att = Attachment(BytesIO(resp.content), filename="avatar.png")
att.save()
Things to know about the constructor:
service_namedefaults to whateverSTORAGEresolves to at runtime. Passservice_name="amazon"to override (write to a different bucket, for example, even though the active default islocal).filenameis normalized: lowercased, special characters replaced with dashes, the extension preserved as a separate part."My Photo!.JPG"becomes"my-photo.jpg"on disk.content_typeis detected from the filename extension when you don't supply it. The fallback isapplication/octet-stream.byte_sizeis populated by the service duringsave()- never pre-fill it.idisNoneuntilsave()runs.
3.4 Forms and Attachments
The form-side mechanics are covered in detail in the AttachmentField section of the Forms guide. The orientation goes here.
For any ForeignKeyField(Attachment) column on a model, the corresponding form field is f.AttachmentField:
# models/forms/user_form.py
from proper import forms as f
from ..attachment import Attachment
from ..user import User
class UserForm(f.Form):
name = f.TextField()
email = f.EmailField()
avatar = f.AttachmentField(Attachment, required=False)
class Meta:
orm_cls = User
The controller doesn't need to know an upload is involved:
def update(self):
self.form.save()
self.response.redirect_to("User.show", self.user)
form.save() reads the multipart submission, builds an Attachment(upload), calls .save() on it (uploading the bytes to the active service), assigns the new attachment to user.avatar, and queues the previous attachment for deletion. All in one call.
The field interprets a structured payload composed of two sub-inputs:
| User action | <field>[file] |
<field>[_destroy] |
What save() does |
|---|---|---|---|
| Uploaded a new file | populated | (any) | Save new attachment, queue old one for deletion. |
| Clicked "Remove" | empty | "1" |
Clear the FK, queue old one for deletion. |
| Left the field alone | empty | "0" or absent |
Preserve the existing attachment unchanged. |
The render helpers file_input() and destroy_input() produce the two HTML inputs, and the image_input.jx component the storage addon ships does the JS work for drag-and-drop, preview, and the "Remove" toggle. See Rendering Forms - Attachment uploads for the HTML side.
For uploads that don't fit the foreign-key pattern - a one-off CSV import, a webhook from an outside service, a parser that reads bytes and discards the file - work directly with Attachment (Attaching File or IO Objects).
3.5 Validating Attached Files
AttachmentField ships with two server-side validators - max_size and accept - and accepts custom validation through the standard form validate_<field> hook. They run during form.validate(), before any upload would otherwise be saved.
Limiting file size. Pass max_size in bytes:
class BookForm(f.Form):
title = f.TextField()
cover = f.AttachmentField(
Attachment,
max_size=5 * 1024 * 1024, # 5 MB
)
A failed check produces errors.FILE_TOO_LARGE with a formatted size in the error args. The default message is "File size should be 5 MB or less".
max_size is a form-level check: the multipart parser still has to receive the bytes before the field can measure them. For a hard ceiling that rejects oversized requests before they're parsed, set the framework-wide MAX_CONTENT_LENGTH in config/main.py. The two work together: the framework limit protects the server from huge uploads, max_size produces friendly per-field errors for files that fit the request limit but exceed your application's policy.
Restricting content types. Pass accept as a list of patterns:
class UserForm(f.Form):
avatar = f.AttachmentField(
Attachment,
accept=["image/*"],
)
accept uses fnmatch semantics, so image/* covers image/jpeg, image/png, image/webp, and so on. List specific types when you want to be stricter:
attachment = f.AttachmentField(
Attachment,
accept=["image/png", "image/jpeg", "image/webp"],
)
A failed check produces errors.INVALID_CONTENT_TYPE with the rejected list in the error args. Comparison is case-insensitive on both sides.
The HTML accept attribute on the file input is a separate thing - it filters the picker dialog client-side but doesn't validate. Always pair accept= on the field with the same accept= on the rendered input; the addon's image_input.jx component does this for you.
Skipping validation on bound attachments. Both max_size and accept are skipped when the form value is an existing Attachment row rather than a fresh upload. The check looks at whether the value has a size (or content_type) attribute; if not, the validator passes.
This matters when you re-render an edit form. The user opens the page, the form binds the existing avatar, the user changes their name (not the avatar), and submits. The form value at that point is the bound Attachment row, not an upload, so the size and content-type rules don't apply. Existing attachments are grandfathered until the user actually replaces them, at which point the new upload is checked against the current rules.
Custom validators. For checks beyond size and content type, use the standard form validate_<field> hook:
class DocumentForm(f.Form):
file = f.AttachmentField(Attachment, accept=["application/pdf"])
def validate_file(self):
upload = self.file.value
if upload is None or isinstance(upload, Attachment):
return # nothing new to check
filename = getattr(upload, "filename", "") or ""
if " " in filename:
self.file.error = "Filename must not contain spaces"
The isinstance(upload, Attachment) guard mirrors the built-in skip - don't re-validate an already-saved attachment. For reusable rules, write your own subclass of AttachmentField.
Customizing error messages. Pass messages={...} with the message key and your replacement template:
cover = f.AttachmentField(
Attachment,
max_size=2 * 1024 * 1024,
accept=["image/jpeg", "image/png"],
messages={
"file_too_large": "Cover image must be 2 MB or less",
"invalid_content_type": "Cover image must be a JPEG or PNG",
"required": "Please choose a cover image",
},
)
For application-wide message changes, define a translation in your locale files instead of repeating messages={...} on every field. See the Internationalization guide for the keys and overrides.
4. Removing Files
To remove an attachment, call one of the purge methods. Both delete the file from the active service and remove the database row; they differ in when the work happens.
# Synchronously destroy the avatar and actual resource files.
user.avatar.purge()
# Enqueues a Huey task, returns immediately
user.avatar.purge_later()
purge() calls service.purge(), removes any variants of the attachment, and deletes the row. It's the right call from a CLI script or a background job, where you want the work done before the next thing runs.
purge_later() enqueues a Huey task that does the same work in a worker process. The task takes only the attachment's primary key and re-fetches the row before acting, so it's safe even if the row is deleted some other way before the task runs.
AttachmentField uses purge_later() for the previous attachment after a successful replacement: the new file is uploaded and saved synchronously (you want to know if that fails), but the cleanup of the old file happens in the background (a slow S3 delete shouldn't block the form response).
To remove just the variants of an attachment, leaving the original alone:
user.avatar.purge_variants() # synchronous
user.avatar.purge_variants_later() # background
This is occasionally useful after a design change that invalidates dimensions, or in a migration that switches output format.
5. Serving Files
File Storage in Proper supports two ways to serve files: redirecting and proxying.
Public by default
Both storage controllers are publicly accessible by default. The generated URLs are practically impossible to guess, but permanent by design. If your files require a higher level of protection consider implementing Authenticated Controllers.
5.1 Redirect Mode
The permanent url of your attachment is attachment.url. The property returns a URL containing a token signed with your application's secret key.
user.avatar.url # or user.avatar.get_redirect_url()
# /storage/redirect/eyJpZCI6IC1mM...droorU1KWTkQ/my-avatar.jpg
This is the same as doing:
app.url_for(
"StorageRedirect.show",
token=user.avatar.generate_token(salt="redirect"),
filename=user.avatar.filename,
)
The StorageRedirectController decodes the token, checks the signature, looks up the attachment, and redirects to the actual service endpoint. This indirection decouples the service URL from the actual one.
The URL of the service might be valid only for a few minutes (15 is the Amazon S3 default), but the one returned by user.avatar.url never changes.
Note
Technically the URL does change, but only if you remove your current secret key, because the token generated will become invalid. So, if you do that, you need to also purge the cache of any page with old URLs.
5.2 Proxy Mode
Optionally, files can be proxied instead. This means that your application servers will download file data from the storage service in response to requests. This can be useful for serving files from a CDN.
user.avatar.get_proxy_url()
# /storage/proxy/sdaRCI6IC1mM...mieauU1KWXD/my-avatar.jpg
The proxy and redirect URLs look very similar, but the tokens are not interchangeable: simply changing "redirect" to "proxy" in the URL will not work.
5.3 Authenticated Controllers
The signed token proves that a URL was generated by your code; it doesn't prove that the current viewer should be allowed to see the file.
To gate access on application-level rules (e.g. team membership, ownership, payment status, etc.), edit StorageRedirectController:
- Remove the
skip_authentication = Truerule that makes the controller public; - Put the checks inside:
class StorageRedirectController(AppController):
# skip_authentication = True
@router.get("storage/redirect/:token/:filename")
def show(self):
attachment = Attachment.get_signed(
self.params.get("token"),
salt="redirect",
max_age=None,
)
if not attachment:
raise NotFound
# extra checks here
# ...
This will gate-keep all uploaded files. If you want something more granular, create a new controller instead:
class DownloadController(AppController):
def show(self):
att = Attachment.get_signed(
self.params["token"],
salt="secret",
max_age=None,
)
if not att:
raise NotFound
service_url = att.service_url()
if service_url:
self.response.redirect_to(service_url)
else:
att.send_file()
And create the URLs for it like this:
# "secret_file" is an attachment field
document.secret_file.url_for("Download.show", salt="secret")
Choose a different salt than "redirect", otherwise the generated token will be usable to get the file through the StorageRedirectController.
5.4 Displaying Images, Videos, and PDFs
attachment.url works wherever a string URL goes. The most common shapes:
{# An image #}
<img src="{{ user.avatar.url }}" alt="{{ user.name }}">
{# A video, served inline #}
<video src="{{ post.clip.url }}" controls></video>
{# A link that downloads with the original filename #}
<a href="{{ document.file.url }}" download>{{ document.file.filename }}</a>
{# A list of photos with thumbnails (variants are covered later) #}
{% for photo in gallery.photos %}
<a href="{{ photo.attachment.url }}">
<img src="{{ photo.attachment.variant(resize_to_fill=(200, 200)).url }}">
</a>
{% endfor %}
Whether the file shows inline (the <img> actually renders) or downloads (the browser opens a Save dialog) is decided by STORAGE_ALLOWED_INLINE (Baseline Configuration). The default covers image/*, video/*, and application/pdf - exactly the formats that browsers know how to display in place. For everything else, the browser saves the file.
In a JSON response, treat the URL like any other field:
def show(self):
self.response.json = {
"id": self.user.id,
"name": self.user.name,
"avatar_url": self.user.avatar.url if self.user.avatar else None,
}
For PDFs, modern browsers render them in an embedded viewer when served inline. STORAGE_ALLOWED_INLINE defaults include application/pdf, so an <iframe src="{{ doc.file.url }}"> works without extra configuration. If you want PDFs to download instead, drop application/pdf from STORAGE_ALLOWED_INLINE.
When you need a full URL (meaning one that includes the domain name) - for OpenGraph, emails, etc. - you can use:
user.avatar.get_redirect_url(_full=True)
# or
user.avatar.get_proxy_url(_full=True)
6. Downloading Files
Sometimes you need to read an attachment's bytes back into Python: parsing a CSV, hashing a file for a deduplication check, re-uploading to a different service, transcoding through an external tool. download() returns the file as bytes:
data = user.avatar.download()
# => b'...'
Use it for parsing, hashing, transcoding, or any work that needs the file in memory. The whole file is materialized at once, which is fine for images and small documents but inappropriate for multi-gigabyte uploads. For very large files, write a streaming controller that reads from the underlying service and pipes to the response.
Reading the bytes through download() always goes through the service: on Disk, that's a path.read_bytes(); on S3, a get_object followed by reading the body.
7. Analyzing Files
Proper does not automatically extract metadata (image dimensions, audio bitrate, video duration) from uploaded files out of the box. The reason is mostly pragmatic: the analysis pipeline depends on a small zoo of native libraries (libvips for images, ffprobe for video, mutagen for audio) and we'd rather you opt into the ones you actually need.
Every Attachment carries a metadata JSON column that you can populate yourself:
from PIL import Image
att = Attachment(upload, filename="photo.jpg")
att.save()
# Compute and store dimensions:
img = Image.open(att.download_to_tempfile())
att.metadata = {
"width": img.width,
"height": img.height,
"alt": "Sunset over the harbor",
"captured_at": img.getexif().get(36867),
}
att.save()
(download_to_tempfile() is not a built-in method - this example sketches the shape of analyzer code you'd write yourself; pyvips, Pillow, ffprobe, and mutagen all accept either bytes or paths and the bytes are one download() away.)
For analysis that should always run, override save() on your Attachment subclass:
class Attachment(app.attachment_for(BaseModel)):
def save(self, *args, **kwargs):
result = super().save(*args, **kwargs)
if (
self._upload is None
and self.content_type.startswith("image/")
and not self.metadata
):
# Newly persisted image, no metadata yet - extract dimensions.
try:
w, h = self._extract_dimensions()
self.metadata = {"width": w, "height": h}
super().save()
except Exception:
pass # don't block uploads on analysis failures
return result
The pattern is: store first, analyze second, swallow analysis failures so a flaky analyzer can't break uploads.
For metadata that you'd want to query against (uploaded-by user id, gallery id, expiration date), add a real column to your Attachment subclass instead of stuffing it into metadata. JSON is fine for ad-hoc, optional data; a real column is better when it's queryable or required.
8. Transforming Images
A variant is a derived file generated from an original. The classic case is a thumbnail: store one full-size avatar, but render a 200x200 crop in lists, a 64x64 crop in headers, and a blurred hero version on the profile page. Each variant is itself an Attachment row, with parent set to the original and variant_key set to a hash of the operations that produced it.
Variants are enabled by default, but you need to install the system library libvips. See the requirements section for details.
Variants are:
- On-demand - the variant file is generated the first time you ask for it, not at upload time.
- Cached forever - subsequent calls return the existing row without reprocessing.
- Persisted - both the bytes (in the active service) and the row (in the
attachmenttable) are kept.
Variants are not free. The first request that triggers a new variant pays for the whole transformation (decode, resize, encode, upload). For predictable response times in production, pre-generate the variants you know you'll need - see Eager Loading Variants.
8.1 Generating a Variant
Call variant(**ops) on any attachment (with variants enabled):
thumb = user.avatar.variant(resize_to_fill=(200, 200))
thumb.url
# => "/storage/aBcDe..."
The first call processes the image and creates a new Attachment row. Subsequent calls with the same operations look up the existing row by hash and return it.
Variants inherit the parent's service_name unless you override it, so they land in the same service - and inherit its access mode automatically. A parent in a public service gives a public variant; a parent in a private service gives a signed variant. The variant's id is its own UUID.
In a template, calling variant() is cheap once the variant exists - it's a single index lookup by hash, not a recompute - so you can put it directly in the markup without caching gymnastics.
8.2 Available Transformations
Pass any combination of these as keyword arguments to variant():
| Operation | Args | What it does |
|---|---|---|
resize_to_fit |
(width, height) |
Fit inside the box, preserving aspect ratio. Bigger images are downscaled, smaller images upscaled. |
resize_to_limit |
(width, height) |
Like resize_to_fit, but smaller images are not upscaled. |
resize |
(width, height) |
Alias for resize_to_limit. |
resize_to_fill |
(width, height) |
Fill the box exactly, cropping the longer side. Center crop by default. |
resize_and_pad |
(width, height) |
Fit, then pad with black or transparent to reach the exact box. |
rotate |
(degrees) |
Rotate by an arbitrary angle. Corners are filled with black by default. |
fliphor |
() |
Flip horizontally. |
flipver |
() |
Flip vertically. |
grayscale |
() or (r,g,b) |
Convert to grayscale. Default uses BT.601 luminance weights. |
sepia |
() or (r,g,b) |
Apply a sepia tone. Defaults produce a classic warm sepia. |
blur |
(sigma) |
Gaussian blur. Larger sigma means more blur. |
composite |
(overlay) |
Blend an overlay image on top - useful for watermarks. |
Either width and/or height can be None.
Each operation accepts a positional tuple optionally ending with a kwargs dict for advanced settings:
attachment.variant(resize_to_fill=(400, 400))
# smart crop
attachment.variant(resize_to_fill=(400, 400, {"crop": "attention"}))
# use an empty tuple for no args
attachment.variant(blur=())
# white corners
attachment.variant(rotate=(45, {"background": [255, 255, 255]}))
# bottom-right watermark
attachment.variant(composite=("logo.png", {"gravity": "south-east"}))
The full set of pyvips settings is forwarded through; the pyvips documentation is the reference for what each operation supports.
You can chain operations in a single call - they're applied left to right (top to bottom, in this example):
hero = post.cover_image.variant(
resize_to_fill=(1600, 600),
blur=(8.0,),
)
8.3 Format Conversion
Two special keys, load and save, control how the image is read and written:
# Load options - passed to pyvips when opening the file:
attachment.variant(
resize_to_fit=(800, 600),
load={"autorot": True}, # respect EXIF orientation (the default)
)
# Save options - passed to pyvips when writing the variant:
attachment.variant(
resize_to_fit=(800, 600),
save={"format": ".webp", "Q": 80},
)
When you don't pass save["format"], Proper picks one for you based on the source content type:
- If the source matches a pattern in
STORAGE_ALLOWED_VARIANTS(Baseline Configuration), the variant is saved in the source format. A JPEG source produces a JPEG variant, a PNG source produces a PNG variant. - Otherwise, the variant is saved in
STORAGE_FALLBACK_FORMAT(default"png"). This covers source formats like TIFF, BMP, or HEIC that you don't want to expose verbatim.
A caller-supplied save={"format": "..."} overrides both rules. The format string controls both the file extension and the pyvips encoder; per-encoder options (quality, compression level, ...) are forwarded as additional keys in the same save dict.
8.4 Variant Idempotency
variant() is idempotent: same arguments, same variant. The mechanism is a SHA-256 hash of the ops dict, stored as variant_key on the variant row. When you call variant(), Proper:
- Resolves the save format (per the rules in Format Conversion) and injects it into the ops dict.
- Computes the SHA-256 hash from the resolved ops.
- Looks up
Attachment.parent == self AND variant_key == hash. - Returns the existing row if found, otherwise generates the variant and inserts a new row.
The resolved format is part of the hash, so a JPEG source with resize_to_fill=(200, 200) and a TIFF source with the same kwargs produce different keys (and different variants - the TIFF lands as PNG).
The argument order matters for the hash ((200, 100) is not the same as (100, 200)), but the order of keys within the load and save dicts does not - those are sorted before hashing.
This means you can call variant() freely in templates without worrying about duplicate work:
{% for user in users %}
<img src="{{ user.avatar.variant(resize_to_fill=(64, 64)).url }}">
{% endfor %}
Each iteration looks up the same variant by hash. One database query per call, no reprocessing.
8.5 Purging Variants
Call purge_variants() to delete every variant of an attachment, leaving the original in place:
attachment.purge_variants() # synchronous
attachment.purge_variants_later() # queue a Huey task
You'd typically do this after a design change that invalidates dimensions (the 200x200 thumbnail is now 240x240 everywhere, regenerate), or in a migration that switches output format (move from JPEG to WebP variants).
purge() (without _variants) deletes the original and all of its variants in one call.
8.6 Eager Loading Variants
In production you usually want to avoid the "first request pays" cost. Two common patterns:
A. Pre-generate after upload. Right after an attachment is saved, queue the variants you'll need:
@app.queue.task
def generate_avatar_variants(attachment_id):
att = Attachment.get_or_none(Attachment.id == attachment_id)
if att is None:
return
att.variant(resize_to_fill=(64, 64))
att.variant(resize_to_fill=(200, 200))
att.variant(resize_to_fill=(800, 800))
def update(self):
self.form.save()
if self.user.avatar_id:
generate_avatar_variants(str(self.user.avatar_id))
self.response.redirect_to("User.show", self.user)
The first request that displays an avatar finds the variant already in the database and serves it without recomputing.
B. Pre-generate in a migration. When you add a new variant size to your design, walk the existing attachments and generate the new variant once:
# scripts/backfill_variants.py
from myapp.models import User
for user in User.select().where(User.avatar_id.is_null(False)):
user.avatar.variant(resize_to_fill=(64, 64))
Run it once after deploying the design change.
The variant cache (the row in attachment plus the bytes in the service) survives across requests, processes, and deploys. You only pay the generation cost once per (parent, ops) combination ever.
9. Previewing Files
variant() can only handle images. For everything else - PDFs, videos, audio with cover art, ePubs - you need a way to extract an image "preview" of the file first.
The VARIANTS_ENABLED_FOR property of your Attachment model maps content-type patterns to the names of methods on the same class that generate the preview.
VARIANTS_ENABLED_FOR = {
"image/*": "preview_image",
# "application/pdf": "preview_pdf",
# "video/*": "preview_video",
}
Warning
The preview methods also require the libvips system library.
9.1 PDF Previews
PDF previews ship with the framework. To enable them, uncomment the "application/pdf" line in VARIANTS_ENABLED_FOR and install poppler (brew install poppler on macOS, apt install poppler-utils on Debian/Ubuntu):
# models/attachment.py
from ..main import app
from .base import BaseModel
class Attachment(app.attachment_for(BaseModel)):
VARIANTS_ENABLED_FOR = {
"application/pdf": "preview_pdf",
# ...
}
pdf_attachment.variant(resize_to_fit=(400, 400)) produces a PNG thumbnail of the first page, stored as a normal variant - cached, deduplicated, served through whatever service the parent uses.
Two kwargs are specific to preview_pdf:
page=1- 1-indexed page number to render. Passpage=2to preview the second page.dpi=150- render resolution in dots per inch. Bump todpi=300when the source page is dense or when the final variant downscales by a large factor.
Both participate in the variant cache key, so variant(page=1) and variant(page=2) produce distinct cached variants.
9.2 Video Previews
Video previews ship with the framework. To enable them, uncomment the "video/*" line in VARIANTS_ENABLED_FOR and install ffmpeg (brew install ffmpeg on macOS, apt install ffmpeg on Debian/Ubuntu):
class Attachment(app.attachment_for(BaseModel)):
VARIANTS_ENABLED_FOR = {
"video/*": "preview_video",
# ...
}
video_attachment.variant(resize_to_fit=(400, 400)) produces a PNG thumbnail of a single frame.
One kwarg is specific to preview_video:
at_seconds=1.0- timestamp (in seconds) of the frame to extract. The default skips the first second to avoid black opening frames. Pass any float for arbitrary seeking.
It participates in the variant cache key, so variant(at_seconds=1) and variant(at_seconds=5) produce distinct cached variants.
9.3 Custom Previewers
The same shape works for epub cover extraction, audio waveform thumbnails, or anything that can be transformed into an image.
Write a method that takes the attachment bytes plus options and returns the image bytes. Then, add the content-type pattern to VARIANTS_ENABLED_FOR:
class Attachment(app.attachment_for(BaseModel)):
VARIANTS_ENABLED_FOR = {
"application/epub+zip": "preview_epub",
# ...
}
def preview_epub(self, source: bytes, **ops) -> bytes:
image_bytes = extract_cover(source) # your extraction logic
return image_bytes
The preview method shape is deliberately simple. Anything that can read bytes and produce bytes is a valid previewer.
The preview method receives all of the kwargs you passed to variant(), including the resolved save dict. If your transform produces bytes in a fixed format and ignores save["format"], that's fine - the variant_filename is still derived from the resolved format and the bytes are written as-is. If you want the transform to honor format conversions, inspect ops["save"]["format"] and branch accordingly.
10. Testing
The bundled config/storage.py defines a separate test service pointing at temp/storage/. The STORAGE selector switches to it when the environment is "test", so the test suite never writes into your development storage folder.
"test": {
"type": "Disk",
"root": "temp/storage",
}
This is the equivalent of giving your tests their own database: a clean, isolated, throwaway service whose contents you can wipe between tests without losing anything that mattered.
10.1 Discarding Files Stored During Tests
A small autouse fixture wipes the folder between tests:
# tests/conftest.py
import shutil
import pytest
@pytest.fixture(autouse=True)
def clean_storage(app):
test_root = app.root_path.parent / "temp" / "storage"
yield
if test_root.exists():
shutil.rmtree(test_root)
Mark it autouse=True to run on every test (the recommended default) or pull it in explicitly when only some tests upload files. Because the folder is recreated by the Disk service on first use, you don't need to set it up before the test runs - just clean up after.
If you prefer to clean before each test, swap the yield order:
@pytest.fixture(autouse=True)
def clean_storage(app):
test_root = app.root_path.parent / "temp" / "storage"
if test_root.exists():
shutil.rmtree(test_root)
yield
For attachments built in a fixture (a fixture that loads a known set of users with avatars, for example), generate them once per session and let the fixture set up and tear down its own folder.
10.2 Testing Against Cloud Services
For S3-backed code paths, moto mocks the AWS API at the boto3 layer. Configure it as a fixture rather than baking it into the test storage config:
# tests/conftest.py
import os
import pytest
from moto import mock_aws
@pytest.fixture()
def aws_credentials():
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_DEFAULT_REGION"] = "us-east-1"
@pytest.fixture()
def s3_storage(aws_credentials, app):
with mock_aws():
import boto3
boto3.client("s3").create_bucket(Bucket="test-bucket")
# Override the active service for the duration of the test:
original = app.config["STORAGE"]
app.config["STORAGE"] = "amazon"
yield
app.config["STORAGE"] = original
Tests that need the S3 code path opt in by depending on s3_storage; the rest keep using the disk service. moto starts and tears down a per-test in-memory S3 with no network calls.
For end-to-end tests that should hit a real bucket - smoke tests on staging, integration tests that exercise IAM policies - you can use MinIO running in a Docker container. Or point a test environment at a dedicated bucket and run them outside the unit-test loop. The S3 service has no special test mode of its own.
11. Implementing Support for Other Cloud Services
Proper ships with two services: Disk and S3. To add support for another one, subclass proper.storage.services.Service and implement these six methods:
# myapp/storage/gcs.py
from proper.storage.services import Service
class GigaCloud(Service):
def __init__(self, app, **config):
...
def upload(self, upload, att):
"""Write the upload to the service.
Sets `attachment.byte_size` along the way."""
def download(self, att):
"""Read the file out of the service into memory."""
def send_file(self, att, response, as_attachment=False):
"""Stream the file to the active response, with the
right disposition."""
def purge(self, att):
"""Delete the file from the service. Trailing empty
directories may also go."""
def service_url(self, att, *, as_attachment=False):
"""Generates a short-lived signed URL for the file"""
def direct_upload_url(attachment, checksum):
"""Generates a presigned PUT URL + headers where the
browser uploads to directly"""
Once the class is imported anywhere in your app (a top-level import in myapp/__init__.py is the simplest place), the type value in the service config matches the class name:
STORAGES = {
"giga": {
"type": "GigaCloud",
...
},
}
STORAGE = "giga"
Look at proper/storage/services/disk.py and proper/storage/services/s3.py for working references. Both are short and cover all six methods plus their constructors.
For services with native streaming responses (presigned URLs, range requests), send_file is where you'd integrate with that machinery. The signature (attachment, response, as_attachment) is the contract; how you fulfill it is up to the service.
12. What's Next
Storage touches forms, models, async tasks, and the asset pipeline. A few places to go from here:
AttachmentFieldin the Forms guide - the full reference forAttachmentField, including the lifecycle table and transactional save semantics.- Rendering Forms - Attachment uploads - the HTML side:
file_input(),destroy_input(), and theimage_input.jxcomponent. - Models and Relationships - the foreign-key patterns that connect your records to attachments.
- Background Tasks - the queue that runs
purge_later(),purge_variants_later(), and any eager-loading task you write. - pyvips documentation - the full image-processing reference behind
variant().