PostgreSQL Full-Text Search vs Elasticsearch: When Built-in Search Is Enough in 2026
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');
-- tGIN 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:
Data volume up to 10–50 million records in the search table. A GIN index handles these volumes well on properly configured hardware.
Simple relevance model: ranking by term frequency and field weight without machine learning.
Search over structured documents with well-defined fields (articles, products, users).
No real-time indexing requirements with sub-second latency.
No faceted navigation (category filters with item counts).
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 →