PostgreSQL Full-Text Search vs Elasticsearch: Where the Line Actually Is
The instinct to reach for Elasticsearch the moment search requirements appear is understandable but often wrong. Elasticsearch is a capable system, but it is also a separate cluster to deploy, a consistency boundary to manage, a data synchronization problem to solve, and an operational burden to carry indefinitely. For a surprising range of workloads, PostgreSQL’s built-in full-text search is fast enough, expressive enough, and vastly simpler to operate.
This post is about knowing where the line is. PostgreSQL FTS handles millions of documents well, offers phrase search, relevance ranking, field boosting, result snippets, and fuzzy matching through pg_trgm. Elasticsearch adds faceted search, aggregations, autocomplete, multilingual analysis, and semantic vector search at a scale and sophistication that PostgreSQL cannot match. The question is whether your actual requirements cross that line — and most applications, at most stages of their life, do not.
How PostgreSQL Full-Text Search Works
PostgreSQL FTS is built on two types: tsvector and tsquery. Understanding what these types actually store explains both the capabilities and the limits of the system.
tsvector: the processed document
to_tsvector('english', text) takes a string, runs it through a text search configuration, and returns a tsvector: a sorted list of lexemes with their positions.
|
|
What happened:
- Stop words (
the,over) were removed based on theenglishconfiguration’s stop word list - Remaining words were stemmed (
foxes→fox,jumped→jump,lazy→lazi) - Position numbers were preserved (word 3 in the original sentence is
brown)
The positions are not decorative. They enable phrase search — knowing that fox was at position 4 and jump at position 5 is what makes phraseto_tsquery('fox jump') work correctly. If you use tsvector(strip=true) to remove positions, you lose phrase search in exchange for a smaller index.
tsquery: the search expression
Four functions produce tsquery values, each with different semantics and safety profiles:
|
|
websearch_to_tsquery is the right choice for any user-facing search input. It handles the constructs users naturally type — quoted phrases, minus to exclude, OR for alternatives — and never raises a syntax error on bad input. It arrived in PostgreSQL 11 and there is no good reason not to use it.
The match operator is @@:
|
|
Indexes: GIN is the right choice
PostgreSQL offers two index types for FTS: GIN and GiST. GIN wins for the common case.
GIN (Generalized Inverted Index) builds a map from each lexeme to the set of documents containing it — the same structure as an Elasticsearch inverted index. It is non-lossy (no false positives) and roughly 3x faster for queries than GiST. GiST is a lossy structure that requires heap rechecks to eliminate false positives; it builds faster but queries slower.
GIN has one behavioral subtlety: the fastupdate storage parameter (on by default) maintains a “pending list” of new entries that are merged into the main index structure lazily. This speeds up writes at the cost of occasional query latency spikes during merges. The pending list size is controlled by gin_pending_list_limit (default 4MB). For write-heavy tables where predictable query latency matters more than insert throughput, set fastupdate=off on the GIN index.
|
|
Generated columns for pre-computed tsvectors
Computing to_tsvector() at query time is wasteful. PostgreSQL 12+ supports generated stored columns that are computed at write time and maintained automatically:
|
|
The setweight() call assigns importance levels to different fields. 'A' is highest, 'D' is lowest. When ts_rank or ts_rank_cd scores a match, terms found in an 'A'-weighted field contribute more to the score than terms found in a 'C'-weighted field. A query matching the title ranks above an equal query matching only the body.
The generated column updates automatically when title, body, or author change. No application code, no triggers, no synchronization lag.
Ranking
ts_rank_cd() is the better ranking function for most applications. It implements cover density ranking: documents where query terms appear close together score higher than documents where the same terms are scattered. The _cd variant requires position information in the tsvector (the default — only an issue if you deliberately strip positions).
|
|
The third argument to ts_rank_cd is a normalization bitmask. Values can be ORed together:
| Value | Effect |
|---|---|
| 0 | No normalization — raw frequency |
| 1 | Divide by 1 + log(document length) |
| 2 | Divide by document length |
| 4 | Divide by mean harmonic distance between extents |
| 8 | Divide by number of unique words |
| 16 | Divide by 1 + log(number of unique words) |
| 32 | Scale output to the range [0, 1] via rank/(rank+1) |
32 is useful when you want rank values that are comparable across queries. The default (0) produces unbounded values that are only meaningful relative to other results from the same query.
Result snippets
ts_headline() extracts relevant fragments from the original text, with matched terms highlighted:
|
|
Sample output:
...improving <b>PostgreSQL</b> query performance begins with understanding how the query
planner uses <b>indexes</b>. The most common <b>index</b> types ... the executor uses the
<b>index</b> scan to fetch matching rows from the heap...
ts_headline() runs against the original unprocessed text, not the tsvector. It is computationally more expensive than the search itself — fine for pagination-limited result sets, potentially slow if called on thousands of rows simultaneously. Apply it after filtering and limiting, not before.
pg_trgm: Fuzzy Matching and LIKE Acceleration
The pg_trgm extension gives PostgreSQL two capabilities that standard FTS does not: tolerance for typos and acceleration of arbitrary LIKE/ILIKE patterns.
A trigram is a sequence of three consecutive characters. pg_trgm pads strings with spaces at the boundaries: 'cat' becomes ' c', 'ca', 'at', 't '. The similarity between two strings is the fraction of trigrams they share:
|
|
The % operator returns true when similarity exceeds the threshold set by pg_trgm.similarity_threshold (default 0.3):
|
|
The more important use case for most applications is LIKE acceleration. Without pg_trgm, any LIKE pattern with a leading wildcard (%smith%, %@example.com) forces a sequential scan — no B-tree index can help because the index is ordered by the leading characters, not the middle. With a pg_trgm GIN index, PostgreSQL decomposes the pattern into trigrams and uses the index to find candidates:
|
|
The minimum pattern length for trigram acceleration is 3 characters (enough to form one trigram). Patterns shorter than 3 characters still trigger a sequential scan. For a table of 10M users, this is the difference between a 200ms query and a 0.3ms query.
word_similarity() is a variant that tests whether a string is similar to any word-length fragment of another, rather than the full string. It is better than similarity() for fuzzy matching of single words within longer strings — useful for correcting misspelled search terms against a dictionary.
Multilingual Search in PostgreSQL
PostgreSQL ships with text search configurations for major European languages: english, french, german, spanish, portuguese, italian, dutch, russian, swedish, norwegian, and others. Each configuration specifies a stemmer and a stop word list. Querying with the correct configuration is critical: to_tsvector('english', 'running') produces 'run', but to_tsvector('french', 'running') produces 'running' (no French stemmer for English words).
For documents in a single known language, the approach is straightforward: pass the language name to to_tsvector and to_tsquery. For mixed-language documents or for documents whose language is not known until runtime, store the language in a column and pass it dynamically:
|
|
The unaccent extension removes diacritics before indexing, making queries accent-insensitive:
|
|
With unaccent applied at index time and query time, café matches cafe.
For Chinese, Japanese, and Korean, the standard PostgreSQL parser is useless — these languages have no word boundaries that the whitespace/punctuation tokenizer can detect. The pg_jieba extension integrates the Jieba segmentation library for Chinese text. Japanese and Korean require separate extensions or a dedicated search engine. If your content is primarily CJK, PostgreSQL FTS is not the right tool — this is one scenario where Elasticsearch or OpenSearch with language-specific analyzers is genuinely better.
Elasticsearch: Where It Is Actually Different
Elasticsearch (and its downstream fork OpenSearch) is built on Lucene, a Java full-text search library that has been under active development since 2001. The fundamental data structure is the same as PostgreSQL’s FTS: an inverted index mapping terms to documents. The difference is in everything built on top.
BM25 scoring
Elasticsearch has used BM25 as its default scoring algorithm since version 5.0. BM25 improves on raw TF-IDF in two ways: term frequency saturation and document length normalization.
Term frequency saturation: A term appearing 20 times in a document is not 20x as relevant as one appearing once. BM25 applies a saturation function controlled by the k1 parameter (default 1.2). As term frequency increases, the marginal relevance gain decreases rapidly. A term appearing 3 times is roughly as relevant as one appearing 10 times.
Document length normalization: Long documents should not automatically outrank short ones just because they contain more words. The b parameter (default 0.75) penalizes long documents relative to the average document length in the index.
PostgreSQL’s ts_rank_cd() with normalization flags approximates these effects, but BM25 is more principled and produces more consistent relevance across diverse datasets. For simple queries on homogeneous documents the difference is minor; for complex multi-field queries across documents of widely varying lengths, Elasticsearch’s BM25 implementation produces noticeably better results.
Analysis pipeline
Elasticsearch’s analysis pipeline gives you fine-grained control over how text is processed at index time and query time. A custom analyzer chains character filters, a tokenizer, and token filters:
|
|
Synonyms at index time ("pg, postgres, postgresql") mean that a query for pg matches documents containing postgresql without any application code changes. PostgreSQL FTS has no equivalent — you would need to expand synonyms in the query itself before passing it to websearch_to_tsquery, which works but requires maintaining the synonym list in application code.
Faceted search and aggregations
The feature that most commonly justifies Elasticsearch in practice is not search relevance — it is faceted navigation. When a user searches for “laptop” and the results page shows “Filter by brand: Dell (47), Apple (31), Lenovo (28)” and “Filter by price: Under $500 (18), $500-$1000 (52), Over $1000 (36)”, those counts come from aggregations running alongside the search query:
|
|
This returns search results and aggregation counts in a single request. Doing the equivalent in PostgreSQL requires separate COUNT queries with the same WHERE clause for each facet, or a complex query with FILTER clauses. It works, but it is not efficient at scale and requires more application code to maintain.
Autocomplete
PostgreSQL has no native autocomplete support. The closest approximation is a trigram-indexed prefix query or a full-text search of a terms table. Elasticsearch has multiple dedicated strategies:
Edge n-gram tokenizer indexes every prefix of a term: "database" is indexed as "d", "da", "dat", …, "database". A query for "data" matches without any application logic.
search_as_you_type field type (ES 7.10+) creates three sub-fields optimized for prefix, infix, and full-term matching and combines them intelligently based on query input. It produces good results with minimal configuration:
|
|
Completion suggester stores an FST (finite state transducer) in memory for O(1) prefix lookups — extremely fast for autocomplete but limited to exact-prefix matching with no relevance scoring.
Relevance tuning
When the default BM25 score does not produce the ranking you need, Elasticsearch’s function_score query allows custom scoring formulas:
|
|
This boosts recent articles by 1.5x, multiplies by the log of view count, and ranks title matches 3x higher than body matches. Building equivalent scoring in PostgreSQL requires custom SQL expressions that grow unwieldy quickly and are harder to tune interactively.
Vector search
Elasticsearch 8.0 introduced approximate k-nearest-neighbor search using the HNSW (Hierarchical Navigable Small World) algorithm. A dense_vector field stores embeddings from a text encoder model; a knn query retrieves semantically similar documents regardless of keyword overlap:
|
|
|
|
HNSW is approximate — it trades a small recall penalty (typically 95%+ recall) for roughly 1000x speedup over exact nearest-neighbor search. The HNSW graph must fit in memory for performance; a 768-dimensional embedding for 1M documents occupies roughly 3GB of RAM.
PostgreSQL has pgvector, an extension that adds a vector type and IVFFlat/HNSW indexes. As of PostgreSQL 16, pgvector’s HNSW implementation is production-ready for moderate scales (single digits of millions of vectors). For large-scale semantic search, Elasticsearch or a dedicated vector database remains more capable and better tooled.
The Operational Reality
The operational cost of Elasticsearch is real and frequently underestimated.
Data synchronization: PostgreSQL FTS is zero-lag — the search index updates in the same transaction as the write. Elasticsearch requires an external synchronization mechanism. The three patterns:
-
Dual-write: the application writes to both databases. Fast, simple, wrong — a crash between the two writes leaves them inconsistent with no recovery path.
-
Change data capture via Debezium: reads the PostgreSQL WAL, publishes changes to Kafka, streams changes into Elasticsearch. Reliable but adds Debezium, Kafka, and the Kafka-to-Elasticsearch connector as operational dependencies. End-to-end latency is typically 500ms–2s. This is the correct approach for production, but it means running five systems (Postgres, Debezium, Kafka, Kafka Connect, Elasticsearch) where you previously ran one.
-
Scheduled bulk reindex: rebuild the Elasticsearch index from Postgres on a schedule. Simple to implement, but search results are always stale by the interval between runs.
Schema migrations: Adding a field to Elasticsearch requires reindexing all documents. For an index with 100M documents, this is a multi-hour operation (Elasticsearch’s _reindex API, background task, rate-limited to avoid disrupting live traffic). PostgreSQL schema migrations can be done with ALTER TABLE plus an index build in most cases, and the search index updates automatically through the generated column.
Shard tuning: Elasticsearch performance is sensitive to shard count and size. The canonical guidance is 10–50GB per shard. Too few shards limits parallelism; too many shards creates coordination overhead. Getting this wrong on initial deployment means a difficult reindex operation to correct it. PostgreSQL has no equivalent concern.
Where the Line Is
PostgreSQL FTS with GIN indexes handles 10–50 million documents effectively for most query patterns. Beyond that range, performance degrades without additional measures (table partitioning, more aggressive autovacuum, dedicated search replicas). The upper bound is not a hard wall — with careful tuning, some teams run FTS queries against 200M-row tables in under a second — but it requires increasing effort.
The cases where Elasticsearch is genuinely necessary:
- Faceted search with dynamic facet counts at any scale. The aggregation API has no practical PostgreSQL equivalent.
- Autocomplete with sub-50ms latency requirements. PostgreSQL can approximate this but not elegantly.
- Synonyms at index time. Managing synonym expansion in application code is fragile.
- Hundreds of millions of documents without partitioning complexity.
- Polyglot content — multiple languages in the same index with per-language analysis. PostgreSQL requires separate tsvector columns per language.
- Semantic/vector search at meaningful scale (millions of vectors).
- Complex relevance tuning that needs interactive experimentation. The
function_scoreAPI and Query DSL are much easier to iterate on than custom PostgreSQL ranking expressions.
The cases where Postgres FTS is the right answer and Elasticsearch is over-engineering:
- A SaaS application where users search their own content. Documents per user are bounded; total corpus is manageable.
- Internal tools and admin interfaces. Low query volume, modest document counts, transactional consistency matters.
- Blog or documentation search. Millions of documents at most; English only; basic relevance is fine.
- Any application where the team does not have Elasticsearch operational experience and cannot afford to acquire it.
The migration path when Postgres FTS stops being sufficient: implement CDC via Debezium before you need it. If your Postgres tables are already change-captured into Kafka for other reasons, adding an Elasticsearch sink is straightforward. If they are not, the CDC setup is the majority of the work. Either way, keep Postgres as the source of truth and treat Elasticsearch as a read-only search replica.
Reference: SQL Patterns
Multi-column search with ranking and snippets:
|
|
Phrase search:
|
|
Combined keyword + fuzzy matching (recent queries, misspelled titles):
|
|
Find all articles containing a substring (LIKE with trigram index):
|
|
Diagnose FTS query behavior:
|
|
These three queries answer most debugging questions about why a search is or is not returning expected results without needing to run the full production query.
Comments