Postgres is probably enough

Queues, full-text search, vector search, and flexible documents can all live in one Postgres database for longer than most teams expect. Here's how, and when to stop.

Early-stage teams often reach for a new piece of infrastructure for every new need: a message broker for background jobs, a search cluster for search, a vector database for AI features, a document store for flexible data. Each one is reasonable on its own. Together, they're a lot of systems to deploy, secure, back up, monitor, and keep consistent with each other.

For a surprisingly long time, one well-run Postgres database can do all of it.

Background jobs with SKIP LOCKED

FOR UPDATE SKIP LOCKED lets multiple workers pull jobs from the same table without blocking each other or processing the same job twice:

WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE status = 'queued' AND run_at <= now()
  ORDER BY run_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET status = 'running', attempts = attempts + 1, locked_at = now()
FROM next_job
WHERE jobs.id = next_job.id
RETURNING jobs.*;

The big advantage over a separate broker is transactional enqueueing. You can insert an order and its "send confirmation email" job in the same transaction, so you never end up with one without the other.

A few details make it production-ready: an index on (status, run_at), a reaper that requeues jobs whose locked_at is too old (their worker died), a maximum attempt count, and a periodic cleanup of finished jobs.

Full-text search with tsvector

Built-in full-text search handles stemming, ranking, and phrase queries well enough for most in-app search:

ALTER TABLE articles
ADD COLUMN search tsvector
GENERATED ALWAYS AS (
  setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
  setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;

CREATE INDEX articles_search_idx ON articles USING gin (search);

SELECT id, title, ts_rank(search, query) AS rank
FROM articles, websearch_to_tsquery('english', $1) AS query
WHERE search @@ query
ORDER BY rank DESC
LIMIT 20;

websearch_to_tsquery accepts the kind of input users actually type, including quoted phrases and minus signs, without throwing syntax errors.

Vector search with pgvector

The pgvector extension adds a vector type, distance operators, and approximate nearest-neighbor indexes:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
  id bigserial PRIMARY KEY,
  document_id bigint NOT NULL REFERENCES documents (id),
  content text NOT NULL,
  embedding vector(1536) NOT NULL
);

CREATE INDEX chunks_embedding_idx
ON chunks USING hnsw (embedding vector_cosine_ops);

SELECT id, content, embedding <=> $1 AS distance
FROM chunks
WHERE document_id = ANY ($2)
ORDER BY embedding <=> $1
LIMIT 8;

Keeping embeddings next to the rest of your data has real benefits. You can filter by tenant, permissions, or document type in the same query, join back to the source records, and delete embeddings in the same transaction as the documents they came from. That last one matters more than it sounds when a customer asks you to delete their data.

Combining this with the full-text column above gives you hybrid search, keyword and semantic, from a single database.

Flexible data with JSONB

For data whose shape varies, such as integration payloads, user preferences, or form submissions, jsonb columns with GIN indexes give you document-store flexibility without leaving your relational model. A good rule: keep anything you filter, join, or enforce constraints on in real columns, and put the long tail of optional attributes in JSONB.

When to reach for something else

Postgres being enough isn't the same as Postgres being enough forever. Clear signals that it's time to split something out:

  • Job throughput high enough that queue churn creates noticeable bloat and vacuum pressure, or a need for fan-out patterns that a table models awkwardly.
  • Search requirements beyond full-text basics: typo tolerance, faceting across many dimensions, or relevance tuning that has become a product feature in its own right.
  • Vector scale in the tens of millions of embeddings with tight latency budgets and frequent updates.
  • Load isolation, when analytics or search queries start affecting transactional latency. A read replica is usually the first step here, before a new system.

The point isn't to avoid specialized tools. It's to adopt them when a measured need appears, rather than on day one. Every system you don't run is one you don't have to page anyone about.

The practical upshot

For a small team, the most valuable property of an architecture is how few things can break. One database with good backups, sensible indexes, and well-understood failure modes will take most products further than they expect, and it leaves the team's attention for the product itself.