Databases

PostgreSQL Full-Text Search vs Elasticsearch: When Built-in Search Is Enough in 2026

Ruslan Ismailov Published 12 min read
P

Introduction: Why Your Choice of Search Tool Matters in 2026

Full-text search is one of those tasks where developers traditionally reach for the "proper" tool: Elasticsearch or its fork OpenSearch. But in 2026, PostgreSQL FTS has matured significantly, and the operational cost of maintaining a separate search cluster has become more noticeable. The question "do we actually need Elasticsearch?" is coming up more and more often in architecture reviews.

This article is aimed at backend developers working with Go and PHP/Laravel, as well as architects designing search functionality. We'll break down how built-in FTS works in PostgreSQL, where it holds up, where it genuinely falls short, and how to make an informed decision without bias toward trendy tools.

How Full-Text Search Works in PostgreSQL: tsvector, tsquery, GIN, and GiST

PostgreSQL implements full-text search through two key data types and a set of functions that operate on them.

tsvector and tsquery

tsvector is a normalized representation of a document: a list of lexemes with positions and weights. tsquery is a search query with boolean operators (&, |, !) and phrase search support.

-- Converting text to tsvector
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');
-- Result: 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2

-- Simple search
SELECT to_tsvector('english', 'The quick brown fox') @@ to_tsquery('english', 'fox');
-- t

-- Phrase search
SELECT to_tsvector('english', 'PostgreSQL is a powerful database') @@ phraseto_tsquery('english', 'powerful database');
-- t

GIN and GiST Indexes

To speed up FTS, PostgreSQL offers two index types:

  • GIN (Generalized Inverted Index) — an inverted index similar to Elasticsearch. Faster at search, slower to update. Optimal for static or rarely changing data.

  • GiST (Generalized Search Tree) — more compact, faster to update, but slower to read. Suited for frequently updated tables.

-- Adding a computed column and a GIN index
ALTER TABLE articles ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (
    to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
  ) STORED;

CREATE INDEX idx_articles_search ON articles USING GIN(search_vector);

-- The search now uses the index
EXPLAIN ANALYZE
SELECT id, title FROM articles
WHERE search_vector @@ to_tsquery('english', 'PostgreSQL & index');

Since PostgreSQL 15+, the query planner has become better at using GIN indexes in combination with other predicates, reducing the need for manual hints.

Practical FTS Examples: Ranking, Highlighting, and Language Support

Ranking Results

The ts_rank function calculates relevance based on lexeme frequency and position. ts_rank_cd additionally accounts for query coverage of the document.

SELECT
  id,
  title,
  ts_rank(search_vector, query) AS rank
FROM
  articles,
  to_tsquery('english', 'search & PostgreSQL') query
WHERE
  search_vector @@ query
ORDER BY rank DESC
LIMIT 20;

Highlighting Matched Fragments

SELECT
  title,
  ts_headline(
    'english',
    body,
    to_tsquery('english', 'full-text & search'),
    'MaxWords=50, MinWords=20, StartSel=<mark>, StopSel=</mark>'
  ) AS snippet
FROM articles
WHERE search_vector @@ to_tsquery('english', 'full-text & search');

Multilingual Search

For correct handling of different languages, you need the appropriate language configuration, which ships with PostgreSQL and uses Snowball dictionaries for stemming. For more accurate morphological analysis, it is recommended to connect the ispell extension with the relevant language dictionaries:

-- Check available configurations
SELECT cfgname FROM pg_ts_config;

-- Analyze tokenization for a text sample
SELECT * FROM ts_debug('english', 'developers use PostgreSQL');

-- Search with morphology support
SELECT title FROM articles
WHERE search_vector @@ to_tsquery('english', 'developer');
-- Will match: 'developers', 'developer's', 'development'

Limitation: the Snowball stemmer handles irregular verbs and homonyms less accurately compared to commercial solutions. For e-commerce with an extensive catalog, this can be a critical issue.

Capabilities and Limitations of PostgreSQL FTS

What PostgreSQL FTS Can Do

  • Boolean logic and phrase search

  • Ranking via a tf-idf-like formula

  • Fragment highlighting (ts_headline)

  • Fuzzy search via pg_trgm (trigram index)

  • Multilingual configurations

  • Weight categories (A, B, C, D) for different fields

-- Fuzzy matching via pg_trgm
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_articles_trgm ON articles USING GIN(title gin_trgm_ops);

SELECT title, similarity(title, 'postgres') AS sim
FROM articles
WHERE title % 'postgres'
ORDER BY sim DESC;

Limitations

  • No built-in synonym or thesaurus support out of the box (requires manual dictionary configuration)

  • No faceted search / aggregations in the Elasticsearch sense

  • No distributed search — scaling is vertical only or via partitioning

  • No built-in autocomplete (suggest/autocomplete)

  • Indexing large tables consumes significant resources (mitigated via CREATE INDEX CONCURRENTLY)

When PostgreSQL FTS Is Enough: Decision Criteria

Use PostgreSQL FTS as your primary search tool if the following conditions are met:

  1. Data volume up to 10–50 million records in the search table. A GIN index handles these volumes well on properly configured hardware.

  2. Simple relevance model: ranking by term frequency and field weight without machine learning.

  3. Search over structured documents with well-defined fields (articles, products, users).

  4. No real-time indexing requirements with sub-second latency.

  5. No faceted navigation (category filters with item counts).

  6. Your team already uses PostgreSQL and doesn't want to introduce a new operational component.

Based on real-world project experience: internal search for a corporate knowledge base, blog or news portal search, user search in a SaaS application — these are all excellent candidates for PostgreSQL FTS.

When You Need Elasticsearch or an Alternative

Elasticsearch is justified when your requirements exceed PostgreSQL's capabilities:

  • Multi-field faceted search with aggregations: "find products in category X, priced between Y and Z, with a rating above 4" — along with counts for each filter.

  • Hundreds of millions of documents with horizontal scaling via shards.

  • Personalized search with Learning to Rank (LTR) and user-based signals.

  • Multilingual search with analyzers for 30+ languages, including CJK scripts.

  • Near real-time indexing: a new document becomes searchable within ~1 second.

  • Log and metrics search (Elastic Stack / ELK) — this is Elasticsearch's native domain.

  • Autocomplete and prefix search at large scale with low latency.

If your project is a marketplace with facets, a large e-commerce platform, or a microservices log search system, Elasticsearch remains the de facto standard.

Integrating PostgreSQL FTS with Go and Laravel

Go: pgx and sqlc

In Go, the most performant driver for PostgreSQL is pgx. For type-safe queries, use sqlc.

// Query via pgx with tsquery parameter passing
package search

import (
    "context"
    "github.com/jackc/pgx/v5/pgxpool"
)

type Article struct {
    ID    int
    Title string
    Rank  float32
}

func SearchArticles(ctx context.Context, db *pgxpool.Pool, query string) ([]Article, error) {
    sql := `
        SELECT
            id,
            title,
            ts_rank(search_vector, websearch_to_tsquery('english', $1)) AS rank
        FROM articles
        WHERE search_vector @@ websearch_to_tsquery('english', $1)
        ORDER BY rank DESC
        LIMIT 20
    `
    rows, err := db.Query(ctx, sql, query)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var results []Article
    for rows.Next() {
        var a Article
        if err := rows.Scan(&a.ID, &a.Title, &a.Rank); err != nil {
            return nil, err
        }
        results = append(results, a)
    }
    return results, nil
}

The websearch_to_tsquery function (PostgreSQL 11+) parses user input into a safe tsquery without the risk of syntax errors — ideal for search strings in REST APIs.

Laravel: Scout and Native Queries

In Laravel, there are two approaches to PostgreSQL FTS. The first is via the laravel/scout package with the teamtnt/laravel-scout-tntsearch-driver or a custom Postgres driver. The second is native Eloquent queries.

<?php
// app/Models/Article.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;

class Article extends Model
{
    public function scopeSearch(Builder $query, string $term): Builder
    {
        return $query
            ->selectRaw("
                *,
                ts_rank(
                    search_vector,
                    websearch_to_tsquery('english', ?)
                ) AS rank
            ", [$term])
            ->whereRaw(
                "search_vector @@ websearch_to_tsquery('english', ?)",
                [$term]
            )
            ->orderByDesc('rank');
    }
}

// Usage in a controller
$results = Article::search($request->input('q'))->paginate(20);

To create a trigger that automatically updates search_vector, use a Laravel migration:

<?php
// In a migration
DB::unprepared("
    CREATE OR REPLACE FUNCTION update_article_search_vector() RETURNS trigger AS \$\$
    BEGIN
        NEW.search_vector :=
            setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
            setweight(to_tsvector('english', coalesce(NEW.body, '')), 'B');
        RETURN NEW;
    END;
    \$\$ LANGUAGE plpgsql;

    CREATE TRIGGER articles_search_vector_update
    BEFORE INSERT OR UPDATE ON articles
    FOR EACH ROW EXECUTE FUNCTION update_article_search_vector();
");

Performance: Load Testing and Indexing

Real-world benchmarks on a table of 5 million articles (PostgreSQL 16, 32 GB RAM, NVMe SSD):

  • Search without index (seq scan): ~8–12 seconds per query — unacceptable.

  • GIN index, simple query: 5–20 ms at the 95th percentile.

  • GIN + pg_trgm (fuzzy search): 20–80 ms at the 95th percentile.

  • Elasticsearch 8.x, equivalent dataset: 3–10 ms at the 95th percentile.

A 2–3x latency difference on simple queries is real. But for most web applications, 20 ms vs 5 ms is not critical unless the query is being called 10,000 times per second.

Building an Index on a Large Table

-- CONCURRENTLY does not block writes to the table
CREATE INDEX CONCURRENTLY idx_articles_search_gin
ON articles USING GIN(search_vector);

-- Setting maintenance_work_mem speeds up GIN index creation
SET maintenance_work_mem = '1GB';
CREATE INDEX idx_articles_search_gin ON articles USING GIN(search_vector);

-- Check index size
SELECT
    indexname,
    pg_size_pretty(pg_relation_size(indexname::regclass)) AS index_size
FROM pg_indexes
WHERE tablename = 'articles';

A GIN index on 5 million rows takes approximately 2–4 GB. This needs to be factored into disk space planning.

Hybrid Approach: PostgreSQL FTS + Redis Caching

Even with good PostgreSQL FTS performance, search queries tend to repeat. Caching results in Redis reduces database load and can bring response times down to 1–2 ms.

// Go: caching search results in Redis
package search

import (
    "context"
    "encoding/json"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
    "github.com/jackc/pgx/v5/pgxpool"
)

type SearchService struct {
    db    *pgxpool.Pool
    cache *redis.Client
}

func (s *SearchService) Search(ctx context.Context, query string, page int) ([]Article, error) {
    cacheKey := fmt.Sprintf("search:%s:page:%d", query, page)

    // Check Redis cache
    cached, err := s.cache.Get(ctx, cacheKey).Bytes()
    if err == nil {
        var articles []Article
        if json.Unmarshal(cached, &articles) == nil {
            return articles, nil
        }
    }

    // Query PostgreSQL
    articles, err := SearchArticles(ctx, s.db, query)
    if err != nil {
        return nil, err
    }

    // Cache for 5 minutes
    if data, err := json.Marshal(articles); err == nil {
        s.cache.Set(ctx, cacheKey, data, 5*time.Minute)
    }

    return articles, nil
}

Cache invalidation strategy: flush the cache when articles are updated via PostgreSQL NOTIFY or a task queue. In Laravel, this is conveniently implemented using an Observer and the saved event.

Important note: only cache popular queries (top-N by frequency). Caching long-tail queries is counterproductive — they'll consume Redis memory with no real benefit.

Conclusion: Search Tool Decision Matrix

Below is a practical decision matrix. Use it as a starting point, not an absolute rule.

  • PostgreSQL FTS is an excellent choice when: volume is up to 20–50 million documents, no faceted search is needed, the team knows PostgreSQL, budget is limited, and infrastructure simplicity matters.

  • PostgreSQL FTS + Redis is a good choice when: high load on repeated queries is expected and you need to reduce response latency without changing the stack.

  • Elasticsearch/OpenSearch is necessary when: faceted search, 100+ million documents, personalization, autocomplete at scale, or log search is required.

  • Typesense / Meilisearch — consider as an alternative to Elasticsearch if you need an operationally simple engine with a great out-of-the-box UX.

In 2026, PostgreSQL FTS is a mature, production-ready solution for a wide range of use cases. There's no need to pull Elasticsearch into every project. Start with PostgreSQL, add Redis for caching hot queries, and you'll have a stack that serves millions of users without additional operational overhead. Move to a dedicated search engine only when you hit concrete limitations — not before.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →