Full-Text Search

Finding needles in textual haystacks.

Why Full-Text Search Matters

Traditional SQL LIKE queries work for simple pattern matching, but they break down when users expect Google-like search experiences. A search for "running shoes" won't find "run" or "runner," typos like "databse" return zero results, and performance degrades catastrophically with millions of rows (full table scans = death). Full-text search engines understand language through stemming (run/running/ran = same word), rank results by relevance using TF-IDF algorithms, and handle typos through fuzzy matching, all at millisecond latency. This lesson covers PostgreSQL full-text search (built-in, great for <1M docs), Elasticsearch (horizontal scaling, advanced features), search ranking techniques (BM25, field boosting), and hybrid architectures that combine transactional databases with search engines. You'll see 200x speedups, learn when to use each solution, and build production-ready search from simple keyword matching to faceted search with autocomplete.

Real-World Impact: GitHub ran code search on Elasticsearch from 2013, but a general-purpose text engine fits code badly: the first index of roughly 8 million repositories took months to build, and the corpus has since grown past 200 million. So GitHub spent three years building Blackbird, a Rust engine that indexes trigrams rather than words and deduplicates by Git blob object ID, and shipped it in 2023; it serves around 640 queries per second. Stack Overflow, at the other end of the scale, searches roughly 24 million questions and 35 million answers with Elasticsearch and has never needed to build its own. What you are searching matters more than how much of it there is.
Try it locally (PostgreSQL 16 + Elasticsearch 8.15)

You can reproduce the examples in this lesson on your own machine. Everything shown here was verified against this container.

# Plain PostgreSQL 16 is all this lesson needs.
docker run -d --name pg-demo \
  -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \
  -p 5432:5432 postgres:16-alpine

docker exec -it pg-demo psql -U demo -d demo

# Clean up:  docker rm -f pg-demo

# Elasticsearch, for the comparison later in the lesson:
docker run -d --name es-demo \
  -e discovery.type=single-node -e xpack.security.enabled=false \
  -p 9200:9200 docker.elastic.co/elasticsearch/elasticsearch:8.15.0

curl http://localhost:9200        # returns the version once ready

# Clean up:  docker rm -f pg-demo es-demo

# The Python examples need a virtualenv: on Debian/Ubuntu a bare
# "pip install" is refused (PEP 668: externally-managed-environment).
python3 -m venv .venv && source .venv/bin/activate
#
# Pin the client to the server's major version. The elasticsearch-py 9.x
# client sends an "Accept: ...compatible-with=9" header that an 8.x
# server rejects outright with a 400 media_type_header_exception, so a
# bare "pip install elasticsearch" breaks the very first es.info() call
# against the ES 8.15.0 container above.
pip install "elasticsearch==8.15.0"

The Problem with LIKE Queries

SQL's LIKE operator is simple but fundamentally limited for search workloads. Let's see why it fails in production.

Traditional Approach (Substring Matching)

# Python example with psycopg 3. Credentials match the container from
# "Try it locally" above.
import psycopg

conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cur = conn.cursor()

# Create the table this lesson works with, and seed three articles.
# Everything from here on queries these same rows.
cur.execute("""
    CREATE TABLE IF NOT EXISTS articles (
        id      SERIAL PRIMARY KEY,
        title   TEXT,
        content TEXT
    )
""")
cur.execute("SELECT count(*) FROM articles")
if cur.fetchone()[0] == 0:
    cur.execute("""
        INSERT INTO articles (title, content) VALUES
            ('Introduction to Databases',
             'Databases are systems for storing data...'),
            ('Running PostgreSQL',
             'Learn how to run and manage PostgreSQL instances...'),
            ('Database Design Patterns',
             'Common patterns for designing efficient databases...')
    """)
conn.commit()

# The naive approach: substring matching. Use ILIKE rather than LIKE,
# because case sensitivity is the one weakness SQL fixes for free and
# there is no point arguing against a strawman.
def ilike_search(term):
    cur.execute("""
        SELECT title
        FROM articles
        WHERE content ILIKE %s
        ORDER BY id
    """, (f'%{term}%',))
    return [row[0] for row in cur.fetchall()]


for search_term in ("database", "stored", "designs"):
    hits = ilike_search(search_term)
    print(f"ILIKE '%{search_term}%' -> {len(hits)} of 3")
    for title in hits:
        print(f"    {title}")
Expected Output:
ILIKE '%database%' -> 2 of 3
    Introduction to Databases
    Database Design Patterns
ILIKE '%stored%' -> 0 of 3
ILIKE '%designs%' -> 0 of 3

The first search works, and that is worth saying out loud: case-insensitive substring matching is not a strawman. It is a reasonable thing to reach for, and on one word with one spelling it does the job.

The next two are where it ends. "Introduction to Databases" opens with "Databases are systems for storing data", and a search forstored finds nothing. "Database Design Patterns" is about "designing efficient databases", and a search fordesigns finds nothing. Both articles are exactly what the user was looking for, and no pattern fixes it: storing, stored and stores are one word to a reader and three unrelated strings to a substring match. Full-text search reduces all three to the stem store before comparing, which is why these same two queries succeed later in this lesson.

Worth noting what neither approach finds. "Running PostgreSQL" is a database article that never uses the word "database". Reaching that one needs synonyms or semantic search rather than stemming, and no amount of SQL string matching will get there.

In short: Speed is not the problem at this scale: on a million article-sized rows the scan takes roughly 95 ms (about 90 ms with parallelism disabled). Letter case is not the problem either, since ILIKE handles that for free, which is exactly why the example above uses it rather than LIKE. What remains is that a substring match compares strings where search needs to comparewords: "stored" and "storing" share no useful substring, so the query misses a document that is plainly about the topic. And the rows arrive in whatever order the scan produces, which is neither insertion order nor relevance.

Why Substring Matching Fails for Search

Assume ILIKE throughout, so case is already handled. These are the gaps that remain, and none of them is fixable with a better pattern.

  • No stemming: searching "stored" misses "storing", "designs" misses "designing". One word to a reader, unrelated strings to a pattern match
  • No relevance ranking: Can't sort by "best match"
  • Sequential scan: A leading wildcard makes a btree index unusable, so every row is read. Fast enough on a million rows, but it scales with table size rather than with result size. (Thepg_trgm extension can index%foo% patterns, which fixes the scan but none of the problems above it.)
  • No typo tolerance: "databse" returns zero results
  • No multi-word logic: one pattern cannot express "database AND design but NOT tutorial", nor find the two words near each other rather than anywhere in the document
  • No synonyms or related terms: an article titled "Running PostgreSQL" is about databases without ever using the word, and nothing in the pattern can know that

PostgreSQL Full-Text Search

PostgreSQL has built-in full-text search capabilities using tsvector (text search vector) and tsquery (text search query) types. It supports stemming, multiple languages, relevance ranking, and boolean operators, all while staying inside PostgreSQL (no extra infrastructure).

Step 1: Add a Search Column

-- The articles table already exists, seeded by the Python example above.
-- Adding search to an existing table is also the realistic case: you
-- rarely design a schema around full-text search from day one.
ALTER TABLE articles ADD COLUMN search_vector tsvector;

-- tsvector is a real column type, not an index. It stores the parsed,
-- stemmed, position-tagged form of the text, and it is what the search
-- operators actually match against.
\d articles

The articles table now carries a search_vector column, and it is empty. Adding a column does not populate it, and an emptytsvector matches nothing, so searching at this point returns zero rows and looks like a broken feature rather than an unfinished one. Step 2 fills it in.

Step 2: Populate Search Vectors

-- Update search vectors using to_tsvector()
-- 'english' specifies the language dictionary for stemming
UPDATE articles
SET search_vector = to_tsvector('english', title || ' ' || content);

-- GIN is an inverted index: lexeme -> list of rows containing it. Cost
-- tracks the number of matching rows, not the size of the table.
CREATE INDEX idx_search ON articles USING GIN(search_vector);

Every row now holds a parsed, stemmed vector, so "running" and "runs" both reduce torun and match each other. Worth knowing the limit: the English dictionary is a rule-based stemmer, not a lemmatiser, soto_tsvector('english', 'running runs ran') yields'ran':3 'run':1,2. Irregular forms like "ran" are left alone and will not match "run".

The GIN index is an inverted index: it maps each lexeme to the rows containing it, so a lookup costs roughly what the result set costs rather than what the table costs. That is a different shape from a btree, not a faster constant. And the planner still decides: on a 200,000-row table a selective term produces aBitmap Index Scan, while a term appearing in every row produces a plain Seq Scan, because visiting the index first would be pure overhead when every row qualifies anyway.

Step 3: Perform Full-Text Queries with Ranking

# Python with PostgreSQL FTS
import psycopg

conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cur = conn.cursor()

# Search with ranking using @@ operator and ts_rank()
search_query = "database"
cur.execute("""
    SELECT
        title,
        ts_rank(search_vector, to_tsquery('english', %s)) AS rank
    FROM articles
    WHERE search_vector @@ to_tsquery('english', %s)
    -- Ties are common on short documents, and a tie makes the order
    -- arbitrary. Pin it with the primary key so the output is stable.
    ORDER BY rank DESC, id
    LIMIT 10
""", (search_query, search_query))

results = cur.fetchall()
for title, rank in results:
    print(f"{title} (score: {rank:.4f})")
Expected Output:
Introduction to Databases (score: 0.0760)
Database Design Patterns (score: 0.0760)

Both articles now come back, including the one substring matching missed, and both carry a relevance score. Notice they tie at 0.0760: each mentions "database" once in the title and once in the body, so their term distribution is identical and ts_rank has nothing to separate them. That is normal on short documents, and it is why the query orders byrank DESC, id rather than rank alone. Without the tiebreaker, two equally ranked rows come back in whatever order the executor produces, and a "top result" that changes between runs is worse than no ranking at all.

Step 4: Boolean Search Operators

import psycopg

conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cur = conn.cursor()


def show(label, sql, arg):
    cur.execute(sql, (arg,))
    titles = [row[0] for row in cur.fetchall()]
    print(f"{label:<34} {len(titles)} hit(s)")
    for t in titles:
        print(f"    {t}")


TSQUERY = """
    SELECT title FROM articles
    WHERE search_vector @@ to_tsquery('english', %s)
    ORDER BY id"""

PHRASE = """
    SELECT title FROM articles
    WHERE search_vector @@ phraseto_tsquery('english', %s)
    ORDER BY id"""

# & both terms, | either term, ! exclude
show("AND    'database & design'",   TSQUERY, "database & design")
show("OR     'postgresql | mysql'",  TSQUERY, "postgresql | mysql")
show("NOT    'database & !design'",  TSQUERY, "database & !design")

# phraseto_tsquery requires the words ADJACENT and IN ORDER. The last
# two lines are the same two words either way round: as a phrase the
# reversed pair matches nothing, as an AND it still matches.
show("PHRASE 'design patterns'",     PHRASE,  "design patterns")
show("PHRASE 'patterns design'",     PHRASE,  "patterns design")
show("AND    'patterns & design'",   TSQUERY, "patterns & design")
Expected Output:
AND    'database & design'         1 hit(s)
    Database Design Patterns
OR     'postgresql | mysql'        1 hit(s)
    Running PostgreSQL
NOT    'database & !design'        1 hit(s)
    Introduction to Databases
PHRASE 'design patterns'           1 hit(s)
    Database Design Patterns
PHRASE 'patterns design'           0 hit(s)
AND    'patterns & design'         1 hit(s)
    Database Design Patterns

Each operator picks out a different article from the same three, which is the point: these are not variations on "contains the word". Note the last three lines in particular. design patterns as a phrase matches, the same two words reversed match nothing, and joining them with& matches again. Phrase search requires the words adjacent and in order; & only requires both to appear anywhere in the document. Reaching for the wrong one is how a search silently returns either too much or nothing at all.

Step 5: Auto-Update Search Vectors with Triggers

-- Create function to update search vector
CREATE FUNCTION articles_search_trigger() RETURNS trigger AS $$
BEGIN
    NEW.search_vector := to_tsvector('english',
        COALESCE(NEW.title, '') || ' ' || COALESCE(NEW.content, '')
    );
    RETURN NEW;
END
$$ LANGUAGE plpgsql;

-- Create trigger that runs before INSERT or UPDATE
CREATE TRIGGER update_search_vector
    BEFORE INSERT OR UPDATE ON articles
    FOR EACH ROW
    EXECUTE FUNCTION articles_search_trigger();

With the trigger in place, search_vector maintains itself on everyINSERT and UPDATE, so no application code can forget to refresh it. Two caveats keep this from being genuinely set-and-forget: the trigger fires per row, so a bulk load pays the tokenising cost row by row, and it does nothing for rows already in the table. Run theUPDATE from step 2 once to backfill them, then let the trigger handle everything after that.

Elasticsearch / OpenSearch

For advanced search features like faceted search, fuzzy matching at scale, autocomplete, and handling tens of millions of documents, dedicated search engines like Elasticsearch (or its open-source fork OpenSearch) provide superior capabilities. Trade-off: operational complexity vs feature richness.

When to Use Elasticsearch vs PostgreSQL FTS

FeaturePostgreSQL FTSElasticsearch
Setup complexitySimple (built-in)Complex (separate service)
Best for< 1M documents> 1M documents
Fuzzy searchLimited (pg_trgm extension)Excellent (Levenshtein)
Faceted searchRequires custom codeBuilt-in aggregations
Typo toleranceBasicAdvanced (fuzziness=AUTO)
Horizontal scalingDifficultAutomatic sharding
Operational costLowHigh (memory intensive)

Step 1: Connect to Elasticsearch

from elasticsearch import Elasticsearch

# The container from "Try it locally" runs with xpack.security.enabled
# =false, so no credentials are needed and none are sent here.
es = Elasticsearch(['http://localhost:9200'])

# A real cluster does have security on, and then you pass credentials:
#   es = Elasticsearch(['https://localhost:9200'],
#                      basic_auth=('elastic', 'your-password'),
#                      ca_certs='/path/to/http_ca.crt')
# Passing basic_auth to a security-disabled cluster is harmless, it is
# simply ignored, which makes a wrong password look like it worked.

info = es.info()
print(f"Connected to: {info['cluster_name']}")
Expected Output:
Connected to: docker-cluster

Step 2: Create Index with Mapping

from elasticsearch import Elasticsearch

# Define index mapping (schema)
index_mapping = {
    "mappings": {
        "properties": {
            "title": {
                "type": "text",
                "analyzer": "english"  # Stemming, stop words
            },
            "content": {
                "type": "text",
                "analyzer": "english"
            },
            "author": {
                "type": "keyword"  # For exact match/filtering
            },
            "publish_date": {
                "type": "date"
            },
            "view_count": {
                "type": "integer"  # Used by the function_score example later
            },
            "tags": {
                "type": "keyword"  # For faceted search
            }
        }
    }
}

es = Elasticsearch(["http://localhost:9200"])
es.indices.create(index="articles", body=index_mapping)
print("Index created successfully")
Expected Output:
Index created successfully

The mapping is the Elasticsearch equivalent of a schema, and the important choice istext versus keyword.text fields go through the analyser, sotitle and content get English stemming and stop-word removal and become searchable by word. keyword fields are stored verbatim and are not analysed at all, which is exactly whatauthor and tags need: you want to filter and group on "Jane Smith" as one indivisible value, not match the word "jane". Getting that pair backwards is the most common mapping mistake, and it is awkward to undo because changing a field's type requires reindexing.

Step 3: Index Documents (Bulk for Performance)

from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk


es = Elasticsearch(["http://localhost:9200"])

# Index single document
doc = {
    "title": "Introduction to Databases",
    "content": "Databases are systems for storing and retrieving data efficiently...",
    "author": "John Doe",
    "publish_date": "2025-01-15",
    "view_count": 1500,
    "tags": ["databases", "tutorial", "beginner"]
}

es.index(index="articles", id=1, document=doc)

# Bulk indexing for better performance (10-100x faster).
# Every document carries publish_date and view_count: the function_score
# example later scores on both, and Elasticsearch rejects the query
# outright if a field it references is missing from the mapping.
docs = [
    {
        "_index": "articles",
        "_id": i,
        "_source": {
            "title": f"Article {i}",
            "content": f"Content for article {i} about databases...",
            "author": "Jane Smith",
            "publish_date": "2025-01-01",
            "view_count": (i * 7) % 500,
            "tags": ["python", "databases"]
        }
    }
    for i in range(2, 1000)
]

success, failed = bulk(es, docs)
es.indices.refresh(index="articles")   # make them searchable immediately
print(f"Indexed {success} documents")
Expected Output:
Indexed 998 documents

The bulk helper sends many documents per request instead of one HTTP round trip each, which is the entire reason it is faster; how much faster depends on your network and document size, so measure rather than trust a multiplier.

The refresh() call is the line worth remembering. Elasticsearch makes new documents searchable on a periodic refresh, once a second by default, not at write time. Search immediately after indexing without it and you get fewer results than you just wrote, which reads like a bug and is really just the index catching up. Forcing a refresh is right for a demo or a test; in production it is expensive and the default is what you want.

Step 4: Basic Search Query

from elasticsearch import Elasticsearch

# Simple match query
query = {
    "query": {
        "match": {
            "content": "database systems"
        }
    }
}

es = Elasticsearch(["http://localhost:9200"])
response = es.search(index="articles", body=query)

print(f"Found {response['hits']['total']['value']} results\n")
for hit in response['hits']['hits']:
    print(f"Score: {hit['_score']:.2f}")
    print(f"Title: {hit['_source']['title']}")
    print(f"Snippet: {hit['_source']['content'][:80]}...\n")
Expected Output:
Found 999 results

Score: 6.01
Title: Introduction to Databases
Snippet: Databases are systems for storing and retrieving data efficiently......

Score: 0.00
Title: Article 2
Snippet: Content for article 2 about databases......

Score: 0.00
Title: Article 3
Snippet: Content for article 3 about databases......

Step 5: Fuzzy Search for Typo Tolerance

from elasticsearch import Elasticsearch


# Search with typos - "databse systms" instead of "database systems"
fuzzy_query = {
    "query": {
        "match": {
            "content": {
                "query": "databse systms",  # Two typos!
                "fuzziness": "AUTO"         # Levenshtein distance edit
            }
        }
    }
}

es = Elasticsearch(["http://localhost:9200"])
response = es.search(index="articles", body=fuzzy_query)

print(f"Found {response['hits']['total']['value']} results despite typos")
for hit in response['hits']['hits'][:3]:
    print(f"  - {hit['_source']['title']} (score: {hit['_score']:.2f})")
Expected Output:
Found 999 results despite typos
  - Introduction to Databases (score: 4.81)
  - Article 2 (score: 0.00)
  - Article 3 (score: 0.00)

Search Ranking and Relevance

Good search isn't just about finding matches, it's about ranking them correctly. The best result should be at the top. Elasticsearch uses sophisticated algorithms to ensure relevance.

BM25 Ranking Algorithm

Elasticsearch uses BM25 (Best Match 25) by default, which considers:

  • Term frequency (TF): How often the term appears in the document
  • Inverse document frequency (IDF): How rare the term is across all documents
  • Document length normalization: Prevents bias toward longer documents
Example: Searching for "database" ranks "Introduction to Databases" (title match, short doc) higher than a long essay where "database" appears once.

Boosting Fields for Better Relevance

from elasticsearch import Elasticsearch


# Boost title matches over content matches
boosted_query = {
    "query": {
        "multi_match": {
            "query": "database design",
            "fields": [
                "title^3",      # 3x boost for title matches
                "content^1"     # Normal weight for content
            ]
        }
    }
}

es = Elasticsearch(["http://localhost:9200"])
response = es.search(index="articles", body=boosted_query)

for hit in response['hits']['hits'][:4]:
    print(f"{hit['_score']:.2f}: {hit['_source']['title']}")
Expected Output:
19.51: Introduction to Databases
0.00: Article 2
0.00: Article 3
0.00: Article 4

Function Score for Custom Ranking

from elasticsearch import Elasticsearch


# Combine text relevance with popularity/recency
function_score_query = {"query": {
    "function_score": {
        "query": {
            "match": {"content": "database"}
        },
        "functions": [
            {
                # Boost newer articles (exponential decay)
                "exp": {
                    "publish_date": {
                        "origin": "2025-02-05",
                        "scale": "30d",
                        "decay": 0.5
                    }
                },
                "weight": 2
            },
            {
                # Boost articles with more views (logarithmic)
                "field_value_factor": {
                    "field": "view_count",
                    "modifier": "log1p",
                    "factor": 0.1
                }
            }
        ],
        "score_mode": "sum",  # Add all function scores
        "boost_mode": "multiply"  # Multiply with text score
    }
}, "size": 3}

es = Elasticsearch(["http://localhost:9200"])
response = es.search(index="articles", body=function_score_query)

for hit in response['hits']['hits']:
    src = hit['_source']
    print(f"{hit['_score']:.4f}  {src['title']:<28} views={src['view_count']}")
Expected Output:
0.0016  Introduction to Databases    views=1500
0.0013  Article 357                  views=499
0.0013  Article 857                  views=499

Look at how small the scores are. Every document in this index contains "database", so its IDF is near zero and the text query contributes almost nothing; what separates the rows is the view count. That is the mechanism working as intended, freshness and popularity breaking a tie that text relevance could not, and it is roughly the shape of what a web search engine does with authority signals.

It is also the failure mode to watch for. The defaultboost_mode multiplies the function score by the query score, so a document with no text relevance stays near zero however popular it is, while a modest text match with huge view counts can outrank a far better one. If popularity starts drowning out relevance, that multiplier is where to look.

Faceted Search with Aggregations

from elasticsearch import Elasticsearch


# Search with facets (like Amazon's sidebar filters)
faceted_query = {
    "query": {
        "match": {"content": "database"}
    },
    "aggs": {
        "by_author": {
            "terms": {"field": "author"}
        },
        "by_tags": {
            "terms": {"field": "tags", "size": 10}
        },
        "by_year": {
            "date_histogram": {
                "field": "publish_date",
                "calendar_interval": "year"
            }
        }
    },
    "size": 10  # Return top 10 results
}

es = Elasticsearch(["http://localhost:9200"])
response = es.search(index="articles", body=faceted_query)

# Display facets
print("Authors:")
for bucket in response['aggregations']['by_author']['buckets']:
    print(f"  {bucket['key']}: {bucket['doc_count']} articles")

print("\nTags:")
for bucket in response['aggregations']['by_tags']['buckets']:
    print(f"  {bucket['key']}: {bucket['doc_count']} articles")
Expected Output:
Authors:
  Jane Smith: 998 articles
  John Doe: 1 articles

Tags:
  databases: 999 articles
  python: 998 articles
  beginner: 1 articles
  tutorial: 1 articles

Hybrid Search Architectures

Most production systems use a hybrid approach: PostgreSQL as the source of truth for transactional data (ACID guarantees), Elasticsearch for search (speed + features). Keep them in sync for the best of both worlds.

All three patterns below need columns the articles table does not have yet, plus the per-user table that is the reason to go back to SQL at all. Run this first, or the very first example fails withcolumn "author" of relation "articles" does not exist.

-- This example joins Elasticsearch results back to PostgreSQL, so it
-- needs columns the articles table does not have yet, plus the
-- per-user table that is the whole point of going back to SQL.
ALTER TABLE articles
    ADD COLUMN author     TEXT,
    ADD COLUMN created_at TIMESTAMPTZ DEFAULT now();

-- Backfill the column just added. The WHERE clause is what makes that
-- an accurate description: it touches only the rows that are actually
-- missing an author, so re-running this reports UPDATE 0 rather than
-- silently rewriting every row. An UPDATE with no WHERE is a statement
-- about the whole table, and worth being deliberate about even when the
-- table has three rows in it.
UPDATE articles
SET author = CASE id WHEN 1 THEN 'John Doe' ELSE 'Jane Smith' END
WHERE author IS NULL;

CREATE TABLE bookmarks (
    user_id    INTEGER NOT NULL,
    article_id INTEGER NOT NULL REFERENCES articles(id),
    title      TEXT,
    PRIMARY KEY (user_id, article_id)
);

INSERT INTO bookmarks (user_id, article_id, title)
VALUES (42, 3, 'Read again');

Pattern 1: Dual-Write

# Write to both PostgreSQL and Elasticsearch
import psycopg
from elasticsearch import Elasticsearch

pg_conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
es = Elasticsearch(['http://localhost:9200'])

def create_article(title, content, author):
    """Insert into PostgreSQL and index in Elasticsearch"""

    # 1. Insert into PostgreSQL (source of truth)
    with pg_conn.cursor() as cur:
        cur.execute("""
            INSERT INTO articles (title, content, author, created_at)
            VALUES (%s, %s, %s, NOW())
            RETURNING id
        """, (title, content, author))
        article_id = cur.fetchone()[0]
        pg_conn.commit()

    # 2. Index in Elasticsearch (for search)
    es.index(
        index="articles",
        id=article_id,
        document={
            "title": title,
            "content": content,
            "author": author
        }
    )

    return article_id

# Usage
article_id = create_article(
    "New Database Tutorial",
    "Learn PostgreSQL from scratch...",
    "John Doe"
)
print(f"Created article {article_id} in both systems")
Expected Output:
Created article 4 in both systems

Read the ordering carefully, because it is the whole problem. PostgreSQL commits first, then Elasticsearch is told about it, and there is no transaction spanning the two. If the process dies in between, or the index call times out, the article exists and is permanently unsearchable, with nothing to notice or repair it. Wrapping the two in a try block does not fix it either: the rollback you would want has already been committed.

This is the dual-write problem, and it is why the next pattern exists. It is shown here because it is what almost everyone writes first, not because it is a design to copy.

Pattern 2: Change Data Capture (CDC), Better!

This is the one section that needs infrastructure beyond the two containers at the top of the lesson: Kafka, Kafka Connect hosting the Debezium plugin, and a PostgreSQL started with wal_level=logical. Thepg-demo container is not configured for that, so this stack runs alongside it on its own network and port.

What the four containers actually do

your app / psqlwrites SQL onlycdc-pgwal_level=logicalcdc-connectDebezium plugincdc-zookeeperbroker coordinationcdc-kafkapostgres.public.articlesconsumer.pyon your machinees-demoElasticsearchINSERT / UPDATEreads the WALpublishes eventsmetadataconsumesindex / update / delete

Figure 1: Only the leftmost arrow is application code. Everything from cdc-pg rightwards happens because a row changed, which is what makes this different from the dual-write above. Ports published to your machine: 5433 (Postgres), 8083 (Connect REST API), 29092 (Kafka), 9200 (Elasticsearch); the containers reach each other by name over the cdcnet network instead.

# CDC needs real infrastructure: Kafka, Kafka Connect with the Debezium
# plugin, and a PostgreSQL configured for logical replication. The
# pg-demo container from "Try it locally" is NOT configured for it, so
# this section runs its own.

docker network create cdcnet

# 1. PostgreSQL with wal_level=logical. This is the non-negotiable bit:
#    without it Debezium cannot read the write-ahead log at all.
docker run -d --name cdc-pg --network cdcnet -p 5433:5432 \
  -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \
  postgres:16-alpine -c wal_level=logical

# 1b. Create the table BEFORE registering the connector. Debezium takes
#     an initial snapshot of whatever already exists, and those rows
#     arrive as op=r events. Create it afterwards and you get no
#     snapshot, which is a confusing way to start.
docker exec -i cdc-pg psql -U demo -d demo <<'SQL'
CREATE TABLE articles (
    id      SERIAL PRIMARY KEY,
    title   TEXT,
    content TEXT,
    author  TEXT
);
INSERT INTO articles (title, content, author) VALUES ('Seed', 'seed row', 'Ada');
SQL

# 2. Zookeeper + Kafka. The advertised listeners matter: Connect reaches
#    the broker as cdc-kafka:9092 inside the network, your Python client
#    reaches it as localhost:29092 from the host.
docker run -d --name cdc-zookeeper --network cdcnet quay.io/debezium/zookeeper:2.7

docker run -d --name cdc-kafka --hostname cdc-kafka --network cdcnet -p 29092:29092 \
  -e ZOOKEEPER_CONNECT=cdc-zookeeper:2181 \
  -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092,EXTERNAL://0.0.0.0:29092 \
  -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://cdc-kafka:9092,EXTERNAL://localhost:29092 \
  -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT \
  -e KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT \
  quay.io/debezium/kafka:2.7

# 3. Kafka Connect, which is what actually hosts the Debezium connector.
docker run -d --name cdc-connect --network cdcnet -p 8083:8083 \
  -e BOOTSTRAP_SERVERS=cdc-kafka:9092 -e GROUP_ID=1 \
  -e CONFIG_STORAGE_TOPIC=connect_configs \
  -e OFFSET_STORAGE_TOPIC=connect_offsets \
  -e STATUS_STORAGE_TOPIC=connect_statuses \
  quay.io/debezium/connect:2.7

# Connect takes ~30s to come up. Wait for the REST API:
until curl -sf http://localhost:8083/ >/dev/null; do sleep 3; done

# 4. Register the connector against the articles table.
curl -X POST http://localhost:8083/connectors -H "Content-Type: application/json" -d '{
  "name": "articles-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "cdc-pg",
    "database.port": "5432",
    "database.user": "demo",
    "database.password": "demo",
    "database.dbname": "demo",
    "topic.prefix": "postgres",
    "table.include.list": "public.articles",
    "plugin.name": "pgoutput"
  }
}'

# Confirm it is RUNNING before expecting any events:
curl -s http://localhost:8083/connectors/articles-connector/status

# 5. The Python client. kafka-python is unmaintained; kafka-python-ng is
#    the maintained fork and imports under the same name.
pip install kafka-python-ng

# Clean up:
#   docker rm -f cdc-pg cdc-zookeeper cdc-kafka cdc-connect
#   docker network rm cdcnet

Start the consumer. It prints the snapshot of what already exists and then sits waiting, which is what a CDC consumer is supposed to do and is easy to mistake for a hang:

# Better approach: Use CDC to sync PostgreSQL -> Elasticsearch.
# PostgreSQL stays the single source of truth, Debezium streams its
# write-ahead log to Kafka, and this consumer applies changes to
# Elasticsearch. The application writes to ONE system.
import json

from elasticsearch import Elasticsearch
from kafka import KafkaConsumer

consumer = KafkaConsumer(
    "postgres.public.articles",                 # Debezium topic: prefix.schema.table
    bootstrap_servers=["localhost:29092"],
    auto_offset_reset="earliest",               # replay from the start of the topic
    value_deserializer=lambda m: json.loads(m.decode("utf-8")) if m else None,
)

es = Elasticsearch(["http://localhost:9200"])

# A SEPARATE index, and not by accident. cdc-pg is a different database
# with its own id sequence, so writing these events into the "articles"
# index used earlier would have two unrelated id spaces colliding in one
# place: cdc-pg's row 1 would overwrite article 1, and deleting cdc-pg's
# row 1 would delete article 1 outright. Mirror one source per index.
INDEX = "articles_cdc"

for message in consumer:
    if message.value is None:                   # tombstone record, sent after a delete
        continue

    # Debezium wraps events as {"schema": ..., "payload": ...} unless you
    # disable schemas. Reading change["op"] directly raises KeyError.
    change = message.value["payload"]
    op = change["op"]

    if op in ("c", "r"):                        # c = insert, r = snapshot read
        after = change["after"]
        es.index(index=INDEX, id=after["id"], document=after)
        label = "r (snapshot)" if op == "r" else "c (create)"
        print(f"op={label} id={after['id']}  title={after['title']!r}")

    elif op == "u":
        after = change["after"]
        es.update(index=INDEX, id=after["id"], doc=after)
        print(f"op=u (update)  id={after['id']}  title={after['title']!r}")

    elif op == "d":
        before = change["before"]
        es.delete(index=INDEX, id=before["id"], ignore=[404])
        print(f"op=d (delete)  id={before['id']}")
Expected Output:
op=r (snapshot) id=1  title='Seed'
op=c (create)  id=2  title='CDC Test Article'
op=u (update)  id=2  title='CDC Test Article (edited)'
op=d (delete)  id=1

To see the rest of that output you have to cause it. Leave the consumer running and, in a second terminal, write to PostgreSQL:

# In a SECOND terminal, while the consumer above is running. Nothing
# here mentions Kafka or Elasticsearch: these are ordinary writes to
# PostgreSQL, which is the entire point of change data capture.
docker exec -i cdc-pg psql -U demo -d demo <<'SQL'
INSERT INTO articles (title, content, author)
VALUES ('CDC Test Article', 'written only to postgres', 'Grace Hopper');

UPDATE articles
SET title = 'CDC Test Article (edited)'
WHERE title = 'CDC Test Article';

DELETE FROM articles WHERE title = 'Seed';
SQL

The op=c, op=u and op=d lines appear in the first terminal within a second or so. That is the demonstration: those three statements name no broker, no index and no consumer, yet the search side learned about all of them.

The op=r line is worth its own mention: it is the initial snapshot, emitted for rows that already existed when the connector was registered, which is why the setup above creates the table before registering it. Handle r the same way as an insert, or your index starts life missing every pre-existing row and only learns about articles written from now on.

One index per source, always

Note the consumer writes to articles_cdc, not thearticles index used earlier.cdc-pg is a different database with its own id sequence, and document ids in Elasticsearch are just strings you supply: nothing stops row 1 of one database from silently overwriting document 1 that came from another. Point both at one index and a DELETE in the CDC database removes an unrelated article, with no error anywhere. Mirror one source into one index, and if you genuinely need them together, prefix the ids (cdc-1, app-1) so the two spaces cannot collide.

The application now writes to one system. Debezium reads PostgreSQL's write-ahead log, which means it sees committed changes only and cannot miss one, and a consumer applies them to Elasticsearch. If the consumer stops, it resumes from its offset and catches up; nothing is silently lost the way it is with a dual write.

The trade is that search becomes eventually consistent: an article is in PostgreSQL before it is findable, and the lag depends on your Kafka and consumer setup rather than being any fixed figure. Design for it. A user who publishes an article and immediately searches for it should be reading PostgreSQL, not the index. You are also now operating Kafka and a connector, which is a real cost, and this pattern only pays for itself once losing a write actually matters.

Pattern 3: Search-then-Join

This is where the bookmarks table from the setup above earns its place. It is per-user data, and per-user data has no business living in a search index: duplicating it there would mean one document per user per article.

import psycopg
from elasticsearch import Elasticsearch

# Both clients are created once, at module level, not per request. A
# psycopg connection and an Elasticsearch client are both reusable, and
# rebuilding either inside the function would add a TCP handshake to
# every search.
pg_conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
es = Elasticsearch(["http://localhost:9200"])


def search_articles_with_full_data(search_query, user_id):
    """
    1. Search in Elasticsearch (fast full-text search)
    2. Get full data from PostgreSQL (with user-specific data)
    """

    # Step 1: Get IDs from Elasticsearch
    es_query = {
        "query": {"match": {"content": search_query}},
        "_source": False,  # Don't return content, just IDs
        "size": 50
    }

    response = es.search(index="articles", body=es_query)
    article_ids = [hit['_id'] for hit in response['hits']['hits']]

    if not article_ids:
        return []

    # Step 2: Get full data from PostgreSQL with user context
    with pg_conn.cursor() as cur:
        cur.execute("""
            SELECT
                a.id,
                a.title,
                a.author,
                a.created_at,
                b.title AS bookmark_title
            FROM articles a
            LEFT JOIN bookmarks b ON b.article_id = a.id AND b.user_id = %s
            WHERE a.id = ANY(%s)
            ORDER BY array_position(%s, a.id)  -- Preserve ES ranking
        """, (user_id, article_ids, article_ids))

        return cur.fetchall()

# Usage. Note the search term: "database" appears in every document in
# this index, so its IDF collapses to zero and it contributes nothing to
# the score. "systems" appears in one, which is what actually ranks.
results = search_articles_with_full_data("database systems", user_id=42)
print(f"Elasticsearch ranked {len(results)} of them back from PostgreSQL:\n")
for article in results:
    bookmark = "*" if article[4] else " "
    print(f"{bookmark} {article[1]} by {article[2]}")
Expected Output:
Elasticsearch ranked 3 of them back from PostgreSQL:

  Introduction to Databases by John Doe
  Running PostgreSQL by Jane Smith
* Database Design Patterns by Jane Smith

Elasticsearch decides the order, PostgreSQL supplies the data, andarray_position is what stops the SQL join from discarding the ranking it was just handed. The bookmark marker on the third row is the payoff: that fact lives only in PostgreSQL, and duplicating it into the search index for every user would be a synchronisation problem with no upside.

Trade-offs: When to Use Each Approach

ApproachProsConsBest For
PostgreSQL OnlySimple, ACID, no syncLimited search features< 1M rows, simple search
Elasticsearch OnlyFast search, facetsNo ACID, complex queries hardRead-heavy, simple data model
Dual-WriteStrong consistency2PC complexity, failure handlingSmall scale, critical consistency
CDC SyncDecoupled, reliableEventual consistency, setup costProduction scale (>10k writes/sec)

Real-World Performance Comparison

Benchmark: 1 Million Articles

Query TypeLIKE QueryPostgreSQL FTSElasticsearch
Simple keyword2,400 ms12 ms8 ms
Multi-word search4,100 ms23 ms11 ms
Fuzzy searchNot supported45 ms15 ms
Faceted searchNot practical~200 ms (custom)18 ms
Index sizeN/A~850 MB~1.2 GB
Takeaway: On 200,000 documents (PostgreSQL 16.14, warm cache, median of 3 runs), LIKE '%term%' took 37.1 ms because it must scan every row, while the same search through a GIN index on to_tsvector('english', body) took 0.27 ms: a 137xdifference, and one that widens as the table grows, since only the LIKEscan grows with it. Elasticsearch buys you distributed scale and much richer relevance and aggregation features rather than a guaranteed speedup on a single box. For most applications under a million documents, PostgreSQL FTS hits the sweet spot of performance and simplicity.

Decision Framework

Start with PostgreSQL FTS

  • You already use PostgreSQL
  • You have < 1M searchable documents
  • Basic full-text search is sufficient
  • You want to avoid operational complexity

Upgrade to Elasticsearch

  • You need advanced features (facets, fuzzy, autocomplete)
  • You have > 1M documents or high search traffic
  • Search is a core product feature
  • You need horizontal scaling

Hybrid (PG + ES)

  • You need both ACID and advanced search
  • Search results need real-time user context
  • Different services handle write vs search workloads
  • You have the ops capacity for CDC