Postgres for Everything: Why I Stopped Reaching for Redis and MongoDB
Early in my career I treated databases like a specialty tool cabinet: Redis for caching, MongoDB for flexible documents, a queue service for background jobs, and Postgres for "real" relational data. Starting a new project meant provisioning four services before writing a line of application code.
Then I actually learned what Postgres could do. Now that cabinet has mostly one tool in it.
Caching with Postgres
The usual case for Redis is caching: computed values you don't want to recalculate on every request. But Postgres can do this with a cache table and a single index:
CREATE TABLE cache (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX ON cache (expires_at);
Reads are a primary-key lookup. Expiry is a periodic DELETE WHERE expires_at < NOW(). For cache hit rates above ~70% and latency requirements above ~5ms — which describes most web apps — this is indistinguishable from Redis in practice, and it's one fewer service to operate.
Where Redis still wins: sub-millisecond latency at very high throughput, pub/sub, and distributed locks across multiple app instances. Those are real use cases. They're also not the use case for most projects I build.
Flexible schemas with JSONB
The main pitch for MongoDB is schemaless documents. Postgres has had a production-quality JSONB type since version 9.4. You can store arbitrary JSON, index into it, and query it with full SQL:
-- Store arbitrary metadata
ALTER TABLE products ADD COLUMN metadata JSONB DEFAULT '{}';
-- Index a nested field
CREATE INDEX ON products ((metadata->>'category'));
-- Query it
SELECT * FROM products
WHERE metadata->>'category' = 'electronics'
AND (metadata->>'price')::numeric < 100;
The pattern I use: strict columns for fields I know and query on, JSONB for everything else. You get a defined schema where it matters and flexibility where it doesn't — without losing transactions, foreign keys, or SQL.
Background jobs with pg-boss / Postgres queues
Job queues are another reason people add Redis (via Bull/BullMQ) or a managed service. Postgres handles queues well:
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
run_at TIMESTAMPTZ DEFAULT NOW()
);
-- Claim a job atomically
UPDATE jobs
SET status = 'running'
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending' AND run_at <= NOW()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;
FOR UPDATE SKIP LOCKED is the key — it lets multiple workers claim jobs concurrently without collisions. Libraries like pg-boss (Node) and procrastinate (Python) implement this pattern with retry logic and dead-letter queues included.
Full-text search
Postgres has a built-in full-text search engine that handles most search use cases without Elasticsearch:
-- Add a search index
ALTER TABLE articles ADD COLUMN search_vector TSVECTOR
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX ON articles USING GIN (search_vector);
-- Query
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('english', 'machine & learning') query
WHERE search_vector @@ query
ORDER BY rank DESC;
It handles stemming, stop words, and ranking. For full fuzzy search and relevance tuning at scale, Elasticsearch is still the right call. For "users need to search our content" — Postgres is almost always sufficient.
When I do add other databases
I'm not dogmatic about this. I'll add Redis for:
- Pub/sub between services
- Distributed rate limiting
- Cache invalidation patterns that need atomic operations across multiple keys
I'll add a vector database (Qdrant, pgvector) for:
- High-dimensional embedding search where pgvector's HNSW indexes aren't fast enough at scale
I'll add a proper search index for:
- Fuzzy search with complex relevance tuning
- Search over millions of documents with strict latency SLAs
But these are specific, deliberate decisions — not reflexes. The default is Postgres until there's a concrete reason to add something else.
The meta-lesson: operational complexity compounds. Every database you add is a connection pool, a backup policy, a failure mode, and a monitoring dashboard. Postgres doing one job slightly less optimally is often better than two databases doing their jobs optimally but doubling your ops burden. Start with one; earn the second one.