{"id":435,"date":"2026-07-08T13:03:29","date_gmt":"2026-07-08T13:03:29","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/postgres-full-text-search-2026-when-i-reach-for-it-over-elasticsearch\/"},"modified":"2026-07-08T13:03:29","modified_gmt":"2026-07-08T13:03:29","slug":"postgres-full-text-search-2026-when-i-reach-for-it-over-elasticsearch","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/postgres-full-text-search-2026-when-i-reach-for-it-over-elasticsearch\/","title":{"rendered":"Postgres Full-Text Search in 2026: When I Reach For It Over Elasticsearch"},"content":{"rendered":"<p>Confession: I dragged Elasticsearch into a project six months ago because a stakeholder said the word &ldquo;search&rdquo; three times in a planning meeting and I panicked. Two weeks later I ripped it out and shipped the same feature on plain Postgres. The user-facing behavior was indistinguishable, the infra bill dropped by about $60 a month, and I got to delete an entire docker-compose service plus its two backup scripts.<\/p>\n<p>So yeah, I have opinions about when Postgres full-text search is enough, and when it really isn&rsquo;t. This post is mostly the &ldquo;enough&rdquo; cases, because that&rsquo;s the majority, and it&rsquo;s the case people skip past to buy the fancier thing.<\/p>\n<p>Short version if you&rsquo;re scanning: if your corpus fits comfortably in a Postgres instance, your query patterns are exact-ish keyword matches, and you don&rsquo;t need a query-time relevance model tuned by a search engineer, Postgres full-text search will handle it. Below is what I actually use.<\/p>\n<h2 id=\"postgres-full-text-search-in-2026-what-actually-changed\">Postgres full-text search in 2026: what actually changed<\/h2>\n<p>Not much in the last two years, and I mean that as a compliment. The <code>tsvector<\/code>\/<code>tsquery<\/code> machinery has been stable since Postgres 9.0 (2010), and every version since has quietly made it faster or added edge cases: parallel index builds, better statistics on GIN indexes, incremental updates. Postgres 17 landed a small but nice tweak: JSON-aware <code>to_tsvector<\/code> casts feel less like sandpaper.<\/p>\n<p>The real change is external. <code>pg_trgm<\/code> matured, <code>pgvector<\/code> exists for hybrid semantic and keyword search, and extensions like <code>paradedb<\/code> push into &ldquo;actual full-search-engine inside Postgres&rdquo; territory for teams that want it. The default install of Postgres already ships everything I need for typical app search: search by title and body, rank by relevance, ignore stop words, handle stems. If you want to see what&rsquo;s actually in the box, the <a href=\"https:\/\/www.postgresql.org\/docs\/current\/textsearch.html\" rel=\"nofollow noopener\" target=\"_blank\">official Postgres full text search docs<\/a> are famously dry but complete.<\/p>\n<h2 id=\"the-three-index-question-i-ask-myself\">The three-index question I ask myself<\/h2>\n<p>Before I write a single query, I answer three questions. Answer these and the index choice picks itself.<\/p>\n<p><strong>Q1. What am I searching, a title, a body, or both?<\/strong> If it&rsquo;s a title only (short strings), a plain GIN or GiST on <code>to_tsvector('english', title)<\/code> is fine. If it&rsquo;s a body, use GIN. Always. GiST loses on large text.<\/p>\n<p><strong>Q2. Do I need fuzzy matches (&ldquo;laravl&rdquo; to &ldquo;laravel&rdquo;), or just linguistic matches (&ldquo;running&rdquo; to &ldquo;run&rdquo;)?<\/strong> Linguistic goes to <code>tsvector<\/code>. Fuzzy goes to <code>pg_trgm<\/code>. If you want both, keep two indexes and OR the results at query time. Yes, really. Two indexes cost less than one Elasticsearch pod.<\/p>\n<p><strong>Q3. Does the corpus fit in RAM?<\/strong> If the entire indexed column is under, say, 5 GB, you&rsquo;re fine. Postgres GIN indexes are dense; queries return in single-digit ms once warm. Once you&rsquo;re pushing 50+ GB of searchable text, revisit.<\/p>\n<p>Here&rsquo;s the migration I typically ship:<\/p>\n<pre><code class=\"language-sql\">ALTER TABLE posts\n  ADD COLUMN search_vector tsvector\n  GENERATED ALWAYS AS (\n    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n    setweight(to_tsvector('english', coalesce(body,  '')), 'B')\n  ) STORED;\n\nCREATE INDEX posts_search_idx\n  ON posts USING GIN (search_vector);\n\nCREATE INDEX posts_trgm_idx\n  ON posts USING GIN (title gin_trgm_ops);\n<\/code><\/pre>\n<p>Two indexes. A generated column so I don&rsquo;t have to remember to update the vector on writes. <code>setweight<\/code> gives title matches four times the rank of body matches. <code>pg_trgm<\/code> (via the <code>title gin_trgm_ops<\/code> index) is there for the fuzzy fallback I&rsquo;ll show below.<\/p>\n<h2 id=\"real-query-patterns-i-ship\">Real query patterns I ship<\/h2>\n<p>The lookup I use maybe 90% of the time:<\/p>\n<pre><code class=\"language-sql\">SELECT\n  id,\n  title,\n  ts_rank_cd(search_vector, plainto_tsquery('english', $1)) AS rank\nFROM posts\nWHERE search_vector @@ plainto_tsquery('english', $1)\nORDER BY rank DESC, published_at DESC\nLIMIT 20;\n<\/code><\/pre>\n<p><code>plainto_tsquery<\/code> handles the user typing a raw string. <code>ts_rank_cd<\/code> uses cover density: it weights matches that appear near each other higher than matches scattered across the doc. Tiebreak by recency so a stale post with the same rank doesn&rsquo;t win.<\/p>\n<p>For the &ldquo;user is clearly typing a partial word&rdquo; case (autocomplete, mostly), I fall back to <code>pg_trgm<\/code>:<\/p>\n<pre><code class=\"language-sql\">SELECT id, title, similarity(title, $1) AS sim\nFROM posts\nWHERE title % $1\nORDER BY sim DESC\nLIMIT 10;\n<\/code><\/pre>\n<p>The <code>%<\/code> operator uses the trigram index. <code>similarity()<\/code> returns a float between 0 and 1. I usually set <code>pg_trgm.similarity_threshold<\/code> around 0.2 in the session; the default of 0.3 was too strict for how people actually type.<\/p>\n<p>Where it gets interesting: mixing both. If FTS returns fewer than N results, run the trigram query and union the leftovers in. Elasticsearch calls this &ldquo;search-after fallback&rdquo;. In Postgres it&rsquo;s a CTE:<\/p>\n<pre><code class=\"language-sql\">WITH fts AS (\n  SELECT id, ts_rank_cd(search_vector, plainto_tsquery($1)) AS score\n  FROM posts\n  WHERE search_vector @@ plainto_tsquery($1)\n),\ntrgm AS (\n  SELECT id, similarity(title, $1) * 0.5 AS score\n  FROM posts\n  WHERE title % $1 AND id NOT IN (SELECT id FROM fts)\n)\nSELECT id, score FROM fts\nUNION ALL\nSELECT id, score FROM trgm\nORDER BY score DESC\nLIMIT 20;\n<\/code><\/pre>\n<p>The <code>* 0.5<\/code> is my hack to keep trigram matches ranked below any exact-ish keyword hit. Tune to taste.<\/p>\n<h2 id=\"where-ive-been-shipping-this-in-real-apps\">Where I&rsquo;ve been shipping this in real apps<\/h2>\n<p>On a Rust backend I set up recently, I wired this directly through sqlx. The pattern&rsquo;s covered in more detail in my <a href=\"https:\/\/abrarqasim.com\/blog\/axum-sqlx-rust-backend-setup-i-actually-use-in-production\" rel=\"noopener\">Axum + sqlx backend setup post<\/a>, but the short version: the generated <code>search_vector<\/code> column just becomes another sqlx field. No custom query builder. No search service in front of your database. Migrations run through the same pipeline as every other schema change.<\/p>\n<p>If your stack is TypeScript, Drizzle handles tsvector columns cleanly (I get into that in the <a href=\"https:\/\/abrarqasim.com\/blog\/drizzle-vs-prisma-2026-the-typescript-orm-i-actually-ship\" rel=\"noopener\">Drizzle vs Prisma post<\/a>): the raw SQL escape hatch is fine here, since you&rsquo;ll write <code>@@ plainto_tsquery(...)<\/code> inside a tagged-template SQL literal anyway. Prisma is more of a fight; you&rsquo;ll be dropping into <code>$queryRaw<\/code>.<\/p>\n<h2 id=\"when-elasticsearch-still-wins\">When Elasticsearch still wins<\/h2>\n<p>I&rsquo;m not going to pretend Postgres full-text search beats Elasticsearch across the board. Here&rsquo;s my rough list of when I still reach for Elastic (or now, more often, OpenSearch or Meilisearch):<\/p>\n<ul>\n<li><strong>You have a full-time search engineer or want one.<\/strong> Elastic&rsquo;s query DSL is powerful, and if someone is going to spend real hours tuning boosts, analyzers, and synonyms, the ceiling is higher.<\/li>\n<li><strong>Corpus &gt; 100 GB of active searchable text.<\/strong> Postgres will do it, but you&rsquo;ll spend more time on GIN maintenance than on the app.<\/li>\n<li><strong>Cross-language stemming matters.<\/strong> Postgres ships stemmers for a few dozen languages, but Elastic&rsquo;s per-language analyzers are a step ahead for anything past English, German, French.<\/li>\n<li><strong>You need distributed search across shards you control.<\/strong> Postgres has partitioning, but if you&rsquo;re at that scale you probably already know you need Elastic.<\/li>\n<li><strong>You want geo, aggregations, and search in the same query.<\/strong> PostGIS plus FTS in Postgres works, but Elastic&rsquo;s aggregations were built for this.<\/li>\n<\/ul>\n<p>If none of those apply, you&rsquo;re in the &ldquo;Postgres is enough&rdquo; zone. That&rsquo;s most apps. For a nice practical writeup on the Postgres side, the <a href=\"https:\/\/supabase.com\/docs\/guides\/database\/full-text-search\" rel=\"nofollow noopener\" target=\"_blank\">Supabase FTS guide<\/a> is more grounded than most vendor docs.<\/p>\n<h2 id=\"ranking-without-gaming-the-score\">Ranking without gaming the score<\/h2>\n<p>The mistake I made for years: treating <code>ts_rank<\/code> output as a stable score I could compare across queries. It isn&rsquo;t. The score is meaningful within one query (the top result is more relevant than the second), but the absolute number depends on doc length, term frequency, and cover density. Don&rsquo;t build alerts on &ldquo;score &gt; 0.4&rdquo; thresholds. You&rsquo;ll be tuning them forever.<\/p>\n<p>What I do instead: rank within the query, then apply business rules. So the SQL sort becomes <code>ORDER BY rank DESC, boost_column DESC, published_at DESC<\/code>, where <code>boost_column<\/code> is something concrete like &ldquo;is this a featured post&rdquo; or &ldquo;does the author have verified status&rdquo;. That&rsquo;s the same pattern Elastic recommends with <code>function_score<\/code>, just written in SQL.<\/p>\n<h2 id=\"testing-search-is-where-most-teams-give-up\">Testing search is where most teams give up<\/h2>\n<p>Not really a Postgres thing, more of a general take. Search is one of the easiest features to ship a demo of and one of the hardest to test in CI. What I actually keep in the test suite:<\/p>\n<ol>\n<li>A fixture set of 20-ish &ldquo;canonical&rdquo; docs with hand-labeled expected ordering for a handful of real user queries. If any change to the schema or ranking function breaks that ordering, the test fails and I have to look at it.<\/li>\n<li>A snapshot test for the raw <code>EXPLAIN ANALYZE<\/code> plan of the main search query. Not asserting timings; asserting that it still uses the GIN index and hasn&rsquo;t fallen back to a seq scan. This catches migrations that accidentally drop the index or break the generated column.<\/li>\n<li>A property test that runs the search endpoint against a random string and asserts it doesn&rsquo;t 500. Sounds dumb; catches so many parser bugs.<\/li>\n<\/ol>\n<p>I kept most of these habits after reading the classic <a href=\"https:\/\/news.ycombinator.com\/item?id=39018880\" rel=\"nofollow noopener\" target=\"_blank\">Postgres vs Elasticsearch HN thread<\/a>, which has aged pretty well.<\/p>\n<h2 id=\"what-id-do-this-week\">What I&rsquo;d do this week<\/h2>\n<p>If you have a Postgres app with a <code>LIKE %term%<\/code> search endpoint, spend one afternoon and swap it for FTS. The steps: add the generated <code>tsvector<\/code> column and GIN index, swap the <code>LIKE<\/code> for <code>@@ plainto_tsquery<\/code>, keep the exact same API contract. You&rsquo;ll get a 10 to 100x speedup on any table with more than a few thousand rows, better relevance out of the box, and a foundation you can extend into ranking, autocomplete, and fuzzy matches without spinning up new infra.<\/p>\n<p>If a stakeholder says &ldquo;search&rdquo; in a meeting, ask them for three specific queries they want to work and how they&rsquo;d know it&rsquo;s good. If they can&rsquo;t answer, you don&rsquo;t need Elasticsearch. You need a spec.<\/p>\n<p>I write about this kind of &ldquo;keep it in Postgres&rdquo; stuff regularly on <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my site<\/a>; most of my recent client work has been picking database patterns that scale without needing a platform team.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Real experience with Postgres full-text search in 2026: when tsvector and GIN indexes beat Elasticsearch, and the queries I actually ship in production.<\/p>\n","protected":false},"author":2,"featured_media":434,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Real experience with Postgres full-text search in 2026: when tsvector and GIN indexes beat Elasticsearch, and the queries I actually ship in production.","rank_math_focus_keyword":"postgres full text search","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,237],"tags":[49,474,475,472,477,177,473,238,241,476],"class_list":["post-435","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-databases","tag-backend","tag-databases","tag-elasticsearch","tag-full-text-search","tag-gin-index","tag-postgres","tag-postgres-full-text-search","tag-postgresql","tag-sql","tag-tsvector"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/435","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=435"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/435\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/434"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=435"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=435"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=435"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}