Hiring BarSupport

Elasticsearch vs Postgres Full-Text Search

Comparison18 min read3 diagrams

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 whenPick Elasticsearch when
The corpus is up to roughly tens of millions of rows and matches per query are modestThe 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 clusterTypo 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 tsvector column with a GIN index, plus pg_trgm for 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."

Diagram: The Verdict

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#

DimensionPostgres full-text searchElasticsearch
Data modelRows in tables; a tsvector column (often generated) per searchable rowJSON documents in indices; fields mapped to analyzers and types
IndexGIN (inverted) index on tsvector; GiST alternative; pg_trgm GIN for similarity and LIKELucene inverted index per shard; doc values for sorting and aggregations
Relevancets_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 analysisBuilt-in text search configurations per language: stemming, stop words; custom dictionariesRich analyzers: tokenizers, filters, synonyms, n-grams, per-field analysis, many languages
Fuzzy / typo tolerancepg_trgm similarity; no edit-distance in tsqueryFuzzy queries with edit distance, phonetic plugins, completion suggesters
ConsistencyTransactional: the index updates in the same transaction as the rowNear real-time: documents searchable after a refresh (1s default); eventually consistent with the source
Ordering / freshnessRead-your-writes on the primaryFreshness = sync pipeline lag + refresh interval; typically 1–10s end to end
ThroughputHundreds of search QPS per node for selective queries; competes with OLTPThousands of QPS with replicas; isolated from the primary database
Latency1–50ms for selective queries; ranking large match sets can take seconds5–100ms typical for well-sharded indices
Scaling modelVertical, plus read replicas; partitioning by tenantHorizontal: primary shards fixed per index (split or reindex to change), replicas for reads
Operational burdenNone beyond Postgres you already runA cluster: heap, shard sizing (10–50 GB per shard guidance), mappings, upgrades, snapshots
Managed optionsEvery managed PostgresElastic's cloud service; AWS's OpenSearch service (a fork) and other hosts
Cost shapeMarginal: index storage (often 20–50% of the text size) and some CPU on existing nodesA second fleet: nodes sized for heap and disk, replicas, plus the sync pipeline
LicensePostgreSQL LicenseAGPL, SSPL or Elastic License 2.0 (AGPL option added in 2024)

Numbers to bring:

FigureValueCondition
Elasticsearch refresh interval1s defaultDocuments become searchable after a refresh
End-to-end index freshness~1–10sCDC lag + indexer batching + refresh
Elasticsearch shard size guidance10–50 GB, under 200M docs per shardElastic's sizing guidance
Elasticsearch heap~50% of RAM, under ~31 GBLeaves the rest for the filesystem cache
GIN index sizeOften 20–50% of indexed textVaries with language config and positions
Postgres selective search1–50msWhen filters cut matches to thousands
Postgres broad match + rankingSecondsRanking 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.

Diagram: 1. One System vs Two: The Sync Pipeline Is the Real Cost

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_rankElasticsearch BM25
Doc mentioning "postgres" 30 times, "lag" onceRanks high (term frequency)Ranks lower: "postgres" is common in this corpus
Short doc titled "Replication lag"Ranks by frequency and length normalization onlyRanks high: rare terms, title boost
Doc with "replicate" and "lagging"Matches via stemmingMatches 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:

StepWhat it buysCost
Generated tsvector column with weights (A for title, B for body)Title matches outrank body matches; no per-query to_tsvectorA column and a GIN index
websearch_to_tsquery for user inputQuoted phrases, or, negation without parser errorsNone
pg_trgm GIN index on names and titlesTypo-tolerant and substring matchingLarger index, slower writes
Rank only the top candidates (filter, then ORDER BY ts_rank ... LIMIT) on a pre-limited setBounds ranking cost for broad queriesSome relevance loss on huge match sets
Route search to a replicaOLTP isolationOne 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.

CorpusPostgresElasticsearch
1M docs, 50 QPSTrivialOverkill
10M docs, 500 QPS, filtered queriesComfortable with a search replicaComfortable
100M docs, 2K QPS, relevance-rankedStrained: ranking cost, replica sizeDesigned for it: ~10–20 shards with replicas
1B+ docsNot the toolLarge cluster; tiering and index lifecycle management

4. What Each Can Do That the Other Cannot#

CapabilityPostgresElasticsearch
Join search results with relational data in one queryYesNo (denormalize at index time)
Transactional consistency with writesYesNo
Typo tolerance with edit distancePartial (trigram similarity)Yes
Facets over millions of matches in millisecondsSlow (GROUP BY over matches)Yes (aggregations on doc values)
Highlighting with snippetsts_headline (slow on large docs)Yes, several highlighters
Autocomplete / search-as-you-typePrefix tsquery, trigramCompletion suggester, edge n-grams
Synonyms and per-language analyzersDictionaries, configurableExtensive, per field
Vector / hybrid searchVia extensionsBuilt-in kNN and hybrid ranking
Log analytics at volumeNoCommon, though columnar stores often win on cost

Where Each One Breaks#

SystemFailure modeSymptomDetectionMitigationOwner
PostgresRanking a huge match setA one-word query matches 2M rows; ts_rank reads every tsvector; seconds per querySlow query log, EXPLAIN (ANALYZE, BUFFERS)Require selective filters, LIMIT on candidates, rank a pre-filtered subsetProduct team
PostgresSearch load on the primaryOLTP p99 rises during search peaksCPU and I/O on primary, query mixRoute search to a replica; statement timeoutsPlatform
PostgresGIN pending list and write amplificationHeavy write load slows; occasional slow inserts when the pending list flushesInsert latency spikes, index size growthTune fastupdate and gin_pending_list_limit, vacuumPlatform
PostgresWrong language configStemming mangles names or other languages; misses obvious matchesSearch quality complaints, zero-result ratePer-row language config, simple config for names, trigram fallbackProduct team
ElasticsearchIndex and source divergeDeleted or permission-revoked docs still searchable; new docs missingSync lag metric, periodic count and checksum reconciliationIdempotent indexer with versioning, DLQ, reconciliation job, hydrate and re-check at readSearch team
ElasticsearchMapping explosionDynamic mapping creates thousands of fields; heap and cluster state balloonField count per index, master heapStrict mappings, field limits, flattened typesSearch team
ElasticsearchBad shard sizingThousands of tiny shards, or 200 GB shards that take hours to recoverShard count per node, shard size distribution10–50 GB shards, index lifecycle rollover, shrink/splitSearch team
ElasticsearchHeap pressure and GCOld-gen GC pauses; circuit breakers reject queriesJVM heap usage, breaker tripsHeap at ~50% of RAM (under ~31 GB), fewer fields, aggregations boundedSearch team
ElasticsearchReindex requiredAnalyzer change or shard count change needs a full reindexPlanned changeAlias-swap reindex from the source of truth; keep the pipeline replayableSearch team
BothZero-result searchesUsers search terms the index cannot matchZero-result rate by querySynonyms, fuzzy fallback, query logs reviewed weeklyProduct

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 searchElasticsearch
Who runs itWhoever runs Postgres already; no new on-callA search team or platform team (commonly 1–3 engineers for a mid-size deployment), or a managed service
What the bill scales withIndex storage on existing nodes; possibly a dedicated search replicaNodes sized for heap and SSD; replicas (×2 storage minimum); the sync pipeline (Kafka, indexers)
Typical footprint0 extra nodes, or 1 replica3 dedicated masters + 3–6 data nodes for a modest production cluster
Hidden costEngineering time tuning queries once ranking gets slowReindex 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.

ScaleSensible choiceRough infrastructurePeople
Small (1M docs, internal search)Postgres FTS on the primaryNear zero extraNone dedicated
Medium (10–50M docs, customer search, facets)Postgres replica for search, or a small managed search cluster fed by CDCHundreds to low thousands of dollars a month1 engineer part-time
Large (500M+ docs, relevance-critical)Elasticsearch cluster with CDC pipeline, reindex automation, relevance metricsTens of thousands a monthA 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#

MigrationDifficultyWhat's hard to undo
Postgres FTS → ElasticsearchModerate: build CDC or an outbox, backfill, dual-read compare, switchRead-your-writes assumptions in the UI ("I just created it, why can't I find it?")
Elasticsearch → Postgres FTSModerate if Postgres was the source of truthRelevance features users now expect: typo tolerance, facets, highlighting
Elasticsearch as primary store → databaseHardNo transactions, no constraints; data quality issues surface during migration
Elasticsearch ↔ OpenSearchModerate; they have diverged since the 2021 forkVersion-specific APIs, plugins and client libraries
Changing analyzers or shard countFull reindexNeeds a replayable source and alias swaps
Diagram: Switching Later

Adding Elasticsearch, in order:

  1. Define the index document from the read model users search, denormalized, with a version field taken from the source row.
  2. Build the CDC or outbox pipeline with an idempotent indexer (upsert by ID, ignore older versions) and a dead-letter queue.
  3. Backfill from Postgres into a new index behind an alias; start the live pipeline before the backfill finishes so nothing is missed.
  4. Dual-read: serve from Postgres, query Elasticsearch in the background, compare result overlap and latency.
  5. Switch reads behind a flag, keep the Postgres search path as a degraded fallback, and schedule the reconciliation job.

The one-way doors:

  1. 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.
  2. Exposing search-engine query syntax to clients. Wrap it in your own API so you can change engines, analyzers or ranking.
  3. Denormalization choices in the index (embedding author names, permissions). Changing them means reindexing everything.

How Real Companies Chose#

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."

  1. Loading the index…