Postgres Full-Text Search vs Elasticsearch (and Meilisearch)
Postgres full-text search is enough for most app search boxes: it handles stemming, phrases, exclusions and ranking from a GIN index in the database you already run, with no second system to keep in sync. Elasticsearch is the better choice when search is the product itself, with very large indexes, BM25 relevance tuning, heavy faceting and log or analytics workloads spread across a cluster. Meilisearch and Typesense sit in between: separate engines that are simpler to run than Elasticsearch and give you typo tolerance and instant search out of the box.
This comparison is about choosing, not about benchmarks. Search quality depends on your data and your tuning, so test with your own queries.
#Summary table
| Postgres full-text search | Elasticsearch | Meilisearch | Typesense | |
|---|---|---|---|---|
| Where it runs | Inside your database | Separate cluster | Separate service | Separate service |
| Data sync | None; the index updates in the same transaction | You build a pipeline | You build a pipeline | You build a pipeline |
| Ranking | ts_rank / ts_rank_cd (frequency and position based) | BM25 by default, highly tunable | Rule-based ranking, configurable | Configurable ranking |
| Typo tolerance | Not in core full-text; add pg_trgm | Fuzzy queries | On by default | On by default (up to 2 typos) |
| Facets | group by and counts in SQL | Aggregations | Built in | Built in (facet_by) |
| Vector / hybrid | pgvector in the same database | Supported | Supported | Supported (vector_query) |
| Permissions | Row Level Security on the same rows | Your own layer | API keys and tenant tokens | Scoped API keys |
| Scaling | Your database server, plus read replicas | Shards and replicas across nodes | Self-hosted or Meilisearch Cloud | Index kept in RAM, raw data on disk |
Vendor details are from their official docs as of November 2026: Elasticsearch similarity, Meilisearch typo tolerance and Typesense system requirements.
#What Postgres full-text search does well
A complete setup is two statements:
alter table public.articles
add column fts tsvector generated always as (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) stored;
create index articles_fts_idx on public.articles using gin (fts);And a query that accepts what users type:
select id, title, ts_rank(fts, q) as rank
from public.articles, websearch_to_tsquery('english', 'postgres "connection pooling" -mysql') as q
where fts @@ q
order by rank desc
limit 20;What you get:
- Stemming and stop words for many languages (
english,german,frenchand more), orsimplefor exact words. - Phrases,
orand exclusions throughwebsearch_to_tsquery, which does not throw on stray punctuation. - Weighted fields, so a title match ranks above a body match.
- Snippets with
ts_headline. - Transactions. The generated column updates in the same transaction as the row. A user who saves a post and searches for it immediately finds it. There is no indexing lag and no sync job to break.
- Joins and filters.
where fts @@ q and tenant_id = $1 and publishedis one query, using your normal indexes. - Permissions. Row Level Security applies to search like any other read, so users never see results they cannot open.
The detailed build, including trigram fuzzy matching and semantic search, is in Postgres full text search.
#Where Postgres full-text search falls short
Be clear about the gaps before you commit:
- No typo tolerance in core.
to_tsvectormatches stems, not misspellings. "postgers" finds nothing. Thepg_trgmextension fills the gap with similarity matching, but you combine the two yourself. - Simpler ranking.
ts_rankscores by term frequency and weight; it does not use document-frequency statistics across the whole corpus the way BM25 does. For a blog or a SaaS app it is usually fine. For a product where search relevance is the core value, it is a real limitation. - Facets are SQL, not a feature. Counting results per category is a
group by, which is flexible but means extra queries for each facet. - Prefix and instant search need care.
to_tsquery('english', 'postg:*')does prefix matching, but search-as-you-type over millions of rows with sub-50 ms results is what dedicated engines are tuned for. - One server. Search load lands on your primary database unless you route it to a read replica.
#Elasticsearch: when search is the product
Elasticsearch is a distributed search and analytics engine. An index is split into primary shards, each shard is a Lucene index, and replicas copy shards across nodes for redundancy and read capacity (cluster docs). Relevance uses BM25 by default and can be tuned per field, with analyzers, synonyms, boosting and function scores.
It is the better choice when:
- You search hundreds of millions of documents or terabytes of logs.
- Relevance tuning is a full-time concern (e-commerce, marketplaces, document discovery).
- You need rich aggregations across the search results for dashboards and filters.
- You already run the Elastic stack for observability.
The cost is a second system. You need a pipeline from your database (application dual-writes, change data capture, or periodic reindexing), you handle the lag between a write and its appearance in search, you rebuild permissions in the search layer, and you run or pay for a cluster. Elasticsearch source code is available under SSPL 1.0, AGPLv3 and the Elastic License 2.0, according to Elastic's licensing FAQ.
#Meilisearch and Typesense: instant search, simpler to run
Both focus on fast, typo-tolerant search for end users, and are simpler to operate than an Elasticsearch cluster.
- Meilisearch turns typo tolerance on by default: words of five or more characters allow one typo, words of nine or more allow two (default settings, per its docs). Ranking is a configurable list of rules. It is a strong fit for site and product search with search-as-you-type.
- Typesense also tolerates typos by default (
num_typosdefaults to 2) and supports faceting withfacet_byand vector queries. It keeps the whole search index in memory and a copy of the raw data on disk, which makes it fast but means RAM grows with your index.
Pick one of these when you want typo-tolerant, instant search quality without tuning Postgres, and you accept a sync pipeline. They are a common middle ground for apps whose users expect a polished search box.
#The middle path: stay in Postgres, add what you need
Before adding a second system, many apps get close enough with Postgres extensions:
#Typo tolerance with pg_trgm
create extension if not exists pg_trgm with schema extensions;
create index articles_title_trgm on public.articles using gin (title extensions.gin_trgm_ops);
-- 'postgers' still finds 'Postgres connection pooling'
select id, title, extensions.similarity(title, 'postgers') as sim
from public.articles
where title operator(extensions.%) 'postgers'
order by sim desc
limit 10;A common pattern: run the full-text query first, and fall back to trigram similarity when it returns nothing.
#Search by meaning with pgvector
Store an embedding per row with the vector extension and combine keyword rank and vector distance for hybrid search. That gets you "automobile finds car" without leaving the database. See pgvector for AI apps.
#Faceted counts in one query
with hits as (
select id, category from public.articles
where fts @@ websearch_to_tsquery('english', $1)
)
select category, count(*) from hits group by category order by count(*) desc;#Search on StitchDB
StitchDB projects are Postgres databases, so everything above works in SQL. On top of that, StitchDB has a built-in search index that does the wiring for you (Search docs):
- In Studio, open Database, then Search, choose a table (it needs a primary key), up to 8 columns and a language. Put the most important column first; it ranks highest.
- Optionally turn on search by meaning, which embeds each row in the background.
- Query from your app:
import { createClient } from 'stitchdb-js'
const stitchdb = createClient('https://<project-ref>.p.stitchdb.com', '<anon-key>')
const { data, error } = await stitchdb.search('articles', 'connection pooling', { limit: 20 })
// words only, with a phrase and an exclusion
const { data: exact } = await stitchdb.search('articles', '"connection pooling" -mysql', {
mode: 'keyword',
select: 'id,title',
})Results are rows of your table, filtered by its Row Level Security policies like any REST request. Inserts, updates and deletes are picked up by a trigger in the same transaction, so there is no sync pipeline. The index lives in your database and counts toward its size.
Plan limits from the live plans API: Free includes 1 search index with keyword search; semantic and hybrid search start on Pro, which includes 10 indexes and 500,000 embeddings per month (Scale: 50 indexes and 2,000,000). You can also add your own OpenAI-compatible embeddings key to remove the monthly embedding limit. Prices and limits are on the pricing page.
The REST API also supports full-text filters directly (fts, plfts, phfts, wfts) through .textSearch(), if you prefer to manage the tsvector column yourself (Filtering).
#Who should pick what
- Postgres full-text search (or the StitchDB search index): SaaS apps, internal tools, blogs, docs, marketplaces under a few million rows, anything where results must respect per-user permissions and freshness matters more than tuned relevance.
- Meilisearch or Typesense: consumer-facing search-as-you-type where typo tolerance and polish matter, and you are fine syncing data.
- Elasticsearch: search-heavy products at large scale, complex relevance tuning, log and analytics search across a cluster.
You can also start in Postgres and move later. Keeping search in the database first means you only build a sync pipeline once you know you need one.
#FAQ
#Is Postgres full-text search as fast as Elasticsearch?
For typical app datasets with a GIN index, Postgres returns results quickly enough for a search box. At very large scale or with heavy aggregations, a distributed engine like Elasticsearch is built for the load. Test with your own data.
#Does Postgres full-text search support typos?
Not in the core tsvector matching. Add the pg_trgm extension for similarity matching on misspelled words, or use a semantic search index that matches by meaning.
#Can Postgres do BM25 ranking?
Core Postgres ranks with ts_rank and ts_rank_cd, which use term frequency, position and weights, not BM25. If BM25-style relevance is essential, a dedicated engine is the simpler route.
#Do I need Elasticsearch for faceted search?
Not necessarily. Facet counts are group by queries in Postgres. Dedicated engines make facets easier and faster at scale, which matters most in large catalogs.
#Does the StitchDB search index respect Row Level Security?
Yes. App searches always apply the table's policies. Only Studio's Try box searches as the table owner.
Add search without a second service: read the search docs and create a free project.