Blog/Tutorials & Step-by-Step/Implement Full-Text Search with PostgreSQL
POST
October 01, 2025
LAST UPDATEDOctober 01, 2025

Implement Full-Text Search with PostgreSQL

Master PostgreSQL full-text search with tsvector, tsquery, GIN indexes, weighted ranking, and fuzzy matching using pg_trgm for fast results.

Tags

PostgreSQLSearchDatabaseBackend
Implement Full-Text Search with PostgreSQL
4 min read

Implement Full-Text Search with PostgreSQL

In this tutorial, you will build a complete full-text search system using nothing but PostgreSQL. You will learn how to create tsvector columns, write tsquery expressions, add GIN indexes for speed, rank results with ts_rank, weight different fields, add fuzzy matching with pg_trgm, and expose everything through a Node.js API. No Elasticsearch required.

TL;DR

PostgreSQL full-text search uses tsvector to store normalized document tokens, tsquery to express search terms, GIN indexes for fast lookups, and ts_rank to order results by relevance. Add pg_trgm for typo tolerance and you have a search system that handles most production workloads without any external dependencies.

Prerequisites

  • PostgreSQL 14+ installed and running
  • Node.js 18+ with a project initialized
  • Basic knowledge of SQL and Express.js
  • A table with text data to search (we will create one)
bash
npm init -y
npm install express pg
npm install -D typescript @types/express @types/pg tsx

Step 1: Create the Database Schema

Start with a simple articles table. We will add a generated tsvector column that automatically stays in sync with the source columns.

sql
-- Create the articles table
CREATE TABLE articles (
  id SERIAL PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  body TEXT NOT NULL,
  author VARCHAR(100) NOT NULL,
  category VARCHAR(50),
  published_at TIMESTAMP DEFAULT NOW(),
  created_at TIMESTAMP DEFAULT NOW()
);
 
-- Add a generated tsvector column with weighted fields
ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
  setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
  setweight(to_tsvector('english', coalesce(body, '')), 'D')
) STORED;
 
-- Create a GIN index on the search vector
CREATE INDEX idx_articles_search ON articles USING GIN(search_vector);

The GENERATED ALWAYS AS ... STORED clause means PostgreSQL automatically updates the search_vector column whenever title or body changes. Weight A is the highest priority and D is the lowest. This means title matches will rank higher than body matches.

Step 2: Understand tsvector and tsquery

Before building the search API, you need to understand the two core data types.

sql
-- tsvector normalizes text into sorted lexemes with positions
SELECT to_tsvector('english', 'The quick brown foxes jumped over lazy dogs');
-- Result: 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
 
-- tsquery creates a search expression
SELECT to_tsquery('english', 'quick & fox');
-- Result: 'quick' & 'fox'
 
-- plainto_tsquery is simpler - treats spaces as AND
SELECT plainto_tsquery('english', 'quick fox');
-- Result: 'quick' & 'fox'
 
-- websearch_to_tsquery supports Google-like syntax
SELECT websearch_to_tsquery('english', '"quick fox" OR lazy -brown');
-- Result: 'quick' <-> 'fox' | 'lazi' & !'brown'

Notice how the English dictionary stems words: "foxes" becomes "fox", "jumped" becomes "jump", and "lazy" becomes "lazi". This is how full-text search matches different word forms automatically.

Step 3: Seed Sample Data

Insert enough data to make search results meaningful.

sql
INSERT INTO articles (title, body, author, category) VALUES
('Getting Started with PostgreSQL', 'PostgreSQL is a powerful open-source relational database. This guide covers installation, basic configuration, and your first queries. We explore data types, table creation, and essential SQL commands for beginners.', 'Sadam Hussain', 'database'),
('Advanced PostgreSQL Performance Tuning', 'Learn how to optimize PostgreSQL for high-traffic applications. Topics include query planning with EXPLAIN ANALYZE, index strategies, connection pooling with PgBouncer, and vacuum configuration for large tables.', 'Sadam Hussain', 'database'),
('Building REST APIs with Node.js and Express', 'A comprehensive guide to building production-ready REST APIs. Covers routing, middleware, error handling, validation with Zod, and connecting to PostgreSQL using the pg library.', 'Sadam Hussain', 'backend'),
('React Server Components Explained', 'Server Components change how we think about React architecture. They run on the server, have zero bundle size impact, and can directly access databases and file systems without API routes.', 'Sadam Hussain', 'frontend'),
('Docker for Full-Stack Developers', 'Containerize your entire stack with Docker. This tutorial walks through Dockerfiles, multi-stage builds, docker-compose for local development, and deploying containers to production.', 'Sadam Hussain', 'devops');

Step 4: Write Search Queries with Ranking

Now build increasingly sophisticated search queries.

sql
-- Basic search
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, plainto_tsquery('english', 'postgresql performance') AS query
WHERE search_vector @@ query
ORDER BY rank DESC;
 
-- Search with headline snippets (highlighted matches)
SELECT
  title,
  ts_rank(search_vector, query) AS rank,
  ts_headline('english', body, query,
    'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15'
  ) AS snippet
FROM articles, websearch_to_tsquery('english', 'postgresql') AS query
WHERE search_vector @@ query
ORDER BY rank DESC;
 
-- Boosted ranking with normalization
SELECT
  title,
  ts_rank_cd(search_vector, query, 32) AS rank
FROM articles, plainto_tsquery('english', 'react server') AS query
WHERE search_vector @@ query
ORDER BY rank DESC;

The ts_headline function generates highlighted snippets showing where matches occur in the text. The 32 normalization flag in ts_rank_cd divides the rank by the document length, preventing longer documents from always ranking higher.

Step 5: Add Fuzzy Matching with pg_trgm

Full-text search does not handle typos. The pg_trgm extension adds trigram-based similarity matching for typo tolerance.

sql
-- Enable the extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;
 
-- Create trigram indexes
CREATE INDEX idx_articles_title_trgm ON articles USING GIN(title gin_trgm_ops);
 
-- Similarity search (handles typos)
SELECT title, similarity(title, 'postgre perfomance') AS sim
FROM articles
WHERE title % 'postgre perfomance'
ORDER BY sim DESC;
 
-- Combine full-text search with fuzzy fallback
SELECT title, rank, sim FROM (
  -- Primary: full-text search
  SELECT title,
    ts_rank(search_vector, query) AS rank,
    1.0 AS sim,
    1 AS source
  FROM articles, plainto_tsquery('english', 'postgresql') AS query
  WHERE search_vector @@ query
 
  UNION ALL
 
  -- Fallback: trigram similarity (only if full-text returns nothing)
  SELECT title,
    0 AS rank,
    similarity(title, 'postgresql') AS sim,
    2 AS source
  FROM articles
  WHERE title % 'postgresql'
) combined
ORDER BY source, rank DESC, sim DESC
LIMIT 10;

The % operator returns true when the similarity between two strings exceeds the threshold (default 0.3). You can adjust it with SET pg_trgm.similarity_threshold = 0.2; for more lenient matching.

Step 6: Build the Node.js Search API

Now expose the search functionality through an Express API.

typescript
// src/server.ts
import express, { Request, Response } from "express";
import { Pool } from "pg";
 
const app = express();
app.use(express.json());
 
const pool = new Pool({
  host: "localhost",
  port: 5432,
  database: "searchdb",
  user: "postgres",
  password: "password",
});
 
interface SearchQuery {
  q: string;
  category?: string;
  page?: string;
  limit?: string;
}
 
app.get("/api/search", async (req: Request<{}, {}, {}, SearchQuery>, res: Response) => {
  const { q, category, page = "1", limit = "10" } = req.query;
 
  if (!q || q.trim().length === 0) {
    return res.status(400).json({ error: "Search query is required" });
  }
 
  const pageNum = Math.max(1, parseInt(page));
  const limitNum = Math.min(50, Math.max(1, parseInt(limit)));
  const offset = (pageNum - 1) * limitNum;
 
  try {
    const params: (string | number)[] = [q, limitNum, offset];
    let categoryFilter = "";
 
    if (category) {
      categoryFilter = "AND a.category = $4";
      params.push(category);
    }
 
    const result = await pool.query(
      `
      WITH search_results AS (
        SELECT
          a.id,
          a.title,
          a.author,
          a.category,
          a.published_at,
          ts_rank(a.search_vector, websearch_to_tsquery('english', $1)) AS rank,
          ts_headline('english', a.body, websearch_to_tsquery('english', $1),
            'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15'
          ) AS snippet
        FROM articles a
        WHERE a.search_vector @@ websearch_to_tsquery('english', $1)
        ${categoryFilter}
      )
      SELECT
        *,
        COUNT(*) OVER() AS total_count
      FROM search_results
      ORDER BY rank DESC
      LIMIT $2 OFFSET $3
      `,
      params
    );
 
    const totalCount = result.rows[0]?.total_count ?? 0;
 
    res.json({
      query: q,
      results: result.rows.map(({ total_count, ...row }) => row),
      pagination: {
        page: pageNum,
        limit: limitNum,
        total: parseInt(totalCount),
        pages: Math.ceil(parseInt(totalCount) / limitNum),
      },
    });
  } catch (error) {
    console.error("Search error:", error);
    res.status(500).json({ error: "Search failed" });
  }
});
 
app.get("/api/suggest", async (req: Request<{}, {}, {}, { q: string }>, res: Response) => {
  const { q } = req.query;
 
  if (!q || q.length < 2) {
    return res.json({ suggestions: [] });
  }
 
  try {
    const result = await pool.query(
      `
      SELECT DISTINCT title,
        similarity(title, $1) AS sim
      FROM articles
      WHERE title % $1
      ORDER BY sim DESC
      LIMIT 5
      `,
      [q]
    );
 
    res.json({
      suggestions: result.rows.map((row) => ({
        title: row.title,
        relevance: parseFloat(row.sim.toFixed(3)),
      })),
    });
  } catch (error) {
    console.error("Suggest error:", error);
    res.status(500).json({ error: "Suggestion failed" });
  }
});
 
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`Search API running on port ${PORT}`);
});

The /api/search endpoint uses websearch_to_tsquery so users can write natural queries like "react server" OR components. The /api/suggest endpoint uses trigram similarity for typeahead suggestions as the user types.

Step 7: Optimize for Production

A few important optimizations for production deployments.

sql
-- Partial index for only published articles
CREATE INDEX idx_published_search ON articles USING GIN(search_vector)
WHERE published_at IS NOT NULL;
 
-- Composite index for filtered searches
CREATE INDEX idx_category_search ON articles USING GIN(search_vector)
WHERE category IS NOT NULL;
 
-- Monitor index usage
SELECT
  indexrelname AS index_name,
  idx_scan AS times_used,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan DESC;
 
-- Analyze query performance
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, plainto_tsquery('english', 'postgresql') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;

The partial index trick is powerful. If you only search published articles, a partial GIN index will be smaller and faster than indexing every row.

The Complete Setup Checklist

  1. Create a generated tsvector column with weighted fields so it auto-updates
  2. Add a GIN index on the tsvector column for fast lookups
  3. Enable pg_trgm and add trigram indexes for typo tolerance
  4. Use websearch_to_tsquery for user-facing search to support natural syntax
  5. Generate snippets with ts_headline to show match context
  6. Rank results with ts_rank_cd and normalization flags
  7. Add pagination using LIMIT/OFFSET with a window count
  8. Monitor performance with EXPLAIN ANALYZE and index usage stats

Next Steps

  • Add search analytics to track popular queries and zero-result searches
  • Implement synonym dictionaries for domain-specific terminology
  • Build faceted search by combining full-text search with category aggregations
  • Add search-as-you-type with debounced trigram queries on the frontend
  • Consider materialized views for complex search across joined tables

FAQ

Is PostgreSQL full-text search good enough for production?

Yes, for most applications with up to millions of rows, PostgreSQL full-text search with GIN indexes provides sub-millisecond query times and eliminates the operational overhead of a separate search service.

What is the difference between tsvector and tsquery?

A tsvector is a sorted list of normalized lexemes extracted from a document, while a tsquery is a search expression containing lexemes connected by Boolean operators like AND, OR, and NOT.

When should you use pg_trgm instead of full-text search?

Use pg_trgm when you need fuzzy matching for typo tolerance, partial word matching, or similarity-based ordering. Full-text search is better for natural language queries with stemming and ranking.

How do you handle search across multiple columns?

Create a generated tsvector column that concatenates multiple source columns with different weights, such as giving the title weight A and the body weight D, then index that single column.

Does PostgreSQL full-text search support multiple languages?

Yes, PostgreSQL ships with dictionaries for many languages. Specify the language configuration like english, spanish, or german when creating tsvectors and tsqueries.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

How to Add Observability to a Node.js App with OpenTelemetry

Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
Mar 21, 20266 min read
Next.js
Node.js
BFF

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js

A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.

How I Structure CI/CD for Next.js, Docker, and GitHub Actions
Mar 21, 20265 min read
CI/CD
Next.js
Docker

How I Structure CI/CD for Next.js, Docker, and GitHub Actions

A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.