Database Selection and Trade-offs Questions
Choosing the right database and data platform for a workload: relational versus NoSQL versus specialized stores, managed versus self-hosted, and matching technology to consistency, scale, cost, and query or access-pattern needs. Covers OLTP versus OLAP and transactional-versus-analytical workload splits, polyglot persistence across multiple data stores, structuring an ambiguous selection prompt, naming trade-offs, and defending a recommendation to stakeholders.
You must choose between PostgreSQL and MongoDB for an application with flexible JSON fields, occasional complex joins, and multi-entity transactions. Define evaluation criteria including query patterns, transactions, index support, operational expertise, scaling needs, and provide a recommendation plus a migration plan if choosing one over the other.
Sample Answer
Direct answer
Recommend PostgreSQL, using JSONB columns (a binary JSON column type that stays queryable and indexable) for the flexible fields, over MongoDB for this specific combination of requirements, because "occasional complex joins" and "multi-entity transactions" are exactly PostgreSQL's strengths and MongoDB's comparative weak points, while PostgreSQL's JSONB support gives most of the schema flexibility the question is asking for almost for free. That recommendation flips if the flexible fields genuinely dominate the workload (most fields are dynamic, joins and multi-entity transactions are rare, and the system needs to scale writes horizontally past what a single PostgreSQL primary can absorb), in which case MongoDB becomes the better default.
Structured elaboration
Why the two named requirements favor PostgreSQL specifically. MongoDB has supported multi-document ACID transactions since version 4.0, extended across sharded clusters since 4.2, so "MongoDB cannot do transactions" is an outdated objection. The real, current gap is cost and capability, not existence: a multi-document transaction in MongoDB is a heavier, more deliberately-invoked operation than PostgreSQL's native, always-on transactional model, and MongoDB's join mechanism (the $lookup aggregation stage) is meaningfully less capable and less performant than a relational query planner's native multi-way join, especially once more than two collections are involved. For a workload where joins and multi-entity transactions are a stated, real requirement, even an "occasional" one, PostgreSQL is answering the requirement natively where MongoDB is answering it through a heavier mechanism.
Where JSONB gets you most of MongoDB's advantage without giving up the relational strengths. A JSONB column holds a schemaless document inside an otherwise ordinary relational table row. It can be indexed (commonly with a Generalized Inverted Index, GIN, which supports efficient containment and key-existence queries against the JSON structure) and queried with operators for containment, path extraction, and key lookup, all inside standard SQL, alongside standard columns and standard joins to other tables. The result is a table that behaves relationally where the data is relational, and flexibly where it is not, in the same row.
Evaluation criteria, both directions.
| Criterion | Favors PostgreSQL + JSONB | Favors MongoDB |
|---|---|---|
| Query patterns | Multi-table joins are common or occasional but real | Nearly all reads are single-document, self-contained |
| Transactions | Multi-entity, all-or-nothing operations are common | Transactions are rare, mostly single-document writes |
| Index support | GIN indexes on JSONB cover the needed containment/path queries | Deeply nested, highly varied query patterns across many different flexible shapes |
| Operational expertise | Team already runs and understands PostgreSQL | Team has existing MongoDB operational depth |
| Scaling needs | Read replicas and a single well-sized primary are sufficient | Genuine need for horizontal write scaling past one primary |
Migration plan, PostgreSQL direction (the recommended default here). Identify the genuinely relational entities (users, orders, whatever has real foreign-key relationships and needs real joins) and normalize those into standard tables. Put the dynamic, variable-shaped attributes into a JSONB column on the relevant table, rather than either a wide sparse table or an entity-attribute-value pattern. Add a GIN index on the JSONB column scoped to the actual query patterns the application uses against it (containment queries need a different index configuration than path-based lookups; verify which the application needs before indexing broadly).
Migration plan, MongoDB direction (if the flip condition applies). Even though the schema is intentionally flexible, define an explicit schema-validation ruleset (MongoDB's $jsonSchema validator) rather than leaving every collection fully unconstrained; an unconstrained flexible schema drifts silently over time as different code paths write slightly different shapes, and the validator catches that drift at write time instead of letting it surface later as an application bug.
Two worked scenarios that both look "flexible-schema" but land on different sides of the recommendation.
Evolving-schema documents (for example, a product catalog where attributes vary wildly by category and change often). This is the strongest case for a document-shaped model: catalog items rarely need multi-entity transactions (updating one product's attributes is a self-contained operation), and cross-category joins are rare by nature (a shoe's size chart has nothing to join against a book's page count). If a catalog service is the entire system, MongoDB, or PostgreSQL with JSONB and no meaningful relational structure at all, are close to equivalent; PostgreSQL with JSONB still edges ahead if the catalog needs to join to inventory, pricing, or supplier tables elsewhere in the same system, which most real catalogs eventually do.
ML model metadata (hyperparameters, evaluation metrics that vary by model type, training lineage). This looks equally flexible-schema at first glance, hyperparameters and metrics genuinely vary by model type, but it is actually a strong case FOR PostgreSQL, not against it, because the metadata almost never stands alone: it needs to join back to a dataset table, an experiment table, and a user or team table, and the single most important operation on this data, atomically flipping which model version is flagged "production" while un-flagging the previous one, is a transactional, joined, all-or-nothing operation by nature. Storing the variable hyperparameter and metric fields in a JSONB column on an otherwise normal, foreign-keyed model_versions table gets the schema flexibility this scenario needs while keeping the transactional swap and the joins to dataset and experiment tables fully native.
Worked example
The concrete case for choosing between PostgreSQL and MongoDB rarely comes down to which one can store flexible data (both can); it comes down to what the actual query and transaction pattern costs on each. Frame the "production model swap" operation from the ML-metadata scenario explicitly as SQL to make the transactional requirement concrete:
-- atomic production-model swap, the transactional
-- operation that makes this a PostgreSQL-favoring scenario despite the flexible metadata.
BEGIN;
UPDATE model_versions
SET is_production = false
WHERE model_id = 'checkout-fraud-classifier'
AND is_production = true;
UPDATE model_versions
SET is_production = true
WHERE model_id = 'checkout-fraud-classifier'
AND version = 'v14';
-- hyperparameters and metrics live in a JSONB column on the same row:
-- metadata @> '{"framework": "xgboost"}' -- containment query, uses a GIN index
-- metadata -> 'eval_metrics' ->> 'auc' -- path extraction for a single metric
COMMIT;
Both updates inside this single transaction succeed together or fail together, so it is never possible for two model versions (or zero) to be simultaneously flagged production, a correctness property this scenario genuinely needs. The commented containment and path-extraction queries show the JSONB column answering the "flexible metadata" half of the requirement in the same table, with the same transaction boundary, and the same standard SQL connection any BI or migration tool already speaks.
Trade-offs & pitfalls
- Treating "MongoDB doesn't support transactions" as still true. It has since version 4.0 (single-shard) and 4.2 (across shards); the real, current gap is relative cost and capability versus PostgreSQL's native transactional model, not existence.
- Assuming a flexible-schema scenario automatically favors a document database. The ML-metadata scenario looks flexible-schema on the surface and is actually a strong relational case, once the join and atomic-swap requirements are considered; check the actual operations the data needs, not just its shape.
- Leaving a MongoDB collection's schema completely unvalidated "because it's flexible." Without a validator, schema drift accumulates silently across different code paths and surfaces later as a confusing application bug, not a database error.
- Over-normalizing the JSONB fields into rigid columns "for consistency." That defeats the reason to use JSONB in the first place; keep genuinely dynamic, sparse, or per-category-varying fields in the JSONB column and normalize only what is truly common and stable across every row.
- Indexing a JSONB column generically without checking the actual query pattern. A GIN index tuned for containment queries does not necessarily serve path-based extraction queries efficiently; match the index configuration to how the application actually queries the column.
Your team is considering migrating from a monolithic Postgres instance to a distributed NewSQL database to handle scale. What criteria would you use to decide whether the migration is worth it, and what would a migration checklist look like that minimizes data loss and downtime during the cutover?
Sample Answer
Direct answer
Migrate from a Postgres monolith to a distributed NewSQL database (a class of databases that gives you the familiar SQL relational model but spreads storage and transactions across many machines instead of one, something a single-primary relational database cannot do on its own) only with concrete evidence that vertical scaling, read replicas, and partitioning have a visible ceiling for the actual workload, not a growth projection, since this is one of the highest-risk, highest-effort changes available to a data platform team. When the evidence supports it, cut over online and reversibly: a CDC-based replication bridge running in parallel with the old system, verified before any traffic depends on it, and a fast rollback path kept alive through a burn-in window.
Structured elaboration
Is the migration worth it: decision criteria
- Concrete scaling evidence: is the system actually hitting a wall vertical scaling, read replicas, and partitioning cannot solve, for example a single-writer throughput ceiling, or a working set that no longer fits a single node's memory and I/O budget even after partitioning, not a projection of future growth.
- Multi-region write requirement: does the application genuinely need low-latency writes from multiple geographic regions with strong consistency, something a single-primary Postgres cannot do natively. This is the strongest legitimate driver for NewSQL specifically, as opposed to "more scale" in general, which sharded Postgres or a good caching layer often solves more cheaply.
- Team readiness: does the team have, or can it build within a reasonable time, real operational expertise in the target system's specific failure modes, which differ from Postgres's.
- Application compatibility: how much of the app's SQL, transaction shape, and driver usage is compatible with the target system's dialect and consistency model without a rewrite. Systems marketed as Postgres-compatible typically support a subset, with real gaps around cross-shard transactions and some SQL features.
- Cost at the new scale: is the new system's compute and licensing cost genuinely lower than the alternative of continuing to invest engineering time in scaling Postgres further.
A migration checklist that minimizes data loss and downtime
- Schema compatibility audit: run the actual DDL against the target system and catalog every incompatibility, data types, index types, extensions, stored procedures, before writing any migration code.
- CDC bridge: stand up a change-data-capture pipeline that streams every committed change from Postgres into the new system continuously, so the target stays a live, current replica rather than a one-time snapshot.
- Backfill and reconcile: bulk-load historical data while the CDC bridge runs, then reconcile, comparing row counts and checksums per table or shard between source and target, since the backfill and the live stream can race and only reconciliation proves they actually converged.
- Shadow reads: once reconciled, send a copy of read traffic to the new system without depending on its answers yet, and diff the results against Postgres's answers for the same queries, to catch correctness bugs before anything depends on them.
- Cutover with a fast rollback path: flip writes to the new system behind a routing layer that can be reverted in minutes, and keep the CDC bridge, or a reverse one, running through a defined burn-in window so a fallback to Postgres loses no data if something goes wrong.
- Decommission window: only after a burn-in period with no rollback triggered do you stop dual-writing and retire the old primary, keeping a final consistent snapshot of it for a defined retention period.
Worked example
Assume a 4 TB Postgres database with roughly 2 billion rows. Reconciliation checksums batches of 2,000 rows at a time; assume, as a stated planning assumption rather than a measured benchmark, a sustained checksum throughput of 5,000 rows per second for a single worker: 2,000,000,000 divided by 5,000 is 400,000 seconds, about 111.1 hours, roughly 4.6 days, if run serially. That is why reconciliation has to be parallelized across many workers in practice: splitting the same job across 50 parallel workers brings it to about 111.1 divided by 50, roughly 2.2 hours, a legitimate input for planning how long the reconciliation phase of a cutover window actually needs.
| Step | What it verifies | Rollback implication |
|---|---|---|
| Schema audit | The target can represent every table without silent data loss | Found early, costs nothing to reverse |
| Backfill + reconcile | Target's historical data matches source, byte for byte, not just row count | A mismatch here blocks cutover entirely, no traffic has moved yet |
| Shadow reads | Target's query results match source's for real production queries | A mismatch pauses cutover, no traffic depends on the target yet |
| Cutover | Writes succeed against the target under real load | Routing layer reverts to Postgres in minutes; CDC bridge kept running to avoid data loss during the revert |
| Decommission | New system has run cleanly through a full burn-in period | No longer reversible without restoring from the retained final snapshot |
Trade-offs & pitfalls
The single biggest risk is treating the backfill as the whole migration and skipping shadow reads: a schema or type-mapping bug, for example a NUMERIC column silently losing precision when mapped to the target's numeric type, often shows up only in query results, not in a row-count reconciliation. The single biggest operational mistake is decommissioning the old primary too early, before a full business cycle, a month-end close, for a financial workload, has run cleanly on the new system.
You're designing the storage layer for a new e-commerce product catalog and order service. For each of these two use cases, justify whether you'd choose a relational database (e.g., PostgreSQL), a document store (e.g., MongoDB), or a key-value store. Include considerations such as schema flexibility, transactional guarantees, query patterns (joins, filters), indexing, and expected operational complexity.
Sample Answer
Direct answer
For the CATALOG, prefer a document store: flexible, evolving product attributes with no cross-entity transaction requirement. For the ORDER SERVICE, prefer a relational database: placing an order genuinely needs multi-row atomicity (create the order, decrement inventory, write a payment record, all-or-nothing), exactly what ACID (atomicity, consistency, isolation, durability) transactions across related tables are for. A key-value store is not a strong PRIMARY-store fit for either use case; it is a fine cache in front of either, but neither entity is a pure opaque-blob-by-id access pattern.
Structured elaboration
Product catalog: document store. Schema flexibility: new product categories bring new attribute sets constantly (a shirt has size and color, a laptop has RAM and storage) without forcing every product into one rigid table shape or a sparse, mostly-null relational schema. Query patterns: filter and browse by nested attributes, not joins across many other business entities. Transactional guarantees: catalog writes are single-document updates (change this product's price), rarely needing multi-entity atomicity. Indexing: multikey and compound indexes on nested fields, as with a session-versus-catalog comparison. Operational complexity: moderate, mostly around index and mapping management as attribute sets evolve.
Order service: relational. Transactional guarantees are the deciding factor: placing an order typically means, in one atomic unit, inserting the order row, decrementing stock in the inventory table, and writing a payment or ledger entry, and if any step fails the whole thing must roll back, because a half-completed order (charged but not recorded, or recorded but inventory not decremented) is a business-critical bug, not a tolerable edge case. Query patterns: relational joins across orders, order line items, inventory, payments, and customers (for reporting, refunds, and support lookups) are exactly what SQL and foreign keys are built for. Schema flexibility: an order's shape is comparatively STABLE compared to a catalog's attribute set, so relational rigidity is a feature here, not a cost. Indexing: standard B-tree indexes on order id, customer id, status, and created-at timestamp cover the access patterns. Operational complexity: lower for this shape of data; the main cost is planning for checkout-peak write volume (connection pooling, read replicas for reporting so they don't compete with the transactional write path).
Why not key-value for either. A KV store doesn't index into structure, ruling out catalog facet browsing without a second system, and doesn't offer multi-key transactions across DIFFERENT keys the way a relational database does across tables, ruling out the order's atomic multi-write requirement. Its honest place in this architecture is as a CACHE in front of either store (hot product pages, an in-flight order's status for fast polling), never as the system of record for either.
Worked example
BEGIN;
INSERT INTO orders(id, customer_id, status) VALUES (:order_id, :customer_id, 'placed');
UPDATE inventory
SET stock = stock - :qty
WHERE sku = :sku AND stock >= :qty;
INSERT INTO payments(order_id, amount, status) VALUES (:order_id, :amount, 'captured');
COMMIT;
The inventory UPDATE's WHERE stock >= :qty guard prevents overselling under concurrent orders: the row lock the UPDATE takes serializes concurrent decrements against the same SKU, so a second, simultaneous order for the last unit either succeeds against updated stock or fails the guard and rolls back, never both succeeding against the same unit. The document-store equivalent needs either a multi-document transaction (supported by MongoDB since version 4.0, but positioned as an exception path, not the default access pattern, and it adds latency) or application-level compensating logic (a saga, a sequence of local transactions with defined undo steps for partial failure), meaningfully more code and more failure modes for the same guarantee relational gives for free.
Trade-offs & pitfalls
Teams sometimes pick ONE database for the whole application "for simplicity" and either fight a document store to get order transactions working, or fight rigid relational tables (constant migrations, or a sprawl of nullable columns) to hold the evolving catalog. Using two purpose-fit stores costs one extra piece of operational surface area but avoids both failure modes. The answer doesn't actually turn on the specific product names chosen (Postgres or any equivalent relational engine, MongoDB or any equivalent document store); it turns on which of the two access-pattern shapes, flexible-attribute browsing versus multi-entity atomic transaction, the entity in question actually has.
You must design a global, low-latency user profile store for 200M users supporting reads under 20ms from any region and occasional writes (profile updates). Candidate technologies: DynamoDB Global Tables, Cassandra multi-dc, PostgreSQL with read replicas. Choose a solution, justify it, and outline replication, conflict resolution, cost implications, and read routing.
Sample Answer
Direct answer
Recommend DynamoDB Global Tables for the profile-store use case as stated: writes are occasional and profile fields rarely conflict across regions at the same instant, so eventually-consistent, last-writer-wins (LWW) cross-region replication is an acceptable trade for fully managed, active-active writes with local reads under 20ms in every region.
Structured elaboration
Why every candidate needs a LOCAL copy of the data in each read region. A synchronous cross-region round trip alone typically busts a 20ms budget by an order of magnitude. The physics floor for a round trip is the distance divided by the speed of light in fiber (roughly two-thirds the speed of light in a vacuum, due to the refractive index of glass):
import math
def great_circle_km(lat1, lon1, lat2, lon2, R=6371):
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
dlat, dlon = lat2 - lat1, lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2
return R * 2 * math.asin(math.sqrt(a))
d_km = great_circle_km(39.0, -77.5, 1.35, 103.8) # N. Virginia <-> Singapore
c_fiber_km_s = 300_000 * (2/3)
rtt_ms = (d_km / c_fiber_km_s) * 2 * 1000
print(f"great-circle distance: {d_km:,.0f} km")
print(f"physics-floor RTT (straight-line fiber, no routing overhead): {rtt_ms:.1f} ms")
great-circle distance: 15,526 km
physics-floor RTT (straight-line fiber, no routing overhead): 155.3 ms
Real internet paths are always slower than this straight-line floor (routing hops, congestion), so a design that answers every read with a synchronous cross-region call cannot meet a 20ms budget at all; every candidate must serve reads from a copy of the data that already lives in the requesting region.
"Occasional writes" does not demand low write latency from every region, only that writes eventually land everywhere without being lost.
Candidates.
- DynamoDB Global Tables: replicates via DynamoDB Streams to every region; every region can read AND write locally; conflicts are resolved last-writer-wins (LWW) by internal timestamp; propagation between regions is typically sub-second, though AWS documents no hard SLA on that figure; fully managed, billed per region's capacity plus a replicated-write-unit charge for each additional region.
- Cassandra multi-DC:
NetworkTopologyStrategyplaces replicas per datacenter, andLOCAL_QUORUMreads/writes stay in-region for low latency while replication to other datacenters happens in the background, a similar shape to Global Tables but with tunable, per-request consistency rather than a single fixed LWW rule. This is the natural pick if the organization is not committed to a single cloud provider: Cassandra or Scylla multi-DC is the closest cross-cloud equivalent to what Global Tables gives on AWS alone, at the cost of self-managing the cluster (or paying a managed-Cassandra vendor). - PostgreSQL with read replicas: one write-primary region, asynchronous streaming replication to per-region read replicas. Reads are local and fast; but EVERY write funnels through the one primary region regardless of where the user is, so a write from a non-primary region pays the full cross-region round trip (tolerable for genuinely occasional profile edits, a poor fit the moment writes need to feel local too). It is also a single point of write failure: if the primary region goes down, every other region loses write capability until a primary is promoted, and any replica that had not caught up loses its most recent writes on promotion.
Read routing. Route each request to the nearest region's local replica, for example with latency-based DNS routing (directing a client to whichever region answers fastest) or an edge/CDN layer that forwards to the nearest regional API, so the read never leaves the region.
flowchart LR
U1[User: EU] --> R1[EU region: local Global Table replica]
U2[User: US] --> R2[US region: local Global Table replica]
U3[User: APAC] --> R3[APAC region: local Global Table replica]
R1 <-. async replication .-> R2
R2 <-. async replication .-> R3
R1 <-. async replication .-> R3
Worked example
Two regions each write the same user's display_name field within the same short replication window, close enough together that both writes are in flight before either has propagated. DynamoDB Global Tables resolves the conflict by keeping whichever write has the later internal timestamp and silently discarding the other, no error, no merge. The practical implication: don't build a "who last touched this field" audit feature purely off the base table, since the losing write leaves no trace there; log profile changes to a separate append-only table or stream if that provenance genuinely matters.
Trade-offs & pitfalls
What would flip the recommendation. For a REAL-TIME game-data variant of this same shape (position, health, inventory, written far more frequently, usually from a session pinned to one region at a time), the calculus changes: Cassandra/Scylla multi-DC with LOCAL_QUORUM (tunable, and not tied to one vendor's specific LWW rule) is the better fit, especially on a multi-cloud platform. If the organization is not on AWS at all, Cassandra/Scylla multi-DC answers that constraint directly, since it runs the same way on any cloud or on-premises. Two pitfalls to name explicitly: Global Tables' replicated-write-unit cost scales with the number of regions (each additional region adds roughly another full write's worth of cost), and "no SLA on propagation" means there is no contractual recovery-point guarantee, only an empirically sub-second one; anything that truly cannot tolerate an unbounded (if rare) propagation delay needs a design that does not rely on Global Tables' default consistency model at all.
A team is debating between storing large binary objects (images and videos) in the database vs object storage (e.g., S3) with URL references in the DB. List pros/cons for each approach, including backup size, transactional guarantees, performance, and cost. As an engineer, recommend a pattern for a social media app where media is frequently uploaded and read.
Sample Answer
Direct answer
For a social media app where users constantly upload and view photos and video, store the actual bytes in object storage (an S3-style service: a flat store of immutable byte blobs addressed by a key, not a filesystem) and keep only a small reference row (storage key, content type, size, uploader id, status) in the relational database. Do not put the image or video bytes themselves into a BLOB or bytea column in the primary transactional database at social-media scale. The database should describe the media, not contain it.
Structured elaboration
Binary in the database vs object storage plus a reference
| Dimension | Binary in the database | Object storage plus a reference in the DB |
|---|---|---|
| Backup size | Every full backup re-serializes the blob bytes along with the rest of the table, so backup size grows in lockstep with total media stored, and a routine backup has to move every byte every time it runs | Backups of the database stay proportional to row count, not media size. The bytes get their own lifecycle (versioning, replication, tiering to cold storage: automatically moving rarely accessed objects to a cheaper, slower storage class) handled by the object store, not by the database backup job |
| Transactional guarantees | A blob write participates in the same transaction as the rest of the row, so a write either lands with its metadata or not at all | The upload and the DB row that references it are two separate operations against two separate systems, which opens a short window where one can succeed without the other (see the worked example) |
| Performance | Every read of a large object flows through the database's connection pool and cache, competing for the same capacity your transactional queries need | Reads bypass the database entirely: the client, or a CDN in front of the object store, fetches bytes directly, so a viral photo's read spike never touches the transactional connection pool |
| Cost | Database storage on managed relational services is priced for replicated, low-latency, random-access I/O, expensive per gigabyte for what large binaries actually need | Object storage is priced for exactly this shape of workload: cheap per-gigabyte storage with tiered pricing for infrequently accessed data |
Why this pattern fits a social app specifically
Social media traffic is read-dominated (one upload gets viewed far more than once) and each object is large relative to a typical row. Routing that traffic through the primary database means every read of a popular photo consumes I/O and cache capacity that the feed, notifications, and comment writes are also competing for, and it drags large, effectively-immutable objects through backup and replication streams that exist to protect small, frequently-changing rows. Splitting the two lets each system do what it is good at.
Worked example
Backup size. Assume 200,000 photo and video uploads a day, averaging 4 MB each: 200,000 times 4 MB is 800,000 MB, or 800 GB of new binary data a day. Stored as BLOB columns, a nightly full backup grows by roughly 800 GB every night it runs, even though almost none of yesterday's uploads were rewritten. If only a reference row lives in the database, say 200 bytes for storage key, content type, size, and status, the same day adds 200,000 times 200 bytes, 40,000,000 bytes, about 40 MB, to what the backup moves: roughly 20,000 times less data through the transactional backup path for the same day of uploads.
Closing the two-write gap. Because the object write and the database write are separate systems, the pattern is: (1) the client requests a short-lived presigned upload URL from the app server (a URL that embeds a temporary, cryptographically signed permission slip from the app, so the object store accepts an upload straight from the client without the client needing its own credentials), (2) the client uploads bytes directly to object storage, (3) the object store or a webhook notifies the app once the upload finishes, and only then (4) the app inserts the reference row with status available. Until step 4, nothing in the feed or search index can see the media. A row stuck at pending (an abandoned or failed upload) is cleaned up by a periodic job that deletes stale pending rows and their orphaned objects. This trades one ACID write for a bounded, cleaned-up inconsistency window, which is the right trade for read-heavy, large-object traffic.
A second common instance of the same decision: an ML model registry. The same choice appears when the binary is trained model weights instead of photos, and the atomic-update requirement is sharper because you are promoting a specific version to serve production traffic.
-- Pattern A: weights inside the database (only viable for small models)
CREATE TABLE model_versions (
id BIGSERIAL PRIMARY KEY,
model_name TEXT NOT NULL,
version INT NOT NULL,
weights BYTEA NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (model_name, version)
);
-- Pattern B: weights in object storage, DB holds the reference
CREATE TABLE model_versions (
id BIGSERIAL PRIMARY KEY,
model_name TEXT NOT NULL,
version INT NOT NULL,
storage_uri TEXT NOT NULL, -- e.g. s3://models/fraud-scorer/v42/weights.bin
checksum TEXT NOT NULL, -- sha256, verified after upload completes
size_bytes BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (model_name, version)
);
-- One small row decides which version actually serves traffic
CREATE TABLE model_registry_pointer (
model_name TEXT PRIMARY KEY,
active_version_id BIGINT NOT NULL REFERENCES model_versions(id)
);
Weights can run from tens of megabytes to tens of gigabytes, so pattern B is the only one that scales, for the same backup and I/O reasons as the photo case. What differs from the social media case is atomic promotion: weights are immutable once uploaded, so promoting version 43 or rolling back to version 41 is never a change to model_versions at all. It is a single row update inside one transaction: UPDATE model_registry_pointer SET active_version_id = 41 WHERE model_name = 'fraud-scorer'. At every instant, every reader of the pointer sees either the old id or the new one, never a half-updated model, and a bad rollout is undone by one row-level write rather than by re-uploading anything.
Trade-offs & pitfalls
Keeping bytes in the database is still right for small, low-volume binaries that must be part of the same transaction as other business data, for example a 2 KB avatar in an internal tool with a few hundred users, where a second storage system is not worth the operational overhead. Once storage and reference are split, watch for: orphaned objects (uploaded bytes with no confirming row, handled by lifecycle rules or a sweep job), dangling references (a row whose object was deleted out of band, avoided by making deletes DB-first with an async object cleanup rather than the reverse), and serving stale content through a CDN after a new version is promoted (mitigated by content-addressed keys, so a new version is a new URL rather than an overwrite the CDN might still be caching).
Unlock Full Question Bank
Get access to all Database Selection and Trade-offs interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.