← Back to blog

When PostgreSQL Is Enough

6 min read
postgresqlarchitecturebackendplatform

A few months ago a team next to ours at Zendesk stood up a new service. Within the first sprint the design doc had Postgres for the primary store, Redis for caching and rate limiting, SQS for job queues, and OpenSearch for the search box in the admin panel. Four systems, four failure modes, four sets of credentials to rotate, before a single customer had used the feature. When I asked why, the answer was some version of "that's just what you use for this." Nobody had actually measured whether Postgres alone would fall over.

It wouldn't have. The service processes a few hundred requests a second at peak, the search box needed to filter maybe 50,000 rows, and the job queue handled a few thousand jobs a day. Postgres does all three of those without noticing. What actually shipped, three iterations later, was one database. The rewrite wasn't a downgrade — it was fewer things that could break, fewer places for state to drift, and one connection pool to reason about instead of four.

The queue you already have

SELECT ... FOR UPDATE SKIP LOCKED gives you a real job queue with transactional guarantees a message broker can't match for free: enqueue a job in the same transaction that creates the record it depends on, and you never end up with a queue entry pointing at a row that doesn't exist. With SQS or a Redis-backed queue, that consistency is something you build yourself — outbox tables, idempotency keys, dead-letter handling you write and test.

UPDATE jobs
SET status = 'processing', locked_at = now()
WHERE id = (
  SELECT id FROM jobs
  WHERE status = 'pending'
  ORDER BY created_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
RETURNING *;

This pattern handles the vast majority of internal job queues teams stand up — the kind that runs a few thousand to a few hundred thousand jobs a day, not the kind processing millions of events per second across a fleet of consumers. Know which one you have before you reach past it. If you're already running Kafka for event streaming across a dozen services, this isn't an argument to unwind that. It's an argument against adding a queue to a service that doesn't need one yet, just because a queue is the reflexive answer.

The honest limitation is throughput and fan-out. A single Postgres queue table gets contentious under very high write concurrency, and it can't broadcast one event to fifty independent consumers the way a pub/sub system can. If you're building for that shape of problem today, build the message broker. Most services aren't.

Full-text search that's actually full-text search

Postgres's tsvector/tsquery machinery, especially with a GIN index, handles ranked full-text search with stemming and relevance scoring well past the point most teams assume they need Elasticsearch. We ran this exact comparison for an internal admin search feature: a GIN-indexed tsvector column against roughly 2 million rows returned ranked results in under 40ms. The team's original design had a whole OpenSearch cluster provisioned for it, with a sync pipeline to keep it consistent with the source of truth.

That sync pipeline is the part people underweight when they reach for a search engine. The moment your searchable data lives in two systems, you've signed up for a consistency problem: what happens when the indexer job dies mid-batch, or a write to Postgres succeeds but the corresponding write to Elasticsearch times out? Answering that well is real engineering effort, ongoing, for the life of the service. A tsvector column has no consistency problem, because it's not a second system. It's a column with an index on it, updated in the same transaction as everything else.

Where this stops working is faceted search across many dimensions, typo-tolerant fuzzy matching at scale, or query volume high enough that a dedicated search cluster's caching and sharding actually pay for themselves. Those are real, common needs at the scale of a product-wide search experience. They're not the needs of an admin panel's filter box.

Caching: the case that's genuinely closer

Redis is the one people push back on hardest, and it's the one I'm least willing to wave away. A well-indexed Postgres query is often fast enough that an in-memory cache saves you single-digit milliseconds you'll never notice — for those cases, Redis is complexity with no payoff. But Redis also does things Postgres structurally can't: sub-millisecond access shared across many app instances without touching disk, atomic counters for rate limiting under real concurrency, pub/sub for ephemeral fan-out, TTL-based expiry as a first-class primitive rather than a cron job.

The practical rule I use: if you can express the problem as "a query that's occasionally too slow," fix the query or add an index — don't add a cache. If the problem is "state that needs to be shared and mutated atomically across many stateless instances, faster than a network round trip to Postgres allows," that's Redis's actual job, not a workaround for one. Rate limiting and distributed locks are the honest cases. A read-through cache in front of a query you haven't tried to optimize yet usually isn't.

What actually justifies the extra system

None of this is an argument that Postgres always wins. It's an argument for sequencing. Add the specialized system when you have a measurement that says the general-purpose one is the bottleneck, not before. That measurement is usually simple to get: run the query against production-shaped data and look at the timing. If it's fine, you've saved yourself an operational dependency. If it's not, you now know exactly what problem the new system needs to solve, instead of guessing at a shape for it based on what you read on a blog.

The infrastructure you add earns its complexity by solving a problem you can point to. The infrastructure you add by default just because "that's the stack" earns nothing — it just sits there as one more thing that has to stay up, one more library version to bump, one more system your on-call engineer has to understand at 2am. Every piece of infrastructure is a promise to operate it for as long as the service lives.

The takeaway

The four-system design doc gets written before anyone runs a single benchmark, because it's the path of least resistance — a checklist of "what a real production service has," not a response to an actual constraint. Postgres has spent thirty years absorbing the jobs that used to require separate tools. Reach for the extra system when the numbers tell you Postgres can't do the job, not when the architecture diagram feels incomplete without one.