How to Implement Full-Text Search Before You Reach for Elasticsearch

    6 min read3 approaches compared

    Most applications add a search box, reach straight for Elasticsearch, and take on a second datastore that must be kept in sync forever. For a large share of them, Postgres full-text search would have been enough for years.

    That said, Postgres genuinely does run out, and the boundary is sharper than the "just use Postgres" crowd admits. It has no typo tolerance, its relevance ranking is basic, and faceted filtering across many dimensions gets slow. Knowing exactly where the line is saves you both a premature migration and a painful late one.

    Postgres until a specific requirement breaks it

    Start in the database you already have. Move only when you can name the feature that forced it — that requirement then tells you which alternative to pick.

    • Under ~1M documents, exact-ish matching, simple ranking: Postgres. Comfortably.
    • Users expect typo tolerance ('recieve' finds 'receive'): Postgres can't do this well. Trigram similarity helps but isn't the same thing.
    • Sub-50ms search-as-you-type: hosted search. Postgres can hit it with tuning; the hosted options do it by default.
    • Faceted filtering with live counts across many attributes: this is where Postgres gets genuinely painful.
    • Whatever you choose beyond Postgres: you now have two datastores and a sync problem. Budget for it — it's the real cost, not the query syntax.

    The approaches

    Postgres full-text search (tsvector + GIN)

    // best for: Most applications, most of the time, with no new infrastructure.

    • PostgreSQL
    • Supabase
    • Django
    • Ruby on Rails
    • Laravel
    • Nhost

    Postgres converts text into a `tsvector` of normalized lexemes and matches it against a `tsquery`, with a GIN index making it fast. Stemming means "running" matches "run"; stop words are dropped; `ts_rank` gives you ordering.

    The implementation detail that matters is storing the `tsvector` in a generated column rather than computing it per query. A generated column is maintained by Postgres automatically, indexed once, and never drifts — which removes the entire sync problem that every external search engine introduces.

    Weighting is the underused feature. Setting a title to weight A and body to weight D makes title matches rank far above body matches, which is usually the single biggest quality improvement available and costs one line.

    For typos, `pg_trgm` gives trigram similarity, good enough for "did you mean" on short fields like names.

    sql
    -- Generated column: Postgres maintains it, so it can never
    -- drift from the row the way an external index can.
    alter table articles add column search tsvector
      generated always as (
        setweight(to_tsvector('english', coalesce(title, '')),   'A') ||
        setweight(to_tsvector('english', coalesce(body,  '')),   'D')
      ) stored;
    
    create index articles_search_idx on articles using gin(search);
    
    select id, title, ts_rank(search, q) as rank
    from articles, websearch_to_tsquery('english', $1) q
    where search @@ q
    order by rank desc
    limit 20;

    The gotcha

    Use `websearch_to_tsquery`, not `to_tsquery`. `to_tsquery` requires strict syntax and throws a syntax error on ordinary user input — a search for `cats and dogs` or anything with an apostrophe crashes the endpoint. `websearch_to_tsquery` parses Google-style input and never throws.

    Hosted search (Algolia, Typesense, Meilisearch)

    // best for: Search-as-you-type, typo tolerance, and faceted filtering as product features.

    • Elasticsearch
    • MongoDB
    • Vercel
    • Netlify
    • Railway

    These are purpose-built for the interactive search experience: typo tolerance out of the box, sub-20ms responses, faceting with counts, and client libraries that let the browser query the index directly with a restricted key.

    Typesense and Meilisearch are open source and self-hostable, which makes them much cheaper than Algolia at volume while covering most of the same ground. Algolia's advantage is operational — nothing to run — plus mature relevance tuning and analytics.

    The architectural cost is that your search index is a second copy of your data. Every write must propagate, and the failure mode is silent: a document that failed to index simply never appears in results, with no error surfaced anywhere. You need reconciliation — a periodic job comparing counts, and ideally checksums — because you will not notice drift otherwise.

    The gotcha

    Indexing synchronously inside the request that saves a record couples your write path to a third party's availability. Their outage becomes your failed writes. Queue index updates and make them retryable, so a search outage degrades search rather than breaking your application.

    Elasticsearch / OpenSearch

    // best for: Log analytics, aggregations over huge corpora, or genuinely complex relevance work.

    • Elasticsearch
    • AWS
    • Docker
    • Kubernetes
    • Google Cloud

    Elasticsearch is enormously capable and correspondingly demanding. It earns its place for log and event analytics at scale, complex aggregation pipelines, and cases where you need real control over analyzers, scoring, and multi-field relevance.

    What it is not is a drop-in for "we need a search box." You take on cluster sizing, shard strategy, JVM heap tuning, and index lifecycle management. Shard count in particular is fixed at index creation, so getting it wrong means reindexing everything.

    If you're reaching for Elasticsearch purely because it's the name you know for search, try Postgres first and a hosted engine second. Choose Elasticsearch when you can articulate the specific capability the others lack.

    The gotcha

    Elasticsearch is near-real-time, not real-time: documents become visible after a refresh interval, one second by default. Code that writes a record and immediately searches for it gets nothing back, and the test passes locally where volume is low and fails intermittently in CI.

    Common pitfalls

    No index sync reconciliation

    External indexes drift — a failed webhook, a bulk import that skipped indexing, a deploy mid-write. Because missing documents produce no error, you find out from a customer. Run a scheduled job comparing source and index counts and alert on divergence.

    Searching with LIKE '%term%'

    A leading wildcard cannot use a B-tree index, so every search is a full table scan. It works fine on the 500 rows in development and takes eight seconds in production. If you must do substring matching, use a `pg_trgm` GIN index.

    Ignoring permissions in search results

    Search indexes are commonly built without the authorization filters the main query applies, so results leak the existence of records the user can't open — titles and snippets included. Filter by permission at query time, or store the ACL in the index and filter on it.

    Not weighting fields

    Treating the title and a 4,000-word body as equally relevant means an incidental body mention outranks an exact title match. Weighting is one line in Postgres and a config field in every hosted engine, and it's the highest-leverage relevance change available.

    Questions people actually ask

    When does Postgres full-text search stop being enough?

    The usual triggers, in order: users expect typo tolerance, you need faceted filtering with live counts, or you need consistent sub-50ms search-as-you-type over millions of rows. Raw document count alone rarely forces the move — a few million rows with a GIN index is fine.

    Do I need a separate search index?

    Only once Postgres has failed a requirement you can name. A second datastore means a sync pipeline, reconciliation, and a new failure mode where results are silently incomplete. That's a real ongoing cost, not a one-time setup.

    How do I handle typos in Postgres?

    `pg_trgm` trigram similarity gets you reasonable fuzzy matching on short fields — names, titles, tags. It's noticeably weaker than a purpose-built engine's typo tolerance across long documents, which is exactly the requirement that justifies moving.

    Need someone who's done this before?

    Browse vetted developers who work with these tools day to day.

    Related guides