Technology
A Practical Guide to Postgres Full-Text Search
Most teams reach for a separate search service the moment someone types the word "search" into a planning doc. That instinct is often premature. If your data already lives in Postgres, you can add fast, relevant text search without running another server, syncing another index, or paying for another vendor. This guide walks through the pieces that matter and the order to add them in.
The one idea behind it all: tsvector
Full-text search in Postgres rests on a single data type, tsvector. A
tsvector is a sorted list of lexemes — normalized words with their positions.
When you run to_tsvector('english', 'The cats were running'), Postgres lowercases
the text, drops stop-words like "the" and "were", and reduces each remaining word
to its stem: 'cat':2 'run':4. Searching then compares stems to stems, so a query
for "cat" matches "cats" and "running" matches "run".
The mirror image of tsvector is tsquery, the parsed representation of what a
user is looking for. You rarely write tsquery by hand. Instead you use
websearch_to_tsquery, which accepts the kind of input people actually type —
quoted phrases, or, and a leading - to exclude a term — and turns it into a
safe query. That function alone removes a surprising amount of custom parsing code.
Store the vector, don't compute it every time
The naive approach recomputes to_tsvector on every request. That works for a
demo and falls over in production, because the database has to scan and tokenize
every row for every search. The fix is to store the vector once, in a column, and
let Postgres keep it up to date.
A generated column is the cleanest option:
ALTER TABLE post
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(excerpt, '')), 'B') ||
setweight(to_tsvector('english', coalesce(body, '')), 'C')
) STORED;
Two things are happening here. First, the column is derived automatically, so it
can never drift from the source columns. Second, setweight tags each part of the
document with an importance class from A (highest) to D. A match in the title
should rank higher than a match buried in the body, and these weights are how you
express that later during ranking.
Make it fast with a GIN index
A stored vector is not enough on its own; without an index, Postgres still reads every row. The right index for full-text search is GIN (Generalized Inverted Index), which maps each lexeme to the rows that contain it — exactly the lookup a search needs.
CREATE INDEX post_search_idx ON post USING GIN (search_vector);
With that index in place, a query filters to just the candidate rows instead of scanning the table:
SELECT id, title
FROM post
WHERE search_vector @@ websearch_to_tsquery('english', 'postgres search')
LIMIT 20;
The @@ operator is the match test: it returns true when the vector satisfies the
query. On a table with millions of rows, this returns in a few milliseconds.
Ranking: the part users actually feel
Matching tells you whether a row is relevant; ranking tells you how relevant,
and it is what makes results feel smart. ts_rank_cd scores each match using term
frequency and the weights you assigned earlier, so a keyword in the title
outranks the same keyword in the body.
SELECT id, title,
ts_rank_cd(search_vector, query) AS rank
FROM post, websearch_to_tsquery('english', 'postgres search') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
Order by that rank and your best results rise to the top. For content sites, a
small tweak pays off: blend text rank with a freshness or popularity signal so a
strong-but-ancient article does not always beat a timely one. A weighted sum of
ts_rank_cd and a recency score is usually enough.
Highlighting the match
Users trust results more when they can see why something matched.
ts_headline returns a snippet with the matching terms wrapped in markers you
choose, ready to render in a results list:
SELECT ts_headline('english', body,
websearch_to_tsquery('english', 'gin index'),
'StartSel=<mark>, StopSel=</mark>')
FROM post
WHERE id = $1;
Because ts_headline reprocesses the original text, run it only on the handful of
rows you actually display, never across the whole result set.
A realistic rollout plan
You do not need every feature on day one. A sensible order is: add the generated
column, add the GIN index, switch your query to @@ with
websearch_to_tsquery, then layer in ranking and highlighting once the basics are
serving traffic. Each step is independently shippable and reversible.
If you want a managed Postgres that supports all of this out of the box, a provider like Neon's serverless Postgres gives you branching databases and generous full-text support without any extra search infrastructure. Whatever you host on, the techniques above are standard Postgres — they move with you.
When to graduate to a dedicated engine
Postgres search has real limits. It does not do fuzzy typo tolerance well, its synonym handling is manual, and very large faceted-search workloads are happier on a purpose-built engine. Those are good reasons to migrate later, with real usage data in hand. They are poor reasons to add a second system before you have a single paying user. Start with the database you already trust, measure, and graduate only when the numbers say so.
Sources: PostgreSQL — Full Text Search · PostgreSQL — Full Text Search index types (GIN and GiST)
FAQ
Frequently asked questions
Is Postgres full-text search good enough for production?
For most content and application databases, yes. With a GIN index it handles millions of rows and returns ranked results in single-digit milliseconds.
When should I reach for a dedicated search engine instead?
When you need typo tolerance, faceting at scale, synonyms, or cross-cluster search. Until then, Postgres avoids an entire moving part.
Written by
The BlogsPublication Team
EditorBlogsPublication is researched, written, and edited by a small team. We work from primary sources and link to them, a human editor is responsible for everything we publish, and we note substantive corrections on the article itself. Where AI tools help with research or drafting, a person still verifies the result before it runs.
BlogsPublication reporting is guided by our editorial standards.
The newsletter
Good writing, once a week.
Our best essays and reporting, delivered to your inbox. No noise, unsubscribe anytime.
Comments
Loading comments…
Keep reading
Related articles
CrUX vs Lighthouse: Why Your Two Scores Disagree
A green Lighthouse score and a failing Core Web Vitals report are not a contradiction. One is a simulation on your machine; the other is 28 days of real Chrome users. Only one of them counts for ranking.
How to Track AI Search Traffic in GA4 (And What It Misses)
GA4 now has a native AI Assistant channel, and it undercounts. Here is how to set up the reporting properly, why a large share of assistant clicks land in Direct no matter what you configure, and how to sanity-check the real number.
Does Schema Markup Help You Get Cited in AI Search?
The stat doing the rounds — that most AI-cited pages carry structured data — is real and proves much less than it is used to prove. What schema demonstrably does, what the evidence cannot support, and why it is still worth shipping.
Impressions but No Clicks? Read the Position Data First
Thousands of impressions and zero clicks usually is not a title problem. It is a position problem — and Search Console's average position column is the first place to look, once you know how it is actually calculated.