This comparison comes up every time a product adds a search box. Postgres already holds the data and has full-text search built in: tsvector, tsquery, GIN indexes, language stemming and trigram matching. Elasticsearch is a separate distributed search engine built on Lucene, with BM25 relevance, analyzers, fuzzy matching, highlighting, aggregations and horizontal sharding. The real trade is not features versus no features. It is one system with transactional consistency versus two systems with a sync pipeline between them. The question that decides it is: is search a filter on your data, or is search the product? If users mostly find records they already know something about — an order number, a customer name, a ticket title — and relevance ranking is secondary, Postgres is enough and saves a whole system. If relevance, typo tolerance, facets and sub-100ms queries over tens of millions of documents are what users judge you on, you need a search engine, and you sign up for keeping it in sync.
The Verdict#
Default: Postgres full-text search until relevance quality or scale is a measured problem. Then Elasticsearch (or a compatible engine), fed from Postgres through CDC or an outbox, with Postgres remaining the source of truth.
| Pick Postgres full-text search when | Pick Elasticsearch when |
|---|---|
| The corpus is up to roughly tens of millions of rows and matches per query are modest | The corpus is hundreds of millions of documents or growing past one node |
| Search is a filter combined with relational predicates (tenant, status, permissions) | Relevance is the product: BM25 tuning, boosting, synonyms, "did you mean" |
| Results must be transactionally consistent with writes (read-your-writes) | Facets and aggregations over the result set must be fast at high QPS |
| One team, one database, and nobody to own a second cluster | Typo tolerance, autocomplete, highlighting and multi-language analyzers matter |
| Search QPS is low to moderate (tens to hundreds per second) | Search QPS is high (thousands per second) and must not compete with OLTP writes |
🎯 Staff Move: "For the admin search over 5 million tickets I'd use Postgres: a generated
tsvectorcolumn with a GIN index, pluspg_trgmfor fuzzy name matches. It stays consistent with writes and adds no system. If we launch customer-facing search over the knowledge base, where ranking quality is the product, I'd add Elasticsearch fed by CDC and keep Postgres as the source of truth."
When the non-default wins:
- Elasticsearch on day one wins when search is the product from launch — a marketplace, a job board, a documentation site — and the team already knows how to run it. Retrofitting relevance onto a database search is slower than starting right.
- Postgres beyond "tens of millions" still wins when every query is scoped to a tenant: per-tenant partitions keep each search small, and transactional consistency stays free.
- Neither wins for logs and metrics at volume: aggregation-heavy, append-only data usually belongs in a columnar store.
At a Glance#
| Dimension | Postgres full-text search | Elasticsearch |
|---|---|---|
| Data model | Rows in tables; a tsvector column (often generated) per searchable row | JSON documents in indices; fields mapped to analyzers and types |
| Index | GIN (inverted) index on tsvector; GiST alternative; pg_trgm GIN for similarity and LIKE | Lucene inverted index per shard; doc values for sorting and aggregations |
| Relevance | ts_rank / ts_rank_cd: term frequency and proximity, no corpus-wide statistics (no IDF) | BM25 by default with corpus statistics; boosting, function scores, rescoring |
| Text analysis | Built-in text search configurations per language: stemming, stop words; custom dictionaries | Rich analyzers: tokenizers, filters, synonyms, n-grams, per-field analysis, many languages |
| Fuzzy / typo tolerance | pg_trgm similarity; no edit-distance in tsquery | Fuzzy queries with edit distance, phonetic plugins, completion suggesters |
| Consistency | Transactional: the index updates in the same transaction as the row | Near real-time: documents searchable after a refresh (1s default); eventually consistent with the source |
| Ordering / freshness | Read-your-writes on the primary | Freshness = sync pipeline lag + refresh interval; typically 1–10s end to end |
| Throughput | Hundreds of search QPS per node for selective queries; competes with OLTP | Thousands of QPS with replicas; isolated from the primary database |
| Latency | 1–50ms for selective queries; ranking large match sets can take seconds | 5–100ms typical for well-sharded indices |
| Scaling model | Vertical, plus read replicas; partitioning by tenant | Horizontal: primary shards fixed per index (split or reindex to change), replicas for reads |
| Operational burden | None beyond Postgres you already run | A cluster: heap, shard sizing (10–50 GB per shard guidance), mappings, upgrades, snapshots |
| Managed options | Every managed Postgres | Elastic's cloud service; AWS's OpenSearch service (a fork) and other hosts |
| Cost shape | Marginal: index storage (often 20–50% of the text size) and some CPU on existing nodes | A second fleet: nodes sized for heap and disk, replicas, plus the sync pipeline |
| License | PostgreSQL License | AGPL, SSPL or Elastic License 2.0 (AGPL option added in 2024) |
Numbers to bring:
| Figure | Value | Condition |
|---|---|---|
| Elasticsearch refresh interval | 1s default | Documents become searchable after a refresh |
| End-to-end index freshness | ~1–10s | CDC lag + indexer batching + refresh |
| Elasticsearch shard size guidance | 10–50 GB, under 200M docs per shard | Elastic's sizing guidance |
| Elasticsearch heap | ~50% of RAM, under ~31 GB | Leaves the rest for the filesystem cache |
| GIN index size | Often 20–50% of indexed text | Varies with language config and positions |
| Postgres selective search | 1–50ms | When filters cut matches to thousands |
| Postgres broad match + ranking | Seconds | Ranking reads every matching tsvector |
How They Actually Differ#
1. One System vs Two: The Sync Pipeline Is the Real Cost#
With Postgres, the search index is just another index. INSERT the row and the tsvector (as a generated column) and its GIN entry commit atomically; a search one millisecond later finds it. Deletes and permission changes are instant.
With Elasticsearch, Postgres stays the source of truth and every change must flow to the search cluster. The safe patterns are CDC from the write-ahead log (logical decoding into Kafka, then an indexer) or a transactional outbox. Both work; both introduce lag, backfill jobs, a reindex procedure, and a class of bugs where search and the database disagree.
The hidden requirement in the two-system design: permission and deletion correctness. If a document is deleted or a user loses access, the search index lags by seconds to minutes during an incident. Production designs either filter results through Postgres at read time (hydrate by ID and re-check access) or accept and document the window.
🎯 Staff Insight: Adding Elasticsearch is not adding an index. It is adding a distributed system, a pipeline, a reindex runbook and a consistency window. Price those before you price the cluster.
2. Relevance: Rank Without Corpus Statistics vs BM25#
Postgres's ranking functions use term frequency, proximity and document length, but the documentation is explicit that they do not use any global information — there is no inverse document frequency, so a rare, meaningful term and a common one count alike. Ranking also requires reading the tsvector of every matching document, which the documentation warns can be I/O bound and slow when queries match many rows.
Elasticsearch scores with BM25, which uses corpus-wide term statistics so rare terms matter more, and gives you knobs: field boosts (title^3), function scores (recency, popularity), synonyms, phrase and proximity boosting, and rescoring of the top N with a costlier model. That is the gap users notice: Postgres returns matching documents; Elasticsearch returns the best matching documents first.
| Query: "postgres replication lag" | Postgres ts_rank | Elasticsearch BM25 |
|---|---|---|
| Doc mentioning "postgres" 30 times, "lag" once | Ranks high (term frequency) | Ranks lower: "postgres" is common in this corpus |
| Short doc titled "Replication lag" | Ranks by frequency and length normalization only | Ranks high: rare terms, title boost |
| Doc with "replicate" and "lagging" | Matches via stemming | Matches via stemming analyzers; synonyms possible |
When Postgres's ranking is good enough: when results are few (selective filters cut matches to under a few thousand) and users scan them, or when the sort is by date or status rather than relevance. Extensions that add BM25 ranking to Postgres exist; they narrow the gap but are another component to evaluate and operate.
The Postgres tuning ladder before adding a search engine:
| Step | What it buys | Cost |
|---|---|---|
Generated tsvector column with weights (A for title, B for body) | Title matches outrank body matches; no per-query to_tsvector | A column and a GIN index |
websearch_to_tsquery for user input | Quoted phrases, or, negation without parser errors | None |
pg_trgm GIN index on names and titles | Typo-tolerant and substring matching | Larger index, slower writes |
Rank only the top candidates (filter, then ORDER BY ts_rank ... LIMIT) on a pre-limited set | Bounds ranking cost for broad queries | Some relevance loss on huge match sets |
| Route search to a replica | OLTP isolation | One more replica |
If the ladder is exhausted and users still complain about relevance, that is the measured signal to add a search engine.
3. Scaling and Isolation#
Postgres full-text search scales with the database: vertically, with read replicas, and by partitioning (per-tenant indexes work well because most queries are tenant-scoped). A GIN index on 10 million documents of a few KB each is comfortable on one node; heavy-match queries and ranking are the limit, not storage. The catch is contention: search queries run on the same machine as OLTP writes unless you route them to a replica.
Elasticsearch scales horizontally. An index is split into primary shards (fixed at creation; the split API or a reindex changes it), each replicated; queries fan out to one copy of every shard and merge. Elastic's guidance is shards of 10–50 GB and under 200 million documents each. Search load is isolated from the primary database by construction.
| Corpus | Postgres | Elasticsearch |
|---|---|---|
| 1M docs, 50 QPS | Trivial | Overkill |
| 10M docs, 500 QPS, filtered queries | Comfortable with a search replica | Comfortable |
| 100M docs, 2K QPS, relevance-ranked | Strained: ranking cost, replica size | Designed for it: ~10–20 shards with replicas |
| 1B+ docs | Not the tool | Large cluster; tiering and index lifecycle management |
4. What Each Can Do That the Other Cannot#
| Capability | Postgres | Elasticsearch |
|---|---|---|
| Join search results with relational data in one query | Yes | No (denormalize at index time) |
| Transactional consistency with writes | Yes | No |
| Typo tolerance with edit distance | Partial (trigram similarity) | Yes |
| Facets over millions of matches in milliseconds | Slow (GROUP BY over matches) | Yes (aggregations on doc values) |
| Highlighting with snippets | ts_headline (slow on large docs) | Yes, several highlighters |
| Autocomplete / search-as-you-type | Prefix tsquery, trigram | Completion suggester, edge n-grams |
| Synonyms and per-language analyzers | Dictionaries, configurable | Extensive, per field |
| Vector / hybrid search | Via extensions | Built-in kNN and hybrid ranking |
| Log analytics at volume | No | Common, though columnar stores often win on cost |
Where Each One Breaks#
| System | Failure mode | Symptom | Detection | Mitigation | Owner |
|---|---|---|---|---|---|
| Postgres | Ranking a huge match set | A one-word query matches 2M rows; ts_rank reads every tsvector; seconds per query | Slow query log, EXPLAIN (ANALYZE, BUFFERS) | Require selective filters, LIMIT on candidates, rank a pre-filtered subset | Product team |
| Postgres | Search load on the primary | OLTP p99 rises during search peaks | CPU and I/O on primary, query mix | Route search to a replica; statement timeouts | Platform |
| Postgres | GIN pending list and write amplification | Heavy write load slows; occasional slow inserts when the pending list flushes | Insert latency spikes, index size growth | Tune fastupdate and gin_pending_list_limit, vacuum | Platform |
| Postgres | Wrong language config | Stemming mangles names or other languages; misses obvious matches | Search quality complaints, zero-result rate | Per-row language config, simple config for names, trigram fallback | Product team |
| Elasticsearch | Index and source diverge | Deleted or permission-revoked docs still searchable; new docs missing | Sync lag metric, periodic count and checksum reconciliation | Idempotent indexer with versioning, DLQ, reconciliation job, hydrate and re-check at read | Search team |
| Elasticsearch | Mapping explosion | Dynamic mapping creates thousands of fields; heap and cluster state balloon | Field count per index, master heap | Strict mappings, field limits, flattened types | Search team |
| Elasticsearch | Bad shard sizing | Thousands of tiny shards, or 200 GB shards that take hours to recover | Shard count per node, shard size distribution | 10–50 GB shards, index lifecycle rollover, shrink/split | Search team |
| Elasticsearch | Heap pressure and GC | Old-gen GC pauses; circuit breakers reject queries | JVM heap usage, breaker trips | Heap at ~50% of RAM (under ~31 GB), fewer fields, aggregations bounded | Search team |
| Elasticsearch | Reindex required | Analyzer change or shard count change needs a full reindex | Planned change | Alias-swap reindex from the source of truth; keep the pipeline replayable | Search team |
| Both | Zero-result searches | Users search terms the index cannot match | Zero-result rate by query | Synonyms, fuzzy fallback, query logs reviewed weekly | Product |
The production surprise for each:
- Postgres: it works beautifully until one common word matches half the table, and the query that took 5ms takes 8 seconds.
- Elasticsearch: the cluster is healthy and search is wrong. The sync pipeline dropped events during a deploy, and nobody reconciles counts.
Incident Sketch: The Search Index That Missed a Deploy#
t=0 Indexer deploy introduces a serialization bug for one document type
t=+1min Indexer sends failures to its retry topic; retries fail the same way
t=+3h Retry topic retention drops the oldest failed events
t=+2d Customer reports a published article that search never returns
t=+2d+4h Investigation finds 18,000 documents missing; full reindex from Postgres
Detection that would have caught it: a dead-letter queue with an alert on growth, a daily reconciliation that compares counts and checksums per type between Postgres and the index, and a synthetic probe that creates a document and searches for it every minute. Owner: the search team owns the pipeline and the reconciliation job.
Incident Sketch: The Common Word That Slowed Postgres#
t=0 A newsletter links to a search for 'update'
t=+1min Query matches 1.8M of 6M rows; ts_rank reads every match's tsvector
t=+2min Each query takes 6-9s; 40 concurrent searches saturate the primary's I/O
t=+3min Checkout writes slow; OLTP p99 from 20ms to 800ms
t=+10min Statement timeout on search lowered to 500ms; search routed to a replica
Prevention: search on a replica from the start, a statement timeout for search queries, and ranking only the top few thousand candidates by a cheaper sort before computing ts_rank. Owner: platform for isolation, product team for query shape.
Cost and Operations#
| Postgres full-text search | Elasticsearch | |
|---|---|---|
| Who runs it | Whoever runs Postgres already; no new on-call | A search team or platform team (commonly 1–3 engineers for a mid-size deployment), or a managed service |
| What the bill scales with | Index storage on existing nodes; possibly a dedicated search replica | Nodes sized for heap and SSD; replicas (×2 storage minimum); the sync pipeline (Kafka, indexers) |
| Typical footprint | 0 extra nodes, or 1 replica | 3 dedicated masters + 3–6 data nodes for a modest production cluster |
| Hidden cost | Engineering time tuning queries once ranking gets slow | Reindex runbooks, mapping governance, reconciliation jobs, version upgrades |
Rough numbers: a modest production Elasticsearch deployment (3 masters, 6 data nodes, a few TB) costs from low thousands to over ten thousand dollars a month in cloud infrastructure, plus the pipeline and a share of someone's time. Postgres search on an existing cluster costs roughly the storage of the GIN index — often 20–50% of the indexed text — and a replica if you isolate it.
| Scale | Sensible choice | Rough infrastructure | People |
|---|---|---|---|
| Small (1M docs, internal search) | Postgres FTS on the primary | Near zero extra | None dedicated |
| Medium (10–50M docs, customer search, facets) | Postgres replica for search, or a small managed search cluster fed by CDC | Hundreds to low thousands of dollars a month | 1 engineer part-time |
| Large (500M+ docs, relevance-critical) | Elasticsearch cluster with CDC pipeline, reindex automation, relevance metrics | Tens of thousands a month | A search team of 3–6 |
Assumptions: cloud list prices, replicas for availability, SSD-backed nodes; order of magnitude only.
🧭 Principal Insight: The decision is reversible in one direction only. Starting on Postgres and adding a search engine later is a well-trodden path. Starting with a search engine as a primary store, and discovering it was never a database, is a migration with data loss risk.
Switching Later#
| Migration | Difficulty | What's hard to undo |
|---|---|---|
| Postgres FTS → Elasticsearch | Moderate: build CDC or an outbox, backfill, dual-read compare, switch | Read-your-writes assumptions in the UI ("I just created it, why can't I find it?") |
| Elasticsearch → Postgres FTS | Moderate if Postgres was the source of truth | Relevance features users now expect: typo tolerance, facets, highlighting |
| Elasticsearch as primary store → database | Hard | No transactions, no constraints; data quality issues surface during migration |
| Elasticsearch ↔ OpenSearch | Moderate; they have diverged since the 2021 fork | Version-specific APIs, plugins and client libraries |
| Changing analyzers or shard count | Full reindex | Needs a replayable source and alias swaps |
Adding Elasticsearch, in order:
- Define the index document from the read model users search, denormalized, with a version field taken from the source row.
- Build the CDC or outbox pipeline with an idempotent indexer (upsert by ID, ignore older versions) and a dead-letter queue.
- Backfill from Postgres into a new index behind an alias; start the live pipeline before the backfill finishes so nothing is missed.
- Dual-read: serve from Postgres, query Elasticsearch in the background, compare result overlap and latency.
- Switch reads behind a flag, keep the Postgres search path as a degraded fallback, and schedule the reconciliation job.
The one-way doors:
- Making the search engine the source of truth. Keep it rebuildable from the database at all times; a full reindex should be a routine job, not a disaster recovery event.
- Exposing search-engine query syntax to clients. Wrap it in your own API so you can change engines, analyzers or ranking.
- Denormalization choices in the index (embedding author names, permissions). Changing them means reindexing everything.
How Real Companies Chose#
GitLab — Postgres for Basic Search, Elasticsearch for Advanced Search#
GitLab ships both. Its basic search uses other data sources, such as PostgreSQL data and Git data, while advanced search uses Elasticsearch or OpenSearch; administrators can fall back to basic search if the search cluster has problems, and GitLab.com itself runs advanced search on Elasticsearch (GitLab docs).
Staff insight: The product works without the search engine and gets better with it. Designing search as an optional, rebuildable layer over the database is exactly the posture to describe in an interview.
GitHub — Outgrowing a General-Purpose Search Engine for Code#
GitHub explained that general text search products had not served code search well: poor user experience, slow indexing and expensive hosting. Elasticsearch had taken months to index the corpus, and code needs punctuation, regular expressions and substring matching without stemming or stop words. GitHub built its own engine; the post cites 115 TB of code across 15.5 billion documents reduced to a 25 TB index (GitHub Blog).
Staff insight: Elasticsearch is tuned for natural-language relevance. When the query language is different — code, logs, exact substrings — a general engine can be the wrong tool at scale. Name the query semantics before naming the engine.
Uber — Moving Log Analytics off Elasticsearch#
Uber moved its logging platform from Elasticsearch to ClickHouse. It reported that more than 80% of queries were aggregations, which Elasticsearch was not optimized for; strict schemas caused type-conflict errors that dropped logs; and the setup needed 20+ clusters per region. After the move, a single ClickHouse node ingested about 300K logs per second and hardware cost dropped by more than half (Uber Engineering).
Staff insight: A search engine is the right tool for ranked text retrieval, not for aggregation-heavy analytics over logs. Saying "most of our queries are aggregations, so this belongs in a columnar store" is the kind of workload-first reasoning interviewers want.
Follow-Ups to Expect#
| After You Say... | They Will Ask... | What They're Testing |
|---|---|---|
| "Postgres full-text search" | "A one-word query matches 3 million rows. What happens?" | Ranking cost, selective filters, candidate limits |
| "Postgres full-text search" | "How do you handle typos?" | pg_trgm similarity and its limits |
| "Elasticsearch" | "How does a new record get into the index, and how fast?" | CDC or outbox, idempotent indexing, refresh interval, end-to-end lag |
| "Elasticsearch" | "A user's access is revoked. Can they still find the document?" | Consistency window; hydrate and re-check permissions at read time |
| "Elasticsearch" | "You need to change the analyzer. How?" | Alias-swap reindex from a replayable source |
| "Keep Postgres as the source of truth" | "How do you know search and the database agree?" | Reconciliation: counts, checksums, sampled comparisons |
| "Shard the index" | "How many shards, and why?" | 10–50 GB per shard, growth, rollover |
| "Search is critical" | "What happens to the product when the search cluster is down?" | Graceful degradation to a basic database search |
| "Generated tsvector column" | "Users search in three languages. How?" | Per-row language config, simple config, or a per-language column |
| "Hydrate results by ID" | "Doesn't that add latency?" | One batched primary-key lookup for 20 IDs: a few milliseconds |
| "Reconciliation job" | "How often, and what does it compare?" | Counts and checksums per type and time window; repair by reindexing the diff |
What to Say in the Interview#
"The deciding question is whether search is a filter or the product. For finding known records with relational filters, Postgres full-text search keeps one system and transactional consistency."
"Postgres ranks without corpus statistics, so once relevance quality is what users judge us on, I'd add Elasticsearch with BM25, synonyms and typo tolerance."
"Postgres stays the source of truth. Changes flow through CDC into an idempotent indexer, I alert on sync lag, and I can rebuild the index from the database at any time with an alias swap."
"For permissions, I'll re-check access when hydrating results from the database, so a lagging index can't leak a document someone just lost access to."
Related Guides#
- Elasticsearch — Lucene internals, sharding, relevance and operations in depth
- PostgreSQL — the database most search features start in
- Design a Search Engine — web-scale indexing and ranking
- Design Typeahead — autocomplete, where latency budgets are tightest
- Indexes — inverted indexes, GIN and B-trees from first principles
- Read-Heavy Systems — isolating read workloads from the primary
- Batch and Stream Pipelines — CDC, backfills and reconciliation
- ClickHouse vs Druid vs Pinot — when the "search" workload is really aggregation