The standard answer to "we need search" is Elasticsearch. Spin up a cluster, sync your data, write query DSL, manage index mappings. For large-scale dedicated search, that trade-off sometimes makes sense. For most application search — finding documents, filtering content, search-as-you-type boxes — you already have everything you need inside PostgreSQL.
This module covers PostgreSQL's full-text search system: how it represents and indexes text, how queries work, how to rank results by relevance, and how to set it up so searches stay fast at scale.
SELECT * FROM articles WHERE body LIKE '%postgresql performance%';
This works. It also requires a full sequential scan of the table for every search — no index can help with a leading wildcard. At a thousand rows it is fast. At a million rows it is a problem.
ILIKE (case-insensitive) is slower still. And neither handles linguistic variations: a search for "running" will not find articles containing "runs" or "ran."
Full-text search solves both problems: it is indexable and it understands language.
PostgreSQL full-text search is built around two data types:
tsvector — a preprocessed representation of a document. It is a sorted list of lexemes (normalized word stems) with their positions in the original text. When you convert text to tsvector, PostgreSQL normalizes words (removing stop words like "the", stemming "running" → "run"), and records where each term appeared.
tsquery — a search query. It is a boolean expression of lexemes that gets matched against a tsvector.
-- Convert 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 -- Note: "The", "over" removed (stop words), "jumps"→"jump", "lazy"→"lazi"
-- Convert a search query to tsquery SELECT to_tsquery('english', 'jump & fox'); -- Result: 'jump' & 'fox'
-- Match a tsvector against a tsquery with the @@ operator SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog') @@ to_tsquery('english', 'jump & fox'); -- Result: true
The @@ operator is the match operator. It returns true if the tsvector satisfies the tsquery.
Understands Google-style search syntax. The best choice for search boxes.
SELECT websearch_to_tsquery('english', 'postgresql "full text" -mysql'); -- 'postgresql' & 'full' <-> 'text' & !'mysql' -- (the English Snowball stemmer doesn't truncate "postgresql" to "postgres" — -- there's no matching suffix rule, so it stays as one lexeme)
-- Quoted phrases, minus sign for exclusion — familiar to users
websearch_to_tsquery is the right function for production search boxes. It handles messy user input gracefully and supports the syntax users expect.
The solution is to pre-compute the tsvector and index it. The index type for tsvector is GIN (Generalized Inverted Index). A GIN index maps each lexeme to the set of rows that contain it — exactly like the index at the back of a book.
CREATE INDEX idx_articles_fts ON articles USING GIN (to_tsvector('english', title || ' ' || body));
The query must use the exact same expression for the index to be used:
-- This uses the index: SELECT id, title FROM articles WHERE to_tsvector('english', title || ' ' || body) @@ websearch_to_tsquery('english', 'postgresql performance');
Expression indexes work but have a maintenance overhead: the expression is recomputed on every INSERT and UPDATE.
CREATE INDEX idx_articles_search_vector ON articles USING GIN (search_vector);
Now queries are clean and simple:
SELECT id, title FROM articles WHERE search_vector @@ websearch_to_tsquery('english', 'postgresql performance');
The search_vector column is maintained by PostgreSQL automatically on INSERT and UPDATE. The GIN index is built on the stored column. Query performance is fast.
Different columns have different importance — a match in the title should rank higher than a match in the body. setweight assigns a weight label (A, B, C, D — in descending importance) to a tsvector.
-- Title gets weight A (highest), body gets weight B ALTER TABLE articles ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(body, '')), 'B') ) STORED;
Weights are used by the ranking functions to score results. A query matching in the title scores higher than the same query matching only in the body.
SELECT id, title, ts_rank(search_vector, query) AS rank FROM articles, websearch_to_tsquery('english', 'postgresql performance') AS query WHERE search_vector @@ query ORDER BY rank DESC;
ts_rank computes relevance based on how frequently the query terms appear in the document. Higher frequency = higher rank.
SELECT id, title, ts_rank_cd(search_vector, query) AS rank FROM articles, websearch_to_tsquery('english', 'postgresql performance') AS query WHERE search_vector @@ query ORDER BY rank DESC;
ts_rank_cd ("cover density") also considers how close together the matching terms appear in the document. Generally produces more intuitive rankings when terms appear in proximity to each other.
FragmentDelimiter — separator between fragments (default " ... ")
Important: do not run ts_headline on the tsvector column — run it on the original text column. ts_headline is slow (it re-processes the document to find context). Compute it only on the result rows, never in a WHERE clause.
For tables with many text fields, aggregate them all into one search_vector:
CREATE TABLE products ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT, tags TEXT, -- comma-separated or space-separated tags category TEXT );
Every to_tsvector and to_tsquery call takes a language configuration name as the first argument. This controls which stop words are removed and which stemming rules apply.
-- English stemming SELECT to_tsvector('english', 'running runner runs'); -- 'run':1,2,3 (all three map to the same lexeme)
Full-text search works naturally alongside SQL filters. The GIN index is used for the text filter; additional B-tree indexes can be used for other conditions.
-- Full-text search scoped to a specific category and date range SELECT id, title, published_at FROM articles WHERE search_vector @@ websearch_to_tsquery('english', 'index performance') AND category = 'database' AND published_at > now() - interval '1 year' ORDER BY ts_rank_cd(search_vector, websearch_to_tsquery('english', 'index performance')) DESC LIMIT 20;
PostgreSQL's planner will use the GIN index for the text condition and may use a B-tree index on category or published_at depending on estimated selectivity.
GIN index build time: GIN indexes are expensive to build on large tables. For tables with millions of rows, build the index during a maintenance window and be aware that inserts and updates are slightly slower while the index is maintained.
ts_headline cost: It re-parses the document to find the best snippet context. Only call it on the final result set (after LIMIT), never in a subquery used for filtering.
ts_rank is computed per row: Sorting a million rows by rank is expensive. Always filter with WHERE search_vector @@ query first, then rank the surviving rows. The GIN index reduces the candidate set to a small fraction of the table before ranking.
websearch_to_tsquery is cheap: Parse the query once (ideally in application code) and pass it as a parameter. Don't reconstruct it in every subquery.
-- Efficient: query computed once, used twice WITH q AS ( SELECT websearch_to_tsquery('english', $1) AS query ) SELECT a.id, a.title, ts_rank_cd(a.search_vector, q.query) AS rank, ts_headline('english', a.body, q.query) AS snippet FROM articles a, q WHERE a.search_vector @@ q.query ORDER BY rank DESC LIMIT 10;
PostgreSQL full-text search handles the majority of application search requirements. The cases where Elasticsearch or a dedicated search engine makes more sense:
Autocomplete / prefix search: PostgreSQL FTS handles whole words. For character-by-character autocomplete, consider pg_trgm (trigram extension) or a dedicated autocomplete system.
Real-time, sub-millisecond search across billions of rows: At extreme scale, dedicated search clusters with sharding and caching start to win.
Complex relevance tuning: Elasticsearch's BM25 and field boosting configuration is more flexible than PostgreSQL's ranking functions for search-intensive products.
For everything else — blog search, document search, product search, internal tooling — PostgreSQL is the right tool.
-- 1. Create the table CREATE TABLE blog_posts ( id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, author TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() );
-- 3. Create the GIN index CREATE INDEX idx_blog_posts_fts ON blog_posts USING GIN (search_vector);
-- 4. Insert some data INSERT INTO blog_posts (title, body, author) VALUES ('Getting Started with PostgreSQL', 'PostgreSQL is a powerful open source relational database...', 'Alice Chen'), ('Index Internals and Performance', 'Understanding B-tree structure helps write faster queries...', 'Bob Kim'), ('Row-Level Security in Production', 'RLS policies enforce data isolation at the database layer...', 'Alice Chen'), ('Full-Text Search Without Elasticsearch', 'PostgreSQL tsvector and GIN indexes provide production-grade search...', 'Carol Wang');
-- 5. Search with ranking SELECT id, title, author, ts_rank_cd(search_vector, q) AS rank FROM blog_posts, websearch_to_tsquery('english', 'postgresql index performance') AS q WHERE search_vector @@ q ORDER BY rank DESC;
-- 6. Add highlighted snippets SELECT title, ts_headline('english', body, q, 'MaxWords=20, MinWords=10, StartSel=**, StopSel=**' ) AS snippet FROM blog_posts, websearch_to_tsquery('english', 'postgresql index') AS q WHERE search_vector @@ q;
-- 7. Verify index is used EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM blog_posts WHERE search_vector @@ websearch_to_tsquery('english', 'postgresql'); -- Look for: Bitmap Index Scan on idx_blog_posts_fts
PostgreSQL's full-text search system provides production-grade text search without external dependencies:
tsvector stores a normalized, stemmed representation of document text. tsquery expresses search queries with boolean operators. The @@ operator matches them. A GIN index on the tsvector column makes search fast at scale. Generated stored columns keep the vector automatically updated with no application code. ts_rank_cd provides relevance ranking. ts_headline generates highlighted snippets.
For the majority of application search use cases — content search, product search, document discovery — this is all you need. Elasticsearch solves a different problem at a different scale.
Next up: P-8 — Performance Tuning for Application Engineers — EXPLAIN ANALYZE in depth, connection pooling with PgBouncer, key configuration parameters, and eliminating the N+1 query problem.
Knowledge Check
A developer implements full-text search by creating a GIN index on a tsvector generated column. The application displays search results using ts_headline to show highlighted snippets. In testing, searching returns results instantly, but when querying the production database containing 5 million rows, the search page takes several seconds to load. What is the most likely architectural cause of this performance degradation?
You want to implement a robust user-facing search box for your application. Users often type phrases in quotes ("error handling"), exclude terms with a minus sign (-java), and occasionally make syntax errors like unbalanced quotes or trailing ampersands. Which PostgreSQL function is the safest and most effective choice for converting their raw input into a search query?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Question 3: A team implements a full-text search feature using an expression index: CREATE INDEX idx_search ON documents USING GIN (to_tsvector('english', title || ' ' || body));. The application executes the following search query, but EXPLAIN ANALYZE shows a slow Sequential Scan instead of using the GIN index. Why?
SELECT title FROM documents WHERE to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, '')) @@ websearch_to_tsquery('english', 'performance');
A) The @@ operator does not support GIN indexes; it requires a GiST index.
B) The websearch_to_tsquery function generates dynamic queries that cannot be evaluated against pre-computed indexes.
C) The expression in the WHERE clause (to_tsvector(...)) does not exactly match the expression used to create the index.
D) EXPLAIN ANALYZE always executes a sequential scan on local development databases because the tables are too small for the query planner to justify reading the index.
Reveal Answer
Correct Answer: C
Expression indexes in PostgreSQL are highly literal. For the query planner to use an expression index, the expression in the WHERE clause must match the indexed expression exactly character-for-character (conceptually). In this scenario, the index was created using title || ' ' || body, but the query uses coalesce(title, '') || ' ' || coalesce(body, ''). Because these expressions are logically different to the planner, it cannot guarantee the index contains the right data, so it falls back to a full sequential scan, recomputing the coalesce expression for every row. (This is a major reason why storing the tsvector in a generated column is heavily preferred over expression indexes—it eliminates this exact failure mode).
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.