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.
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 3The 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.
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. (The
pg_trgmextension 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 PatternsEach 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
| Feature | PostgreSQL FTS | Elasticsearch |
|---|---|---|
| Setup complexity | Simple (built-in) | Complex (separate service) |
| Best for | < 1M documents | > 1M documents |
| Fuzzy search | Limited (pg_trgm extension) | Excellent (Levenshtein) |
| Faceted search | Requires custom code | Built-in aggregations |
| Typo tolerance | Basic | Advanced (fuzziness=AUTO) |
| Horizontal scaling | Difficult | Automatic sharding |
| Operational cost | Low | High (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
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
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 cdcnetStart 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';
SQLThe 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
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| PostgreSQL Only | Simple, ACID, no sync | Limited search features | < 1M rows, simple search |
| Elasticsearch Only | Fast search, facets | No ACID, complex queries hard | Read-heavy, simple data model |
| Dual-Write | Strong consistency | 2PC complexity, failure handling | Small scale, critical consistency |
| CDC Sync | Decoupled, reliable | Eventual consistency, setup cost | Production scale (>10k writes/sec) |
Real-World Performance Comparison
Benchmark: 1 Million Articles
| Query Type | LIKE Query | PostgreSQL FTS | Elasticsearch |
|---|---|---|---|
| Simple keyword | 2,400 ms | 12 ms | 8 ms |
| Multi-word search | 4,100 ms | 23 ms | 11 ms |
| Fuzzy search | Not supported | 45 ms | 15 ms |
| Faceted search | Not practical | ~200 ms (custom) | 18 ms |
| Index size | N/A | ~850 MB | ~1.2 GB |
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