How to Serve AI Images in Go: Better Object Storage Than Relational Databases
Short answer: Object storage is usually better for serving generated image bytes, while relational databases remain better for tenant ownership, report state, and authorization metadata.
The operational constraint is access control. For a developer-tools platform that serves authenticated customers generated reports containing AI images, put the image bytes in object storage and keep ownership, generation state, and the object key in a relational database. Do not make the database carry the binary payload unless the images are genuinely small, short-lived, and inseparable from a transaction.
That split makes delivery simpler to reason about: the database answers who may access an image, while the storage layer handles an opaque byte stream. A report row can be present while its object is temporarily unavailable, so the application can show a precise state instead of turning a large database transaction into a customer-facing outage.
Why is object storage better than relational databases for storing images?
I once approached this class of design as a schema question: should an image be a bytea column, a blob field, or a path? That framing was too narrow. The real question was which system should own the expensive, independently delivered part of the request.
A generated report has at least three different lifecycles. Its metadata needs indexed queries and transactional updates. Its image needs streaming, retention, and integrity checks. Its authorization decision needs tenant context and an audit trail. Combining those lifecycles in one row makes every read, backup, replication, and migration concern inherit the largest payload.
Object storage is a better fit for the payload because it treats the image as an addressable object with metadata and a body, rather than as a value that every relational query must carry around. The database remains the system of record for the relationship between customer, report, and object key. This is an architectural separation, not a claim that object storage is automatically cheaper or more reliable in every workload.
The invariant is straightforward: no request gets an object key from the client and then fetches that key directly. The server first authenticates the customer, checks the report ownership, verifies the image belongs to that report, and only then returns a short-lived capability or streams the object through an authorization-aware service.
That is the boundary I would review first.
The failure mode that deserves the most attention is a successful upload followed by an incomplete authorization record. Imagine a worker receives a generation result, writes the image to a private namespace, and is terminated before the report transaction commits. The customer must not receive a guessed key, and an operator must not have to infer state from a directory listing. The report should remain pending or failed according to the state machine; the object should be marked for reconciliation; and the reconciler should be able to distinguish an unreferenced object from a delayed transaction without deleting a valid image. The reverse case matters just as much: a committed row pointing at a missing object should produce a stable unavailable response, a metric, and an actionable repair path. This is why I keep the payload and the relationship separate but test them as one workflow. The separation gives each system a manageable job; it does not give the application permission to forget that the two jobs are coupled at the customer boundary.
In a real review, I would trace one request across the whole path: identity supplies the tenant, the report query proves access, the image row proves ownership, and the download is constrained to that key. I would repeat it with a customer paired with another tenant's report, a deleted report, a pending generation, and an object with no committed metadata. Each case needs a deliberate result and an auditable reason. A correctly configured storage API does not make an application safe if it skips one of those joins.
A small Go model makes the ownership rule explicit
Start with metadata that describes the relationship, not a path that the browser can invent. The checksum is useful for detecting an incomplete upload or a mismatched object; it is not a substitute for authorization.
package reports import ( "context" "crypto/sha256" "encoding/hex" "fmt" "io" ) type ImageRecord struct { TenantID string ReportID string ObjectKey string MediaType string SizeBytes int64 SHA256 string } func CheckImage(ctx context.Context, tenantID, reportID string, image ImageRecord, body io.Reader) error { if image.TenantID != tenantID || image.ReportID != reportID { return fmt.Errorf("image does not belong to report") } h := sha256.New() if _, err := io.Copy(h, body); err != nil { return fmt.Errorf("read image: %w", err) } if got := hex.EncodeToString(h.Sum(nil)); got != image.SHA256 { return fmt.Errorf("image checksum mismatch") } return nil } The important detail is the order of decisions. A storage object key is data, not permission. The handler must derive the tenant and report from the authenticated request, load the corresponding row using both identifiers, and reject a mismatch before asking the storage service for bytes. A single-column lookup by ObjectKey is a tempting shortcut that turns a leaked key into a cross-tenant read.
For a report-generation worker, write the object first under a non-public key, verify the completed upload, then commit the metadata row with a status such as ready. If the database transaction fails after the upload, a reconciler can find unreferenced objects by prefix and age. If the upload fails before the row is committed, the report stays incomplete. Those states should be visible to operators and to the customer-facing API.
Which failure modes should the SLO cover?
The delivery SLO needs more than a database query latency percentile. Define separate objectives for report authorization, metadata lookup, object retrieval, and end-to-end time to first byte. They fail differently, and collapsing them into one number hides the useful signal.
For example, a report page can meet its metadata latency objective while every image request is timing out. Conversely, the object can be available while a stale authorization cache continues to allow access after a customer membership change. The useful alert is tied to the customer action: an authorized customer can retrieve a ready image within the promised window, and an unauthorized customer is denied.
Instrument the decision path with a correlation ID, tenant ID in controlled logs, report ID, object size, status outcome, and reason category. Never log signed URLs or image contents. Track orphaned objects, incomplete reports, checksum failures, denied requests, and the age of the oldest pending generation. Capacity planning should include concurrent downloads and egress, not only database connections; ten thousand small thumbnails and one hundred large renders create different pressure on the same service.
A useful test is to remove each dependency in turn. If the database is unavailable, a new authorization decision should fail closed. If object storage is unavailable, the report metadata should still load and identify the image as temporarily unavailable. If a worker dies after upload and before commit, cleanup must be bounded by a retention rule. These are boring cases. They are also the cases customers experience.
What belongs in the database, and what should stay outside it?
The database should hold tenant ownership, report identity, generation status, media type, byte count, checksum, object key, creation time, and retention or deletion state. Those fields support filtering, joins, audit, and a transactional state machine. The image body should stay outside the relational engine when it has an independent delivery lifecycle or can become large relative to the rows around it.
| Concern | Relational database | Object storage |
|---|---|---|
| Tenant and report lookup | Strong fit for indexed, transactional queries | Poor fit for relational joins |
| Image byte streaming | Possible, but couples reads to database capacity | Natural fit for whole-object reads and ranges |
| Authorization | Application policy and row ownership | Private namespace and request controls |
| Integrity | Checksum column and transaction state | Object checksum or application verification |
| Cleanup | Foreign keys and state transitions | Lifecycle rules plus reconciliation |
| Backup and restore | Restores metadata and relationships | Requires a separately tested object recovery path |
This is where buy-versus-build judgment matters. A managed object service can reduce the amount of storage plumbing the platform team owns, but it introduces API semantics, availability boundaries, retention behavior, and possible migration work. A self-hosted object system can preserve more control, but the team then owns disks, replication, upgrades, repair, and the operational path between the report service and storage. The decision should follow the team’s SLO and on-call capacity, not a headline feature list.
The same caution applies to direct browser downloads. A time-limited signed request can reduce application bandwidth, but it must be minted only after the authorization check and constrained to the exact object, method, and expiry the client needs. An application proxy gives tighter policy and observability, at the cost of another streaming hop and more service capacity. Neither pattern fixes a broken ownership model. The limitation is real: object storage adds a second durable system, so a small application with tiny images and no independent download path may be better served by one database.
Keep the boundary only when its operational benefit exceeds the extra recovery, monitoring, and access-policy work.
When is a relational database still the right answer?
Use the database for the image body when the payload is small, the image must participate in an atomic transaction with adjacent values, and the operational cost of a second durable system outweighs the delivery benefit. A moderation decision that must commit with a tiny preview may reasonably keep that preview close to its row. A platform serving large generated images to many authenticated customers has a different shape.
The decision rule I use is practical: if the payload needs independent streaming, retention, replication, or access patterns, separate it from relational metadata; if it is a small transactional value with no independent lifecycle, keeping it in the row may be clearer. Measure the actual distribution of image sizes and request concurrency before setting a threshold. Do not choose one based on an average that hides the largest render.
The preventative path is simple to state: authenticate, authorize against tenant-owned metadata, retrieve a private object by a server-controlled key, verify state and integrity, and emit only the minimum response needed by the client. Test the denied path as seriously as the successful path.
That approach does not eliminate failure. It keeps failure local, makes ownership inspectable, and lets the report database remain good at the work relational databases are designed to do.