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 searchElasticsearchMeilisearchTypesense
Where it runsInside your databaseSeparate clusterSeparate serviceSeparate service
Data syncNone; the index updates in the same transactionYou build a pipelineYou build a pipelineYou build a pipeline
Rankingts_rank / ts_rank_cd (frequency and position based)BM25 by default, highly tunableRule-based ranking, configurableConfigurable ranking
Typo toleranceNot in core full-text; add pg_trgmFuzzy queriesOn by defaultOn by default (up to 2 typos)
Facetsgroup by and counts in SQLAggregationsBuilt inBuilt in (facet_by)
Vector / hybridpgvector in the same databaseSupportedSupportedSupported (vector_query)
PermissionsRow Level Security on the same rowsYour own layerAPI keys and tenant tokensScoped API keys
ScalingYour database server, plus read replicasShards and replicas across nodesSelf-hosted or Meilisearch CloudIndex 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:

SQL
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:

SQL
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, french and more), or simple for exact words.
  • Phrases, or and exclusions through websearch_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 published is 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_tsvector matches stems, not misspellings. "postgers" finds nothing. The pg_trgm extension fills the gap with similarity matching, but you combine the two yourself.
  • Simpler ranking. ts_rank scores 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_typos defaults to 2) and supports faceting with facet_by and 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

SQL
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

SQL
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):

  1. 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.
  2. Optionally turn on search by meaning, which embeds each row in the background.
  3. Query from your app:
JavaScript
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.

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.