<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: antoniodipinto</title>
    <description>The latest articles on Forem by antoniodipinto (@antoniodipinto).</description>
    <link>https://forem.com/antoniodipinto</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F451197%2F1c058bc3-e72b-49fc-b310-95dda9c611d7.png</url>
      <title>Forem: antoniodipinto</title>
      <link>https://forem.com/antoniodipinto</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/antoniodipinto"/>
    <language>en</language>
    <item>
      <title>How We Tracked 10,000,000 Spins Per Day with Go and PostgreSQL Partitioning</title>
      <dc:creator>antoniodipinto</dc:creator>
      <pubDate>Fri, 20 Feb 2026 17:19:13 +0000</pubDate>
      <link>https://forem.com/antoniodipinto/how-we-tracked-10000000-spins-per-day-with-go-and-postgresql-partitioning-4elh</link>
      <guid>https://forem.com/antoniodipinto/how-we-tracked-10000000-spins-per-day-with-go-and-postgresql-partitioning-4elh</guid>
      <description>&lt;h2&gt;
  
  
  Every spin is a legal record
&lt;/h2&gt;

&lt;p&gt;I worked at an online casino gaming company. Regulated market, multiple jurisdictions, thousands of concurrent players, hundreds of game titles. Every time a player clicked "Spin" on a slot machine, we had to record it. Not just the result. The bet amount, the win amount, the game ID, the player ID, the jurisdiction, the random number generator seed, and a timestamp accurate to the millisecond.&lt;/p&gt;

&lt;p&gt;This was not optional. Gaming regulators require a complete, auditable trail of every wager. If a regulator asks "show me every spin player X made in September 2019," you need to produce it within hours. If you lose data, you lose your license.&lt;/p&gt;

&lt;p&gt;At peak hours we were recording around 10,000,000 spin events per day. On promotional weekends with free spins campaigns that number doubled. The &lt;code&gt;spins&lt;/code&gt; table grew by roughly 300,000,000 rows per month.&lt;/p&gt;

&lt;p&gt;For the first year, this worked. Then it didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table that stopped responding
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;spins&lt;/code&gt; table looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;player_id&lt;/span&gt;     &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;game_id&lt;/span&gt;       &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;jurisdiction&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bet_amount&lt;/span&gt;    &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;win_amount&lt;/span&gt;    &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rng_seed&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_spins_player_created&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_spins_game_created&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_spins_jurisdiction_created&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jurisdiction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 500,000,000 rows, three things broke at the same time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queries on recent data got slow.&lt;/strong&gt; The daily revenue report ran a simple aggregation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;game_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bet_amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;win_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;game_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 500M rows, the planner chose a sequential scan of the &lt;code&gt;idx_spins_game_created&lt;/code&gt; index. The index itself was 18GB. Traversing it to find 24 hours of data in a 2-year table took 14 seconds. The finance team ran this query every morning. Sometimes they ran it three times because they thought it was stuck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting old data became dangerous.&lt;/strong&gt; Gaming regulations in most jurisdictions require keeping records for 5 years. But some internal analytics tables derived from &lt;code&gt;spins&lt;/code&gt; only needed 90 days. Running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;spins_analytics&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'90 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;on a 200M-row derived table took 40 minutes, generated gigabytes of WAL, and caused replication lag that triggered alerts. Autovacuum then spent another hour cleaning up the dead tuples.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Index maintenance crushed writes.&lt;/strong&gt; Every insert updated three indexes. At 10M inserts per day, that is 30M index entry writes per day. The indexes were bloated, and periodic &lt;code&gt;REINDEX&lt;/code&gt; operations required downtime or a &lt;code&gt;CONCURRENTLY&lt;/code&gt; build that ran for hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Range partitioning: one table, many partitions
&lt;/h2&gt;

&lt;p&gt;PostgreSQL declarative partitioning splits a logical table into physical partitions. Each partition is a separate table on disk, but queries against the parent table work transparently. PostgreSQL routes reads and writes to the correct partition automatically.&lt;/p&gt;

&lt;p&gt;For time-series data like spins, &lt;code&gt;RANGE&lt;/code&gt; partitioning on the timestamp column is the natural fit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;bigserial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;player_id&lt;/span&gt;     &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;game_id&lt;/span&gt;       &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;jurisdiction&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bet_amount&lt;/span&gt;    &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;win_amount&lt;/span&gt;    &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rng_seed&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;RANGE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create one partition per month:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;spins_2025_09&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2025-09-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2025-10-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;spins_2025_10&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2025-10-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2025-11-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;spins_2025_11&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2025-11-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2025-12-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you insert a row with &lt;code&gt;created_at = '2025-10-15 14:32:01'&lt;/code&gt;, PostgreSQL routes it to &lt;code&gt;spins_2025_10&lt;/code&gt; automatically. No application code changes. The insert statement is the same &lt;code&gt;INSERT INTO spins (...)&lt;/code&gt; it always was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three problems, solved
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fast queries on recent data
&lt;/h3&gt;

&lt;p&gt;The daily revenue query now only scans the current month's partition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;game_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bet_amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;win_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;game_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Append  (cost=0.00..48231.45 rows=112847 width=44)
       (actual time=0.031..82.417 rows=10241847 loops=1)
  -&amp;gt;  Seq Scan on spins_2026_02  (cost=0.00..48231.45 rows=112847 width=44)
        (actual time=0.029..61.340 rows=10241847 loops=1)
        Filter: (created_at &amp;gt;= (now() - '24:00:00'::interval))
Planning Time: 0.412 ms
Execution Time: 83.109 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL pruned every partition except the current month. Instead of scanning a 18GB index across 500M rows, it scanned a single partition with ~10M rows. From 14 seconds to 83 milliseconds.&lt;/p&gt;

&lt;p&gt;This is called &lt;strong&gt;partition pruning&lt;/strong&gt;. The planner looks at the &lt;code&gt;WHERE&lt;/code&gt; clause, compares it against the partition boundaries, and eliminates partitions that cannot contain matching rows. It happens at plan time, before any data is read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Instant data removal
&lt;/h3&gt;

&lt;p&gt;Dropping old data is no longer a &lt;code&gt;DELETE&lt;/code&gt;. It is a &lt;code&gt;DROP TABLE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;spins_2024_06&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is instantaneous regardless of how many rows the partition contained. No dead tuples, no vacuum, no WAL bloat, no replication lag. The files are unlinked from disk and the space is reclaimed immediately.&lt;/p&gt;

&lt;p&gt;For our compliance requirement, we kept 5 years of monthly partitions (60 partitions). For the analytics-derived tables, we kept 3 months and dropped anything older. The 40-minute &lt;code&gt;DELETE&lt;/code&gt; became a sub-second &lt;code&gt;DROP&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Smaller indexes, faster writes
&lt;/h3&gt;

&lt;p&gt;Each partition has its own indexes. Instead of one 18GB B-tree across 500M rows, you have 24 smaller B-trees (one per month), each covering ~20M rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_spins_2025_10_player&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;spins_2025_10&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_spins_2025_10_game&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;spins_2025_10&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_spins_2025_10_jurisdiction&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;spins_2025_10&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jurisdiction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Smaller indexes mean faster lookups, faster inserts (less tree traversal per write), and faster maintenance. Reindexing a single month's partition takes seconds, not hours.&lt;/p&gt;

&lt;p&gt;PostgreSQL can also create indexes on the parent table and propagate them automatically to new partitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jurisdiction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every new partition inherits these index definitions. No manual step needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  BRIN indexes for time-ordered partitions
&lt;/h2&gt;

&lt;p&gt;Inside each monthly partition, rows arrive in roughly chronological order. When data is physically ordered by time, BRIN (Block Range Index) is a good fit.&lt;/p&gt;

&lt;p&gt;A BRIN index stores the min/max value for each block of pages instead of indexing every row. It is tiny compared to a B-tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_spins_2025_10_created_brin&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;spins_2025_10&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;brin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A BRIN index on a 20M-row partition is roughly 48KB. The equivalent B-tree would be around 400MB. For time-range scans within a single partition ("show me all spins between 3 PM and 4 PM on October 15"), BRIN is fast enough and uses almost no disk.&lt;/p&gt;

&lt;p&gt;We kept B-tree indexes on &lt;code&gt;player_id&lt;/code&gt; and &lt;code&gt;game_id&lt;/code&gt; (for point lookups and joins) but switched to BRIN for &lt;code&gt;created_at&lt;/code&gt; within each partition. The total index footprint dropped from 54GB to under 8GB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Go insert pipeline
&lt;/h2&gt;

&lt;p&gt;Recording 10M spins per day means sustaining roughly 115 inserts per second on average, with peaks of 500+/sec during evening hours across European and Latin American markets.&lt;/p&gt;

&lt;p&gt;Single-row inserts with &lt;code&gt;database/sql&lt;/code&gt; would work at this rate, but we were also shipping data in batches from game providers. Some providers sent results in bulk, a batch of 50,000 spins every 30 seconds. For those, the COPY protocol was essential.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;SpinRecord&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;PlayerID&lt;/span&gt;     &lt;span class="kt"&gt;int64&lt;/span&gt;
    &lt;span class="n"&gt;GameID&lt;/span&gt;       &lt;span class="kt"&gt;int32&lt;/span&gt;
    &lt;span class="n"&gt;Jurisdiction&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;BetAmount&lt;/span&gt;    &lt;span class="kt"&gt;float64&lt;/span&gt;
    &lt;span class="n"&gt;WinAmount&lt;/span&gt;    &lt;span class="kt"&gt;float64&lt;/span&gt;
    &lt;span class="n"&gt;RNGSeed&lt;/span&gt;      &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;CreatedAt&lt;/span&gt;    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;insertSpinBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;SpinRecord&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([][]&lt;/span&gt;&lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spins&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;spins&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{}{&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PlayerID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GameID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Jurisdiction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BetAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WinAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RNGSeed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CopyFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Identifier&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"spins"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"player_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"game_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"jurisdiction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"bet_amount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"win_amount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"rng_seed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CopyFromRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"copy spins: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CopyFrom&lt;/code&gt; uses PostgreSQL's binary COPY protocol. No SQL parsing per row, no per-statement overhead. A batch of 50,000 rows completes in under 2 seconds. PostgreSQL handles the partition routing automatically -- the COPY targets the parent table &lt;code&gt;spins&lt;/code&gt;, and each row lands in the correct monthly partition based on its &lt;code&gt;created_at&lt;/code&gt; value.&lt;/p&gt;

&lt;p&gt;We ran 4 worker goroutines, each with its own connection from a &lt;code&gt;pgxpool.Pool&lt;/code&gt;, consuming batches from a channel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;runIngestWorkers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;SpinRecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;errgroup&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Go&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"acquire conn: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;insertSpinBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Conn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Release&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"insert batch: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The producer read from game provider feeds (HTTP webhooks and message queues), buffered spins into slices of 10,000, and pushed them into the channel. The workers drained the channel and COPYed into Postgres. Backpressure was handled by the channel's buffer size. If all workers were busy, the producer blocked until a slot opened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automated partition creation
&lt;/h2&gt;

&lt;p&gt;If a row arrives and no matching partition exists, PostgreSQL rejects the insert with an error. You need partitions to exist before data arrives. We automated this with a Go goroutine that ran daily:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ensurePartitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monthsAhead&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;monthsAhead&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;partName&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"spins_%d_%02d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Year&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Month&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;rangeStart&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Year&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Month&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;rangeEnd&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;rangeStart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`
            DO $$
            BEGIN
                IF NOT EXISTS (
                    SELECT 1 FROM pg_class WHERE relname = '%s'
                ) THEN
                    EXECUTE format(
                        'CREATE TABLE %s PARTITION OF spins FOR VALUES FROM (%%L) TO (%%L)',
                        '%s', '%s'
                    );
                    RAISE NOTICE 'Created partition %s';
                END IF;
            END $$;
        `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rangeStart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RFC3339&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;rangeEnd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RFC3339&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;partName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ensure partition %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We called this with &lt;code&gt;monthsAhead = 3&lt;/code&gt; so there were always 3 future partitions ready. If the job failed one day, there was a 3-month buffer before any inserts would be rejected. The function is idempotent. Running it twice does nothing the second time.&lt;/p&gt;

&lt;p&gt;Partition dropping for old analytics data followed the same pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;dropOldPartitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepMonths&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;keepMonths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cutoffName&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s_%d_%02d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Year&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Month&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`
        SELECT inhrelid::regclass::text AS partition_name
        FROM pg_inherits
        JOIN pg_class ON pg_class.oid = inhrelid
        WHERE inhparent = $1::regclass
        ORDER BY partition_name
    `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"list partitions: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scan partition: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoffName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DROP TABLE IF EXISTS %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"drop %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dropped partition %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One function creates future partitions. Another drops expired ones. Both are idempotent and safe to run on any schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;After migrating to partitioned tables, here is what changed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before (single table)&lt;/th&gt;
&lt;th&gt;After (monthly partitions)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily revenue query&lt;/td&gt;
&lt;td&gt;14 seconds&lt;/td&gt;
&lt;td&gt;83ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data deletion (90-day cleanup)&lt;/td&gt;
&lt;td&gt;40 minutes + vacuum&lt;/td&gt;
&lt;td&gt;Sub-second DROP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total index size&lt;/td&gt;
&lt;td&gt;54 GB&lt;/td&gt;
&lt;td&gt;8 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert throughput (COPY, 4 workers)&lt;/td&gt;
&lt;td&gt;~45,000 rows/sec&lt;/td&gt;
&lt;td&gt;~48,000 rows/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replication lag during deletes&lt;/td&gt;
&lt;td&gt;2-5 minutes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reindex time (single month)&lt;/td&gt;
&lt;td&gt;Hours (full table)&lt;/td&gt;
&lt;td&gt;15 seconds (one partition)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Insert throughput was roughly the same. Partitioning does not slow down writes meaningfully. The gains were entirely on the read side, the maintenance side, and the operational sanity side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration
&lt;/h2&gt;

&lt;p&gt;Moving 500M rows from a non-partitioned table to a partitioned one is its own project. We did it without downtime using this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the new partitioned table (&lt;code&gt;spins_new&lt;/code&gt;) with all partitions.&lt;/li&gt;
&lt;li&gt;Start writing to both &lt;code&gt;spins&lt;/code&gt; (old) and &lt;code&gt;spins_new&lt;/code&gt; (new) via dual-write in the application. Both inserts in the same transaction.&lt;/li&gt;
&lt;li&gt;Backfill historical data in batches: &lt;code&gt;INSERT INTO spins_new SELECT * FROM spins WHERE created_at BETWEEN $1 AND $2&lt;/code&gt;. We processed one month at a time, 30 batches total.&lt;/li&gt;
&lt;li&gt;Verify row counts per month between old and new.&lt;/li&gt;
&lt;li&gt;Swap the names: &lt;code&gt;ALTER TABLE spins RENAME TO spins_legacy; ALTER TABLE spins_new RENAME TO spins&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Remove the dual-write code path.&lt;/li&gt;
&lt;li&gt;Drop &lt;code&gt;spins_legacy&lt;/code&gt; after a 2-week safety window.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The backfill took about 4 hours running sequentially (one month at a time to avoid spiking I/O). The rename was instantaneous. Total downtime: zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons from running this at scale
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Partition by month, not by day.&lt;/strong&gt; Daily partitions sound appealing for fine-grained control, but at 10M rows/day you end up with 365 partitions per year. The planner has to evaluate all of them during planning. Monthly partitions (12 per year, 60 for 5 years) keep the partition list manageable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always create partitions ahead of time.&lt;/strong&gt; A missing partition at midnight on the first of the month means rejected inserts. Three months of buffer is cheap insurance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use BRIN for time, B-tree for everything else.&lt;/strong&gt; BRIN on &lt;code&gt;created_at&lt;/code&gt; within each partition is the best tradeoff between index size and query speed for range scans. B-tree on &lt;code&gt;player_id&lt;/code&gt; and &lt;code&gt;game_id&lt;/code&gt; is still necessary for point lookups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DROP is better than DELETE.&lt;/strong&gt; If your retention policy aligns with partition boundaries (and it should), you never need to &lt;code&gt;DELETE&lt;/code&gt; rows. &lt;code&gt;DROP TABLE&lt;/code&gt; is O(1) regardless of row count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitor partition sizes.&lt;/strong&gt; If one month suddenly has 3x the rows of others, something changed in traffic patterns. We tracked &lt;code&gt;pg_total_relation_size()&lt;/code&gt; per partition in our monitoring dashboard as an early warning signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  PostgreSQL as an event store
&lt;/h2&gt;

&lt;p&gt;The gaming industry generates extraordinary volumes of transactional data, and every row matters legally. We evaluated dedicated event stores, time-series databases, and distributed log systems. In the end, PostgreSQL with range partitioning handled 10M events per day, maintained 5 years of history, and answered regulatory queries in milliseconds.&lt;/p&gt;

&lt;p&gt;The tooling was straightforward: Go for the ingest pipeline, &lt;code&gt;pgx&lt;/code&gt; for the COPY protocol, goroutines for parallelism, and PostgreSQL's built-in partitioning for lifecycle management. No Kafka, no ClickHouse, no Cassandra. One relational database with a feature that has been stable since PostgreSQL 10.&lt;/p&gt;

&lt;p&gt;For most applications that generate millions of rows per day, analytics events, IoT telemetry, transaction logs, audit trails, this same pattern applies. Partition by time, index within partitions, drop instead of delete, and automate the lifecycle.&lt;/p&gt;

&lt;p&gt;If you want to test partitioning on a real PostgreSQL instance, &lt;a href="https://noctaploy.io/register" rel="noopener noreferrer"&gt;start with a free database on Noctaploy&lt;/a&gt;. Create a partitioned table, load a few million rows with COPY, and run your queries. Extensions like &lt;code&gt;pg_stat_statements&lt;/code&gt; are available from the dashboard to profile query performance across partitions.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>go</category>
      <category>performance</category>
      <category>partitioning</category>
    </item>
    <item>
      <title>Full-Text Search in PostgreSQL: When You Can Skip Elasticsearch</title>
      <dc:creator>antoniodipinto</dc:creator>
      <pubDate>Thu, 19 Feb 2026 23:13:43 +0000</pubDate>
      <link>https://forem.com/antoniodipinto/full-text-search-in-postgresql-when-you-can-skip-elasticsearch-51f9</link>
      <guid>https://forem.com/antoniodipinto/full-text-search-in-postgresql-when-you-can-skip-elasticsearch-51f9</guid>
      <description>&lt;h2&gt;
  
  
  The search bar that almost became a new service
&lt;/h2&gt;

&lt;p&gt;Someone opens a ticket: "We need a search bar on the articles page." The team huddles. One person suggests Elasticsearch. Another says Algolia. Someone mentions Meilisearch. Within ten minutes the discussion has shifted from "add a search bar" to "provision a new cluster, sync data with a pipeline, handle eventual consistency, and add a new service to the on-call rotation."&lt;/p&gt;

&lt;p&gt;Meanwhile, the articles already live in PostgreSQL.&lt;/p&gt;

&lt;p&gt;The first attempt is usually &lt;code&gt;ILIKE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%connection pooling%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;  &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%connection pooling%'&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works in development with 500 rows. In production with 2 million rows it takes 3 seconds, returns results in no particular order of relevance, and cannot distinguish "connection pooling" from "the connection to the pooling facility." There is no stemming, no ranking, no concept of how well a document matches.&lt;/p&gt;

&lt;p&gt;The fix is not a new service. It is five lines of SQL using features PostgreSQL has shipped since version 8.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why LIKE fails at scale
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ILIKE '%term%'&lt;/code&gt; forces PostgreSQL to read every row in the table. A B-tree index cannot help because the pattern has a leading wildcard. There is no shortcut -- Postgres must scan the full text of every column in every row, check for a substring match, and discard the rest.&lt;/p&gt;

&lt;p&gt;On a table with 2 million articles, that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seq Scan on articles  (cost=0.00..389420.00 rows=1843 width=312)
                      (actual time=12.881..3042.617 rows=347 loops=1)
  Filter: ((title ~~* '%connection pooling%') OR (body ~~* '%connection pooling%'))
  Rows Removed by Filter: 1999653
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nearly 2 million rows scanned, 3 seconds of wall time, all to return 347 results with no ranking. Every time a user types a query and hits enter, the database does the same full scan again.&lt;/p&gt;

&lt;p&gt;Beyond performance, &lt;code&gt;ILIKE&lt;/code&gt; has no understanding of language. It cannot match "deploying" when the user searches for "deploy." It does not remove stop words like "the" or "is." It returns results in insertion order, not relevance order. For anything beyond a toy prototype, you need something better.&lt;/p&gt;

&lt;h2&gt;
  
  
  What tsvector and tsquery are
&lt;/h2&gt;

&lt;p&gt;PostgreSQL has a built-in document search engine. It works with two data types: &lt;code&gt;tsvector&lt;/code&gt; and &lt;code&gt;tsquery&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;tsvector&lt;/code&gt; is a sorted list of normalized words (called lexemes) extracted from text. PostgreSQL tokenizes the input, applies stemming (reducing "running" to "run", "deployed" to "deploy"), and removes stop words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Deploying PostgreSQL with zero-downtime migrations'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'deploy':1 'migrat':5 'postgresql':2 'zero':4 'zero-downtim':3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice: "Deploying" became "deploy", "migrations" became "migrat", and the stop word "with" disappeared. Each lexeme has a position number.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;tsquery&lt;/code&gt; is a search expression. It supports boolean operators (&lt;code&gt;&amp;amp;&lt;/code&gt; for AND, &lt;code&gt;|&lt;/code&gt; for OR, &lt;code&gt;!&lt;/code&gt; for NOT):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'deploy' &amp;amp; 'postgr'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@@&lt;/code&gt; operator checks whether a &lt;code&gt;tsvector&lt;/code&gt; matches a &lt;code&gt;tsquery&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Deploying PostgreSQL with zero-downtime migrations'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user searched for "deploy" and "postgres." The document contains "Deploying" and "PostgreSQL." The stemmer reduced both sides to the same lexemes, and the match succeeds. This is the core of PostgreSQL full-text search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it fast with a GIN index
&lt;/h2&gt;

&lt;p&gt;Without an index, every &lt;code&gt;@@&lt;/code&gt; query still requires a sequential scan. PostgreSQL has to compute &lt;code&gt;to_tsvector&lt;/code&gt; for every row and check it against the query. On 2 million rows, this is faster than &lt;code&gt;ILIKE&lt;/code&gt; (because the matching is more efficient), but still slow.&lt;/p&gt;

&lt;p&gt;A GIN (Generalized Inverted Index) solves this. GIN indexes map each lexeme to the list of rows that contain it. When a query arrives, PostgreSQL looks up the lexemes in the index and intersects the row lists. No table scan needed.&lt;/p&gt;

&lt;p&gt;You can create a GIN index directly on an expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_articles_fts&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there is a better approach for production. Add a stored generated column that precomputes the &lt;code&gt;tsvector&lt;/code&gt;, then index that column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="n"&gt;tsvector&lt;/span&gt;
  &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_articles_search&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated column updates automatically whenever &lt;code&gt;title&lt;/code&gt; or &lt;code&gt;body&lt;/code&gt; changes. The GIN index stays in sync. Now a search query hits the index instead of scanning the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit  (cost=52.08..117.34 rows=20 width=268)
       (actual time=0.412..0.931 rows=20 loops=1)
  -&amp;gt;  Bitmap Heap Scan on articles  (cost=52.08..11342.19 rows=3467 width=268)
        (actual time=0.410..0.924 rows=20 loops=1)
        Recheck Cond: (search_vector @@ '''deploy'' &amp;amp; ''postgr'''::tsquery)
        -&amp;gt;  Bitmap Index Scan on idx_articles_search  (cost=0.00..51.21 rows=3467 width=0)
              (actual time=0.287..0.288 rows=347 loops=1)
              Index Cond: (search_vector @@ '''deploy'' &amp;amp; ''postgr'''::tsquery)
Planning Time: 0.134 ms
Execution Time: 0.972 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From 3,042ms to under 1ms. The GIN index looked up "deploy" and "postgr" in the inverted index, intersected the results, and returned matching rows without touching the rest of the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranking results
&lt;/h2&gt;

&lt;p&gt;Finding matching documents is only half the problem. Users expect the most relevant result first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ts_rank&lt;/code&gt; scores each document based on how often the query terms appear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For longer documents where term proximity matters more than raw frequency, &lt;code&gt;ts_rank_cd&lt;/code&gt; (cover density ranking) gives better results. It rewards documents where the search terms appear close together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank_cd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also highlight matching terms in the output with &lt;code&gt;ts_headline&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ts_headline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                   &lt;span class="s1"&gt;'StartSel=&amp;lt;b&amp;gt;, StopSel=&amp;lt;/b&amp;gt;, MaxFragments=2'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;snippet&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns text fragments with matching terms wrapped in &lt;code&gt;&amp;lt;b&amp;gt;&lt;/code&gt; tags, ready to display in a search results page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phrase search and advanced queries
&lt;/h2&gt;

&lt;p&gt;Basic &lt;code&gt;to_tsquery&lt;/code&gt; splits terms into individual lexemes connected by boolean operators. For phrase search, where word order and proximity matter, PostgreSQL offers more specific functions.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;phraseto_tsquery&lt;/code&gt; matches terms that appear next to each other in the document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;phraseto_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'connection pooling'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only matches documents where "connection" and "pooling" (after stemming) appear adjacent, not scattered across the text.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;websearch_to_tsquery&lt;/code&gt; (PostgreSQL 11+) accepts the kind of input users actually type into search bars:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'"connection pooling" -pgbouncer'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matches the exact phrase "connection pooling" but excludes documents containing "pgbouncer." The syntax is close to what Google users expect: quotes for phrases, minus for exclusion.&lt;/p&gt;

&lt;p&gt;Prefix matching works with the &lt;code&gt;:*&lt;/code&gt; suffix, useful for search-as-you-type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'depl:*'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matches "deploy", "deployment", "deploying", and any other word that stems to a lexeme starting with "depl."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fuzzy and typo-tolerant search with pg_trgm
&lt;/h2&gt;

&lt;p&gt;Full-text search requires exact lexeme matches after stemming. If a user types "postgre" instead of "postgres", &lt;code&gt;tsvector&lt;/code&gt; search returns nothing. For typo tolerance and fuzzy matching, the &lt;code&gt;pg_trgm&lt;/code&gt; extension fills the gap.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pg_trgm&lt;/code&gt; breaks strings into trigrams (three-character sequences) and compares the overlap. The &lt;code&gt;similarity&lt;/code&gt; function returns a score between 0 and 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'postgre'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'postgresql'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- 0.5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A GIN index with trigram operators makes fuzzy search fast on large tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_articles_title_trgm&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'postgre'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="s1"&gt;'postgre'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;%&lt;/code&gt; operator returns rows where the similarity exceeds the threshold (default 0.3, adjustable with &lt;code&gt;SET pg_trgm.similarity_threshold&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;A practical pattern is to combine both approaches. Use &lt;code&gt;pg_trgm&lt;/code&gt; for the autocomplete dropdown (matching partial, possibly misspelled input against titles) and &lt;code&gt;tsvector&lt;/code&gt; for the full results page (matching stemmed terms across title and body with ranking).&lt;/p&gt;

&lt;p&gt;For applications that serve users typing in multiple languages or with accented characters, the &lt;code&gt;unaccent&lt;/code&gt; extension normalizes text so that "cafe" matches "cafe":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;unaccent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;unaccent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deploiement rapide du service de base de donnees'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- 'Deploiement rapide du service de base de donnees'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can create a custom text search configuration that chains &lt;code&gt;unaccent&lt;/code&gt; with the standard dictionary, so accented and unaccented input produce the same lexemes.&lt;/p&gt;

&lt;h2&gt;
  
  
  When PostgreSQL search is enough
&lt;/h2&gt;

&lt;p&gt;PostgreSQL full-text search is not a toy. It handles real workloads. Here are practical thresholds where it works well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Corpus size&lt;/strong&gt;: up to tens of millions of documents. GIN index lookups are fast, and the index stays compact relative to the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query volume&lt;/strong&gt;: a few hundred queries per second is comfortable for a well-indexed setup. For most applications with a search bar, this is far more than needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language support&lt;/strong&gt;: PostgreSQL ships with configurations for over 20 languages. If your corpus is in one or a handful of known languages, built-in support covers it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature set&lt;/strong&gt;: stemming, ranking, phrase search, prefix matching, highlighting, boolean operators, and fuzzy matching with &lt;code&gt;pg_trgm&lt;/code&gt;. This covers the needs of most CRUD applications with a search feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational simplicity&lt;/strong&gt;: the data already lives in PostgreSQL. There is no sync pipeline, no second cluster, no replication lag between the source of truth and the search index. One database, one connection string, one backup strategy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When you actually need a dedicated search engine
&lt;/h2&gt;

&lt;p&gt;There are workloads where PostgreSQL is not the right tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Faceted navigation with aggregated counts&lt;/strong&gt;. An e-commerce catalog where users filter by brand, price range, color, and size, and every filter shows a live count of matching products. Elasticsearch was designed for this. PostgreSQL can approximate it with &lt;code&gt;GROUP BY&lt;/code&gt; and CTEs, but it is slower and harder to maintain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hundreds of millions of documents with sub-10ms p99&lt;/strong&gt;. At this scale, Elasticsearch's distributed architecture and in-memory segment caching give it an edge that a single PostgreSQL instance cannot match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex scoring pipelines&lt;/strong&gt;. Learning-to-rank, boosting by recency or popularity, vector-plus-keyword hybrid search. These are possible in PostgreSQL but require significant custom SQL, while dedicated search engines provide them as configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic multi-language corpora&lt;/strong&gt;. If documents arrive in dozens of languages and you need automatic language detection per document, a dedicated engine handles this more gracefully.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key question is not "can PostgreSQL do search?" It can. The question is whether you need capabilities that justify operating a second system. Most applications with a search bar over their own data never reach that threshold. Starting with PostgreSQL and migrating later if needed is a lower-risk path than introducing Elasticsearch on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;Here is a minimal setup you can run against any PostgreSQL 12+ database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="n"&gt;tsvector&lt;/span&gt;
  &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_articles_search&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'full text search postgres'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three statements: a generated column, a GIN index, and a ranked search query. No external service, no data pipeline, no new dependency.&lt;/p&gt;

&lt;p&gt;If you want to test this on a real PostgreSQL instance, &lt;a href="https://noctaploy.io/register?ref=devto" rel="noopener noreferrer"&gt;start with a free database on Noctaploy&lt;/a&gt;. Extensions like &lt;code&gt;pg_trgm&lt;/code&gt;, &lt;code&gt;unaccent&lt;/code&gt;, and &lt;code&gt;btree_gin&lt;/code&gt; are pre-installed and can be enabled from the dashboard with one click. Create your GIN index, run your first &lt;code&gt;ts_rank&lt;/code&gt; query, and see if PostgreSQL covers your search needs before reaching for anything else.&lt;/p&gt;

&lt;p&gt;This article was originally published on &lt;a href="https://noctaploy.io/?ref=devto" rel="noopener noreferrer"&gt;Noctaploy.io.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>elasticsearch</category>
    </item>
    <item>
      <title>Full-Text Search in PostgreSQL: When You Can Skip Elasticsearch</title>
      <dc:creator>antoniodipinto</dc:creator>
      <pubDate>Thu, 19 Feb 2026 23:13:43 +0000</pubDate>
      <link>https://forem.com/antoniodipinto/full-text-search-in-postgresql-when-you-can-skip-elasticsearch-4ael</link>
      <guid>https://forem.com/antoniodipinto/full-text-search-in-postgresql-when-you-can-skip-elasticsearch-4ael</guid>
      <description>&lt;h2&gt;
  
  
  The search bar that almost became a new service
&lt;/h2&gt;

&lt;p&gt;Someone opens a ticket: "We need a search bar on the articles page." The team huddles. One person suggests Elasticsearch. Another says Algolia. Someone mentions Meilisearch. Within ten minutes the discussion has shifted from "add a search bar" to "provision a new cluster, sync data with a pipeline, handle eventual consistency, and add a new service to the on-call rotation."&lt;/p&gt;

&lt;p&gt;Meanwhile, the articles already live in PostgreSQL.&lt;/p&gt;

&lt;p&gt;The first attempt is usually &lt;code&gt;ILIKE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%connection pooling%'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;  &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%connection pooling%'&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works in development with 500 rows. In production with 2 million rows it takes 3 seconds, returns results in no particular order of relevance, and cannot distinguish "connection pooling" from "the connection to the pooling facility." There is no stemming, no ranking, no concept of how well a document matches.&lt;/p&gt;

&lt;p&gt;The fix is not a new service. It is five lines of SQL using features PostgreSQL has shipped since version 8.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why LIKE fails at scale
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ILIKE '%term%'&lt;/code&gt; forces PostgreSQL to read every row in the table. A B-tree index cannot help because the pattern has a leading wildcard. There is no shortcut -- Postgres must scan the full text of every column in every row, check for a substring match, and discard the rest.&lt;/p&gt;

&lt;p&gt;On a table with 2 million articles, that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seq Scan on articles  (cost=0.00..389420.00 rows=1843 width=312)
                      (actual time=12.881..3042.617 rows=347 loops=1)
  Filter: ((title ~~* '%connection pooling%') OR (body ~~* '%connection pooling%'))
  Rows Removed by Filter: 1999653
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nearly 2 million rows scanned, 3 seconds of wall time, all to return 347 results with no ranking. Every time a user types a query and hits enter, the database does the same full scan again.&lt;/p&gt;

&lt;p&gt;Beyond performance, &lt;code&gt;ILIKE&lt;/code&gt; has no understanding of language. It cannot match "deploying" when the user searches for "deploy." It does not remove stop words like "the" or "is." It returns results in insertion order, not relevance order. For anything beyond a toy prototype, you need something better.&lt;/p&gt;

&lt;h2&gt;
  
  
  What tsvector and tsquery are
&lt;/h2&gt;

&lt;p&gt;PostgreSQL has a built-in document search engine. It works with two data types: &lt;code&gt;tsvector&lt;/code&gt; and &lt;code&gt;tsquery&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;tsvector&lt;/code&gt; is a sorted list of normalized words (called lexemes) extracted from text. PostgreSQL tokenizes the input, applies stemming (reducing "running" to "run", "deployed" to "deploy"), and removes stop words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Deploying PostgreSQL with zero-downtime migrations'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'deploy':1 'migrat':5 'postgresql':2 'zero':4 'zero-downtim':3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice: "Deploying" became "deploy", "migrations" became "migrat", and the stop word "with" disappeared. Each lexeme has a position number.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;tsquery&lt;/code&gt; is a search expression. It supports boolean operators (&lt;code&gt;&amp;amp;&lt;/code&gt; for AND, &lt;code&gt;|&lt;/code&gt; for OR, &lt;code&gt;!&lt;/code&gt; for NOT):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'deploy' &amp;amp; 'postgr'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@@&lt;/code&gt; operator checks whether a &lt;code&gt;tsvector&lt;/code&gt; matches a &lt;code&gt;tsquery&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Deploying PostgreSQL with zero-downtime migrations'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user searched for "deploy" and "postgres." The document contains "Deploying" and "PostgreSQL." The stemmer reduced both sides to the same lexemes, and the match succeeds. This is the core of PostgreSQL full-text search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it fast with a GIN index
&lt;/h2&gt;

&lt;p&gt;Without an index, every &lt;code&gt;@@&lt;/code&gt; query still requires a sequential scan. PostgreSQL has to compute &lt;code&gt;to_tsvector&lt;/code&gt; for every row and check it against the query. On 2 million rows, this is faster than &lt;code&gt;ILIKE&lt;/code&gt; (because the matching is more efficient), but still slow.&lt;/p&gt;

&lt;p&gt;A GIN (Generalized Inverted Index) solves this. GIN indexes map each lexeme to the list of rows that contain it. When a query arrives, PostgreSQL looks up the lexemes in the index and intersects the row lists. No table scan needed.&lt;/p&gt;

&lt;p&gt;You can create a GIN index directly on an expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_articles_fts&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there is a better approach for production. Add a stored generated column that precomputes the &lt;code&gt;tsvector&lt;/code&gt;, then index that column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="n"&gt;tsvector&lt;/span&gt;
  &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_articles_search&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated column updates automatically whenever &lt;code&gt;title&lt;/code&gt; or &lt;code&gt;body&lt;/code&gt; changes. The GIN index stays in sync. Now a search query hits the index instead of scanning the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit  (cost=52.08..117.34 rows=20 width=268)
       (actual time=0.412..0.931 rows=20 loops=1)
  -&amp;gt;  Bitmap Heap Scan on articles  (cost=52.08..11342.19 rows=3467 width=268)
        (actual time=0.410..0.924 rows=20 loops=1)
        Recheck Cond: (search_vector @@ '''deploy'' &amp;amp; ''postgr'''::tsquery)
        -&amp;gt;  Bitmap Index Scan on idx_articles_search  (cost=0.00..51.21 rows=3467 width=0)
              (actual time=0.287..0.288 rows=347 loops=1)
              Index Cond: (search_vector @@ '''deploy'' &amp;amp; ''postgr'''::tsquery)
Planning Time: 0.134 ms
Execution Time: 0.972 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From 3,042ms to under 1ms. The GIN index looked up "deploy" and "postgr" in the inverted index, intersected the results, and returned matching rows without touching the rest of the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranking results
&lt;/h2&gt;

&lt;p&gt;Finding matching documents is only half the problem. Users expect the most relevant result first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ts_rank&lt;/code&gt; scores each document based on how often the query terms appear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For longer documents where term proximity matters more than raw frequency, &lt;code&gt;ts_rank_cd&lt;/code&gt; (cover density ranking) gives better results. It rewards documents where the search terms appear close together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank_cd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also highlight matching terms in the output with &lt;code&gt;ts_headline&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ts_headline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                   &lt;span class="s1"&gt;'StartSel=&amp;lt;b&amp;gt;, StopSel=&amp;lt;/b&amp;gt;, MaxFragments=2'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;snippet&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deploy &amp;amp; postgres'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns text fragments with matching terms wrapped in &lt;code&gt;&amp;lt;b&amp;gt;&lt;/code&gt; tags, ready to display in a search results page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phrase search and advanced queries
&lt;/h2&gt;

&lt;p&gt;Basic &lt;code&gt;to_tsquery&lt;/code&gt; splits terms into individual lexemes connected by boolean operators. For phrase search, where word order and proximity matter, PostgreSQL offers more specific functions.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;phraseto_tsquery&lt;/code&gt; matches terms that appear next to each other in the document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;phraseto_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'connection pooling'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only matches documents where "connection" and "pooling" (after stemming) appear adjacent, not scattered across the text.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;websearch_to_tsquery&lt;/code&gt; (PostgreSQL 11+) accepts the kind of input users actually type into search bars:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'"connection pooling" -pgbouncer'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matches the exact phrase "connection pooling" but excludes documents containing "pgbouncer." The syntax is close to what Google users expect: quotes for phrases, minus for exclusion.&lt;/p&gt;

&lt;p&gt;Prefix matching works with the &lt;code&gt;:*&lt;/code&gt; suffix, useful for search-as-you-type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'depl:*'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matches "deploy", "deployment", "deploying", and any other word that stems to a lexeme starting with "depl."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fuzzy and typo-tolerant search with pg_trgm
&lt;/h2&gt;

&lt;p&gt;Full-text search requires exact lexeme matches after stemming. If a user types "postgre" instead of "postgres", &lt;code&gt;tsvector&lt;/code&gt; search returns nothing. For typo tolerance and fuzzy matching, the &lt;code&gt;pg_trgm&lt;/code&gt; extension fills the gap.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pg_trgm&lt;/code&gt; breaks strings into trigrams (three-character sequences) and compares the overlap. The &lt;code&gt;similarity&lt;/code&gt; function returns a score between 0 and 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'postgre'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'postgresql'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- 0.5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A GIN index with trigram operators makes fuzzy search fast on large tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_articles_title_trgm&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'postgre'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="s1"&gt;'postgre'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;%&lt;/code&gt; operator returns rows where the similarity exceeds the threshold (default 0.3, adjustable with &lt;code&gt;SET pg_trgm.similarity_threshold&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;A practical pattern is to combine both approaches. Use &lt;code&gt;pg_trgm&lt;/code&gt; for the autocomplete dropdown (matching partial, possibly misspelled input against titles) and &lt;code&gt;tsvector&lt;/code&gt; for the full results page (matching stemmed terms across title and body with ranking).&lt;/p&gt;

&lt;p&gt;For applications that serve users typing in multiple languages or with accented characters, the &lt;code&gt;unaccent&lt;/code&gt; extension normalizes text so that "cafe" matches "cafe":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;unaccent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;unaccent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deploiement rapide du service de base de donnees'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- 'Deploiement rapide du service de base de donnees'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can create a custom text search configuration that chains &lt;code&gt;unaccent&lt;/code&gt; with the standard dictionary, so accented and unaccented input produce the same lexemes.&lt;/p&gt;

&lt;h2&gt;
  
  
  When PostgreSQL search is enough
&lt;/h2&gt;

&lt;p&gt;PostgreSQL full-text search is not a toy. It handles real workloads. Here are practical thresholds where it works well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Corpus size&lt;/strong&gt;: up to tens of millions of documents. GIN index lookups are fast, and the index stays compact relative to the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query volume&lt;/strong&gt;: a few hundred queries per second is comfortable for a well-indexed setup. For most applications with a search bar, this is far more than needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language support&lt;/strong&gt;: PostgreSQL ships with configurations for over 20 languages. If your corpus is in one or a handful of known languages, built-in support covers it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature set&lt;/strong&gt;: stemming, ranking, phrase search, prefix matching, highlighting, boolean operators, and fuzzy matching with &lt;code&gt;pg_trgm&lt;/code&gt;. This covers the needs of most CRUD applications with a search feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational simplicity&lt;/strong&gt;: the data already lives in PostgreSQL. There is no sync pipeline, no second cluster, no replication lag between the source of truth and the search index. One database, one connection string, one backup strategy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When you actually need a dedicated search engine
&lt;/h2&gt;

&lt;p&gt;There are workloads where PostgreSQL is not the right tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Faceted navigation with aggregated counts&lt;/strong&gt;. An e-commerce catalog where users filter by brand, price range, color, and size, and every filter shows a live count of matching products. Elasticsearch was designed for this. PostgreSQL can approximate it with &lt;code&gt;GROUP BY&lt;/code&gt; and CTEs, but it is slower and harder to maintain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hundreds of millions of documents with sub-10ms p99&lt;/strong&gt;. At this scale, Elasticsearch's distributed architecture and in-memory segment caching give it an edge that a single PostgreSQL instance cannot match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex scoring pipelines&lt;/strong&gt;. Learning-to-rank, boosting by recency or popularity, vector-plus-keyword hybrid search. These are possible in PostgreSQL but require significant custom SQL, while dedicated search engines provide them as configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic multi-language corpora&lt;/strong&gt;. If documents arrive in dozens of languages and you need automatic language detection per document, a dedicated engine handles this more gracefully.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key question is not "can PostgreSQL do search?" It can. The question is whether you need capabilities that justify operating a second system. Most applications with a search bar over their own data never reach that threshold. Starting with PostgreSQL and migrating later if needed is a lower-risk path than introducing Elasticsearch on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;Here is a minimal setup you can run against any PostgreSQL 12+ database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="n"&gt;tsvector&lt;/span&gt;
  &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_articles_search&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'full text search postgres'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three statements: a generated column, a GIN index, and a ranked search query. No external service, no data pipeline, no new dependency.&lt;/p&gt;

&lt;p&gt;If you want to test this on a real PostgreSQL instance, &lt;a href="https://noctaploy.io/register?ref=devto" rel="noopener noreferrer"&gt;start with a free database on Noctaploy&lt;/a&gt;. Extensions like &lt;code&gt;pg_trgm&lt;/code&gt;, &lt;code&gt;unaccent&lt;/code&gt;, and &lt;code&gt;btree_gin&lt;/code&gt; are pre-installed and can be enabled from the dashboard with one click. Create your GIN index, run your first &lt;code&gt;ts_rank&lt;/code&gt; query, and see if PostgreSQL covers your search needs before reaching for anything else.&lt;/p&gt;

&lt;p&gt;This article was originally published on &lt;a href="https://noctaploy.io/?ref=devto" rel="noopener noreferrer"&gt;Noctaploy.io.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>elasticsearch</category>
    </item>
    <item>
      <title>How I Turned Feedback into Fuel: A Solopreneur’s Journey</title>
      <dc:creator>antoniodipinto</dc:creator>
      <pubDate>Mon, 22 Jul 2024 20:24:09 +0000</pubDate>
      <link>https://forem.com/antoniodipinto/how-i-turned-feedback-into-fuel-a-solopreneurs-journey-4kpg</link>
      <guid>https://forem.com/antoniodipinto/how-i-turned-feedback-into-fuel-a-solopreneurs-journey-4kpg</guid>
      <description>&lt;p&gt;Hey! Ever felt like networking is the final boss level in the game of solopreneurship? You’re not alone. I’m a developer, and for me, networking was like debugging a never-ending loop — confusing and frustrating. But guess what? I decided to embrace the “sharing is caring” mantra, the social/marketing/whatever-you-want-to-call-it approach. I’m working on &lt;a href="https://feedrika.com" rel="noopener noreferrer"&gt;feedrika.com&lt;/a&gt;, a news API service.&lt;/p&gt;

&lt;p&gt;So, about two weeks ago, I gathered my courage, got over my fears, and began posting about my project everywhere, asking for &lt;a href="https://dev.to/antoniodipinto/developed-a-news-api-service-looking-for-feedback-3e41"&gt;feedback&lt;/a&gt;. And that’s when &lt;a class="mentioned-user" href="https://dev.to/ayush_saran"&gt;@ayush_saran&lt;/a&gt; entered the scene. He dropped some feedback, and we started chatting. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Now, imagine someone giving you advice on the product you built — initially, it felt like being schooled. But Ayush’s suggestions were pure gold.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He didn’t just stop at feedback; he built an MVP for his ideas!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxw9v5wehue4oogvfs3al.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxw9v5wehue4oogvfs3al.png" alt="P.R. War" width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One of his brilliant creations is &lt;a href="https://pr-war.fly.dev/" rel="noopener noreferrer"&gt;P.R. War&lt;/a&gt;, an app that shows who’s winning the public relations battle between any two topics. A light bulb went off in my head — with the US elections around the corner, what if I use this to check the sentiment between Kamala Harris and Trump? Boom! Instant marketing angle! Check it out: &lt;a href="https://pr-war.fly.dev/battle?topic_a=kamala&amp;amp;topic_b=trump" rel="noopener noreferrer"&gt;Kamala vs. Trump&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This experience was a revelation. It really made me understand the importance of connections and feedback. Just by asking for feedback, someone ended up using feedrika.com to build something cool. So, if you’re feeling disheartened, remember this: keep connecting, keep pushing. Solopreneurship is a journey that rewards persistence.&lt;/p&gt;

&lt;p&gt;Now I see why platforms like Product Hunt and Reddit give badges based on your activity. It’s all about consistency and staying engaged. So, don’t lose hope. Share your journey, seek feedback, and who knows? You might stumble upon the next big thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Oh, and a fun side note: I tried to buy the domain kamalavstrump.com but was already too late 😅.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0m59j0j3o2ib6i0o1wj9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0m59j0j3o2ib6i0o1wj9.png" alt="namecheap" width="800" height="164"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>startup</category>
      <category>api</category>
      <category>networking</category>
    </item>
    <item>
      <title>From Angular Advocate to React Enthusiast: My Journey to the Other Side</title>
      <dc:creator>antoniodipinto</dc:creator>
      <pubDate>Mon, 22 Jul 2024 08:32:39 +0000</pubDate>
      <link>https://forem.com/antoniodipinto/from-angular-advocate-to-react-enthusiast-my-journey-to-the-other-side-2akm</link>
      <guid>https://forem.com/antoniodipinto/from-angular-advocate-to-react-enthusiast-my-journey-to-the-other-side-2akm</guid>
      <description>&lt;p&gt;When it comes to front-end development, I’ve always been an Angular advocate. Angular was my go-to for all my apps, especially since I developed a lot of Ionic mobile applications, which were initially based on Angular or pure JavaScript. My comfort zone was solidified by years of Angular development, but the day came when I decided to venture into the world of React.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Beginning: My Angular Comfort Zone
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flqfy2hly6uz8qkt6062y.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flqfy2hly6uz8qkt6062y.gif" alt="Source https://giphy.com/gifs/life-advice-sweat-1hMdWxlfKh13NOMRvP" width="659" height="652"&gt;&lt;/a&gt;&lt;br&gt;
My journey started with Angular, and I stuck with it through thick and thin. I appreciated its structured approach and how it seamlessly integrated with Ionic for mobile app development.** When Ionic added support for React, I was skeptical and assumed it was just a marketing gimmick**. After all, I’d been building software since 2012 and had tried one of the early versions of React around 2015/2016. My initial experience was less than stellar. Back in the day, React was a mess, props were everywhere, nested within other props, tangled and confusing. The “component” theory was still in its infancy, and JSX felt awkward and clunky. Frustrated, I switched to Angular.js (yes, is not a typo, is with .js at the end, I’m that old) and didn’t look back.&lt;/p&gt;

&lt;p&gt;For the next eight years, I built production web apps, mobile apps, and custom dashboards with Angular. It served me well, especially since I always focused more on backend development, treating the frontend as a necessary task rather than a passion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The React Boom
&lt;/h2&gt;

&lt;p&gt;Over the years, I noticed the React community growing rapidly. People were raving about React being the future of front-end development. Despite the hype, I was content with my Angular setup. I’d given React a shot before and felt no need to revisit it.&lt;/p&gt;

&lt;p&gt;However, curiosity eventually got the best of me. Curiosity led me to try Next.js because I needed a server-side rendering (SSR) solution, and Angular was proving to be a pain for this purpose. This is where everything changed. Next.js introduced me to a whole new world.&lt;/p&gt;

&lt;p&gt;Typescript, React, SSR, and an endless array of libraries for anything I could think of. The number of pre-made components blew my mind. I whipped up a fully functional app in just one day, with login, authentication, and Stripe integration all set up. The productivity boost was unreal. &lt;strong&gt;I was able to build the frontend and the payment logic for &lt;a href="https://feedrika.com" rel="noopener noreferrer"&gt;Feedrika&lt;/a&gt; a News API Service in just 1 day&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsw3a89672hgk2tf06vyo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsw3a89672hgk2tf06vyo.gif" alt="Source https://giphy.com/gifs/fallontonight-jimmy-fallon-no-way-tonightshow-S8PgGLIp9gHjuE1qcm" width="480" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I found myself being more efficient with a framework that was new to me than with Angular, a framework I’d been using for years. It was a shocking revelation.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This experience completely changed how I see React. I went from being a skeptic to seeing its potential. The flexibility of React’s ecosystem and the incredible community support really impressed me. &lt;strong&gt;While I’m not ditching Angular, the productivity boost I got from React was shocking, and I plan to keep exploring this direction.&lt;/strong&gt; My journey taught me the importance of staying open to new technologies and continuously evolving as a developer.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>react</category>
    </item>
    <item>
      <title>Building Feedrika A News API Service: The Journey Continues</title>
      <dc:creator>antoniodipinto</dc:creator>
      <pubDate>Fri, 12 Jul 2024 18:46:11 +0000</pubDate>
      <link>https://forem.com/antoniodipinto/building-feedrika-a-news-api-service-the-journey-continues-1b2h</link>
      <guid>https://forem.com/antoniodipinto/building-feedrika-a-news-api-service-the-journey-continues-1b2h</guid>
      <description>&lt;p&gt;In my first article on &lt;a href="https://medium.com/@antoniodipinto/ive-built-a-news-api-service-and-here-s-why-a7bc65a26272" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;, I shared the inception of &lt;strong&gt;Feedrika&lt;/strong&gt;, a news API service designed to simplify access to diverse news sources for developers and businesses. The response has been overwhelmingly positive, and I’m thrilled to continue this journey with you all. Today, I want to dive deeper into the challenges and strategies involved in growing Feedrika from idea to a real startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Navigating the Marketing Maze
&lt;/h2&gt;

&lt;p&gt;Launching a tech product is only half the battle, the real challenge lies in marketing it effectively. For Feedrika, this has meant diving headfirst into various online communities and platforms. Engaging with Reddit communities has been particularly impactful. These communities, or subreddits, are treasure troves of niche interests and discussions. By identifying and participating in the right subreddits, I’ve managed to share updates, seek feedback, and participate in discussions that drive traffic and provide invaluable insights into user needs.&lt;/p&gt;

&lt;p&gt;Twitter has also been a powerful tool for connecting with potential users. Regularly tweeting about updates, sharing insightful content, &lt;a href="https://x.com/feedrika" rel="noopener noreferrer"&gt;I also created a news like profile on X&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Platforms like Startupbase.io and Product Hunt are essential for gaining visibility in the startup ecosystem. Listing Feedrika on these sites has driven significant traffic and user engagement. The feedback from these communities has been crucial for refining my product and understanding market demands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Learning to Sell
&lt;/h2&gt;

&lt;p&gt;Selling a product, especially a tech service like Feedrika and especially for a developer like me, it's hard and is an art that requires constant learning and adaptation. Understanding the audience it’s a big task itself. Knowing who the potential users are and what they need is crucial. For Feedrika, this means understanding the pain points of developers and businesses looking for reliable and affordable news data.&lt;/p&gt;

&lt;p&gt;Highlighting Feedrika’s unique features has also been critical. I offer several unique features such as real-time updates, webhooks (soon), easy integration (trust me it’s a feature). Highlighting these strengths in all communications… it’s a challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overcoming Fear
&lt;/h2&gt;

&lt;p&gt;One of the most significant challenges has been overcoming the fear of doing something wrong and the fear of looking like a pushy seller. It’s easy to get caught up in self-doubt and worry about making mistakes. Embracing these fears and pushing through them has been crucial in building and marketing Feedrika.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Presenting and promoting Feedrika without feeling like a traditional salesman is a delicate balance. Authenticity is key. Sharing genuine stories (like I’m doing here), successes, and even failures helps create a connection with the audience and builds trust. It’s about showing passion for what Feedrika can do rather than just trying to sell a product.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Road Ahead
&lt;/h2&gt;

&lt;p&gt;The journey of building and marketing Feedrika is far from over. Each day brings new challenges and opportunities. My goal is to continue refining my product, expanding the user base, and ensuring that Feedrika remains a valuable tool for accessing news data.&lt;/p&gt;

&lt;p&gt;I am incredibly grateful for the support and feedback from the community. If you haven’t already, I invite you to check out Feedrika at &lt;a href="https://feedrika.com" rel="noopener noreferrer"&gt;feedrika.com&lt;/a&gt; and join me on this exciting journey. Your insights and support are what make this venture possible.&lt;/p&gt;

&lt;p&gt;Stay tuned for more updates, and feel free to reach out if you have any questions or feedback. Let’s keep building something amazing together!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>saas</category>
      <category>news</category>
    </item>
    <item>
      <title>Developed a News API Service - Looking for Feedback!</title>
      <dc:creator>antoniodipinto</dc:creator>
      <pubDate>Mon, 08 Jul 2024 08:18:41 +0000</pubDate>
      <link>https://forem.com/antoniodipinto/developed-a-news-api-service-looking-for-feedback-3e41</link>
      <guid>https://forem.com/antoniodipinto/developed-a-news-api-service-looking-for-feedback-3e41</guid>
      <description>&lt;p&gt;Hello everyone,&lt;/p&gt;

&lt;p&gt;Hope you're all doing great!&lt;/p&gt;

&lt;p&gt;I’ve been struggling to find an affordable news API service for a while, so I decided to build one myself. After a lot of hard work, it’s finally up and running at feedrika.com.&lt;/p&gt;

&lt;p&gt;If you're interested in checking it out and giving me some feedback, I’d really appreciate it. Your insights would be super valuable in helping me make it better.&lt;/p&gt;

&lt;p&gt;No pressure, though—just thought I'd share something that solved a pain point for me and might help others too.&lt;/p&gt;

&lt;p&gt;Thanks a ton in advance!&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>ai</category>
      <category>news</category>
    </item>
  </channel>
</rss>
