Implementing full-text search with tsvector in SQLAlchemy

Store a weighted TSVECTOR in a generated column, index it with GIN, and filter with Product.search_vector.bool_op("@@")(func.websearch_to_tsquery("english", q)) — then rank with ts_rank_cd and limit before you highlight. This guide belongs to querying PostgreSQL JSONB, arrays and full-text search.

Quick Answer

The slow way computes the document vector on every search, and the fragile way passes raw user input to to_tsquery. A stored, indexed vector and a forgiving parser fix both.

Compute the vector once, not per query Left: the WHERE clause calls to_tsvector on name and description for every row the query touches, so PostgreSQL parses every product description on every search and cannot use an index. Right: a stored generated column holds the tsvector, maintained by PostgreSQL on insert and update, and a GIN index on it lets the search read only matching rows. WHERE to_tsvector(...) @@ query parses every description, every search sequential scan of the whole table cost grows with the catalogue no index can help generated tsvector + GIN index PostgreSQL maintains it on write Bitmap Index Scan on the GIN index cost grows with the matches rank only the rows that matched An expression index on to_tsvector('english', name) also works, but only for queries that repeat the expression exactly; a column is harder to get subtly wrong.

Before — vector computed per query, parser that raises on user input:

from sqlalchemy import func, select

from shop.models import Product


def search_products(q: str):
    document = func.to_tsvector("english", Product.name + " " + Product.description)
    return select(Product).where(document.bool_op("@@")(func.to_tsquery("english", q)))

# search_products("usb c cable") ->
# ProgrammingError: syntax error in tsquery: "usb c cable"

After — a generated, weighted vector, a GIN index and websearch syntax:

from sqlalchemy import Computed, Index, func, select
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Product(Base):
    __tablename__ = "products"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    description: Mapped[str] = mapped_column(default="")
    search_vector: Mapped[str] = mapped_column(
        TSVECTOR,
        Computed(
            "setweight(to_tsvector('english', coalesce(name, '')), 'A') || "
            "setweight(to_tsvector('english', coalesce(description, '')), 'B')",
            persisted=True,
        ),
    )

    __table_args__ = (
        Index("ix_products_search_vector", "search_vector", postgresql_using="gin"),
    )


def search_products(q: str, limit: int = 20):
    query = func.websearch_to_tsquery("english", q)
    rank = func.ts_rank_cd(Product.search_vector, query).label("rank")
    return (
        select(Product, rank)
        .where(Product.search_vector.bool_op("@@")(query))
        .order_by(rank.desc(), Product.id)
        .limit(limit)
    )

websearch_to_tsquery accepts what people type into search boxes — quoted phrases, or, a leading minus to exclude — and never raises on malformed input, so the endpoint cannot be broken by a stray colon.

Execution Context & Async Workflow Integration

Full-text search in PostgreSQL has two halves, and SQLAlchemy mostly stays out of the way of both. A tsvector is a normalised, sorted list of lexemes with positions and weights: to_tsvector('english', 'Wireless Mice') becomes 'mice':2 'wireless':1, with stemming applied and stop words dropped. A tsquery is a boolean expression over lexemes. The @@ operator asks whether a vector satisfies a query, and a GIN index over vectors answers that for a whole table by looking up each lexeme.

From a search box to ranked rows Five steps. The raw user text, such as wireless -mouse "usb c", is passed as a bound parameter to websearch_to_tsquery with the english configuration, which never raises on syntax. The @@ operator matches it against the stored search_vector using the GIN index. ts_rank_cd scores the matching rows using the weights. ORDER BY rank with LIMIT keeps one page. ts_headline, which re-parses the original text and is expensive, runs only on that page. user input wireless -mouse "usb c" bound parameter, never formatted websearch_to_tsquery('english', :q) 'wireless' & !'mous' & 'usb' <-> 'c' never raises on bad syntax search_vector @@ query GIN index finds candidates weights A/B stored in the vector ts_rank_cd + ORDER BY + LIMIT 20 one page of results headline only what you show ts_headline on 20 rows snippets with <b> marks ts_headline re-parses the source text, so calling it before LIMIT multiplies the cost by the match count.

The generated column is where the work moves from read time to write time. Computed(..., persisted=True) emits GENERATED ALWAYS AS (...) STORED, so PostgreSQL recomputes the vector whenever name or description changes, and the ORM treats the column as server-generated — it never writes to it, and after an insert or update it expires the attribute so the next read fetches the fresh value. Two rules make the expression acceptable to PostgreSQL: it must be immutable, which is why the two-argument to_tsvector('english', ...) form is required (the one-argument form depends on a session setting), and it may only reference columns of the same row.

On the read side, SQLAlchemy's .match() renders @@ plainto_tsquery(...), which ANDs every word and ignores operators. That is adequate for simple cases; for a search box, bool_op("@@") with func.websearch_to_tsquery gives users the syntax they expect. Either way, the search text travels as a bound parameter through asyncpg, so there is no injection surface in the SQL itself.

Under async, nothing about the query needs special handling — it is an ordinary select() awaited through session.execute(). The part that does need thought is result size. ts_rank_cd must evaluate every matching row to sort them, so a query that matches half the catalogue ranks half the catalogue. Keep LIMIT in the statement, add a cheap pre-filter where the domain has one (category, availability), and apply keyset pagination on (rank, id) rather than OFFSET for deep pages.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
syntax error in tsqueryRaw user input passed to to_tsquery.Use websearch_to_tsquery or plainto_tsquery.
generation expression is not immutableOne-argument to_tsvector(text) depends on default_text_search_config.Pass the configuration: to_tsvector('english', ...).
cannot insert a non-DEFAULT value into column "search_vector"The column was mapped without Computed, so the ORM tried to write it.Map it with Computed(..., persisted=True).
Search is slow; the plan shows Seq ScanVector computed in the WHERE clause, or no GIN index.Store the vector and index it with postgresql_using="gin".
No results for "mice" when the text says "mouse"Stemming is language-specific; the query and vector used different configurations.Use the same regconfig on both sides.
Rows with a NULL description are never foundname || ' ' || NULL is NULL, so the whole vector is NULL.coalesce() every input, as in the example.
Search endpoint slow only for common wordsts_headline computed for every match before LIMIT.Rank and limit in a subquery, headline the page.
Choosing the query parser Four tiles. to_tsquery expects operator syntax and raises a syntax error on ordinary user input. plainto_tsquery ANDs all words and never raises; it is what SQLAlchemy match renders. phraseto_tsquery requires the words to be adjacent. websearch_to_tsquery understands quotes, OR and minus like a web search engine and never raises, which makes it the best default for a search box. to_tsquery 'wire:* & mouse' raises on raw user input plainto_tsquery all words ANDed what .match() renders phraseto_tsquery words must be adjacent exact phrases only websearch_to_tsquery quotes, OR, -exclude safe default for a search box Use to_tsquery only for queries you build yourself, such as prefix matches from autocomplete.

The NULL concatenation row catches many first implementations, because it fails silently — the rows are simply never found. coalesce() each source column separately, as the generated expression above does, and use setweight per column rather than concatenating text, which also keeps the weights meaningful.

Changing the generated expression later is a migration concern. Alembic's autogenerate does not detect a change to a Computed expression; it warns and skips. Write the migration by hand — drop and re-add the column, or for large tables follow the staged approach below — and review it like any autogenerated migration script.

Advanced: Ranking, Snippets and Prefix Matching

Ranking is where search starts to feel good or bad, and it has three levers: weights, normalisation and what you do with ties.

Weights decide what ranks first Bar chart with default weights, where A is 1.0, B is 0.4, C is 0.2 and D is 0.1. A product with the term in its name, weight A, scores highest. The same term only in the description, weight B, scores 40 percent of that. Only in a tag, weight C, scores 20 percent. term in name (weight A) relative score 1.0 term in description (weight B) 0.4 term in tags (weight C) 0.2 Default weight array {0.1, 0.2, 0.4, 1.0} for D, C, B, A. Pass your own array as the first argument.

Weights come from setweight in the generated column. ts_rank_cd multiplies each matched lexeme's contribution by its weight, with a default array of {0.1, 0.2, 0.4, 1.0} for D, C, B and A. A product whose name contains the query therefore outranks one that mentions it only in the description. Pass a custom array as the first argument to change the balance, and a normalisation flag as the last to stop long descriptions accumulating score merely by being long:

from sqlalchemy import func, literal_column, select

from shop.db import Session
from shop.models import Product

WEIGHTS = literal_column("'{0.05, 0.1, 0.5, 1.0}'::float4[]")


async def search_page(q: str, limit: int = 20) -> list[dict]:
    query = func.websearch_to_tsquery("english", q)
    # 32 = rank / (rank + 1): scales into 0..1 and damps very long documents.
    rank = func.ts_rank_cd(WEIGHTS, Product.search_vector, query, 32).label("rank")

    page = (
        select(Product.id, Product.name, Product.description, rank)
        .where(Product.search_vector.bool_op("@@")(query))
        .order_by(rank.desc(), Product.id)
        .limit(limit)
        .subquery()
    )
    # Headline only the rows on this page; ts_headline re-parses the source text.
    snippet = func.ts_headline(
        "english", page.c.description, query,
        "MaxWords=24, MinWords=12, StartSel=<mark>, StopSel=</mark>",
    ).label("snippet")

    async with Session() as session:
        rows = await session.execute(
            select(page.c.id, page.c.name, page.c.rank, snippet).order_by(
                page.c.rank.desc(), page.c.id
            )
        )
        return [dict(row._mapping) for row in rows]

ts_headline output contains the markers you asked for, and the surrounding text is your data, so escape the text and allow only the marker tags when rendering it in HTML.

Autocomplete is the one case where to_tsquery belongs, because you build the query yourself. Normalise the user's last word to letters and digits, and append :* for a prefix match:

import re

from sqlalchemy import func

_WORD = re.compile(r"[^\w]+", re.UNICODE)


def prefix_query(text: str):
    words = [w for w in _WORD.split(text.lower()) if w]
    if not words:
        return None
    terms = " & ".join(words[:-1] + [f"{words[-1]}:*"])
    return func.to_tsquery("english", terms)

For typo tolerance, full-text search is the wrong tool — stemming does not forgive misspellings. The pg_trgm extension with a GIN trigram index on name handles "wirless" matching "wireless", and a common design runs the tsvector search first and falls back to trigram similarity when it returns nothing.

Rolling Out Search on a Large Existing Table

The generated column in the quick answer is one statement to add, and on a small table that is the right way to do it. On a table with tens of millions of rows it is a problem: adding a stored generated column rewrites every row while holding an ACCESS EXCLUSIVE lock, which blocks reads and writes for the duration.

Adding search to a table that already has data Three bands. Adding a stored generated column rewrites the table under an ACCESS EXCLUSIVE lock, which is fine for small tables. For large tables, add a plain nullable tsvector column and a trigger, backfill in batches, then create the GIN index concurrently. Only switch queries to the new column once the index is valid. small table: ADD COLUMN ... GENERATED ALWAYS AS (...) STORED one statement, but it rewrites every row under an ACCESS EXCLUSIVE lock large table: nullable column + trigger, then batched backfill writes keep flowing; the trigger keeps new and updated rows current CREATE INDEX CONCURRENTLY, then switch the queries check pg_index.indisvalid before relying on the index

The staged alternative keeps the table online. Add an ordinary nullable tsvector column, which is a metadata-only change. Install a trigger that sets it on insert and update, so new writes are covered from that moment. Backfill existing rows in primary-key batches, committing each batch, so no single transaction holds locks for long or bloats the WAL. Then build the index with CREATE INDEX CONCURRENTLY, which in Alembic has to run outside the migration's transaction:

# alembic/versions/7c1e_products_search_index.py
from alembic import op


def upgrade() -> None:
    with op.get_context().autocommit_block():
        op.create_index(
            "ix_products_search_vector",
            "products",
            ["search_vector"],
            postgresql_using="gin",
            postgresql_concurrently=True,
        )


def downgrade() -> None:
    with op.get_context().autocommit_block():
        op.drop_index(
            "ix_products_search_vector", table_name="products", postgresql_concurrently=True
        )

A concurrent build that fails leaves an INVALID index behind, which PostgreSQL maintains on every write but never uses for reads. Check pg_index.indisvalid after the migration, and drop and rebuild if it is false.

In the ORM, a trigger-maintained column is mapped with server_default=FetchedValue() and server_onupdate=FetchedValue() instead of Computed, which tells SQLAlchemy the database sets it and the attribute must be re-read after a write. The same expand, backfill, switch sequence underlies most large-table changes, and adding a NOT NULL column without locking walks through its batching and verification in more detail.

Frequently Asked Questions

Should I use .match() or bool_op("@@")?

.match() renders @@ plainto_tsquery(...), which is safe and ANDs every word. Use bool_op("@@") with func.websearch_to_tsquery when users should be able to quote phrases, use or, or exclude words — which is almost always what a search box wants.

Do I need Elasticsearch instead?

Not for most application search. PostgreSQL full-text search handles stemming, weights, ranking, phrases and millions of documents with a GIN index. Dedicated engines earn their operational cost with faceting at scale, typo tolerance built in, and relevance tuning beyond weights.

How do I search in several languages?

Store a regconfig per row and use it on both sides, or keep one vector column per language. A generated column cannot read a per-row configuration from another column and stay immutable, so per-row language usually means a trigger.

Why does ts_rank return tiny numbers?

Raw ranks are unnormalised and depend on document length and weights. Use a normalisation flag such as 32 to scale into zero to one, and treat ranks as an ordering, not as a meaningful absolute score.