Data Engineering
PostgreSQL Full-Text Search: A Practical Starting Point for SaaS
Build PostgreSQL full-text search with language configuration, an index, permission filters, and relevance tests before adding a separate search service.
In this article
A startup adding search doesn't always need a separate search cluster immediately. PostgreSQL's built-in full-text search can be a useful starting point when the data already lives there and the product needs word-oriented document search rather than an elaborate search platform.
The hard part isn't making one query return a result. It's keeping the indexed representation current, respecting permissions, choosing language behaviour, and deciding whether the results actually answer users' questions. This guide builds a small, reviewable starting point for a SaaS application.
Understand the representation
PostgreSQL converts text into a searchable representation using language-dependent processing. A tsvector represents processed document terms, and a tsquery represents the search condition. The @@ operator checks whether they match.
The official full-text search introduction explains why this differs from simple substring matching. Keep the distinction in mind: full-text search isn't automatically typo correction, semantic search, or multilingual relevance tuned to your customers.
Choose a configuration intentionally. An English stemming configuration can be useful for English prose but unsuitable for some identifiers or mixed-language content. Test representative terms rather than assuming that every string should be processed as an English sentence.
Create an explicit search vector
For an illustrative PostgreSQL table, a generated search column can keep the representation aligned with stored text:
CREATE TABLE notes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
title text NOT NULL,
body text NOT NULL,
search_vector tsvector GENERATED ALWAYS AS (
to_tsvector('english', title || ' ' || body)
) STORED
);
CREATE INDEX notes_search_idx ON notes USING GIN (search_vector);This is a teaching example, not a complete production schema. Review generated-column support and function requirements for your server version. Existing large tables need a migration plan; don't run a structural change blindly against production.
Use SQL Formatter to review a sanitised schema or query. It improves readability but doesn't execute SQL or confirm index selection. Validate syntax and behaviour in your own PostgreSQL environment.
Query with parameters and permission filters
A search query should bind the user's search text as data. For user-facing search, a function such as websearch_to_tsquery can offer a more forgiving input form than requiring users to write query syntax themselves. Check PostgreSQL's text-search controls for exact behaviour.
An illustrative query uses driver-supplied parameters:
SELECT id, title
FROM notes
WHERE tenant_id = $1
AND search_vector @@ websearch_to_tsquery('english', $2)
LIMIT 20;The tenant restriction belongs in the query, not in an after-the-fact interface filter. Adapt placeholders to your driver. The tenant identifier must come from trusted authorisation context, not simply from an arbitrary field the caller can change.
Rank results against a useful test set
Matching and ranking are different tasks. Add a ranking expression if the product needs ordered relevance, then inspect results for common searches, rare terms, and ambiguous queries. A score only has meaning within the ranking method and data involved.
Create a small set of searches with expected useful documents. Include titles, body text, synonyms users actually use, and queries that should return nothing. Don't tune only for a demo phrase that perfectly matches one document.
If titles deserve more influence, investigate weighted vectors and test the effect. More title weight may improve navigation searches while harming other queries. Preserve before-and-after results so the team can see trade-offs rather than relying on a subjective impression.
Verify the plan and the update path
Use EXPLAIN to inspect the planned query, and run careful measurements in staging with representative volume. A tiny table may legitimately use a sequential scan. The presence of a GIN index doesn't guarantee that every query will use it or become faster.
See slow-query EXPLAIN review for interpreting plans without treating an index scan as an automatic success. Check filters, estimated rows, and actual workload alongside the search condition.
Test inserts, updates, and deletions. Search should reflect the intended data lifecycle. If you use application-maintained vectors instead of generated columns, verify that every write path updates them, including imports and administrative scripts.
Know when the starting point is no longer enough
Built-in full-text search may be sufficient for a focused application, but requirements can grow. Typo tolerance, complex language support, advanced faceting, very large independent search workloads, or specialised relevance models may justify another system.
Record the unmet requirement before adding infrastructure. Sometimes the problem is a missing synonym or poor document structure rather than the database engine. Sometimes a separate search service is the right answer. Measure the actual gap.
For broader architecture choices, read SQLite versus PostgreSQL for startups. Database and search decisions should follow the product's workload rather than the appeal of another technology in the stack.
Conclusion
Start with a clear text representation, an appropriate index, parameterized queries, and permission filters. Then test relevance and performance with realistic data. PostgreSQL full-text search becomes useful when it is treated as a product workflow, not merely a query that happens to match a word.