How to Add Postgres Full-Text Search (tsvector and tsquery)

Postgres full-text search handles stemming and relevance ranking that ILIKE can never do. Searching 'running' can match articles about 'run' - and the most relevant result comes first. You get this with to_tsvector, plainto_tsquery, and ts_rank, all inside Postgres.

Fullstack Engineerfullstackpostgresfull-text-search

Why ILIKE falls short

A query like body ILIKE '%running%' looks simple but has three hard limits:

Postgres full-text search solves all three with built-in functions.

The core pattern

Replace the ILIKE WHERE clause with a tsvector @@ tsquery match and add ts_rank to the ORDER BY:

import psycopg2
from flask import Flask, request, jsonify

app = Flask(__name__)

def db():
    return psycopg2.connect(host="127.0.0.1", dbname="app", user="postgres")

@app.get("/api/search")
def search():
    q = request.args.get("q", "")
    conn = db()
    try:
        cur = conn.cursor()
        cur.execute(
            """
            SELECT id, title,
                   ts_rank(to_tsvector('english', title || ' ' || body),
                           plainto_tsquery('english', %s)) AS rank
            FROM articles
            WHERE to_tsvector('english', title || ' ' || body)
                  @@ plainto_tsquery('english', %s)
            ORDER BY rank DESC
            """,
            (q, q),
        )
        rows = [{"id": r[0], "title": r[1]} for r in cur.fetchall()]
    finally:
        conn.close()
    return jsonify(results=rows)

to_tsvector('english', text) tokenizes and stems the document - 'running' becomes the lexeme 'run'. plainto_tsquery('english', q) does the same to the search term, so they match even when the exact word form differs. @@ is the match operator, and ts_rank assigns a float score used for ordering.

Speed it up with a GIN index

Calling to_tsvector inside the WHERE clause re-computes the document vector on every query. For production, add a generated column and a GIN index:

ALTER TABLE articles
  ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (
    to_tsvector('english', title || ' ' || body)
  ) STORED;

CREATE INDEX articles_fts_idx ON articles USING GIN (search_vector);

Then simplify the query to use the stored column:

SELECT id, title,
       ts_rank(search_vector, plainto_tsquery('english', $1)) AS rank
FROM articles
WHERE search_vector @@ plainto_tsquery('english', $1)
ORDER BY rank DESC;

Production extras

Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.

Try it in a workspace →

What you'll practice

FAQ

What is the difference between ILIKE and Postgres full-text search?

ILIKE does a literal substring scan with no stemming, no ranking, and no index support for leading wildcards. Postgres full-text search uses to_tsvector and tsquery to match stemmed lexemes, rank results by relevance with ts_rank, and accelerate lookups with a GIN index.

How do I rank Postgres full-text search results by relevance?

Use ts_rank(tsvector, tsquery) in the SELECT and ORDER BY that column descending. ts_rank returns a float representing how well the document matches the query - higher means more relevant.

How do I add a GIN index for Postgres full-text search?

Add a generated tsvector column (GENERATED ALWAYS AS ... STORED) to the table, then CREATE INDEX ... USING GIN on that column. The query planner will use it automatically when the WHERE clause matches the indexed column.

Keep learning

Fix a React useState Stale-State BugFullstack projectFix a Blank React Dashboard (Failed Fetch)Fullstack projectFix the React Search That Won't Filter (useEffect Deps)Fullstack projectFullstack roadmapStep by step to hiredFullstack interview questionsSTAR answersAll Fullstack projectsProjects hub

Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.

Open the workspace →