How to run full-text search across multiple columns in PostgreSQL
reading · 00%

How to run full-text search across multiple columns in PostgreSQL

One search box across several columns, with a generated tsvector column and a GIN index.

Published
Reading5 min
CategoryData foundations
Words1,041

A user types a product name into the search box and gets nothing back. The record exists. It sits on page 23 of 50, and the filter only ran against the page loaded in the browser.

Client-side search works until a table needs pagination. After that, the app filters a slice of the data and reports the slice as the answer. The fix is to move search into the database. PostgreSQL has a built-in type for it, tsvector, which searches several columns, and columns from joined tables, in one WHERE clause. No chained LIKE clauses, and no separate search service for keyword search.

What tsvector stores

A tsvector value is a sorted list of lexemes: normalized word forms built for searching. When PostgreSQL converts text into one, it:

  • Drops stopwords such as "the", "a", and "in".

  • Applies stemming, so "running" and "run" become the same token.

  • Stores the result in a form the query planner and an index can use.

It does not keep the original string. It keeps the searchable representation of it. Several columns, including columns from a joined table, can be combined into one vector before the search runs.

How the functions fit together

  • to_tsvector('english', text) turns text into lexemes, using English stopwords and stemming.

  • plainto_tsquery('english', phrase) turns what the user typed into a query in which every word must match.

  • @@ returns true when the vector contains every lexeme in the query.

For a search box in an internal tool, plainto_tsquery() is the right default, because users do not need to know any query syntax. websearch_to_tsquery() accepts quoted phrases and - for exclusion, in the style of a web search engine. to_tsquery() gives full control over operators and prefixes, and expects well-formed input.

The first version: build the vector in the query

Take a products table joined to categories, and a text input the user types into. This query searches the product name, the description, and the category name together.

SQLsearch-inline.sql
SELECT
  p.id,
  p.name AS product_name,
  p.description,
  c.name AS category_name
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE
  to_tsvector(
    'english',
    coalesce(p.name, '') || ' ' ||
    coalesce(p.description, '') || ' ' ||
    coalesce(c.name, '')
  ) @@ plainto_tsquery('english', {{ SearchTextInput.value }});

What each part does:

  • coalesce(p.name, '') replaces a NULL with an empty string. Without it, one NULL column makes the whole concatenation NULL, and the row never matches. Keep it even on NOT NULL columns, in case the schema changes.

  • || ' ' || joins the columns with a space, so words from two columns do not merge into one.

  • 'english' sets the stopwords and stemming. Change it if the content is in another language.

  • {{ SearchTextInput.value }} is Retool syntax for the current value of a text input. Retool passes it to Postgres as a query parameter, so user input is never spliced into the SQL. In another framework, use its own parameter syntax.

This version works immediately on any table. It also builds a vector for every candidate row on every search, which gets expensive as the table grows.

The production version: a generated column and a GIN index

Store the vector once and index it. Since PostgreSQL 12, a generated column keeps it current on every insert and update, with no trigger to maintain.

SQLsearch-vector.sql
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
  setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
  setweight(to_tsvector('english', coalesce(description, '')), 'B')
) STORED;

CREATE INDEX products_search_idx
ON products
USING GIN (search_vector);

setweight marks name matches as more important than description matches, which the ranking below uses. A plain UPDATE products SET search_vector = ... also works once, but rows inserted afterwards have no vector until someone runs it again. The generated column removes that gap.

The search then reads the stored column, and can rank the results:

SQLsearch-ranked.sql
SELECT
  p.id,
  p.name AS product_name,
  p.description,
  ts_rank(p.search_vector, q) AS rank
FROM products p,
  plainto_tsquery('english', {{ SearchTextInput.value }}) AS q
WHERE p.search_vector @@ q
ORDER BY rank DESC
LIMIT 50;

The GIN index finds the matching rows. ts_rank orders them, so a product with the term in its name comes before one that mentions it in passing.

Searching across joined tables

A generated column can only read its own row. If the category name changes, products.search_vector does not know. Search across tables needs a search document that is maintained on purpose.

One pattern is a materialized view that flattens the searchable data:

SQLsearch-documents.sql
CREATE MATERIALIZED VIEW product_search_documents AS
SELECT
  p.id AS product_id,
  p.name AS product_name,
  p.description,
  c.name AS category_name,
  to_tsvector(
    'english',
    coalesce(p.name, '') || ' ' ||
    coalesce(p.description, '') || ' ' ||
    coalesce(c.name, '')
  ) AS search_vector
FROM products p
JOIN categories c ON p.category_id = c.id;

CREATE INDEX product_search_documents_idx
ON product_search_documents
USING GIN (search_vector);

The app searches the view:

SQLsearch-view.sql
SELECT
  product_id,
  product_name,
  description,
  category_name
FROM product_search_documents
WHERE search_vector @@ plainto_tsquery('english', {{ SearchTextInput.value }});

The view has to be refreshed when products or categories change:

SQLrefresh.sql
REFRESH MATERIALIZED VIEW product_search_documents;

Run the refresh on a schedule, or after the writes that matter. If the data changes constantly and results must be current to the second, keep a search table up to date with triggers instead.

What full-text search does not do

It matches lexemes, not substrings. Stemming means "manage" and "management" may match each other, but "prod" will not match "products". For prefix matching, to_tsquery supports the :* operator, as in to_tsquery('english', 'prod:*'). For true substring or typo-tolerant matching, the pg_trgm extension with a trigram index fits better.

The approach works in any tool that sends raw SQL to a Postgres connection: Retool, Appsmith, Tooljet, or a custom back end. Only the parameter syntax changes.

Our view

We recommend the generated column and GIN index for any search over a single table, because it stays current without triggers and costs one index. Add the materialized view only when search must cover joined tables, because the refresh schedule is one more thing someone has to own. The inline query is fine for a prototype, and for tables small enough never to paginate.

Search is one of the first places an internal tool feels slow as it grows. Slow tools are an architecture problem covers the other common causes, and non-overlapping ranges with INT4RANGE shows another rule worth handing to the database.

Filed underData foundations
Worth passing along? Share this note.
LinkedInX
Newsletter

Get the next one in your inbox

We write when something is worth reading. Subscribing means you are happy to hear from us when that happens. Leave any time. Privacy policy.

Ahmed Ezzat

Keep readingData foundations
Related notes

Still here? Good sign

More notes from real builds, written up for the next person who needs one.

All articles
ContactLet's chat
Let's chat

Tell us what
you're building

Describe the process that is slowing your team down: the inbox, the spreadsheet, the portal nobody owns. We'll come back with a concrete approach, a rough timeline, and, where it helps, a working prototype within weeks.

What happens next
  1. We read every messageAn architect reads what you sent before anyone replies.
  2. You hear back in ≤ 2 daysA short reply with first questions and a suggested next step.
  3. We scope and prototypeA scoping call, then a working surface, not a slide deck.
Where are you in the process?