PostgreSQL is a strong default for the system of record: it gives you transactions, constraints, joins, mature indexing, and enough extensibility to handle many product requirements without adding another datastore.
The important part is knowing where that default stops being enough. Payment flows need correct isolation and idempotency. JSONB fields need the right indexes. Large tables need partitioning choices tied to query patterns. Read replicas help read throughput, but they also introduce lag and failover questions.
This chapter focuses on those decisions.
The diagram traces the path a client request takes from application servers through PgBouncer, into the primary's query pipeline, and out to storage and streaming replicas, showing how each layer fits together:
Client applications (App Server 1..N) often do not connect to PostgreSQL directly. They go through PgBouncer, which maintains a smaller pool of database connections and multiplexes many client requests onto them. This keeps connection overhead low and improves throughput under high concurrency.
PgBouncer is a connection pooler, not a SQL-aware read/write router. If an architecture uses read replicas, the application or a separate proxy layer decides whether a query should use the primary or a replica.
Inside the primary, each SQL statement flows through the core execution pipeline:
As the executor reads/writes data, it interacts with the Buffer Manager, which serves pages from memory when possible and fetches from Storage when needed. For durability, changes are recorded in the WAL (Write-Ahead Log): PostgreSQL ensures the WAL is safely written before considering a transaction committed.
For scaling reads, applications can route safe read-only queries to replicas (Replica 1/2). The replicas stay up to date via streaming replication, where a WAL sender process on the primary ships WAL records to replicas. This lets you offload read traffic, while keeping the primary as the source of truth for writes.
Choose PostgreSQL when correctness and query flexibility matter more than easy horizontal write scaling. It is usually a good fit for transactional product data, relational models, reporting-style queries over operational data, and workloads that benefit from constraints.
Most real-world data is relational. Users have orders. Orders have products. Products have categories. When your queries need to traverse these relationships, PostgreSQL's full SQL support with JOINs, subqueries, CTEs, and window functions makes complex questions straightforward.
A query like "find all users who ordered a product in category X but never in category Y" is natural in SQL but awkward in most NoSQL databases.
When a payment transfer must either complete fully or not happen at all, you need atomicity. When your inventory count must never go negative, you need consistency.
PostgreSQL provides these guarantees by default, making it the natural choice for financial systems, inventory management, and booking platforms.
NoSQL databases often require you to know your query patterns upfront and design your data model around them. PostgreSQL inverts this: you model your data naturally, and with proper indexing, the database handles whatever queries you throw at it.
This flexibility proves invaluable as requirements evolve.
PostgreSQL's JSONB type bridges the gap between rigid schemas and document flexibility. You can store complex nested objects, query into them efficiently, and index specific fields, all while maintaining the relational guarantees you need for the rest of your data.
For many applications, PostgreSQL's built-in text search is sufficient, eliminating the operational complexity of maintaining a separate search engine.
When your search needs grow beyond what PostgreSQL handles well, you can add Elasticsearch incrementally.
The PostGIS extension transforms PostgreSQL into a powerful geospatial database. If you are building anything location-aware, from delivery routing to store finders, PostGIS provides the primitives you need.
PostgreSQL is a strong default, but not a universal answer. Call out the cases where another system is simpler or scales more naturally.
PostgreSQL's ACID guarantees come with overhead. The database must coordinate transactions, maintain indexes, and ensure durability on every write.
For append-only workloads with millions of writes per second, such as event logging or IoT telemetry, databases like Cassandra or ClickHouse that sacrifice some consistency for write performance are better suited.
PostgreSQL was designed as a single-node database. While you can shard it manually or with extensions like Citus, this adds significant operational complexity.
If your data will grow to hundreds of terabytes and require automatic horizontal scaling, native distributed databases like CockroachDB, Cassandra, or DynamoDB handle this more gracefully.
If your access pattern is purely "get value by key" and "set key to value," PostgreSQL's query parser, planner, and executor are unnecessary overhead.
Redis provides sub-millisecond latency for these patterns, and DynamoDB offers managed key-value storage at scale.
PostgreSQL can store time-series data, but it was not optimized for the append-heavy, time-windowed query patterns common in observability and IoT applications.
TimescaleDB (a PostgreSQL extension) or purpose-built databases like InfluxDB handle these workloads more efficiently.
PostgreSQL reads from disk. Even with aggressive caching, it cannot match the microsecond latencies of in-memory stores.
When you need sub-millisecond reads for hot data, Redis or Memcached belong in front of PostgreSQL, not instead of it.
| System | Why PostgreSQL Works |
|---|---|
| Payment System | ACID transactions prevent double-spending |
| E-commerce (orders, inventory) | Complex relationships, transactions |
| User Management | Flexible queries, relationships |
| Booking System | Prevents double-booking with transactions |
| Financial Ledger | Audit trails, consistency guarantees |
| Content Management | JSONB for flexible content, full-text search |
| Multi-tenant SaaS | Row-level security, schemas for isolation |
ACID describes the guarantees PostgreSQL gives around transactions. For payments, bookings, inventory, and ledgers, these guarantees are the reason the database can prevent partial updates, invalid states, and unsafe concurrent changes.
Atomicity ensures that transactions are all-or-nothing. Consider a money transfer: deducting from one account and crediting another. If the system crashes between these operations, atomicity guarantees you will never end up with money deducted but not credited. Either both operations complete, or neither does.
Consistency means the database moves from one valid state to another. PostgreSQL enforces this through constraints: foreign keys that prevent orphaned records, check constraints that keep values in valid ranges, unique constraints that prevent duplicates. A transaction that would violate any constraint is rejected entirely.
Isolation addresses what happens when multiple transactions run concurrently. Without isolation, two transactions reading and modifying the same data could corrupt it. Isolation ensures each transaction operates as if it were the only one running, even when thousands execute simultaneously.
Durability promises that committed data survives failures. PostgreSQL achieves this through the Write-Ahead Log (WAL): before acknowledging a commit, the database writes the changes to durable storage. If the server crashes immediately after returning success, the data is safe.
Isolation is where things get interesting, and where interviews often probe your understanding.
Perfect isolation (every transaction behaves as if it ran alone) is expensive. Weaker isolation improves performance but allows certain anomalies. Understanding these trade-offs is essential for choosing the right level for each use case.
PostgreSQL supports four isolation levels, arranged from weakest to strongest:
Before examining each level, let us understand the anomalies they prevent:
| Isolation Level | Dirty Read | Non-Repeatable Read | Phantom Read | Performance |
|---|---|---|---|---|
| Read Uncommitted | No* | Possible | Possible | Same as Read Committed |
| Read Committed (default) | No | Possible | Possible | Fast |
| Repeatable Read | No | No | No* | Medium |
| Serializable | No | No | No | Slowest |
*PostgreSQL accepts READ UNCOMMITTED, but internally treats it as READ COMMITTED, so dirty reads are not possible. PostgreSQL's Repeatable Read also prevents phantom reads, going beyond the SQL standard requirement.
The three anomalies above are the SQL standard's list, but two more matter in practice and explain why the stronger levels exist:
Read Committed (Default): Each statement sees only committed data, but different statements within the same transaction may see different database states as other transactions commit.
Repeatable Read: Transaction sees a consistent snapshot from start. No changes from other transactions visible.
Serializable: The strongest level. PostgreSQL ensures transactions execute as if they ran one at a time, in some serial order. It accomplishes this by detecting potential conflicts and aborting transactions that would cause anomalies. This is the only level that prevents write skew, which is why it is the right choice for invariants that span multiple rows.
When PostgreSQL detects that two serializable transactions would conflict, it aborts one with a serialization error. Your application must be prepared to retry.
| Use Case | Isolation Level | Why |
|---|---|---|
| Most read queries | Read Committed | Default, good performance |
| Reports requiring consistency | Repeatable Read | Consistent snapshot |
| Balance transfers | Repeatable Read or Serializable | Prevent inconsistencies |
| Inventory management | Serializable | Prevent overselling |
| Audit logs | Read Committed | Append-only, no conflicts |
Serializable isolation may abort transactions due to conflicts. Your application must retry:
For explicit row-level locking, use SELECT FOR UPDATE:
Variants:
| Lock Type | Behavior |
|---|---|
| FOR UPDATE | Exclusive lock, blocks all other locks |
| FOR NO KEY UPDATE | Blocks updates but allows foreign key checks |
| FOR SHARE | Shared lock, allows reads, blocks writes |
| FOR KEY SHARE | Weakest, only blocks exclusive locks |
Design note: For critical financial operations like balance transfers, Serializable isolation can remove race conditions at the database level. The trade-off is retry logic for serialization failures.
In most workloads, these failures are rare because truly conflicting transactions are uncommon. The few milliseconds of retry latency are acceptable for the guarantee that money never disappears or appears from nowhere.
The consistent snapshots that isolation levels rely on are not magic. They come from MVCC (Multi-Version Concurrency Control), which is the mechanism behind almost everything in this section, and a frequent interview topic in its own right.
The core idea: PostgreSQL never overwrites a row in place. An UPDATE writes a new version of the row and marks the old version as expired. A DELETE only marks the existing version as expired.
Each version carries the transaction IDs that created and expired it, so every transaction can be shown the version that was valid as of its snapshot.
This is why readers and writers do not block each other in PostgreSQL. A long-running SELECT keeps reading the old row versions while a concurrent UPDATE writes new ones. There is no shared read lock to contend on.
The cost of this design is dead tuples. Every update and delete leaves behind an expired row version that still occupies space on disk. Without cleanup, tables and indexes grow indefinitely even when the live row count is flat.
This wasted space is called bloat, and it slows down scans because the database reads more pages to find the same live rows.
VACUUM reclaims this space by removing dead tuples that are no longer visible to any active transaction. PostgreSQL runs autovacuum in the background to do this automatically, but on large, frequently updated tables it can fall behind, which is a common production issue worth naming in an interview.
Two related points come up often:
The practical takeaway for a design discussion: PostgreSQL trades extra background maintenance for lock-free reads. When you mention update-heavy tables, also mention autovacuum tuning and bloat as the operational cost.
A query without an appropriate index forces PostgreSQL to examine every row in the table. On a million-row table, that means reading millions of rows to find potentially only one.
Proper indexing transforms this from a sequential scan taking seconds to an index lookup taking milliseconds.
Indexes improve reads by adding work to writes. PostgreSQL must update every relevant index when the row changes, and each index consumes storage. Create indexes for real query patterns and verify them with query plans.
B-tree is the workhorse index type, suitable for the vast majority of use cases. It organizes data in a balanced tree structure that supports both equality lookups and range queries efficiently.
How B-tree works:
The tree structure ensures that lookups, inserts, and deletes all complete in O(log N) time. For a table with a million rows, that means roughly 20 comparisons to find any value.
When you know you will only ever need equality lookups, hash indexes offer slightly faster performance than B-trees.
The trade-off is: hash indexes cannot support range queries, ordering, or prefix matching.
B-tree indexes are usually the better choice. The performance difference is small, and you keep the option to support range queries later without recreating the index.
B-tree indexes work for scalar values. But what about columns containing arrays, JSON documents, or text that needs full-text search? These require a different approach.
GIN (Generalized Inverted Index) indexes are designed for composite values. They work by indexing each element within the value separately, allowing efficient queries like "find all documents containing this key" or "find all posts with this tag."
The trade-off with GIN indexes is write performance. When you insert or update a row, PostgreSQL must update the index for every element in the array or JSON document. For write-heavy workloads with large composite values, this overhead can be significant.
Some queries ask questions that B-tree cannot answer: "Do these two time ranges overlap?" or "Which locations are within 5 kilometers of this point?" GiST indexes support these geometric and range-based queries.
For time-series data, there is often a natural correlation between when data was inserted and its value. Events from January are stored near other January events because they were inserted around the same time. BRIN indexes exploit this physical ordering.
Instead of indexing every row, BRIN stores the minimum and maximum values for each block of pages. When you query for a date range, PostgreSQL can skip entire blocks that cannot contain matching data.
BRIN indexes shine when three conditions are met: the data is physically ordered by the indexed column (typically true for timestamp columns), the table is large (millions of rows), and some imprecision is acceptable (BRIN may scan a few extra blocks).
The size advantage is dramatic:
This makes BRIN ideal for time-series data where you rarely query by exact timestamp but frequently query by time ranges.
Real queries often filter on multiple columns. A composite index on those columns can be far more effective than separate indexes on each.
Column order matters critically. PostgreSQL can use a composite index when you query by the leftmost columns, but not when you skip them. Think of it like a phone book: you can find all Smiths, or all Smiths named John, but you cannot efficiently find all Johns regardless of last name.
The general rule: place equality columns first, then range columns. Place the most selective column (the one that eliminates the most rows) first.
Why index rows you will never query? Partial indexes include only a subset of table rows, making them smaller and faster.
Partial indexes are particularly powerful when your queries always include a specific filter. If 90% of your queries look for active users, a partial index on active users is smaller, faster to maintain, and faster to query.
After finding a row through an index, PostgreSQL normally needs to read the actual table row to get other column values. A covering index includes additional columns, allowing PostgreSQL to answer queries entirely from the index.
This eliminates random I/O to fetch table rows, which can dramatically improve performance for queries that only need a few columns.
Choosing the right index type comes down to understanding your query patterns:
| Query Pattern | Index Type | Example |
|---|---|---|
| Equality (=) | B-tree or Hash | WHERE email = 'x' |
| Range (<, >, BETWEEN) | B-tree | WHERE date > '2024-01-01' |
| Prefix match (LIKE 'x%') | B-tree | WHERE name LIKE 'John%' |
| JSONB containment | GIN | WHERE attrs @> '{"a":1}' |
| Full-text search | GIN | WHERE tsv @@ query |
| Array containment | GIN | WHERE tags @> ARRAY['x'] |
| Geometric/Range overlap | GiST | WHERE range && other_range |
| Naturally ordered data | BRIN | Time-series created_at |
As tables grow into hundreds of millions of rows, several problems emerge. Queries slow down even with indexes because the indexes themselves become massive. Maintenance operations like VACUUM take hours. Deleting old data requires scanning the entire table.
Partitioning addresses these problems by dividing a large table into smaller, more manageable pieces. From the application's perspective, it is still one table. PostgreSQL automatically routes queries to the relevant partitions and combines results transparently.
Partitioning replaces one large, unwieldy table with a set of smaller physical pieces that PostgreSQL manages as a unit. The diagram contrasts a single monolithic table against a quarterly range-partitioned version of the same data:
The benefits compound as data grows:
When queries filter on the partition key, PostgreSQL skips irrelevant partitions entirely. A query for January's data touches only the January partition, not the entire year.
Operations like VACUUM, REINDEX, and backup work on individual partitions. Vacuuming a 10 million row partition takes minutes; vacuuming a 500 million row table takes hours.
Deleting old data becomes trivial: drop the partition. This is instantaneous regardless of partition size, compared to DELETE which must scan and log every row.
PostgreSQL can parallelize queries across partitions, using multiple CPU cores to scan different partitions simultaneously.
PostgreSQL supports three partitioning strategies, each suited to different access patterns.
Range Partitioning divides data by ranges of values, most commonly dates. This is the natural choice for time-series data where queries typically filter by time periods.
List Partitioning groups data by discrete values. This works well for multi-region deployments or categorical data where queries typically target specific categories.
Hash Partitioning distributes data evenly across a fixed number of partitions using a hash function. This is useful when you have a high-cardinality column (like user_id) and want balanced partition sizes without natural range boundaries.
Hash partitioning ensures even distribution, but unlike range or list partitioning, you cannot drop a single partition to remove a subset of data.
| Data Pattern | Partition Strategy | Key |
|---|---|---|
| Time-series (logs, events) | Range by time | created_at (monthly/quarterly) |
| Multi-tenant | List or Hash | tenant_id |
| Geographic | List | region |
| High cardinality | Hash | user_id, entity_id |
Adding partitions:
Dropping old data:
PostgreSQL automatically skips irrelevant partitions:
Without partition key in WHERE, all partitions are scanned.
A single PostgreSQL server is a single point of failure. And servers do fail. When the primary goes down, your application goes down with it.
Replication solves this by keeping one or more replicas in sync with the primary. If the primary fails, a replica can be promoted and the system can continue operating.
Replication also helps with read scaling. One PostgreSQL instance can serve only so many queries. By sending read traffic to replicas while keeping writes on the primary, you can scale read capacity roughly with the number of replicas.
PostgreSQL streaming replication continuously ships WAL (Write-Ahead Log) records from the primary to replicas. Every change is first written to WAL on the primary; replicas receive the WAL stream and replay it to apply the same changes locally.
The key tradeoff is commit semantics: when does PostgreSQL consider a transaction committed?
Asynchronous replication (default) commits transactions as soon as they are durable on the primary. The primary does not wait for replicas to acknowledge. This provides the best write latency but risks data loss: if the primary fails before WAL records reach the replica, recent transactions may be lost.
Synchronous replication waits for at least one replica to confirm receiving and writing the WAL records before acknowledging the commit. This guarantees no data loss on primary failure but increases write latency by the round-trip time to the replica.
For systems where data loss is unacceptable (financial transactions, for example), synchronous replication is essential despite the latency cost.
A strong HA answer does not stop at "use replicas." A primary/replica topology needs an answer for how a replica is promoted when the primary fails, how applications discover the new primary, whether any acknowledged writes can be lost, and how long writes are unavailable during failover.
Manual failover is simple but slow and error-prone. Managed Postgres, Patroni-style automation, or a database proxy can reduce recovery time, but they add operational complexity. If the system cannot tolerate data loss, pair failover with synchronous replication or a managed HA setup.
Physical (streaming) replication copies the entire cluster. Logical replication is more granular: it replicates selected tables (or sets of tables) via publications/subscriptions.
This makes it useful for upgrading PostgreSQL versions with minimal downtime, replicating to different PostgreSQL versions, and selective replication of only specific tables.
The distinction is what matters: streaming replication is the normal HA/read-replica mechanism, while logical replication is useful when you need selective table-level movement or version/shape changes.
A common pattern is: write to primary and route read queries to replicas:
Replication lag consideration: replicas are usually slightly behind the primary. If a user must read their own write immediately, route that user's reads to the primary for a short window or wait until the replica has caught up.
| Approach | Interview takeaway |
|---|---|
| Manual failover | Simple, but recovery is slower and more error-prone |
| Automated failover | Better recovery time, but adds coordination and operational complexity |
| Managed HA | Good default when operating the database is not the core problem |
| Synchronous replication | Reduces data-loss risk, but increases write latency |
PostgreSQL follows a process-per-connection model: every client connection is served by a dedicated backend process. This design gives strong isolation and predictable behavior, but it comes with real overhead. Each connection consumes memory, often ~10MB+ baseline depending on workload and settings. Opening a new connection is also relatively expensive because it involves process setup and authentication, often tens of milliseconds.
At small scale, you don't notice. At microservices scale, it becomes a bottleneck fast.
Consider a system with 50 services. Each service runs 10 instances, and each instance keeps a modest pool of 10 connections. That works out to 50 × 10 × 10 = 5,000 connections.
That's before bursts, admin tools, cron jobs, migrations, and retries. PostgreSQL will struggle long before this point and even if it survives, the memory footprint is wasteful.
Connection pooling fixes the problem by letting thousands of client connections share a much smaller number of actual database connections. Instead of "one app connection = one DB process," you get multiplexing.
The design point is simple: do not let every service instance open an unbounded number of database connections. Use local application pools for per-process reuse and a fleet-level pooler such as PgBouncer when connection pressure grows.
PgBouncer has three pool modes:
| Mode | Description | Use Case |
|---|---|---|
| Session | Connection held for entire session | Session variables needed |
| Transaction | Connection returned after transaction | Most applications (recommended) |
| Statement | Connection returned after each statement | Simple queries, maximum sharing |
In most designs, "use transaction pooling unless the application depends on session state" is enough. The main trade-off is queueing: too small a pool increases latency, while too large a pool overwhelms PostgreSQL.
Once you understand PostgreSQL fundamentals, the next level is knowing the patterns that show up repeatedly in real systems and system design interviews.
These patterns solve the same handful of problems again and again:
When two users update the same row at the same time, the "last write wins" model can silently overwrite someone's work. Optimistic locking detects this by attaching a version number to the row.
The core idea: conflicts are detected, not prevented. Most updates succeed with no blocking. If a conflict happens, the application must reload and retry (or show the user a merge/conflict message). This works best when conflicts are rare.
Not all coordination maps neatly to "lock a row." Sometimes the thing you need to protect is business logic, not a specific record. PostgreSQL's advisory locks let you define your own lock keys without locking any actual data.
Common use cases include ensuring only one worker processes a job, serializing access to a logical resource so there is only one in-flight operation per key, and preventing concurrent workflows on the same entity such as "payout_user_123".
UPSERT makes writes idempotent by combining insert + update into a single atomic statement. It's a go-to tool for "safe retries."
This pattern shows up everywhere: user upserts, event ingestion, deduplication, idempotency keys.
The RETURNING clause lets you get results from INSERT/UPDATE/DELETE without an extra query. Useful for generated IDs, timestamps, and updated values.
It's cleaner, faster, and avoids race conditions between "write" and "read back."
Complex SQL often becomes unreadable when everything is nested. CTEs (WITH clauses) turn the query into a pipeline of named steps.
CTEs are especially useful in analytics-style queries, reporting, and multi-step transformations.
For booking systems (rooms, seats, appointments), the hard requirement is: no overlapping reservations.
PostgreSQL can enforce this at the database level using range types + an exclusion constraint.
This is powerful because it makes correctness non-negotiable. Even if two requests race, the database guarantees that only one wins.
For sensitive tables (accounts, permissions, payouts), you often need an immutable history of changes. Triggers can automatically log writes into an audit table.
This gives you a consistent audit trail without relying on every application code path to "remember to log."
| Pattern | Use Case | Key Feature |
|---|---|---|
| Optimistic locking | Concurrent updates | Version column |
| Advisory locks | Cross-process coordination | pg_advisory_lock |
| UPSERT | Idempotent writes | ON CONFLICT |
| RETURNING | Avoid extra query | Get result inline |
| CTEs | Complex queries | Readable, composable |
| Exclusion constraint | Prevent overlaps | Range types |
| Audit triggers | Change tracking | Automatic logging |
When queries slow down, "add an index" is rarely the right first move. Use a simple workflow: observe -> explain -> fix -> verify.
Before you tune anything, you need to know where time is spent. EXPLAIN ANALYZE executes the query and shows the real plan PostgreSQL used, along with timings. Adding BUFFERS tells you whether time is going into CPU or I/O.
Key metrics to examine:
| Metric | Meaning | Action |
|---|---|---|
| Seq Scan | Full table scan | Add index |
| Index Scan | Using index | Good |
| Bitmap Heap Scan | Index + table lookup | Normal for many rows |
| Nested Loop | O(n*m) join | Check join conditions |
| Hash Join | Build hash table | Good for large joins |
| Sort | Sorting in memory/disk | Check memory settings |
| actual time | Real execution time | Optimize slow steps |
| rows | Actual vs estimated | Update statistics |
A practical rule: optimize the step with the biggest actual time, not the one that "looks suspicious." If row estimates are far off, mention stale statistics and ANALYZE; if scans are slow despite indexes, mention table/index bloat and vacuum.
| Anti-Pattern | Problem | Solution |
|---|---|---|
| SELECT * | Unnecessary data transfer | Select only needed columns |
| N+1 queries | Many small queries | Use JOINs or batch queries |
| Missing indexes | Slow queries | Add appropriate indexes |
| Over-indexing | Slow writes | Remove unused indexes |
| Long transactions | Lock contention | Keep transactions short |
| Large IN lists | Poor optimization | Use ANY(ARRAY[...]) or temp table |
| OFFSET pagination | Scans skipped rows | Use keyset pagination |
Keyset pagination (cursor-based):
A concise answer should cover:
EXPLAIN ANALYZESELECT *, N+1 queries, large offsets, and long transactionsDesign note: Start optimization with the queries that consume the most total time. pg_stat_statements is usually more useful than chasing the single slowest query because frequency matters: a query running 10,000 times at 50 ms can cost more than one query running once at 2 seconds.
For the top offenders, use EXPLAIN ANALYZE to understand execution plans, then add indexes or rewrite queries as needed. Monitor the impact after changes to verify improvements.
Use this comparison to justify PostgreSQL against common alternatives:
| Alternative | Prefer PostgreSQL when... | Prefer the alternative when... |
|---|---|---|
| MySQL | You need richer SQL, JSONB indexing, extensions, or stricter data integrity | The workload is simple, read-heavy, and the team already operates MySQL well |
| MongoDB | Relationships, joins, and transactions are central | The data is naturally document-shaped and schema changes frequently |
| DynamoDB | Queries are flexible and transactional correctness matters | Access patterns are simple, predictable, and need managed horizontal scale |
| CockroachDB / Citus | A single PostgreSQL primary is enough | You need distributed SQL or horizontal write scaling |
Use PostgreSQL when the system needs relational queries, strong consistency, and room for requirements to evolve. The main trade-offs are connection management, write scaling, vacuum/maintenance, and replica lag.
Anchor the answer on four points: transaction guarantees, query/index strategy, scaling plan, and failure handling. Mention PgBouncer for connection pressure, replicas for read scaling, partitioning for large tables, and retries when using Serializable isolation.
20 quizzes