TL;DR: Put the generated image bytes in object storage and keep a small, transactional record in the relational database. The database should answer who owns an image, which prompt produced it, and whether the audit copy is complete. It should not carry the growing byte stream through every transaction, backup, replica, and query path.
That division matters even more in a developer-tools system that processes receipts. A receipt may be an ordinary image, a generated reconstruction, or a transformed derivative, but the original file still has to survive for audit. Large-file throughput is the constraint that changes the design: the application should stream bytes to a durable object key, then commit metadata that points to that key.
The better boundary is usually the boring one.
For storing images, the relational databases should hold the searchable facts rather than the file body. That makes the better design measurable: fewer large payloads cross database connections, while object storage handles the streaming path.
Why is object storage better than relational databases for storing large images?
A relational database is excellent at relationships, constraints, and short atomic updates. It is a poor place to make every receipt or AI-generated image participate in the same hot path as tenants, jobs, permissions, and audit events. Large binary values increase the amount of data moved by writes and backups, and replicas must receive those bytes even when most application queries need only a filename, status, or owner.
Object storage gives the file a separate data plane. The application can stream an upload, address it by an opaque key, and retrieve it without loading the entire value into application memory. The database row stays small enough to index and join efficiently. This is a separation of responsibilities, not a claim that one system is universally superior. If a binary must be updated together with several relational rows in one transaction, the boundary becomes harder to manage and a database-backed blob can be reasonable.
The audit requirement exposes the important distinction. A row saying status = complete is not proof that the original bytes are present. The upload and the metadata transition need an explicit protocol.
A small protocol for original files
I keep three states for the file record: pending, ready, and quarantined. A client first creates a pending row with the expected content type and a generated object key. The upload goes directly to object storage through the service's controlled interface. After the service verifies the received length and digest, it changes the row to ready in a short database transaction. A failed or abandoned upload remains pending until a reconciler marks it quarantined.
The digest is useful for integrity and idempotency, but it is not a substitute for authorization. Every download still checks the tenant and the record's state before returning a signed, time-limited location or streaming the object through the service. Object keys should be unguessable and should not be treated as access control.
Here is deliberately plain Python showing the ordering. The storage interface is generic because the protocol matters more than a provider-specific SDK.
from dataclasses import dataclass @dataclass class ReceiptFile: record_id: str object_key: str state: str byte_count: int | None = None sha256: str | None = None def complete_upload(db, objects, record_id: str, payload: bytes, digest: str) -> None: record = db.get_receipt_file(record_id) if record is None or record.state != "pending": raise ValueError("upload is not pending") objects.put(record.object_key, payload) stored_count, stored_digest = objects.stat(record.object_key) if stored_count != len(payload) or stored_digest != digest: db.mark_quarantined(record_id) raise ValueError("stored object failed integrity checks") db.mark_ready(record_id, stored_count, stored_digest) The example accepts bytes to keep the protocol readable, but a production path should stream a large file and calculate the digest while reading it. Otherwise the application recreates the memory pressure that the storage split was meant to remove. Also notice what happens on a process crash: an object can exist while the row is still pending. That is an ordinary intermediate state, so a periodic reconciler must inspect pending records and apply a retention policy rather than pretending the two systems share a transaction.
What does the trade-off look like in practice?
| Concern | Object bytes plus relational metadata | Binary values inside the relational database |
|---|---|---|
| Large-file throughput | Separate streaming path and storage scaling | Database writes and replication carry the payload |
| Metadata queries | Small indexed rows remain convenient | Queries can coexist with larger row storage |
| Atomicity | Requires pending/ready reconciliation | A single database transaction can cover both |
| Backups | Separate backup and restore contracts | One database backup contains both categories |
| Access control | Application must enforce object authorization | Database permissions can cover the blob path |
| Audit deletion | Requires coordinated retention and tombstoning | Database transaction can simplify deletion |
This is where architecture reviews often go wrong. “Object storage scales” is not a test plan. Measure the path that matters: concurrent upload streams, median and tail completion time, retry behavior, digest verification time, and the time between an object becoming visible and its row becoming ready. For receipt processing, include scans with very different dimensions and generated images with different encoded sizes. A throughput average can hide a queue that fails only when several large files arrive together.
Durability and consistency also need separate questions. How is the object protected? When is a newly written object observable to a reader? What does a failed multipart or resumable upload leave behind? Which system is the source of truth during restore? The answers belong in the runbook and in integration tests. A database row restored without its object is an audit defect; an object restored without its row is orphaned data.
How should a team roll this out?
Start with one file class and make the state machine observable before moving existing data. Emit an event for upload started, integrity verified, ready, download denied, and reconciliation failure. Track byte counts and age by state. Alert on old pending records and on ready rows whose object check fails. These signals catch the boundary failures that a database health dashboard cannot see.
For migration, copy each original to a deterministic new key, verify its length and digest, then create or update metadata only after verification. Keep reads able to find the old location until the population is complete, and make the copy operation idempotent. Do not delete the old representation in the same step as the first successful copy; restoration is part of the migration, not an afterthought.
The decision rule is compact: use relational storage for facts that need relational constraints, and object storage for large immutable or append-oriented files whose main operation is streaming. Keep the original receipt and generated image as addressable artifacts, keep their lineage and authorization in relational metadata, and test the failure between those two commits. That is the part that determines whether an audit record is real.
Sources
References:
- PostgreSQL documentation on large objects: https://www.postgresql.org/docs/current/largeobjects.html
- Amazon Web Services, Amazon S3 data consistency model: https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html
- RFC 9530, Digest Fields: https://www.rfc-editor.org/rfc/rfc9530
- NIST SP 800-57 Part 1 Rev. 5, key management guidance: https://csrc.nist.gov/pubs/sp/800/57/pt1/r5/final

