Postgres full-text search is good enough to replace Elasticsearch for most apps
← Back
April 4, 2026Database7 min read

Postgres full-text search is good enough to replace Elasticsearch for most apps

Published April 4, 20267 min read

I was about to add Elasticsearch to our stack for product search. Before I did, a colleague suggested trying Postgres full-text search first. Two hours later, I had relevance-ranked search with phrase matching, highlighting, and per-field weights — and our search results were actually better than the Elasticsearch prototype because we could join to inventory data in the same query. Here is the complete implementation.

Setting up the search column

sql
-- Option 1: Generated column (automatically updated)
CREATE TABLE products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    description TEXT,
    brand TEXT,
    tags TEXT[] DEFAULT '{}',
    
    -- Weighted tsvector: name has highest weight (A), brand (B), description (C)
    search_vector tsvector GENERATED ALWAYS AS (
        setweight(to_tsvector('english', COALESCE(name, '')), 'A') ||
        setweight(to_tsvector('english', COALESCE(brand, '')), 'B') ||
        setweight(to_tsvector('english', COALESCE(description, '')), 'C') ||
        setweight(to_tsvector('english', COALESCE(array_to_string(tags, ' '), '')), 'B')
    ) STORED
);

CREATE INDEX idx_products_search ON products USING gin(search_vector);

Basic search queries

sql
-- Simple search: match any word
SELECT name, ts_rank(search_vector, query) AS rank
FROM products, plainto_tsquery('english', 'wireless headphones') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;

-- Phrase search: words must appear in order
SELECT name FROM products
WHERE search_vector @@ phraseto_tsquery('english', 'noise cancelling headphones');

-- Advanced query syntax: AND, OR, NOT, prefix
SELECT name FROM products
WHERE search_vector @@ to_tsquery('english', 'wireless & headphones & !wired');

-- Prefix matching (good for autocomplete)
SELECT name FROM products
WHERE search_vector @@ to_tsquery('english', 'headph:*')
LIMIT 10;

Relevance ranking with ts_rank

sql
SELECT 
    id,
    name,
    brand,
    price_cents / 100.0 AS price,
    -- ts_rank: position-based ranking
    ts_rank(search_vector, query) AS text_rank,
    -- ts_rank_cd: cover density (prefers matches close together)
    ts_rank_cd(search_vector, query) AS cover_density_rank,
    -- Combined with business score (e.g., sales velocity)
    ts_rank(search_vector, query) * 0.7 + (sales_last_30d / 1000.0) * 0.3 AS final_rank
FROM products,
     plainto_tsquery('english', $1) AS query
WHERE search_vector @@ query
  AND in_stock = true
ORDER BY final_rank DESC
LIMIT 20;

Search highlighting

sql
SELECT
    id,
    name,
    -- Highlight matching terms in the description
    ts_headline(
        'english',
        description,
        plainto_tsquery('english', $1),
        'StartSel = , StopSel = , MaxWords = 40, MinWords = 20'
    ) AS description_highlighted
FROM products
WHERE search_vector @@ plainto_tsquery('english', $1)
LIMIT 10;

Autocomplete with trigrams

sql
-- For autocomplete, combine prefix FTS with trigram similarity
-- pg_trgm handles typos; prefix FTS handles completions

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING gin(name gin_trgm_ops);

-- Autocomplete: prefix match OR fuzzy match
SELECT DISTINCT name
FROM products
WHERE (
    -- Prefix match: fast and exact
    to_tsvector('english', name) @@ to_tsquery('english', $1 || ':*')
    OR
    -- Trigram similarity: handles typos (similarity > 0.3)
    similarity(name, $1) > 0.3
)
  AND in_stock = true
ORDER BY 
    CASE WHEN name ILIKE $1 || '%' THEN 0 ELSE 1 END,  -- Prefix first
    similarity(name, $1) DESC
LIMIT 10;

When Elasticsearch is actually better

Postgres FTS is excellent for:

  • Single-language content
  • Moderate data volumes (under ~100M documents)
  • Queries that join to relational data
  • Apps already on Postgres

Elasticsearch is better for:

  • Multi-language content with language-specific analyzers
  • Complex faceting across many dimensions
  • Terabyte-scale indexes
  • Real-time indexing at very high write rates
  • Geospatial search

For most SaaS applications, Postgres FTS is sufficient. Adding Elasticsearch is a meaningful operational burden (another service, another deployment, index sync logic). The bar for adding it should be high — exhaust Postgres first.

Share this
← All Posts7 min read