Full-Text Search
Most people reach for a dedicated search product the moment they need anything beyond a LIKE '%keyword%' query. What a lot of teams don't realize is that PostgreSQL ships with a genuinely capable full-text search engine built in — no extension, no separate service, no data synchronization pipeline to keep in sync. For a large share of real-world "search this table of text" problems, it is more than enough.
tsvector and tsquery
PostgreSQL's text search revolves around two special data types:
tsvector— a normalized, searchable representation of a document. It breaks text into lexemes (word stems), removes noise, and stores positional information for ranking.tsquery— a normalized representation of a search query, supporting boolean operators (&for AND,|for OR,!for NOT) between search terms.
You compare the two with the @@ match operator, which returns true when the query matches the document.
basic-match.sql
SELECT to_tsvector('english', 'A fast brown fox jumps over lazy dogs')
@@ to_tsquery('english', 'fox & jump');
-- returns true — 'jumps' is stemmed down to the same lexeme as 'jump'Notice that "jump" matched "jumps" — the english configuration applies stemming, so searches match different grammatical forms of the same word automatically. It also strips out common stop words like "a" and "over".
A Worked Example: Searching Product Descriptions
Suppose you have a products table and want to let users search across the name and description:
product-search.sql
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT
);
-- Search directly by building the tsvector on the fly
SELECT id, name
FROM products
WHERE to_tsvector('english', name || ' ' || description)
@@ to_tsquery('english', 'wireless & headphones');Building the tsvector on every row for every query works, but it's wasteful to redo that normalization work each time. A common pattern is to store the tsvector in a dedicated generated column that's kept up to date automatically:
stored-tsvector-column.sql
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, ''))
) STORED;
SELECT id, name
FROM products
WHERE search_vector @@ to_tsquery('english', 'wireless & headphones');Ranking Results with ts_rank
A boolean match tells you whether a row matches, but not how relevant it is. ts_rank() scores each match based on how often and how prominently the search terms appear, so you can order results from most to least relevant:
ranked-search.sql
SELECT id, name,
ts_rank(search_vector, to_tsquery('english', 'wireless & headphones')) AS rank
FROM products
WHERE search_vector @@ to_tsquery('english', 'wireless & headphones')
ORDER BY rank DESC
LIMIT 20;When Is Built-in FTS Enough?
Full-text search in PostgreSQL handles a lot of real-world search requirements well: multi-word queries, stemming, ranking, and filtering combined with normal SQL WHERE clauses (so you can search text and filter by price, category, or date in one query). But dedicated search engines like Elasticsearch or OpenSearch still win in certain areas:
Capability | PostgreSQL FTS | Dedicated Search Engine |
|---|---|---|
Typo / fuzzy tolerance | Limited (needs extensions like | Built-in, mature fuzzy matching out of the box |
Faceted search / aggregated filters | Possible with SQL | First-class support, designed for this exact use case |
Scale (hundreds of millions+ of documents) | Works, but requires careful indexing and tuning | Built to shard and scale horizontally by design |
Advanced relevance tuning | Basic ranking via | Rich scoring models, machine-learned relevance |
The practical takeaway: if you're not sure whether you need Elasticsearch, you probably don't yet. Start with PostgreSQL full-text search — it's already running, it's transactionally consistent with the rest of your data, and it covers the majority of "search a table of text" needs without adding an entire new system to operate.