<?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>DEV Community: Damaso Sanoja</title>
    <description>The latest articles on DEV Community by Damaso Sanoja (@damasosanoja).</description>
    <link>https://dev.to/damasosanoja</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%2F346479%2F3b8ceb9d-fe63-4052-8d28-4728bceb7111.jpeg</url>
      <title>DEV Community: Damaso Sanoja</title>
      <link>https://dev.to/damasosanoja</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/damasosanoja"/>
    <language>en</language>
    <item>
      <title>Database Observability: An Engineer's Guide to Full-Stack Monitoring Across SQL, NoSQL, and Cloud Databases</title>
      <dc:creator>Damaso Sanoja</dc:creator>
      <pubDate>Wed, 08 Apr 2026 18:08:17 +0000</pubDate>
      <link>https://dev.to/damasosanoja/database-observability-an-engineers-guide-to-full-stack-monitoring-across-sql-nosql-and-cloud-1b3o</link>
      <guid>https://dev.to/damasosanoja/database-observability-an-engineers-guide-to-full-stack-monitoring-across-sql-nosql-and-cloud-1b3o</guid>
      <description>&lt;p&gt;Nobody plans a three-dashboard monitoring setup. It grows on its own. You deploy &lt;a href="https://dev.mysql.com/doc/" rel="noopener noreferrer"&gt;MySQL&lt;/a&gt;, so you add &lt;code&gt;mysqld_exporter&lt;/code&gt;. The team moves a workload to RDS, so you wire up a CloudWatch integration. Then &lt;a href="https://www.mongodb.com/docs/atlas/" rel="noopener noreferrer"&gt;MongoDB Atlas&lt;/a&gt; enters the stack, and Atlas ships its own metrics view. Three databases, three dashboards, three alert pipelines, zero correlation between them.&lt;/p&gt;

&lt;p&gt;At 2:47am, that fragmentation has a price. A &lt;a href="https://one2n.io/blog/sre-math-percentiles-in-sre-why-averages-lie-about-latency" rel="noopener noreferrer"&gt;p99&lt;/a&gt; latency spike fires an alert, and you spend fifteen minutes switching between tools before tracing it to a missing index. The data existed in three places. The relationship between those data points existed in none.&lt;/p&gt;

&lt;p&gt;That gap is the difference between &lt;a href="https://www.site24x7.com/what-is-database-monitoring.html" rel="noopener noreferrer"&gt;metric collection&lt;/a&gt; and observability. Metric collection tells you something crossed a threshold. Observability gives you the distributed trace connecting an application service, a SQL statement, host disk I/O, and a slow query log entry into one causal chain, so you can answer &lt;em&gt;why&lt;/em&gt; without adding new instrumentation after the incident starts.&lt;/p&gt;

&lt;p&gt;Most production environments already run this kind of mixed stack. &lt;a href="https://www.postgresql.org/" rel="noopener noreferrer"&gt;PostgreSQL&lt;/a&gt; handles transactional writes, MongoDB stores document data, Aurora or RDS manages read-heavy workloads, and a Redis or Memcached caching layer sits adjacent to all of it. This guide focuses on primary data stores: SQL, NoSQL, and cloud-managed databases. Caching layers have a different telemetry profile and are outside scope here. Each engine has a different telemetry model, a different collection method, and a different set of signals that actually predict trouble. Stitching observability across the full mix is the hard part, and it starts with knowing which signals to watch per engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually monitor, by database type
&lt;/h2&gt;

&lt;p&gt;A single &lt;a href="https://github.com/prometheus/mysqld_exporter" rel="noopener noreferrer"&gt;&lt;code&gt;mysqld_exporter&lt;/code&gt;&lt;/a&gt; instance can publish hundreds of Prometheus series. &lt;a href="https://www.postgresql.org/docs/current/monitoring-stats.html" rel="noopener noreferrer"&gt;PostgreSQL's statistics collector&lt;/a&gt; exposes a comparable volume. During an incident, almost none of that matters. What matters is the handful of signals that predict user-facing degradation before it becomes a page.&lt;/p&gt;

&lt;h3&gt;
  
  
  SQL databases: PostgreSQL and MySQL
&lt;/h3&gt;

&lt;p&gt;The signals worth watching for PostgreSQL and MySQL:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Query latency at p50, p95, and p99.&lt;/strong&gt; Average latency hides the outliers your users actually feel. A mean of 12ms tells you nothing if the p99 is 800ms, because that 1% of slow requests lands on real user sessions and drives timeout errors, retry storms, and SLA breaches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active connections versus connection limit.&lt;/strong&gt; On PostgreSQL, compare &lt;code&gt;numbackends&lt;/code&gt; in &lt;a href="https://www.postgresql.org/docs/current/monitoring-stats.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_stat_database&lt;/code&gt;&lt;/a&gt; against &lt;a href="https://www.postgresql.org/docs/current/runtime-config-connection.html" rel="noopener noreferrer"&gt;&lt;code&gt;max_connections&lt;/code&gt;&lt;/a&gt;. On MySQL, compare &lt;code&gt;Threads_connected&lt;/code&gt; from &lt;code&gt;SHOW GLOBAL STATUS&lt;/code&gt; against the &lt;a href="https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_max_connections" rel="noopener noreferrer"&gt;&lt;code&gt;max_connections&lt;/code&gt;&lt;/a&gt; system variable. Connection saturation causes query queuing before it causes timeouts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache hit ratio.&lt;/strong&gt; On PostgreSQL, that's &lt;code&gt;heap_blks_hit / (heap_blks_hit + heap_blks_read)&lt;/code&gt; from &lt;a href="https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STATIO-ALL-TABLES-VIEW" rel="noopener noreferrer"&gt;&lt;code&gt;pg_statio_user_tables&lt;/code&gt;&lt;/a&gt;. A ratio &lt;a href="https://www.red-gate.com/hub/product-learning/redgate-monitor/understanding-postgresqls-cache-hit-ratio" rel="noopener noreferrer"&gt;below 95% signals trouble; aim for 99%&lt;/a&gt;. On MySQL, the equivalent is the &lt;a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool.html" rel="noopener noreferrer"&gt;InnoDB buffer pool hit ratio&lt;/a&gt;: &lt;code&gt;1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)&lt;/code&gt; from &lt;code&gt;SHOW GLOBAL STATUS&lt;/code&gt;, where the same 99%+ target applies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replication lag&lt;/strong&gt; in seconds. On PostgreSQL, query &lt;a href="https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-REPLICATION-VIEW" rel="noopener noreferrer"&gt;&lt;code&gt;pg_stat_replication&lt;/code&gt;&lt;/a&gt; for &lt;code&gt;replay_lag&lt;/code&gt;. Lag that climbs steadily means replicas are falling behind on writes, and read queries hitting those replicas will return stale data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.postgresql.org/docs/current/explicit-locking.html" rel="noopener noreferrer"&gt;Lock wait count&lt;/a&gt;.&lt;/strong&gt; Rising lock contention is the precursor to deadlocks. A sustained increase in waiting locks means transactions are blocking each other, and throughput will degrade before any single query times out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow query rate&lt;/strong&gt; over a rolling window. A sudden increase in the proportion of queries exceeding your slow-query threshold (typically 100ms-1s depending on workload) signals a regression, whether from a bad deployment, plan change, or resource contention.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of these signals aren't surfaced in default dashboards. You need to query them directly to establish a baseline before automating collection.&lt;/p&gt;

&lt;p&gt;The PostgreSQL cache hit ratio from &lt;code&gt;pg_statio_user_tables&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;round&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;heap_blks_hit&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;nullif&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;heap_blks_hit&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;heap_blks_read&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;4&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;hit_ratio&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_statio_user_tables&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;nullif&lt;/code&gt; call guards against division-by-zero on a cold instance where no blocks have been read yet. The &lt;code&gt;round&lt;/code&gt; wrapper gives you a clean four-decimal ratio instead of a long float.&lt;/p&gt;

&lt;p&gt;For query-level performance, &lt;a href="https://www.postgresql.org/docs/current/pgstatstatements.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_stat_statements&lt;/code&gt;&lt;/a&gt; is where the data lives on PostgreSQL. Once the extension is enabled (see the implementation section), this query pulls the top 15 queries by total execution time:&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="k"&gt;left&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="mi"&gt;80&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;query_preview&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;total_exec_time&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)::&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;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;total_time_sec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;mean_exec_time&lt;/span&gt;&lt;span class="p"&gt;)::&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;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;avg_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;rows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&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;total_exec_time&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;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ordering matters. A query called 50,000 times at 2ms each burns far more total database time than one called 10 times at 500ms, yet only the latter trips a slow-query alert. Ranking by cumulative time surfaces both patterns.&lt;/p&gt;

&lt;p&gt;On MySQL, the equivalent lives in the &lt;a href="https://dev.mysql.com/doc/refman/8.0/en/performance-schema.html" rel="noopener noreferrer"&gt;Performance Schema&lt;/a&gt;. The &lt;code&gt;events_statements_summary_by_digest&lt;/code&gt; table provides normalized query fingerprints with execution counts, total latency, and lock time:&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="k"&gt;LEFT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DIGEST_TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&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;query_digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;COUNT_STAR&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;exec_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SUM_TIMER_WAIT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;e12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&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;total_sec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AVG_TIMER_WAIT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;e12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&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;avg_sec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;SUM_ROWS_EXAMINED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;SUM_ROWS_SENT&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events_statements_summary_by_digest&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;SUM_TIMER_WAIT&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;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL's Performance Schema stores timer values in picoseconds, so the &lt;code&gt;/ 1e12&lt;/code&gt; conversion gives you seconds. The &lt;code&gt;SUM_ROWS_EXAMINED&lt;/code&gt; versus &lt;code&gt;SUM_ROWS_SENT&lt;/code&gt; comparison is useful too: a large gap between examined and sent rows often points to missing indexes.&lt;/p&gt;

&lt;p&gt;MySQL replication lag is available via &lt;code&gt;SHOW REPLICA STATUS\G&lt;/code&gt; under the &lt;code&gt;Seconds_Behind_Source&lt;/code&gt; field. If you're still on a version before 8.0.22, the command is &lt;code&gt;SHOW SLAVE STATUS&lt;/code&gt; and the field is &lt;code&gt;Seconds_Behind_Master&lt;/code&gt;; both old names were &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/mysql-nutshell.html" rel="noopener noreferrer"&gt;dropped entirely in MySQL 8.4&lt;/a&gt;. One caveat: this metric measures delay at the SQL apply thread, not end-to-end data freshness. Under multi-source replication or GTID-based topologies, it can report zero while a channel is actually stalled. Percona's &lt;code&gt;pt-heartbeat&lt;/code&gt; (or a custom heartbeat table that your application writes to and replicas read from) gives you a ground-truth lag measurement independent of the replication thread's self-reporting.&lt;/p&gt;

&lt;h3&gt;
  
  
  NoSQL databases: MongoDB
&lt;/h3&gt;

&lt;p&gt;MongoDB's signals that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operation latency&lt;/strong&gt; from &lt;a href="https://www.mongodb.com/docs/manual/reference/command/serverStatus/#mongodb-serverstatus-serverstatus.opLatencies" rel="noopener noreferrer"&gt;&lt;code&gt;serverStatus.opLatencies&lt;/code&gt;&lt;/a&gt;, broken down by reads, writes, and commands. Separating read and write latency is critical because MongoDB workloads are often asymmetric, and a write latency spike won't show up in a combined average if reads dominate throughput.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue depth&lt;/strong&gt; via &lt;a href="https://www.mongodb.com/docs/v7.0/reference/command/serverstatus/" rel="noopener noreferrer"&gt;&lt;code&gt;globalLock.currentQueue.total&lt;/code&gt;&lt;/a&gt;. A rising queue means operations are waiting for execution faster than the engine can process them. Sustained queue growth precedes the latency cliff where response times go nonlinear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replication oplog window&lt;/strong&gt; in hours. This is your buffer before a lagging secondary falls off the oplog and needs a full resync. An oplog window under 4 hours on a write-heavy deployment leaves little recovery margin (&lt;a href="https://www.mongodb.com/community/forums/t/oplog-window-best-practice-value/215225" rel="noopener noreferrer"&gt;community discussion on oplog sizing&lt;/a&gt; shows operators typically target 24+ hours). Your safe minimum depends on how long a full resync takes in your environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.mongodb.com/docs/manual/core/wiredtiger/" rel="noopener noreferrer"&gt;WiredTiger&lt;/a&gt; cache utilization&lt;/strong&gt; as a ratio of bytes in cache to the configured maximum (&lt;a href="https://www.percona.com/blog/mongodb-101-how-to-tune-your-mongodb-configuration-after-upgrading-to-more-memory/" rel="noopener noreferrer"&gt;default: the larger of 50% of (RAM minus 1 GB) or 256 MB&lt;/a&gt;). When the internal cache fills, eviction pressure forces the engine to discard and re-read pages more frequently. The resulting latency pattern looks like disk-bound behavior but originates inside the storage engine's own memory management, not the OS page cache. You won't identify this &lt;a href="https://www.percona.com/blog/mongodb-101-how-to-tune-your-mongodb-configuration-after-upgrading-to-more-memory/" rel="noopener noreferrer"&gt;eviction-driven latency&lt;/a&gt; from host-level memory metrics alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four signals come from a single shell command. Run &lt;code&gt;db.runCommand({ serverStatus: 1 })&lt;/code&gt; and extract what you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;serverStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Operation latency (microseconds) — split by read/write/command&lt;/span&gt;
&lt;span class="nf"&gt;printjson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opLatencies&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Queue depth — operations waiting for execution&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Queued ops:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalLock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// WiredTiger cache pressure — ratio approaching 1.0 means eviction trouble&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wiredTiger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bytes currently in the cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wiredTiger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;maximum bytes configured&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache fill:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the oplog window, &lt;code&gt;db.getReplicationInfo().timeDiff / 3600&lt;/code&gt; gives you hours of runway before a lagging secondary needs a full resync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atlas users:&lt;/strong&gt; On &lt;a href="https://www.mongodb.com/docs/atlas/monitor-cluster-metrics/" rel="noopener noreferrer"&gt;MongoDB Atlas&lt;/a&gt;, &lt;code&gt;serverStatus&lt;/code&gt; access depends on your cluster tier (M10+ for full stats). Atlas exposes metrics through its own Monitoring UI and the &lt;a href="https://www.mongodb.com/docs/atlas/api/atlas-admin-api-ref/" rel="noopener noreferrer"&gt;Atlas Administration API&lt;/a&gt;. The OTel &lt;code&gt;mongodb&lt;/code&gt; receiver connects to Atlas clusters via SRV connection strings (&lt;code&gt;mongodb+srv://&lt;/code&gt;) with SCRAM authentication.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud-managed databases: RDS, Aurora, and Cloud SQL
&lt;/h3&gt;

&lt;p&gt;With managed databases, you don't have SSH access or direct access to system views. The signals that matter are the same (connections, IOPS, replication, storage), but collection runs through cloud provider APIs instead.&lt;/p&gt;

&lt;p&gt;The signals to watch (metric names below use AWS CloudWatch conventions; Azure Monitor and GCP Cloud Monitoring expose equivalents under different names, e.g., &lt;code&gt;connection_count&lt;/code&gt; on Cloud SQL, &lt;code&gt;connection_successful&lt;/code&gt; on Azure SQL):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DatabaseConnections&lt;/code&gt; versus the engine's max connection limit.&lt;/strong&gt; Managed instances enforce the same connection ceiling as self-hosted engines, but you can't tune OS-level socket limits to buy time. When you hit the cap, new connections are refused outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReadIOPS&lt;/code&gt; and &lt;code&gt;WriteIOPS&lt;/code&gt; versus provisioned IOPS limits.&lt;/strong&gt; Exceeding provisioned IOPS triggers throttling at the storage layer, adding latency that looks like slow queries but originates below the engine. The queries themselves haven't changed; the disk can't keep up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FreeStorageSpace&lt;/code&gt;.&lt;/strong&gt; Alert before autoscaling triggers, not after. Autoscaling events cause a brief I/O pause on some instance types, and if autoscaling is disabled, a full volume means writes stop entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReplicaLag&lt;/code&gt;.&lt;/strong&gt; Same concern as self-managed replication: read replicas serving stale data. The difference is that you can't inspect the replication thread directly, so this CloudWatch metric is your only visibility into how far behind a replica has fallen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CPUCreditBalance&lt;/code&gt; on burstable instance types (T3, T4g).&lt;/strong&gt; A depleted credit balance is a hidden latency trigger that looks like a CPU spike but is actually credit exhaustion. Once credits hit zero, the instance is capped at baseline CPU, and every query slows down uniformly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Collection runs through &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_GetMetricData.html" rel="noopener noreferrer"&gt;CloudWatch &lt;code&gt;GetMetricData&lt;/code&gt;&lt;/a&gt; for RDS and Aurora, the &lt;a href="https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/rest-api-walkthrough" rel="noopener noreferrer"&gt;Azure Monitor REST API&lt;/a&gt; for Azure SQL, and the &lt;a href="https://cloud.google.com/monitoring/api/v3" rel="noopener noreferrer"&gt;Cloud Monitoring API&lt;/a&gt; for Cloud SQL.&lt;/p&gt;

&lt;p&gt;The resolution tradeoff with CloudWatch matters. Standard RDS metrics publish at 1-minute intervals. &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_Monitoring.OS.overview.html" rel="noopener noreferrer"&gt;AWS Enhanced Monitoring&lt;/a&gt; drops that to 1-second granularity for OS-level metrics, and &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_PerfInsights.html" rel="noopener noreferrer"&gt;Performance Insights&lt;/a&gt; adds DB load sampling at 1-second resolution with query-level attribution (the per-second samples are aggregated to produce the Top SQL view; query statistics themselves come from engine-level stats). Note: AWS has announced the &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_PerfInsights.Overview.html" rel="noopener noreferrer"&gt;Performance Insights console experience will reach end-of-life on June 30, 2026&lt;/a&gt;, with functionality migrating to CloudWatch Database Insights. Native engine-level metrics through CloudWatch stay at 1-minute resolution, so transient sub-minute anomalies at the engine level are invisible by default.&lt;/p&gt;

&lt;p&gt;Hosted platforms like &lt;a href="https://www.site24x7.com/help/database-monitoring/" rel="noopener noreferrer"&gt;ManageEngine's database monitoring&lt;/a&gt; consolidate these cross-provider APIs into a single query interface, which is useful when a single fleet spans RDS, Azure SQL, and Cloud SQL simultaneously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Universal signals across all database types
&lt;/h3&gt;

&lt;p&gt;Regardless of engine, four metrics travel across any database and make cross-database comparison possible: query error rate, connection pool saturation (used / max), query throughput (QPS or TPS), and disk I/O wait percentage.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;th&gt;AWS RDS / Aurora&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Query latency&lt;/td&gt;
&lt;td&gt;pg_stat_statements (total_exec_time)&lt;/td&gt;
&lt;td&gt;events_statements_summary_by_digest&lt;/td&gt;
&lt;td&gt;opLatencies (reads/writes/commands)&lt;/td&gt;
&lt;td&gt;ReadLatency, WriteLatency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection pressure&lt;/td&gt;
&lt;td&gt;numbackends vs max_connections&lt;/td&gt;
&lt;td&gt;Threads_connected vs max_connections&lt;/td&gt;
&lt;td&gt;currentQueue.total&lt;/td&gt;
&lt;td&gt;DatabaseConnections vs engine max&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache health&lt;/td&gt;
&lt;td&gt;heap_blks_hit ratio (target ≥99%)&lt;/td&gt;
&lt;td&gt;InnoDB buffer pool hit ratio&lt;/td&gt;
&lt;td&gt;WiredTiger cache fill ratio&lt;/td&gt;
&lt;td&gt;BufferCacheHitRatio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replication delay&lt;/td&gt;
&lt;td&gt;pg_stat_replication.replay_lag&lt;/td&gt;
&lt;td&gt;Seconds_Behind_Source (or pt-heartbeat)&lt;/td&gt;
&lt;td&gt;oplog window in hours&lt;/td&gt;
&lt;td&gt;ReplicaLag (seconds)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow query signal&lt;/td&gt;
&lt;td&gt;pg_stat_statements + slow log&lt;/td&gt;
&lt;td&gt;slow_query_log + Perf Schema&lt;/td&gt;
&lt;td&gt;currentOp + database profiler&lt;/td&gt;
&lt;td&gt;Performance Insights / Database Insights&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage / I/O pressure&lt;/td&gt;
&lt;td&gt;blks_read, I/O wait %&lt;/td&gt;
&lt;td&gt;Innodb_data_reads, I/O wait %&lt;/td&gt;
&lt;td&gt;WiredTiger eviction rate&lt;/td&gt;
&lt;td&gt;WriteIOPS vs provisioned IOPS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Knowing which signals matter is the first step. Collecting them consistently across every engine in a single pipeline is the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a unified telemetry pipeline
&lt;/h2&gt;

&lt;p&gt;Three approaches exist for collecting database telemetry in production, each with a different tradeoff between setup speed, vendor independence, and long-term maintenance cost:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vendor agents with proprietary instrumentation.&lt;/strong&gt; Fastest to deploy and lowest initial maintenance since the vendor manages the agent lifecycle. The cost is vendor independence: switching backends means re-instrumenting everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://prometheus.io/docs/instrumenting/exporters/" rel="noopener noreferrer"&gt;Prometheus exporters&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;postgres_exporter&lt;/code&gt;, &lt;code&gt;mysqld_exporter&lt;/code&gt;, &lt;code&gt;mongodb_exporter&lt;/code&gt;). Moderate setup, vendor-neutral, and battle-tested. Maintenance stays low once running, but they're metric-only. They don't share a data model with your application traces, so correlation requires stitching across separate pipelines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://opentelemetry.io/docs/collector/" rel="noopener noreferrer"&gt;OpenTelemetry Collector&lt;/a&gt; with database-specific receivers.&lt;/strong&gt; Its &lt;code&gt;postgresql&lt;/code&gt;, &lt;code&gt;mysql&lt;/code&gt;, and &lt;code&gt;mongodb&lt;/code&gt; receivers normalize metrics into shared &lt;a href="https://opentelemetry.io/docs/specs/semconv/database/" rel="noopener noreferrer"&gt;semantic conventions&lt;/a&gt;, so telemetry from different engines lands in a comparable format. Fully vendor-portable and trace-aware, but the most setup effort upfront and the highest ongoing maintenance (config drift, biweekly releases, semantic convention changes).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This guide uses the OTel Collector path. As of 2026, &lt;a href="https://dev.to/kubefeeds/observability-in-2025-opentelemetry-and-ai-to-fill-in-gaps-4bpm"&gt;OpenTelemetry is the de facto standard for new observability instrumentation&lt;/a&gt;, and it's the only option above that unifies database metrics and application traces under the same data model. Building on proprietary agents now means repeating this work at the next platform migration.&lt;/p&gt;

&lt;p&gt;Two common &lt;a href="https://opentelemetry.io/docs/collector/deployment/" rel="noopener noreferrer"&gt;deployment patterns&lt;/a&gt; exist. In &lt;strong&gt;agent mode&lt;/strong&gt;, a Collector runs on each database host, collects local metrics, and forwards them to a central gateway or directly to the backend. In &lt;strong&gt;gateway mode&lt;/strong&gt;, a centralized Collector reaches out to remote database endpoints. Agent mode gives you host-level correlation for free (the Collector inherits &lt;code&gt;host.id&lt;/code&gt;). Gateway mode reduces the number of Collector instances to manage. Most production setups use agent mode for self-managed databases and gateway mode for cloud-managed instances where you can't deploy locally.&lt;/p&gt;

&lt;p&gt;The following sections walk through receiver configuration for each database type, starting with PostgreSQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting up the PostgreSQL receiver
&lt;/h3&gt;

&lt;p&gt;One gotcha before the first receiver config: the &lt;code&gt;postgresql&lt;/code&gt;, &lt;code&gt;mysql&lt;/code&gt;, and &lt;code&gt;mongodb&lt;/code&gt; receivers ship in the &lt;a href="https://opentelemetry.io/docs/collector/distributions/" rel="noopener noreferrer"&gt;contrib distribution&lt;/a&gt;, not the core binary. Download &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-releases/releases" rel="noopener noreferrer"&gt;&lt;code&gt;otelcol-contrib&lt;/code&gt;&lt;/a&gt; (also available as Docker image &lt;code&gt;otel/opentelemetry-collector-contrib&lt;/code&gt;) or the receivers won't be available. The configs below were validated against &lt;code&gt;otelcol-contrib&lt;/code&gt; v0.115.0. Receiver config schemas can change between releases; check the &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver" rel="noopener noreferrer"&gt;receiver README&lt;/a&gt; for your installed version if you encounter validation errors.&lt;/p&gt;

&lt;p&gt;Create a dedicated monitoring user on your PostgreSQL instance (PostgreSQL 10+):&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;ROLE&lt;/span&gt; &lt;span class="n"&gt;otel_reader&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'change_me'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;pg_monitor&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;otel_reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/18/functions-admin.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_monitor&lt;/code&gt;&lt;/a&gt; is a built-in role (introduced in PostgreSQL 10) that bundles read access to every statistics view the receiver needs: activity stats, background writer stats, database-level stats, and &lt;code&gt;pg_stat_statements&lt;/code&gt; if the extension is loaded. On PostgreSQL 9.x, you'll need to grant &lt;code&gt;SELECT&lt;/code&gt; on each view individually since the bundled role doesn't exist.&lt;/p&gt;

&lt;p&gt;A minimal OTel Collector configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost:5432&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel_reader&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${env:PGMON_PASS}"&lt;/span&gt;
    &lt;span class="na"&gt;databases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;app_prod&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;app_analytics&lt;/span&gt;
    &lt;span class="na"&gt;collection_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
    &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# disable for production; configure certs instead&lt;/span&gt;

&lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlp/primary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;otel-gateway.internal:4317"&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp/primary&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth noting. The &lt;code&gt;tls: insecure: true&lt;/code&gt; flag disables TLS verification, acceptable for local development but not production. The &lt;code&gt;${env:VAR_NAME}&lt;/code&gt; syntax is the Collector's built-in expansion for OS environment variables. The Collector doesn't read &lt;code&gt;.env&lt;/code&gt; files, so set them before starting the process (e.g., &lt;code&gt;export PGMON_PASS=secret &amp;amp;&amp;amp; ./otelcol-contrib --config config.yaml&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/receiver/postgresqlreceiver/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;postgresql&lt;/code&gt; receiver&lt;/a&gt; pulls metrics from &lt;code&gt;pg_stat_bgwriter&lt;/code&gt;, &lt;code&gt;pg_stat_database&lt;/code&gt;, and related system views. At the span level, verify that &lt;code&gt;db.system.name&lt;/code&gt;, &lt;code&gt;db.operation.name&lt;/code&gt;, and &lt;code&gt;db.query.text&lt;/code&gt; attributes are populating (these are the current names per &lt;a href="https://opentelemetry.io/docs/specs/semconv/database/" rel="noopener noreferrer"&gt;OTel Semantic Conventions v1.33.0&lt;/a&gt;). Older documentation may reference the deprecated &lt;code&gt;db.system&lt;/code&gt;, &lt;code&gt;db.operation&lt;/code&gt;, and &lt;code&gt;db.statement&lt;/code&gt; attributes, so check which version your instrumentation library implements.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting up the MySQL receiver
&lt;/h3&gt;

&lt;p&gt;The same pattern applies: create a monitoring user, then point the receiver at it.&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="c1"&gt;-- MySQL 8.0+ monitoring role&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="s1"&gt;'otel_reader'&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="s1"&gt;'localhost'&lt;/span&gt; &lt;span class="n"&gt;IDENTIFIED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="s1"&gt;'change_me'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;PROCESS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;REPLICATION&lt;/span&gt; &lt;span class="n"&gt;CLIENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s1"&gt;'otel_reader'&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="s1"&gt;'localhost'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s1"&gt;'otel_reader'&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="s1"&gt;'localhost'&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 yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mysql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost:3306&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel_reader&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${env:MYMON_PASS}"&lt;/span&gt;
    &lt;span class="na"&gt;collection_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
    &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;mysql&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp/primary&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/receiver/mysqlreceiver/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;mysql&lt;/code&gt; receiver&lt;/a&gt; collects from &lt;code&gt;SHOW GLOBAL STATUS&lt;/code&gt;, &lt;code&gt;SHOW REPLICA STATUS&lt;/code&gt;, and &lt;code&gt;performance_schema&lt;/code&gt; tables. Enable Performance Schema (&lt;code&gt;performance_schema=ON&lt;/code&gt; in &lt;code&gt;my.cnf&lt;/code&gt;) for query-level metrics. It has been on by default since MySQL 5.6.6, so most installations already have it active.&lt;/p&gt;

&lt;h3&gt;
  
  
  Collecting CloudWatch metrics for RDS
&lt;/h3&gt;

&lt;p&gt;Cloud-managed databases don't allow local agent deployment, so the collection path differs. The OTel Collector's &lt;code&gt;awscloudwatchreceiver&lt;/code&gt; only supports logs, not metrics. For RDS metric collection through the OTel pipeline, the proven approach is &lt;a href="https://github.com/prometheus-community/yet-another-cloudwatch-exporter" rel="noopener noreferrer"&gt;YACE (Yet Another CloudWatch Exporter)&lt;/a&gt;, a Prometheus exporter maintained under the &lt;code&gt;prometheus-community&lt;/code&gt; org. YACE polls CloudWatch's &lt;code&gt;GetMetricData&lt;/code&gt; API and exposes the results as Prometheus metrics, which the Collector scrapes via its &lt;code&gt;prometheus&lt;/code&gt; receiver.&lt;/p&gt;

&lt;p&gt;YACE uses the standard AWS credential chain (instance profile, &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt;/&lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;, or &lt;code&gt;~/.aws/credentials&lt;/code&gt;). The IAM principal requires &lt;code&gt;cloudwatch:GetMetricData&lt;/code&gt;, &lt;code&gt;cloudwatch:ListMetrics&lt;/code&gt;, and &lt;code&gt;tag:GetResources&lt;/code&gt; permissions.&lt;/p&gt;

&lt;p&gt;YACE configuration (&lt;code&gt;yace-config.yml&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;discovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS/RDS&lt;/span&gt;
      &lt;span class="na"&gt;regions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
      &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DatabaseConnections&lt;/span&gt;
          &lt;span class="na"&gt;statistics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Average&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
          &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ReadIOPS&lt;/span&gt;
          &lt;span class="na"&gt;statistics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Average&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
          &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ReplicaLag&lt;/span&gt;
          &lt;span class="na"&gt;statistics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Maximum&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
          &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;YACE auto-discovers all RDS instances in the specified region. To limit to specific instances, add a &lt;code&gt;searchTags&lt;/code&gt; filter with a tag key/value pair you've applied to your RDS instances.&lt;/p&gt;

&lt;p&gt;YACE exposes metrics on port 5000 by default. Point the OTel Collector's &lt;code&gt;prometheus&lt;/code&gt; receiver at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus/cloudwatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yace-rds&lt;/span&gt;
          &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;
          &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost:5000"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;prometheus/cloudwatch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp/primary&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;scrape_interval&lt;/code&gt; should match YACE's &lt;code&gt;period&lt;/code&gt; to avoid gaps or duplicate data points.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting up the MongoDB receiver
&lt;/h3&gt;

&lt;p&gt;Back to the standard pattern for self-managed instances. Create a monitoring user with the &lt;code&gt;clusterMonitor&lt;/code&gt; role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Run in mongosh connected to the admin database&lt;/span&gt;
&lt;span class="nx"&gt;use&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;otel_reader&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change_me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;roles&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clusterMonitor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// needed for oplog access&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mongodb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mongo-primary.internal:27017&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel_reader&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${env:MONGOMON_PASS}"&lt;/span&gt;
    &lt;span class="na"&gt;collection_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
    &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This receiver collects the &lt;code&gt;serverStatus&lt;/code&gt; metrics covered earlier (operation latency, queue depth, WiredTiger cache utilization, and replication oplog data) without requiring manual shell queries. For Atlas clusters, the same receiver connects via SRV connection strings (&lt;code&gt;mongodb+srv://&lt;/code&gt;) with SCRAM authentication; replace the &lt;code&gt;endpoint&lt;/code&gt; with your Atlas SRV URI.&lt;/p&gt;

&lt;h3&gt;
  
  
  The complete pipeline
&lt;/h3&gt;

&lt;p&gt;With all four receivers configured, the pipeline routes through a single Collector:&lt;/p&gt;

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

&lt;p&gt;All telemetry, from a PostgreSQL instance on-prem, a MongoDB Atlas cluster, or an RDS replica in &lt;code&gt;us-east-1&lt;/code&gt;, routes through the same collector, lands in the same backend, and shares the same resource attributes (&lt;code&gt;host.id&lt;/code&gt;, &lt;code&gt;service.name&lt;/code&gt;, &lt;code&gt;db.name&lt;/code&gt;). Those shared attributes are what make cross-signal correlation possible, which is where the real incident-resolution speed comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-signal correlation: three axes that close incidents
&lt;/h2&gt;

&lt;p&gt;A unified pipeline gives you the raw material. But collection alone doesn't explain &lt;em&gt;why&lt;/em&gt; a latency spike happened. A PostgreSQL dashboard showing elevated p95 tells you something is wrong. It doesn't tell you whether the cause is a bad query, a contended host, or a deployment that changed application behavior. Answering that requires correlating database metrics with signals from outside the database.&lt;/p&gt;

&lt;p&gt;Three correlation axes progressively narrow the search space during an incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Axis 1: Database metrics + APM traces = which query caused it.&lt;/strong&gt; Slow database spans in distributed traces carry &lt;code&gt;db.query.text&lt;/code&gt; attributes that link directly to the responsible statement. When p95 spikes, the span shows the exact SQL. That span-to-query linkage automates what &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; does manually, across every query variant, on every request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Axis 2: Database metrics + infrastructure metrics = what constrained it.&lt;/strong&gt; CPU steal, disk I/O wait, and network throughput on the database host reveal whether a slowdown is a resource contention issue. A report query that normally completes in 25ms but suddenly takes 1.2 seconds, with no deployment in between, is usually competing for disk or CPU on a shared host rather than running a degraded plan (though lock contention, stale statistics, or index bloat can look similar). Without the infrastructure layer, you'd waste time chasing query-level explanations for a host-level problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Axis 3: Database metrics + logs = what sequence of events led to it.&lt;/strong&gt; Slow query logs, error logs, and lock contention events provide the narrative that metric time series cannot. Metrics show what changed. Logs explain what happened. For example, lock contention is one of the most common incident triggers, and the metric alone (rising lock wait count) doesn't tell you &lt;em&gt;which&lt;/em&gt; session is blocking. Querying &lt;code&gt;pg_stat_activity&lt;/code&gt; with &lt;a href="https://www.postgresql.org/docs/current/functions-info.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_blocking_pids()&lt;/code&gt;&lt;/a&gt; (PostgreSQL 9.6+; for earlier versions, query &lt;code&gt;pg_locks&lt;/code&gt; directly) pinpoints the blocking session, its query, and how long it's been holding the lock:&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;blocker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocker_pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocker&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="mi"&gt;100&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;blocker_query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;waiting_pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;waiting&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="mi"&gt;100&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;waiting_query&lt;/span&gt;&lt;span class="p"&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;blocker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state_change&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lock_held_for&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="n"&gt;blocker&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;blocker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Lock'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Together, these three axes turn an alert into a causal chain: the trace identifies responsible queries, infrastructure metrics rule out host-level bottlenecks, and log correlation surfaces the trigger. Whether that chain resolves in one interface or across three separate tools depends on your platform and your alerting setup.&lt;/p&gt;

&lt;p&gt;Correlation closes the gap between alert and cause, but only if the alerts that wake you up are actually worth investigating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alert fatigue is a design problem, platform choice is the fix
&lt;/h2&gt;

&lt;p&gt;Static thresholds on database metrics produce high false-positive rates. Query patterns vary by hour and day of week. A batch job that pushes p95 latency to 600ms every Tuesday at 3am is normal, not an incident. A static alert at 500ms pages you every Tuesday.&lt;/p&gt;

&lt;p&gt;Dynamic baselining eliminates this false-positive pattern. Instead of a hardcoded threshold, the alert fires when a metric deviates from its own rolling historical pattern for that time window. p95 at 600ms on Tuesday at 3am is expected. p95 at 600ms on Wednesday at 2pm is a deviation worth investigating.&lt;/p&gt;

&lt;p&gt;But dynamic baselining is only one piece. Whether you can actually implement it, and whether the alerts it produces are actionable, depends on what your observability platform supports. Alert quality is inseparable from platform choice. Six criteria separate a platform that sounds good in a demo from one that holds up at 3am:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Coverage breadth.&lt;/strong&gt; Native support for your actual database mix (PostgreSQL, MySQL, MongoDB, RDS, Aurora, Azure SQL, and whatever else you run) is non-negotiable. Community plugins with no SLA add risk in production.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Query-level visibility.&lt;/strong&gt; CPU and connection counts are necessary but insufficient. You need per-query latency distributions, execution counts, and normalized query fingerprinting that aggregates variants of the same logical query. Without fingerprinting, you're scrolling through raw query strings instead of seeing the handful of patterns that account for most of your total execution time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-signal correlation.&lt;/strong&gt; If database metrics, APM traces, and infrastructure metrics live in separate tools, you're doing the correlation manually. That context switch is where time evaporates during incidents.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Alert quality.&lt;/strong&gt; Static thresholds versus dynamic baselining is the dividing line. Platforms that support rolling historical baselines eliminate most false positives from cyclical workload patterns.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pricing model.&lt;/strong&gt; Per-host pricing behaves differently at 80 nodes than per-metric or per-GB pricing. Project the numbers against your current and expected fleet size before signing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Operational overhead.&lt;/strong&gt; Agent deployment and upgrades across 80+ nodes compound over time. Centralized configuration, auto-upgrade, and agentless collection for cloud-managed databases (where agent deployment isn't an option) matter more than they appear in an initial evaluation.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Criterion #4 (dynamic baselining) is where AI-driven features are pushing the boundary, moving beyond rolling averages into pattern detection that no human would configure manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI-assisted database monitoring: faster triage, not fewer engineers
&lt;/h2&gt;

&lt;p&gt;AI-driven features are gaining traction in observability platforms. The Grafana Observability Survey 2025 found that the two most sought-after AI capabilities were training-based alerts that fire on pattern deviations and faster root cause analysis through automated signal interpretation. These two ranked at the top across nearly every demographic surveyed. Autonomous remediation drew interest, but with significant practitioner skepticism. The pattern is clear: engineers want faster triage, not hands-off automation.&lt;/p&gt;

&lt;p&gt;Where AI adds the most value is in catching what no human would wire up manually: co-occurring metric changes across signals (a replication lag spike alongside a batch job CPU spike on the same host) that only correlate under specific conditions. Capacity forecasting is the other win, spotting growth trends that will cause pressure weeks before the pressure becomes a production incident.&lt;/p&gt;

&lt;p&gt;The judgment call that follows still requires a person. Deciding whether a flagged query needs a composite index, a denormalized read path, or a move to a different storage engine depends on access patterns, consistency requirements, and how the data model will evolve over the next two quarters. No anomaly detector has that context. AI narrows the search; an engineer who understands the domain decides what to do with what it finds.&lt;/p&gt;

&lt;p&gt;These capabilities come from the platform, not the pipeline. If you've built the OTel collection layer yourself, the question becomes what that self-assembled stack actually costs to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The operational cost of a self-assembled stack
&lt;/h2&gt;

&lt;p&gt;If you've followed along this far, you've assembled a capable observability pipeline: OTel Collector with four receivers, application SDK instrumentation, alerting rules, and cross-signal correlation. It works. But it's worth tallying what you're now maintaining.&lt;/p&gt;

&lt;p&gt;The Collector itself needs upgrades. Core and contrib &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/1631" rel="noopener noreferrer"&gt;release together every two weeks&lt;/a&gt;, and each release can bring receiver config changes and semantic convention updates (the &lt;code&gt;db.statement&lt;/code&gt; to &lt;code&gt;db.query.text&lt;/code&gt; rename is a recent example). Across a fleet of 20+ database nodes, that's 20+ Collector configs to keep in sync. YAML drift is quiet until it causes a gap in your telemetry during an incident.&lt;/p&gt;

&lt;p&gt;Alert tuning is ongoing. Static thresholds need manual adjustment as workloads evolve. Dynamic baselines, if your backend supports them, need their own validation. Each new database instance means another set of receiver configs, user grants, and alert rules.&lt;/p&gt;

&lt;p&gt;Cloud-managed databases add a different kind of overhead. IAM policies, CloudWatch API rate limits, and the resolution gaps between standard and enhanced monitoring all require attention that scales with the number of instances.&lt;/p&gt;

&lt;p&gt;None of this is unreasonable for a team with dedicated platform engineering capacity. But for teams where observability is one responsibility among many, the assembly and maintenance cost is the real expense, not the software licenses. The next section walks through the implementation sequence; the managed alternative follows at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started: a concrete implementation sequence
&lt;/h2&gt;

&lt;p&gt;You can get the first piece of actionable data quickly. Run the &lt;code&gt;pg_stat_statements&lt;/code&gt; query from the PostgreSQL section above and see which queries dominate your database's total execution time. The full setup depends on your environment, but each step below is individually small.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Enable pg_stat_statements
&lt;/h3&gt;

&lt;p&gt;Check what's already loaded with &lt;code&gt;SHOW shared_preload_libraries;&lt;/code&gt;. If the result is empty, run &lt;code&gt;ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';&lt;/code&gt;. If other libraries are already loaded (e.g., &lt;code&gt;timescaledb&lt;/code&gt;), append rather than replace: &lt;code&gt;ALTER SYSTEM SET shared_preload_libraries = 'timescaledb, pg_stat_statements';&lt;/code&gt;. This requires a full PostgreSQL restart, which means a maintenance window in production. After the restart, run &lt;code&gt;CREATE EXTENSION pg_stat_statements;&lt;/code&gt; in your target database and query it immediately to get your baseline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Instrument your application with an OTel SDK
&lt;/h3&gt;

&lt;p&gt;The Collector pipeline in Step 3 collects infrastructure-level database metrics. Application-level database spans (the ones carrying &lt;code&gt;db.query.text&lt;/code&gt; that link to APM traces) require your application to emit them via an OTel SDK. Each language and database driver combination needs its own instrumentation library, SDK initialization, and exporter configuration. The &lt;a href="https://opentelemetry.io/ecosystem/registry/" rel="noopener noreferrer"&gt;OTel Instrumentation Registry&lt;/a&gt; covers the specific packages. For a team running multiple services across multiple languages, this step alone touches every application in the stack.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Deploy the OTel Collector
&lt;/h3&gt;

&lt;p&gt;Deploy the Collector with the &lt;code&gt;postgresql&lt;/code&gt; receiver on the same host, using the configuration from the pipeline section above. Point it at your backend via &lt;a href="https://prometheus.io/docs/prometheus/latest/configuration/configuration/#remote_write" rel="noopener noreferrer"&gt;Prometheus remote write&lt;/a&gt; or an OTLP endpoint. Verify that &lt;code&gt;db.system.name&lt;/code&gt;, &lt;code&gt;db.name&lt;/code&gt;, and &lt;code&gt;db.query.text&lt;/code&gt; attributes are populating on spans from your application's database client library.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Set baseline alerts
&lt;/h3&gt;

&lt;p&gt;Three non-negotiable alerts to start with. If your platform supports dynamic baselining, use these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;p95 SELECT latency more than 2x the 7-day rolling baseline for the same hour-of-week&lt;/li&gt;
&lt;li&gt;Connection utilization (active / max) above 80% sustained for 5 minutes&lt;/li&gt;
&lt;li&gt;Replication lag above 30 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 5: Verify cross-signal correlation
&lt;/h3&gt;

&lt;p&gt;Trigger a slow query manually with &lt;code&gt;SELECT pg_sleep(3);&lt;/code&gt; and confirm the resulting database span in your APM traces carries the &lt;code&gt;db.query.text&lt;/code&gt; attribute (or &lt;code&gt;db.statement&lt;/code&gt; if your library uses the older convention) and links back to the metric spike. If it doesn't, your pipeline has a tagging gap that will cost you during the next real incident. Fix it now while the system is quiet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Repeat for your next database
&lt;/h3&gt;

&lt;p&gt;Once PostgreSQL is fully instrumented and alerting is stable, repeat Steps 1 through 5 for your next database type. Each engine means a different receiver config, different monitoring user grants, different signal verification, and a different set of edge cases. A three-database stack means running this sequence three times, each with its own failure modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the DIY path delivers
&lt;/h2&gt;

&lt;p&gt;If you've followed the implementation sequence above, the 2:47am scenario from the introduction looks different now. Instead of fifteen minutes switching between dashboards, you have a single correlated timeline where the responsible query, the host contention, and the triggering event are already connected.&lt;/p&gt;

&lt;p&gt;That's the DIY path. It works, and it's entirely vendor-neutral. The tradeoff is the assembly and ongoing maintenance cost that scales with every database you add to the fleet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managed alternative: same criteria, less assembly
&lt;/h2&gt;

&lt;p&gt;For teams where that tradeoff doesn't pencil out, &lt;a href="https://www.manageengine.com/products/applications_manager/" rel="noopener noreferrer"&gt;ManageEngine Applications Manager&lt;/a&gt; is one option worth evaluating. Here's how it maps against the six criteria from the alerting section:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coverage breadth:&lt;/strong&gt; Out-of-the-box monitoring for &lt;a href="https://www.manageengine.com/products/applications_manager/database-monitoring.html" rel="noopener noreferrer"&gt;50+ database types&lt;/a&gt;, from PostgreSQL and MongoDB to managed offerings like Aurora and Azure SQL. No per-engine receiver assembly or contrib binary juggling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query-level visibility:&lt;/strong&gt; Latency distributions, execution frequency, and fingerprinted query grouping that rolls up thousands of raw statements into the patterns that actually drive load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-signal correlation:&lt;/strong&gt; Database, application, and host telemetry share a single interface. During an incident, you click from a slow query span to the host's CPU timeline without opening a second tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert quality:&lt;/strong&gt; ML-driven baselines that learn your workload's weekly rhythm, so the Tuesday 3am batch job doesn't page anyone but a Wednesday 2pm anomaly does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing model:&lt;/strong&gt; Priced per monitor rather than per GB of ingested telemetry. At 80+ database nodes, this distinction determines whether the bill scales linearly or exponentially.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational overhead:&lt;/strong&gt; Cloud-managed databases connect via JDBC and cloud APIs with no local agent. Self-managed instances use centralized config pushed from the server, so there's no per-node YAML to maintain or drift to chase.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams whose telemetry lives primarily in AWS, Azure, or GCP, the cloud-delivered sibling is &lt;a href="https://www.site24x7.com/database-monitoring.html" rel="noopener noreferrer"&gt;Site24x7&lt;/a&gt;, ManageEngine's SaaS monitoring platform. The same six criteria apply: native coverage for PostgreSQL, MySQL, SQL Server, Oracle, MongoDB, and RDS/Aurora; query-level latency with fingerprinting; correlated application and infrastructure metrics in one console; AI-driven anomaly detection on per-query baselines. The tradeoff flips compared to a self-hosted deployment. No local infrastructure to run, but telemetry leaves your environment, and retention is tied to the subscription tier.&lt;/p&gt;

&lt;p&gt;Whether the managed path or the DIY pipeline is the better fit depends on your team's platform engineering capacity and how many database types you're running. The six criteria give you a framework to evaluate either approach, or any other platform, on equal footing.&lt;/p&gt;




&lt;p&gt;What does your current database monitoring setup look like? If you're running a mixed stack, I'd be curious to hear how you're handling cross-signal correlation today, and where it still breaks down.&lt;/p&gt;

</description>
      <category>database</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>performance</category>
    </item>
    <item>
      <title>LLM Inference Optimization: Techniques That Actually Reduce Latency and Cost</title>
      <dc:creator>Damaso Sanoja</dc:creator>
      <pubDate>Tue, 31 Mar 2026 12:50:09 +0000</pubDate>
      <link>https://dev.to/damasosanoja/llm-inference-optimization-techniques-that-actually-reduce-latency-and-cost-3fjg</link>
      <guid>https://dev.to/damasosanoja/llm-inference-optimization-techniques-that-actually-reduce-latency-and-cost-3fjg</guid>
      <description>&lt;p&gt;Your GPU bill is doubling every quarter, but your throughput metrics haven’t moved. A standard Hugging Face pipeline() call keeps your A100 significantly underutilized under real traffic patterns because it processes one request sequentially while everything else waits. You’re paying for idle silicon.&lt;/p&gt;

&lt;p&gt;The fix is switching from naive serving to optimized serving, which means deploying the same model differently. High-performance teams running Llama-3-70B in production have converged on a specific stack: &lt;a href="https://docs.vllm.ai/en/latest/" rel="noopener noreferrer"&gt;vLLM&lt;/a&gt; or &lt;a href="https://github.com/sgl-project/sglang" rel="noopener noreferrer"&gt;SGLang&lt;/a&gt; as the inference engine, &lt;a href="https://prometheus.io/" rel="noopener noreferrer"&gt;Prometheus&lt;/a&gt; for observability, and &lt;a href="https://www.runpod.io/" rel="noopener noreferrer"&gt;Runpod&lt;/a&gt; as the infrastructure layer that lets them deploy and iterate without managing a Kubernetes cluster. This guide works through that stack in ROI order: quantization (VRAM footprint), serving engine selection (throughput), speculative decoding (latency), and deployment mode (cost-scaling).&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The bottlenecks are compute and memory, not model size alone&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;LLM inference has two phases with different performance characteristics.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.nvidia.com/blog/mastering-llm-techniques-inference-optimization/" rel="noopener noreferrer"&gt;Prefill is the compute-bound phase.&lt;/a&gt; The model processes your entire input prompt in a single forward pass, and &lt;a href="https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html" rel="noopener noreferrer"&gt;that determines your Time to First Token (TTFT)&lt;/a&gt;. On a dense 70B model, a 4,000-token prompt might take 400ms to prefill across a tensor-parallel A100 setup. You can’t parallelize this across requests in the same way, so the only real lever is raw compute.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html" rel="noopener noreferrer"&gt;Decode is the memory-bound phase.&lt;/a&gt; The model generates one token at a time, and each step requires loading the entire model’s KV cache from GPU VRAM. &lt;a href="https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html" rel="noopener noreferrer"&gt;VRAM bandwidth almost entirely determines inter-token latency&lt;/a&gt;, with FLOPs playing a secondary role. An H100 SXM5 has &lt;a href="https://www.nvidia.com/en-us/data-center/h100/" rel="noopener noreferrer"&gt;3.35 TB/s of memory bandwidth&lt;/a&gt; versus an A6000’s 768 GB/s, which explains most of the latency delta between them on long-form generation.&lt;/p&gt;

&lt;p&gt;The KV cache is the core pressure point. For every token in a sequence, attention layers &lt;a href="https://developer.nvidia.com/blog/mastering-llm-techniques-inference-optimization/" rel="noopener noreferrer"&gt;store key and value tensors&lt;/a&gt;. The memory footprint follows this formula: num_layers × 2 × num_kv_heads × head_dim × seq_len × dtype_bytes. For Llama-3-70B (80 layers, GQA with 8 KV heads, head_dim=128) at BF16 (2 bytes): 80 × 2 × 8 × 128 × 4,096 × 2 ≈ 1.3 GB per request at a 4,096-token context. That number scales linearly with sequence length, which is why long-context workloads &lt;a href="https://www.bentoml.com/blog/what-is-gpu-memory-and-why-it-matters-for-llm-inference" rel="noopener noreferrer"&gt;saturate VRAM before FLOPs become the bottleneck&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://prometheus.io/" rel="noopener noreferrer"&gt;Prometheus&lt;/a&gt; lets you see this in real time. The &lt;a href="https://docs.vllm.ai/en/latest/serving/metrics.html" rel="noopener noreferrer"&gt;vLLM metrics endpoint&lt;/a&gt; exposes vllm:gpu_cache_usage_perc and vllm:num_requests_waiting via a /metrics endpoint. Wire those up to &lt;a href="https://grafana.com/" rel="noopener noreferrer"&gt;Grafana&lt;/a&gt;, and you’ll immediately see when you’re cache-bound versus compute-bound, which tells you exactly which optimization to reach for first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnzy8nm533gbrqdsh175n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnzy8nm533gbrqdsh175n.png" alt="General Workflow" width="472" height="944"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For most teams serving 70B-class models under concurrent load, VRAM pressure arrives before compute does.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Quantization strategy: fit more models into less VRAM&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Quantization, specifically switching from BF16 to a 4-bit format, is the single biggest optimization available to most teams. At the unit economics level, a Llama-3-70B model in BF16 &lt;a href="https://community.ibm.com/community/user/cloud/blogs/arindam-dasgupta/2024/09/18/calculating-gpu-requirements-for-efficient-llama-3" rel="noopener noreferrer"&gt;occupies roughly 140GB of VRAM&lt;/a&gt;, which requires at a minimum two H100 80GB GPUs at roughly \$2.69/hr each on Runpod. The same model in 4-bit AWQ &lt;a href="https://www.theregister.com/2024/07/14/quantization_llm_feature/" rel="noopener noreferrer"&gt;fits comfortably on dual RTX A6000s (96GB total)&lt;/a&gt;, which run at approximately \$0.49/hr per GPU on Runpod. That’s over 80% cost reduction with minimal quality loss.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2306.00978" rel="noopener noreferrer"&gt;AWQ (Activation-Aware Weight Quantization)&lt;/a&gt; is the current standard for Llama-class models. AWQ preserves the 1% of weights that have the most impact on activation outputs, which is why the perplexity delta between a well-quantized AWQ model and its BF16 source is often below 0.5 points on standard benchmarks.&lt;/p&gt;

&lt;p&gt;You don’t need to quantize the model yourself. The TechxGenus collection on &lt;a href="https://huggingface.co/TechxGenus" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt; includes production-ready AWQ versions of Llama-3-70B. Deploying it on a Runpod Pod requires pulling the vLLM Docker image and configuring your environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--gpus&lt;/span&gt; all &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 8000:8000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;HF_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_token &lt;span class="se"&gt;\&lt;/span&gt;
  vllm/vllm-openai:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; TechxGenus/Meta-Llama-3-70B-Instruct-AWQ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--quantization&lt;/span&gt; awq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 8192
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://developer.nvidia.com/blog/nvidia-hopper-architecture-in-depth/" rel="noopener noreferrer"&gt;H100s support native FP8 tensor cores&lt;/a&gt;, so if you have access to them, FP8 quantization is worth evaluating. FP8 inference runs without emulation overhead, vLLM enables it with --quantization fp8, and &lt;a href="https://docs.vllm.ai/en/v0.5.4/quantization/fp8.html" rel="noopener noreferrer"&gt;VRAM usage drops by roughly 50% compared to BF16&lt;/a&gt;. The throughput improvement over BF16 reaches up to 1.6x on generation-heavy workloads, which means you can &lt;a href="https://lambda.ai/blog/nvidia-hopper-h100-and-fp8-support" rel="noopener noreferrer"&gt;serve a 70B model on a single H100 SXM&lt;/a&gt; with headroom for longer contexts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/casper-hansen/AutoAWQ" rel="noopener noreferrer"&gt;AutoAWQ&lt;/a&gt; quantizes a custom fine-tuned checkpoint in Python in under 30 minutes on an A10G:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;awq&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AutoAWQForCausalLM&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;

&lt;span class="n"&gt;model_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-finetuned-model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;quant_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-model-awq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;quant_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zero_point&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q_group_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w_bit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GEMM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoAWQForCausalLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tokenizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quant_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;quant_config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_quantized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quant_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With your model’s VRAM footprint reduced, the next constraint is how efficiently your serving engine keeps the GPU saturated under real traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Throughput and structured generation with vLLM and SGLang&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Continuous batching, introduced in &lt;a href="https://www.usenix.org/conference/osdi22/presentation/yu" rel="noopener noreferrer"&gt;Orca (2022)&lt;/a&gt; and &lt;a href="https://blog.vllm.ai/2023/06/20/vllm.html" rel="noopener noreferrer"&gt;implemented in vLLM&lt;/a&gt;, is what makes modern serving engines work. Traditional static batching &lt;a href="https://www.anyscale.com/blog/continuous-batching-llm-inference" rel="noopener noreferrer"&gt;waits for a full batch of requests to complete before starting new ones&lt;/a&gt;. Continuous batching inserts new requests into the decode loop as soon as a slot opens up, keeping GPU utilization well above what you see with sequential processing. &lt;a href="https://www.21medien.de/en/library/continuous-batching" rel="noopener noreferrer"&gt;Real-world figures run 60-85%&lt;/a&gt; under steady traffic compared to the low utilization of naive serving.&lt;/p&gt;

&lt;p&gt;vLLM also implements PagedAttention, which &lt;a href="https://arxiv.org/abs/2309.06180" rel="noopener noreferrer"&gt;treats VRAM like virtual memory for KV cache&lt;/a&gt;, eliminating the need to pre-allocate contiguous blocks. PagedAttention allows more sequences to coexist in memory simultaneously, directly improving throughput on concurrent workloads.&lt;/p&gt;

&lt;p&gt;For agentic workflows, multi-step chains, and structured JSON output, &lt;a href="https://github.com/sgl-project/sglang" rel="noopener noreferrer"&gt;SGLang&lt;/a&gt; frequently outperforms standard vLLM. SGLang’s RadixAttention mechanism automatically reuses the KV cache for shared prompt prefixes across requests. In an agentic workflow where every request starts with the same system prompt and tool definitions (often 1,000+ tokens), RadixAttention computes that prefix once and caches it rather than recomputing it per request. &lt;a href="https://lmsys.org/blog/2024-01-17-sglang/" rel="noopener noreferrer"&gt;LMSYS benchmark data shows SGLang consistently delivering higher throughput on structured generation tasks&lt;/a&gt; compared to equivalent vLLM configurations, specifically because of this shared prefix optimization.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm9zloywwafeo93aakpvu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm9zloywwafeo93aakpvu.png" alt="vLLM vs. SGLang decision matrix" width="800" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few flags have an outsized impact when you deploy via a Runpod Pod or template, regardless of which engine you’re running. For vLLM, --max-num-seqs controls the maximum number of sequences in the batch. Set it too high and you’ll OOM. Set it too low, and you leave throughput on the table. A reasonable starting point for dual A6000s with a quantized 70B is --max-num-seqs 64. Add --disable-log-stats in production to eliminate logging overhead that adds a few milliseconds per batch on high-QPS endpoints.&lt;/p&gt;

&lt;p&gt;For SGLang, --tp 2 sets tensor parallelism across two GPUs. --chunked-prefill-size 512 controls chunked prefill, which prevents long prompts from monopolizing the GPU and improves latency fairness across concurrent requests. Start with 512 for mixed-length workloads. Increase to 1024 if your traffic is predominantly short prompts, or drop to 256 if you’re seeing latency spikes from long system prompts under concurrent load.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Speculative decoding: cut latency without changing hardware&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If your workload skews toward long-form generation (coding assistants, document summarization, report generation), speculative decoding is one of the biggest latency reductions you can get without changing hardware.&lt;/p&gt;

&lt;p&gt;A small draft model (typically 1-7B parameters) generates 3-12 candidate tokens per step. The large target model &lt;a href="https://research.google/blog/looking-back-at-speculative-decoding/" rel="noopener noreferrer"&gt;verifies all candidates in a single parallel forward pass&lt;/a&gt;. When the draft model guesses correctly (at rates as high as 70-90% with a well-matched draft model on domain-specific tasks), you get multiple tokens for roughly the cost of one target model step. &lt;a href="https://arxiv.org/abs/2211.17192" rel="noopener noreferrer"&gt;Research on speculative decoding&lt;/a&gt; shows 2-3x speedups on generation-heavy tasks.&lt;/p&gt;

&lt;p&gt;The economic case is direct: if you’re paying \$3/hr for your inference endpoint and speculative decoding cuts latency by 2x, you either halve your cost per request at the same throughput or serve twice the requests at the same cost. Neither requires touching your hardware configuration.&lt;/p&gt;

&lt;p&gt;Deploying a speculative decoding setup with the &lt;a href="https://docs.runpod.io/sdks/python/overview" rel="noopener noreferrer"&gt;Runpod SDK&lt;/a&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;runpod&lt;/span&gt;

&lt;span class="n"&gt;runpod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your_api_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;pod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;runpod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_pod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llama3-70b-speculative&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;image_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vllm/vllm-openai:latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;gpu_type_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NVIDIA RTX A6000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;gpu_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;container_disk_in_gb&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HF_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your_hf_token&lt;/span&gt;&lt;span class="sh"&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;docker_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--model TechxGenus/Meta-Llama-3-70B-Instruct-AWQ &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--quantization awq &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--tensor-parallel-size 2 &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--speculative-model TechxGenus/Meta-Llama-3-8B-Instruct-AWQ &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--num-speculative-tokens 5 &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-model-len 8192&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pod ID:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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 draft model must come from the same model family as your target. Llama-3-8B-Instruct-AWQ as a draft model for Llama-3-70B-Instruct-AWQ is the canonical pairing. Mismatched architectures produce low acceptance rates that eliminate the speedup entirely. You can verify the draft model’s effectiveness via vLLM’s vllm:spec_decode_draft_acceptance_length metric in Prometheus. If the acceptance rate falls below roughly 0.5 tokens per step, the draft model is poorly matched, and speculative decoding is adding overhead rather than reducing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Serverless vs. pods: architecting for cost&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.runpod.io/serverless/overview" rel="noopener noreferrer"&gt;Runpod Serverless&lt;/a&gt; scales to zero between requests and spins up workers on demand. Billing is per-second of GPU time, so you pay only while a worker is active with no reserved-capacity cost during idle periods. This is the right choice for spiky, unpredictable traffic (a chatbot that sees 1,000 concurrent users at 9 am and 20 at 3 am, for example). The historical objection to serverless LLM hosting was cold start time: loading a large model from cold could take a minute or more, making the first request in any cold-start window intolerable. Runpod’s FlashBoot technology reduces this through container-level and image-level optimizations, making cold starts practical for production use.&lt;/p&gt;

&lt;p&gt;Runpod Pods are persistent GPU instances billed per-second. Use them when your traffic is sustained, when you’re running fine-tuning jobs with &lt;a href="https://docs.ray.io/en/latest/" rel="noopener noreferrer"&gt;Ray&lt;/a&gt;, or when you need consistent latency guarantees for SLA-bound endpoints. A Ray-based distributed fine-tuning job &lt;a href="https://docs.ray.io/en/latest/train/overview.html" rel="noopener noreferrer"&gt;requires consistent inter-node communication&lt;/a&gt; that serverless cold starts would interrupt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft2xj3ghwyxni0f44gtbc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft2xj3ghwyxni0f44gtbc.png" alt="Runpod serverless" width="800" height="901"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Setup time matters too. The delta between Runpod and bare-metal providers like &lt;a href="https://lambdalabs.com/" rel="noopener noreferrer"&gt;Lambda Labs&lt;/a&gt; is large. Reaching an equivalent setup on a bare VM requires provisioning the instance, configuring the OS and CUDA drivers, installing Docker, setting up your orchestration layer (Kubernetes or Slurm), deploying your inference container, configuring autoscaling rules, and wiring up your load balancer. That’s a realistic two-week sprint for an engineer who hasn’t done it before. On Runpod, you select a &lt;a href="https://www.runpod.io/console/explore" rel="noopener noreferrer"&gt;vLLM template&lt;/a&gt;, set your environment variables, and your endpoint is live in minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://lambdalabs.com/" rel="noopener noreferrer"&gt;Lambda Labs&lt;/a&gt; has competitive hardware pricing, but the managed serving layer is thin and you still own the orchestration. If your workload needs auto-scaling inference with short-lived, per-request billing, Runpod’s Serverless infrastructure handles that out of the box. &lt;a href="https://www.coreweave.com/" rel="noopener noreferrer"&gt;CoreWeave&lt;/a&gt; targets enterprises with reserved contracts, which is the wrong motion for a seed-stage startup that needs to validate unit economics before committing to reserved capacity.&lt;/p&gt;

&lt;p&gt;Platform selection is the last dial, but it’s not a small one. A well-optimized model stack on the wrong infrastructure still produces the wrong billing curve.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The optimization sequence&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Start with quantization (AWQ or FP8, depending on your hardware). It’s a one-time change that cuts your VRAM requirements significantly, roughly 75% with 4-bit AWQ or 50% with FP8, and immediately opens up cheaper GPU classes. Then choose your serving engine: SGLang for agentic and structured-output workloads, vLLM for chat and general inference. Add speculative decoding if long-form generation is in your critical path. Monitor everything with &lt;a href="https://prometheus.io/" rel="noopener noreferrer"&gt;Prometheus&lt;/a&gt; so you’re reacting to actual bottlenecks rather than guesses.&lt;/p&gt;

&lt;p&gt;Your implementation checklist:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Quantize with AWQ (or FP8 on H100s) using &lt;a href="https://github.com/casper-hansen/AutoAWQ" rel="noopener noreferrer"&gt;AutoAWQ&lt;/a&gt; or a pre-quantized Hugging Face checkpoint&lt;/li&gt;
&lt;li&gt;Choose your engine: &lt;a href="https://github.com/sgl-project/sglang" rel="noopener noreferrer"&gt;SGLang&lt;/a&gt; for agents and JSON output, &lt;a href="https://docs.vllm.ai/en/latest/" rel="noopener noreferrer"&gt;vLLM&lt;/a&gt; for chat throughput&lt;/li&gt;
&lt;li&gt;Enable speculative decoding on generation-heavy endpoints&lt;/li&gt;
&lt;li&gt;Wire up Prometheus to vllm:gpu_cache_usage_perc before you go to production&lt;/li&gt;
&lt;li&gt;Match your deployment mode to your traffic pattern: Serverless for spiky, Pods for sustained&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A profitable inference endpoint runs on a well-chosen software stack deployed quickly. The hardware matters far less than most teams assume.&lt;/p&gt;

&lt;p&gt;If you’ve run into a different bottleneck or found a combination that works better for your workload, I’d genuinely like to hear it. Drop what you’ve learned in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>performance</category>
    </item>
    <item>
      <title>Stop Tuning Blind: Query Observability as the Foundation for Database Optimization</title>
      <dc:creator>Damaso Sanoja</dc:creator>
      <pubDate>Tue, 24 Mar 2026 11:46:49 +0000</pubDate>
      <link>https://dev.to/damasosanoja/stop-tuning-blind-query-observability-as-the-foundation-for-database-optimization-113p</link>
      <guid>https://dev.to/damasosanoja/stop-tuning-blind-query-observability-as-the-foundation-for-database-optimization-113p</guid>
      <description>&lt;p&gt;A team notices a checkout endpoint slowing down. Response times have crept from 80ms to 900ms over two weeks, but the infrastructure dashboard shows nothing abnormal. So the engineer does what most teams do first: adds an index on the column mentioned in the ticket, deploys, and moves on.&lt;/p&gt;

&lt;p&gt;Two weeks later, the same endpoint is slow again. A different engineer adds another index. Then another. The table now carries 23 indexes. Every &lt;code&gt;INSERT&lt;/code&gt; pays write amplification across all of them. The original slow query is still slow, because the root cause was never the missing index. Stale statistics after a schema migration had triggered a plan regression, and no one caught it because no one was watching &lt;a href="https://www.site24x7.com/what-is-database-monitoring.html" rel="noopener noreferrer"&gt;query-level execution data&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This guide inverts the usual approach. Instead of starting with indexing techniques and treating observability as an afterthought, it starts with the telemetry pipeline: how to capture query-level execution data, correlate it with application traces, and build the feedback loop that makes every subsequent optimization decision measurable. From there, it moves into execution plan analysis, indexing strategies, and resource management, each one grounded in the signals your pipeline surfaces. The principles apply across PostgreSQL, MySQL, and most relational engines. It assumes working knowledge of SQL and basic database administration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instrumenting before you optimize
&lt;/h2&gt;

&lt;p&gt;Database optimization requires three categories of signals, and most teams have at best one of them in place.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;query execution metrics&lt;/strong&gt;: per-query call count, mean latency, execution time standard deviation, rows scanned versus rows returned, and cache hit ratio. In PostgreSQL, &lt;code&gt;pg_stat_statements&lt;/code&gt; captures these metrics directly, though &lt;a href="https://medium.com/javarevisited/mastering-latency-metrics-p90-p95-p99-d5427faea879" rel="noopener noreferrer"&gt;p99 latency&lt;/a&gt; approximations require &lt;code&gt;pg_stat_monitor&lt;/code&gt; (which provides histogram-based latency distributions) or an external metrics store for precise percentile calculations (&lt;code&gt;stddev_exec_time&lt;/code&gt; is the closest proxy &lt;code&gt;pg_stat_statements&lt;/code&gt; provides). Enable it by adding the extension to &lt;code&gt;shared_preload_libraries&lt;/code&gt;, restarting the server, and creating the extension in each target 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="c1"&gt;-- postgresql.conf (restart required after saving)&lt;/span&gt;
&lt;span class="c1"&gt;-- In managed clouds like AWS RDS or GCP Cloud SQL, enable via Parameter Groups or database flags&lt;/span&gt;
&lt;span class="n"&gt;shared_preload_libraries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pg_stat_statements'&lt;/span&gt;
&lt;span class="c1"&gt;-- pg_stat_statements.track = top   -- default: tracks only top-level statements&lt;/span&gt;
&lt;span class="c1"&gt;-- Set to 'all' if your workload runs queries inside functions or stored procedures&lt;/span&gt;
&lt;span class="c1"&gt;-- After restart, run in each target database&lt;/span&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_stat_statements&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Top consumers by total execution time&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_exec_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;mean_exec_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stddev_exec_time&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&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;total_exec_time&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;In MySQL, the Performance Schema is enabled by default and provides equivalent data. Sort by total time consumed, not worst-case single execution. A query that takes 20ms per call but runs 50,000 times per hour contributes 1,000 seconds of database time, far more than a 5-second query that runs twice a day.&lt;/p&gt;

&lt;p&gt;The second signal is &lt;strong&gt;infrastructure-level database metrics&lt;/strong&gt;: connection counts, operation rates, and table I/O. The &lt;a href="https://opentelemetry.io/docs/collector/" rel="noopener noreferrer"&gt;OpenTelemetry Collector&lt;/a&gt; (&lt;code&gt;otelcol-contrib&lt;/code&gt;, not the core distribution) scrapes these on a configurable interval with no application code changes:&lt;/p&gt;

&lt;p&gt;First, create the monitoring user with the required permissions:&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="c1"&gt;-- Create monitoring user (PostgreSQL 10+)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;otel_monitor&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'your_password'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;pg_monitor&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;otel_monitor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- covers pg_stat_statements, pg_stat_activity, etc.&lt;/span&gt;
&lt;span class="c1"&gt;-- If pg_monitor is unavailable (pre-10), grant individually:&lt;/span&gt;
&lt;span class="c1"&gt;-- GRANT SELECT ON pg_stat_statements TO otel_monitor;&lt;/span&gt;
&lt;span class="c1"&gt;-- GRANT SELECT ON pg_stat_user_tables TO otel_monitor;&lt;/span&gt;
&lt;span class="c1"&gt;-- On AWS RDS and GCP Cloud SQL, pg_monitor is available and the preferred approach.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure the collector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost:5432&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel_monitor&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${env:PG_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;collection_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;databases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;myapp_prod&lt;/span&gt;

&lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-backend:4317&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third signal is &lt;strong&gt;application traces&lt;/strong&gt;. &lt;a href="https://opentelemetry.io/docs/languages/" rel="noopener noreferrer"&gt;Auto-instrumentation libraries&lt;/a&gt; for most languages and database clients (Python and Java have the most mature support; Go and Rust require more manual setup) emit a trace span for every database call, carrying the query text and operation type as span attributes. Without application-level tracing, you can identify slow queries but not which service, endpoint, or user action generated them.&lt;/p&gt;

&lt;p&gt;With all three in place, build a baseline dashboard before changing anything. Run four panels for at least one full business cycle (24 to 48 hours): top queries by total execution time, active connections over time, cache hit ratio, and index scan versus sequential scan ratio per table. Grafana works well for this. The baseline is what you compare against after every optimization. Skip it, and you can't confirm whether a change helped or quantify by how much.&lt;/p&gt;

&lt;p&gt;If assembling this stack in-house isn't the right fit, hosted platforms like &lt;a href="https://www.site24x7.com/database-monitoring.html" rel="noopener noreferrer"&gt;Site24x7&lt;/a&gt; collect the same signal categories across PostgreSQL, MySQL, SQL Server, and RDS/Aurora. The rest of this guide applies regardless of where the telemetry lives.&lt;/p&gt;

&lt;p&gt;The next section uses these signals to read execution plans and identify what needs fixing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading what your telemetry surfaces
&lt;/h2&gt;

&lt;p&gt;Your pipeline is collecting query metrics, infrastructure signals, and application traces. The next step is interpreting what they reveal. Three patterns account for the majority of production database problems, and each one leaves a distinct signature in your telemetry before it becomes a user-facing incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plan regressions
&lt;/h3&gt;

&lt;p&gt;Plan regressions appear as a sudden or gradual increase in execution time for a specific query fingerprint, with no corresponding change in query text. The &lt;a href="https://www.postgresql.org/docs/current/planner-optimizer.html" rel="noopener noreferrer"&gt;query planner makes cost-based decisions&lt;/a&gt; using statistics about row counts and value distributions. When those &lt;a href="https://www.postgresql.org/docs/current/planner-stats.html" rel="noopener noreferrer"&gt;statistics go stale&lt;/a&gt; after a bulk load, a migration, or months of organic growth, the planner's row estimate diverges from reality, and the planner picks a worse access path. Your &lt;code&gt;pg_stat_statements&lt;/code&gt; data will show the regression as a jump in &lt;code&gt;mean_exec_time&lt;/code&gt; for that fingerprint. The execution plan confirms it.&lt;/p&gt;

&lt;p&gt;Running &lt;a href="https://www.postgresql.org/docs/current/sql-explain.html" rel="noopener noreferrer"&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;&lt;/a&gt; on the offending query produces the actual execution, not just the planner's estimate. Here is what a plan regression looks like in practice:&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Output (simplified):&lt;/span&gt;
&lt;span class="c1"&gt;-- Seq Scan on events  (cost=0.00..18450.00 rows=50 width=64)&lt;/span&gt;
&lt;span class="c1"&gt;--                     (actual time=0.042..312.7 rows=180000 loops=1)&lt;/span&gt;
&lt;span class="c1"&gt;--   Filter: (user_id = 42)&lt;/span&gt;
&lt;span class="c1"&gt;--   Rows Removed by Filter: 320000&lt;/span&gt;
&lt;span class="c1"&gt;-- Planning Time: 0.08 ms&lt;/span&gt;
&lt;span class="c1"&gt;-- Execution Time: 458.3 ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The planner estimated 50 rows; the actual count was 180,000, a 3,600x divergence. The &lt;code&gt;Seq Scan&lt;/code&gt; node confirms no index was used, even though one exists on &lt;code&gt;user_id&lt;/code&gt;. The &lt;code&gt;Rows Removed by Filter&lt;/code&gt; line shows 320,000 rows were read and discarded. Refreshing statistics manually after large data changes is standard practice:&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="c1"&gt;-- PostgreSQL: refresh statistics for a specific table&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- MySQL: equivalent command&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After running &lt;code&gt;ANALYZE&lt;/code&gt;, re-execute the &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;. If the row estimate now matches reality and the planner switches to an index scan, stale statistics were the root cause.&lt;/p&gt;

&lt;p&gt;Stale statistics are the most common trigger, but plan regressions can also surface through changes in join strategy or CTE materialization. &lt;a href="https://use-the-index-luke.com/sql/join/nested-loops-join-n1-problem" rel="noopener noreferrer"&gt;Nested loop joins&lt;/a&gt; are efficient when one side is small and indexed; &lt;a href="https://www.postgresql.org/docs/current/planner-optimizer.html" rel="noopener noreferrer"&gt;hash joins handle larger unindexed sets, and merge joins work best on pre-sorted input&lt;/a&gt;. When the planner switches strategy between deploys your execution plan will show the new join node and your &lt;code&gt;pg_stat_statements&lt;/code&gt; data will show the performance delta. The same diagnostic applies: compare estimated versus actual rows and check whether stale statistics or data growth changed the cost calculation.&lt;/p&gt;

&lt;p&gt;A related case is Common Table Expression materialization. In PostgreSQL 12 and later, &lt;a href="https://www.postgresql.org/docs/current/queries-with.html" rel="noopener noreferrer"&gt;CTEs are inlined by default&lt;/a&gt; if they are non-recursive, referenced only once, and free of side-effects. In PostgreSQL 11 and earlier, &lt;a href="https://www.enterprisedb.com/blog/postgresqls-ctes-are-optimisation-fences" rel="noopener noreferrer"&gt;all CTEs are materialized as optimization fences&lt;/a&gt;, preventing predicate pushdown into the CTE body. When a CTE is referenced multiple times, PostgreSQL still materializes it to avoid duplicate computation unless you explicitly specify &lt;code&gt;NOT MATERIALIZED&lt;/code&gt;. If your telemetry shows a query &lt;a href="https://hakibenita.com/be-careful-with-cte-in-postgre-sql" rel="noopener noreferrer"&gt;scanning far more rows than expected through a CTE&lt;/a&gt;, check whether materialization is forcing a full scan where a filtered one would suffice. The first diagnostic question is whether the CTE executes once per query or once per row in a join.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contention
&lt;/h3&gt;

&lt;p&gt;Contention shows a different signature. Instead of one query getting slower, many connections wait on the same resource simultaneously. A &lt;code&gt;SHOW PROCESSLIST&lt;/code&gt; (MySQL) or &lt;code&gt;SELECT * FROM pg_stat_activity&lt;/code&gt; (PostgreSQL) &lt;a href="https://www.postgresql.org/docs/current/monitoring-stats.html" rel="noopener noreferrer"&gt;during the incident&lt;/a&gt; might show 140 connections blocked on a table-level lock held by a single long-running transaction.&lt;/p&gt;

&lt;p&gt;Your telemetry surfaces this pattern through execution time variance. The same query fingerprint alternates between 5ms and 4 seconds depending on whether it hits the lock window, producing a high &lt;code&gt;stddev_exec_time&lt;/code&gt; relative to &lt;code&gt;mean_exec_time&lt;/code&gt; in &lt;code&gt;pg_stat_statements&lt;/code&gt;. When you see that ratio spike, investigate lock waits before assuming a plan problem. Contention-driven variance affects multiple unrelated fingerprints at the same time; if only a single fingerprint shows high stddev, the cause is more likely an inherently variable workload than a locking issue.&lt;/p&gt;

&lt;p&gt;To identify the blocking session, use &lt;code&gt;pg_blocking_pids()&lt;/code&gt; (PostgreSQL 9.6+):&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="c1"&gt;-- Find blocking sessions and what they are running&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocked_query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;blocking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocking_pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;blocking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocking_query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="n"&gt;blocked&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="n"&gt;blocking&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;blocking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;cardinality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MySQL equivalent joins &lt;code&gt;performance_schema.data_lock_waits&lt;/code&gt; with &lt;code&gt;performance_schema.threads&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Maintenance drift
&lt;/h3&gt;

&lt;p&gt;Maintenance drift is the slowest-moving pattern, and the hardest to notice because no single event triggers it. Over weeks and months, dead index entries accumulate from row updates and deletes, &lt;a href="https://www.postgresql.org/docs/current/routine-vacuuming.html" rel="noopener noreferrer"&gt;statistics go stale&lt;/a&gt; as migrations reshape data distributions, and indexes that once matched hot access patterns quietly fall out of alignment with what the application actually queries. None of this shows up on a standard infrastructure dashboard.&lt;/p&gt;

&lt;p&gt;What your telemetry &lt;em&gt;does&lt;/em&gt; surface is a gradual increase in the rows-scanned-to-rows-returned ratio across multiple query fingerprints, often paired with a declining cache hit ratio. When a query scans 200,000 rows to return 40, the planner is telling you it can't satisfy that predicate with any existing index. A partial or expression index often closes the gap.&lt;/p&gt;

&lt;h3&gt;
  
  
  Diagnostic triage: from signal to action
&lt;/h3&gt;

&lt;p&gt;The following decision tree maps each telemetry pattern to its diagnostic path and the section that addresses the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A["Telemetry signal detected"] --&amp;gt; B{"Signal pattern?"}
    B --&amp;gt;|"mean_exec_time jump,&amp;lt;br&amp;gt;single fingerprint"| C["Plan regression"]
    B --&amp;gt;|"High stddev_exec_time,&amp;lt;br&amp;gt;multiple fingerprints"| D["Contention"]
    B --&amp;gt;|"Gradual scan ratio rise,&amp;lt;br&amp;gt;cache hit ratio decline"| E["Maintenance drift"]
    C --&amp;gt; F["EXPLAIN ANALYZE: compare&amp;lt;br&amp;gt;estimated vs. actual rows"]
    F --&amp;gt;|"Stale statistics"| G["ANALYZE table, re-check plan"]
    F --&amp;gt;|"Wrong access path"| H["See: Indexing decisions"]
    D --&amp;gt; I["pg_stat_activity /&amp;lt;br&amp;gt;SHOW PROCESSLIST"]
    I --&amp;gt;|"Connection saturation"| J["See: Connection pooling"]
    I --&amp;gt;|"Single lock holder"| K["Identify blocking transaction"]
    E --&amp;gt; L["pgstatindex for bloat /&amp;lt;br&amp;gt;table size for growth"]
    L --&amp;gt;|"Index bloat"| M["See: Index maintenance"]
    L --&amp;gt;|"Unbounded table growth"| N["See: Table partitioning"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you know which queries need attention and why the planner chose poorly, the next question is what structural change fixes it. Indexing decisions, grounded in the signals your telemetry just surfaced, are where that answer starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Indexing decisions driven by what the data shows
&lt;/h2&gt;

&lt;p&gt;The next step is the structural change that fixes what the planner got wrong. Indexing is the most common response to a slow query, and the most commonly misconfigured one. A well-chosen index can cut execution time by orders of magnitude; a poorly chosen one adds write overhead with no measurable read benefit. The difference depends on matching the index design to what your signals actually showed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Composite index column ordering
&lt;/h3&gt;

&lt;p&gt;An Index Scan in the execution plan does not guarantee efficiency. If the planner is still reading far more rows than it returns, the index exists but its &lt;a href="https://use-the-index-luke.com/sql/where-clause/the-equals-sign/concatenated-keys" rel="noopener noreferrer"&gt;column order doesn't match the query's predicate structure&lt;/a&gt;. The general rule for &lt;a href="https://www.postgresql.org/docs/current/indexes-multicolumn.html" rel="noopener noreferrer"&gt;multi-column indexes&lt;/a&gt;: equality predicates go first, then sorting columns (for &lt;code&gt;ORDER BY&lt;/code&gt; or &lt;code&gt;GROUP BY&lt;/code&gt;), and range predicates go last.&lt;/p&gt;

&lt;p&gt;Consider a query filtering by &lt;code&gt;user_id&lt;/code&gt; and ranging on &lt;code&gt;created_at&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="c1"&gt;-- Suboptimal: range predicate on the leading column&lt;/span&gt;
&lt;span class="c1"&gt;-- The index can only be used for the created_at range;&lt;/span&gt;
&lt;span class="c1"&gt;-- user_id filtering happens after the scan, not during it&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_events_ts_user&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&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="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&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;events&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="s1"&gt;'2024-01-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Correct: equality first, range last&lt;/span&gt;
&lt;span class="c1"&gt;-- The index narrows to all rows for user 42, then scans only the timestamp range&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_events_user_ts&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_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;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;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="k"&gt;AND&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="s1"&gt;'2024-01-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Putting &lt;code&gt;user_id&lt;/code&gt; first collapses the initial scan to a single user's rows before the range scan begins. The same principle extends to sorting: placing a range predicate &lt;em&gt;before&lt;/em&gt; the sort column can &lt;a href="https://use-the-index-luke.com/sql/sorting-grouping/index-for-sorting" rel="noopener noreferrer"&gt;force an expensive in-memory sort&lt;/a&gt; instead of using the index's native ordering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Partial (filtered) indexes
&lt;/h3&gt;

&lt;p&gt;When the scan ratio is high only for queries targeting a narrow subset, like the few thousand pending rows in a million-row job queue, a full index wastes I/O on rows those queries never touch.&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="c1"&gt;-- Only index rows where work still needs to happen&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_jobs_pending&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;jobs&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting index is orders of magnitude smaller than the full alternative. Because &lt;a href="https://www.postgresql.org/docs/current/indexes-partial.html" rel="noopener noreferrer"&gt;the query planner recognizes the predicate&lt;/a&gt;, it uses the partial index directly for queries that include &lt;code&gt;WHERE status = 'pending'&lt;/code&gt;. The trade-off is specificity: if your application queries other status values with similar frequency, you'll need separate partial indexes or a full one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Expression (functional) indexes
&lt;/h3&gt;

&lt;p&gt;Sometimes the predicate itself is the problem. When a query filters on a transformed column like &lt;code&gt;LOWER(email)&lt;/code&gt;, a standard B-tree index on the raw column is useless because the planner cannot match the transformation to the stored index entries. An &lt;a href="https://www.postgresql.org/docs/current/indexes-expressional.html" rel="noopener noreferrer"&gt;expression index&lt;/a&gt; indexes the output of the function, not the column itself:&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="c1"&gt;-- Case-insensitive email lookup&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_users_email_lower&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&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;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'user@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- JSON field extraction&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_events_payload_type&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'event_type'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&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;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'event_type'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'checkout'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query predicate must match the indexed expression exactly. &lt;code&gt;WHERE LOWER(email) = '...'&lt;/code&gt; hits &lt;code&gt;idx_users_email_lower&lt;/code&gt;; while &lt;code&gt;WHERE email ILIKE '...'&lt;/code&gt; does not, because the planner treats them as distinct operations. MySQL supports expression indexes from version 8.0 with the same identity requirement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Covering indexes
&lt;/h3&gt;

&lt;p&gt;The heap fetch is one of the most under valued performance bottlenecks. Even when the planner picks the right index and row estimates are accurate, each index hit triggers a random I/O back to the table to retrieve columns not stored in the index. A &lt;a href="https://www.postgresql.org/docs/current/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;covering index&lt;/a&gt; eliminates that secondary lookup by including every column the query needs.&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="c1"&gt;-- Hot path query on a multi-tenant SaaS table&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&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;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Covering index satisfies the full query from the index alone&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_users_tenant_active&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&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;The &lt;a href="https://www.postgresql.org/docs/current/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;&lt;code&gt;INCLUDE&lt;/code&gt; clause&lt;/a&gt; attaches non-key columns to the index leaf pages without affecting the B-tree structure. &lt;a href="https://www.postgresql.org/docs/current/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;PostgreSQL&lt;/a&gt; and &lt;a href="https://learn.microsoft.com/en-us/sql/relational-databases/indexes/create-indexes-with-included-columns" rel="noopener noreferrer"&gt;SQL Server&lt;/a&gt; support it directly. &lt;a href="https://dev.mysql.com/doc/refman/9.3/en/innodb-index-types.html" rel="noopener noreferrer"&gt;MySQL (InnoDB)&lt;/a&gt; has no &lt;code&gt;INCLUDE&lt;/code&gt; keyword, but every secondary index already carries the Primary Key at its leaf nodes, so you achieve the same effect by appending the extra columns to a &lt;a href="https://dev.mysql.com/doc/en/create-index.html" rel="noopener noreferrer"&gt;standard index definition&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The payoff is most pronounced on frequently executed queries where the heap fetch accounts for a measurable share of execution time. The cost is a larger index and added write overhead per row change, so covering indexes make sense for critical hot paths, not general use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Index bloat and maintenance
&lt;/h3&gt;

&lt;p&gt;Your telemetry shows a pattern consistent with maintenance drift: cache hit ratio declining gradually, scan times rising across multiple query fingerprints with no corresponding change in query text or data volume. Dead index entries from row updates and deletes are a common cause. In PostgreSQL, the &lt;a href="https://www.postgresql.org/docs/current/pgstattuple.html" rel="noopener noreferrer"&gt;&lt;code&gt;pgstattuple&lt;/code&gt; extension&lt;/a&gt; provides the &lt;code&gt;pgstatindex&lt;/code&gt; function to measure B-tree bloat directly via page density:&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="c1"&gt;-- Install the extension once per database (required before pgstatindex is available)&lt;/span&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;pgstattuple&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&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;pgstatindex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'idx_events_user_ts'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- avg_leaf_density dropping significantly below its baseline is a signal worth investigating;&lt;/span&gt;
&lt;span class="c1"&gt;-- no single universal threshold applies, but sustained readings below ~70% are a commonly&lt;/span&gt;
&lt;span class="c1"&gt;-- cited starting point; treat it as a prompt to investigate trends, not a hard trigger&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When bloat reaches the point where rebuilds are warranted, most engines can do it online. PostgreSQL offers &lt;a href="https://www.postgresql.org/docs/current/sql-reindex.html" rel="noopener noreferrer"&gt;&lt;code&gt;REINDEX CONCURRENTLY&lt;/code&gt;&lt;/a&gt; (available since PostgreSQL 12); MySQL's InnoDB rebuilds indexes in-place via &lt;a href="https://dev.mysql.com/doc/refman/9.3/en/innodb-online-ddl-operations.html" rel="noopener noreferrer"&gt;&lt;code&gt;ALTER TABLE ... FORCE&lt;/code&gt;&lt;/a&gt; or &lt;a href="https://dev.mysql.com/doc/refman/9.3/en/optimize-table.html" rel="noopener noreferrer"&gt;&lt;code&gt;OPTIMIZE TABLE&lt;/code&gt;&lt;/a&gt;. How often you need to rebuild depends on write volume.&lt;/p&gt;

&lt;p&gt;Both engines include automatic maintenance, but the defaults assume moderate write loads. PostgreSQL's &lt;a href="https://www.postgresql.org/docs/current/routine-autovacuum.html" rel="noopener noreferrer"&gt;autovacuum&lt;/a&gt; fires when the fraction of dead rows in a table crosses &lt;code&gt;autovacuum_vacuum_scale_factor&lt;/code&gt;, which defaults to 0.2 (20%). For a 1,000-row lookup table, that threshold is fine. For a 10-million-row events table, it means 2 million dead rows can accumulate before cleanup begins. MySQL's InnoDB purge thread handles dead-row cleanup continuously, but under heavy update workloads the purge lag (&lt;code&gt;History list length&lt;/code&gt; in &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt;) can grow faster than the thread drains it, producing similar bloat symptoms.&lt;/p&gt;

&lt;p&gt;In PostgreSQL, you can identify tables where autovacuum is falling behind:&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="c1"&gt;-- Identify tables where autovacuum is not keeping up&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_autovacuum&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10000&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;n_dead_tup&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Override autovacuum threshold for a specific high-churn table (no restart required)&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autovacuum_vacuum_scale_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;01&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Now autovacuum fires after 1% dead rows instead of 20%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Unused index audit
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/current/indexes-intro.html" rel="noopener noreferrer"&gt;Every index adds overhead to every write operation&lt;/a&gt; and the overhead compounds silently. The intro scenario's 23-index table is an extreme case, but smaller versions of the same problem are common. Auditing for indexes your query workload never uses is as important as adding new ones. In PostgreSQL, &lt;a href="https://www.postgresql.org/docs/current/monitoring-stats.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_stat_user_indexes&lt;/code&gt;&lt;/a&gt; exposes &lt;code&gt;idx_scan&lt;/code&gt; counts per index.&lt;/p&gt;

&lt;p&gt;Any index with zero or near-zero scans after weeks of production traffic is a candidate for removal, with two caveats. First, make sure the index isn't enforcing a &lt;code&gt;UNIQUE&lt;/code&gt; constraint or Primary Key, since these do critical work enforcing data integrity on every write, even if never explicitly scanned by a &lt;code&gt;SELECT&lt;/code&gt;. Second, make sure your observation window doesn't miss heavy seasonal queries, such as end-of-month reporting or quarterly rollups.&lt;/p&gt;

&lt;p&gt;Indexing addresses the query path. The next layer is the infrastructure around it: connection management, data layout, and write throughput.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managing the infrastructure on which your queries run
&lt;/h2&gt;

&lt;p&gt;Indexing optimized the query path. Three infrastructure-level bottlenecks can negate those gains: connection exhaustion under load, scan costs that grow with table size despite correct indexes, and write latency amplified by row-at-a-time inserts. Each surface in your telemetry before it becomes a production incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connection pooling and routing
&lt;/h3&gt;

&lt;p&gt;The contention pattern from the previous sections, where 140 connections were blocked on a table-level lock, often starts as a connection management problem. Most relational databases carry overhead per connection: process or thread creation, memory allocation, and authentication. In PostgreSQL, idle connections share most memory pages with the parent process via Copy-on-Write, but actual overhead ranges from under 2 MB (with huge pages and minimal prior activity) to over 10 MB, depending on &lt;code&gt;shared_buffers&lt;/code&gt; size and prior query activity. Active connections cost far more: &lt;code&gt;work_mem&lt;/code&gt; is allocated per sort or hash node in the query plan (default 4 MB each), so a complex query with multiple such nodes can consume a multiple of that figure. Connection poolers like &lt;a href="https://www.pgbouncer.org/" rel="noopener noreferrer"&gt;PgBouncer&lt;/a&gt; (PostgreSQL) and &lt;a href="https://proxysql.com/" rel="noopener noreferrer"&gt;ProxySQL&lt;/a&gt; (MySQL and PostgreSQL) multiplex many application connections onto a smaller pool of database connections.&lt;/p&gt;

&lt;p&gt;The architectural decision is the pooling mode. Session mode maps each application connection to a dedicated database connection for its lifetime, preserving session state (prepared statements, advisory locks). Transaction mode returns connections to the pool after each commit, enabling higher concurrency, but breaks any session-scoped feature. Audit your application's session-level usage before migrating modes. For read-heavy workloads with replicas, ProxySQL can route &lt;code&gt;SELECT&lt;/code&gt; queries to replicas and writes to the primary at the proxy layer. The trade-off is replication lag: reads immediately after writes may not reflect the latest state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Table partitioning
&lt;/h3&gt;

&lt;p&gt;Your telemetry shows correct index usage, the planner picks the right index, row estimates are accurate, but execution time still grows month over month. The table itself is growing, and even a good index scan takes longer when the underlying B-tree is larger. &lt;a href="https://www.postgresql.org/docs/current/ddl-partitioning.html" rel="noopener noreferrer"&gt;Range partitioning on a timestamp column&lt;/a&gt; addresses this by enabling partition pruning: when a query includes a predicate on the partition key, the database scans only the relevant 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="c1"&gt;-- Parent table: partitioned by month on created_at&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;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;bigint&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="k"&gt;IDENTITY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_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;action&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="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;span class="c1"&gt;-- One child partition per month&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;events_2025_01&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;events&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-01-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-02-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A query filtering to the last 30 days on a table partitioned by month typically scans 2 partitions rather than the full table. The execution plan confirms pruning via a &lt;code&gt;Partitions&lt;/code&gt; field or equivalent. Teams typically automate partition maintenance (creating future partitions in advance and detaching old ones) with &lt;a href="https://github.com/pgpartman/pg_partman" rel="noopener noreferrer"&gt;&lt;code&gt;pg_partman&lt;/code&gt;&lt;/a&gt;, a PostgreSQL extension that manages partition creation and retention on a configurable schedule. Without this automation, &lt;code&gt;INSERT&lt;/code&gt; statements targeting a date range with no corresponding partition will fail at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch write throughput
&lt;/h3&gt;

&lt;p&gt;Row-at-a-time inserts pay two costs per statement: a network round trip to the server and index maintenance across every index on the table. Batching rows into a single &lt;code&gt;INSERT&lt;/code&gt; pays both costs once per statement instead of once per row. Hundreds to thousands of rows per statement typically deliver 10 to 20x throughput improvement on bulk loads, depending on row width and network latency.&lt;/p&gt;

&lt;p&gt;Each engine imposes a ceiling on batch size. SQL Server caps parameterized queries at 2,100 parameters. MySQL's &lt;code&gt;max_allowed_packet&lt;/code&gt; rejects oversized payloads and closes the connection entirely; check the current limit with &lt;code&gt;SHOW VARIABLES LIKE 'max_allowed_packet'&lt;/code&gt; and increase it globally in &lt;code&gt;my.cnf&lt;/code&gt; or via &lt;code&gt;SET GLOBAL max_allowed_packet = 134217728&lt;/code&gt; (existing connections pick up the new default on reconnection). PostgreSQL's extended query protocol caps any single parameterized statement at 65,535 bind parameters. In practice, chunking into batches of 1,000 to 5,000 rows is the sweet spot across all three engines.&lt;/p&gt;

&lt;p&gt;With the query path and infrastructure tuned, the remaining question is where automation can reduce the ongoing maintenance burden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating optimization and anomaly detection
&lt;/h2&gt;

&lt;p&gt;The telemetry pipeline, execution plan analysis, indexing strategy, and infrastructure tuning covered so far are manual disciplines. Each requires an engineer to interpret signals and decide on a change. Two categories of automation can reduce that burden without replacing the judgment behind it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workload-aware index recommendations
&lt;/h3&gt;

&lt;p&gt;Tools like &lt;a href="https://www.eversql.com/" rel="noopener noreferrer"&gt;EverSQL&lt;/a&gt; ingest production query logs or slow query exports, build a workload model from query fingerprints, simulate execution plans, and generate index recommendations ranked by estimated improvement. Some also suggest query rewrites. The value is prioritization: instead of manually reviewing &lt;code&gt;pg_stat_statements&lt;/code&gt; output to decide which query to optimize first, the tool ranks candidates by aggregate impact and proposes a specific structural change. But no recommendation should go straight to production. Treat these recommendations as a starting point, not a deployment-ready output. Check whether the recommended index covers a write-heavy table, since read performance gains come at the cost of write amplification across every &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt;. Confirm that any rewritten query produces identical results under edge-case data distributions, not just the common case the tool optimized for. &lt;/p&gt;

&lt;h3&gt;
  
  
  Anomaly detection on query metrics
&lt;/h3&gt;

&lt;p&gt;ML-based anomaly detection on time-series query execution metrics can flag plan regressions post-deployment without requiring manual baseline comparison. This addresses the intro scenario directly: the checkout endpoint's latency crept from 80ms to 900ms over two weeks, with no alert firing because no static threshold was breached. An anomaly detector trained on per-fingerprint latency distributions would flag a 10x deviation from the rolling baseline within hours, not weeks.&lt;/p&gt;

&lt;p&gt;This is more useful than static thresholds because it adapts to traffic patterns. A query that naturally runs slower during batch jobs at 2 AM shouldn't generate a 3 AM alert. However, effective anomaly detection requires long-term retention of per-fingerprint query metrics. You can build that on your database's built-in statistics views, on the external metrics store your OTel pipeline already feeds, or delegate it to a hosted tool with anomaly detection built in, such as &lt;a href="https://www.site24x7.com/database-monitoring.html" rel="noopener noreferrer"&gt;ManageEngine's database monitoring&lt;/a&gt;. The trade-off is where the telemetry sits and who retains it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Managed database automation
&lt;/h3&gt;

&lt;p&gt;Cloud-managed databases increasingly bundle automatic index recommendations (&lt;a href="https://learn.microsoft.com/en-us/azure/azure-sql/database/automatic-tuning-overview" rel="noopener noreferrer"&gt;Azure SQL Database&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_PerfInsights.html" rel="noopener noreferrer"&gt;Amazon RDS Performance Insights&lt;/a&gt;) and compute auto-scaling. These autonomous features reduce operational overhead but operate within the bounds set by schema structure and access patterns, both of which require human decisions upstream. They handle the maintenance loop. They don't replace the diagnostic skill of reading an execution plan or the architectural judgment of choosing a partitioning strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a measurable feedback cycle
&lt;/h2&gt;

&lt;p&gt;Whether you automate parts of the maintenance cycle or handle every step manually, the principle is the same: every optimization needs a closed feedback loop to prove it worked.&lt;/p&gt;

&lt;p&gt;With the pipeline described in this guide, the opening scenario plays out differently. The &lt;code&gt;pg_stat_statements&lt;/code&gt; baseline catches the &lt;code&gt;mean_exec_time&lt;/code&gt; regression within a day. The &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; output reveals a 3,600x row estimate divergence, pointing to stale statistics after the schema migration. Running &lt;code&gt;ANALYZE&lt;/code&gt; on the affected table restores the correct execution plan. The unused index audit flags 19 of those 23 indexes as candidates for removal. The baseline dashboard confirms the fix: execution time drops, write throughput recovers, and the next regression, whenever it arrives, will surface in the same pipeline before a user files a ticket.&lt;/p&gt;

&lt;p&gt;The underlying shift is structural: from reacting to symptoms toward building a system that surfaces causes. Query-level telemetry provides the signals. Execution plan analysis reveals what the planner decided and whether it decided well. From there, indexing and infrastructure changes become the levers, and the baseline dashboard closes the loop by confirming whether pulling a lever worked. Each piece feeds the next.&lt;/p&gt;

&lt;p&gt;Database optimization is not a one-time project. It's a feedback loop. The teams that maintain fast, reliable databases over time are not the ones with the best indexing intuition. They're the ones whose instrumentation tells them where to look next. Start with &lt;code&gt;pg_stat_statements&lt;/code&gt; or Performance Schema, build the four-panel baseline, and let the data show you where your first optimization should land.&lt;/p&gt;

</description>
      <category>database</category>
      <category>monitoring</category>
      <category>performance</category>
      <category>sql</category>
    </item>
    <item>
      <title>Beyond Basic Indexes: Advanced Postgres Indexing for Maximum Supabase Performance</title>
      <dc:creator>Damaso Sanoja</dc:creator>
      <pubDate>Mon, 29 Sep 2025 11:12:18 +0000</pubDate>
      <link>https://dev.to/damasosanoja/beyond-basic-indexes-advanced-postgres-indexing-for-maximum-supabase-performance-3oj1</link>
      <guid>https://dev.to/damasosanoja/beyond-basic-indexes-advanced-postgres-indexing-for-maximum-supabase-performance-3oj1</guid>
      <description>&lt;p&gt;My Supabase application started with lightning-fast queries and smooth user interactions. Database operations felt instant, dashboards loaded in milliseconds, and search features responded immediately. Then reality hit: with tens of thousands of users and millions of rows, those same queries now took seconds to complete. That means user complaints and infrastructure costs.&lt;/p&gt;

&lt;p&gt;I wasn't facing a scaling issue - &lt;em&gt;I was experiencing a gap between my application's evolving complexity and my database's indexing strategy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;While &lt;a href="https://supabase.com/docs/guides/database/postgres/indexes" rel="noopener noreferrer"&gt;basic B-tree indexes&lt;/a&gt; efficiently handle simple equality and range queries, they become performance liabilities when applications evolve beyond straightforward patterns. My app needed to handle &lt;a href="https://supabase.com/docs/guides/database/json" rel="noopener noreferrer"&gt;&lt;code&gt;jsonb&lt;/code&gt;&lt;/a&gt; document searches, array operations, function-based queries, and targeted filtering.&lt;/p&gt;

&lt;p&gt;Advanced Postgres indexing strategies—specifically &lt;a href="https://supabase.com/docs/guides/database/postgres/indexes" rel="noopener noreferrer"&gt;expression and partial indexes&lt;/a&gt;—transformed these performance bottlenecks into optimized operations. I also discovered specialized techniques like &lt;a href="https://www.postgresql.org/docs/current/gin.html" rel="noopener noreferrer"&gt;GIN (Generalized Inverted Index)&lt;/a&gt;, &lt;a href="https://supabase.com/docs/guides/database/extensions/postgis" rel="noopener noreferrer"&gt;GiST (Generalized Search Tree)&lt;/a&gt;, and &lt;a href="https://supabase.com/docs/guides/ai/vector-indexes/hnsw-indexes" rel="noopener noreferrer"&gt;HNSW (Hierarchical Navigable Small World)&lt;/a&gt; indexes for complex data types.&lt;/p&gt;

&lt;p&gt;Here are the strategies I used, with real-world examples and performance analysis that helped me maintain peak performance as my application scaled.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Supabase Uses Postgres's Native Indexing Capabilities
&lt;/h2&gt;

&lt;p&gt;Supabase's &lt;a href="https://supabase.com/docs/guides/database/extensions/index_advisor" rel="noopener noreferrer"&gt;Index Advisor&lt;/a&gt; efficiently identifies B-tree optimization opportunities, &lt;a href="https://supabase.com/docs/guides/database/extensions/pg_stat_statements" rel="noopener noreferrer"&gt;&lt;code&gt;pg_stat_statements&lt;/code&gt;&lt;/a&gt; reveals resource-hungry queries, and &lt;a href="https://supabase.com/docs/guides/database/extensions" rel="noopener noreferrer"&gt;additional database extensions can be enabled&lt;/a&gt; for advanced indexing scenarios.&lt;/p&gt;

&lt;p&gt;The performance challenge arises with the increasing complexity of modern application data patterns. &lt;code&gt;jsonb&lt;/code&gt; document queries, array-containment operations, full-text search, and geospatial lookups are sophisticated use cases that require equally sophisticated indexing strategies. No automated tool can fully solve these scenarios because they demand a contextual understanding of your specific data patterns, query frequency, and performance requirements.&lt;/p&gt;

&lt;p&gt;While Supabase provides tooling to identify optimization opportunities, there's a fundamental limitation that automated tools can't address—the default indexing approach that works for simple queries often breaks down completely with these complex operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Your B-Tree Indexes Are Failing Your Users (Original: Why Basic Indexes Aren't Enough)
&lt;/h2&gt;

&lt;p&gt;The core issue isn't your indexing strategy—it's that &lt;em&gt;B-tree indexes simply cannot handle the query patterns your users actually need.&lt;/em&gt; While B-trees excel at simple equality and range operations, they become performance liabilities when applications require complex data operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your performance bottlenecks are hiding in these common patterns:&lt;/strong&gt; &lt;code&gt;jsonb&lt;/code&gt; document queries represent the most severe blind spot. This user preference lookup appears innocent but triggers sequential scans on even moderately sized 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;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;user_profiles&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;preferences&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{"notifications": true, "theme": "dark"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a proper index on the &lt;code&gt;jsonb&lt;/code&gt; column, this query scales terribly—for instance, what executes in fifty milliseconds with ten thousand users could become a three-second operation with one hundred thousand users.&lt;/p&gt;

&lt;p&gt;Array operations suffer similarly. This product search query forces expensive table scans despite having a price index:&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;products&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'electronics'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mobile'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; 
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The array overlap operator (&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;) cannot utilize B-tree indexes, forcing Postgres to examine every row individually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The diagnostic evidence is already in your database:&lt;/strong&gt; Supabase's &lt;code&gt;pg_stat_statements&lt;/code&gt; extension reveals the issue through queries with high &lt;code&gt;total_exec_time&lt;/code&gt; and &lt;code&gt;shared_blks_read&lt;/code&gt; values, which indicate sequential scans where indexes should apply. These metrics don't lie—if your complex queries show massive block reads, you're hitting the B-tree ceiling.&lt;/p&gt;

&lt;p&gt;Consider this full-text search pattern becoming common as applications mature:&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;documents&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&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;content&lt;/span&gt;&lt;span class="p"&gt;)&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;'user search terms'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without proper indexing for full-text search, query times could increase exponentially with document count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost isn't just slow queries:&lt;/strong&gt; Each inefficient query consumes excessive CPU and memory, reducing concurrent capacity. Users abandon slow searches, support tickets multiply, and infrastructure costs spiral as you throw hardware at software problems. Your Supabase application can handle complex data efficiently—but only if you escape B-tree limitations and implement the advanced indexing strategies your data patterns demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expression Indexes: Optimizing Function-Based Queries
&lt;/h2&gt;

&lt;p&gt;Expression indexes solve the critical performance gap between how your application queries data and how Postgres can efficiently access it. When queries consistently apply functions or transformations to column values—such as case-insensitive comparisons, date extractions, or calculated fields—Postgres cannot utilize standard B-tree indexes because the index stores raw column values, not computed results.&lt;/p&gt;

&lt;p&gt;This diagram illustrates how expression indexes work, transforming inconsistent source data to a normalized index structure and enabling efficient queries on computed values rather than raw data.&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;Table&lt;/span&gt; &lt;span class="k"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="n"&gt;Expression&lt;/span&gt; &lt;span class="k"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="n"&gt;Query&lt;/span&gt; &lt;span class="n"&gt;Optimization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="n"&gt;email&lt;/span&gt;                 &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            
&lt;span class="nv"&gt;"John@EXAMPLE.com"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;"john@example.com"&lt;/span&gt; &lt;span class="err"&gt;───┐&lt;/span&gt;  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'john@example.com'&lt;/span&gt;
&lt;span class="nv"&gt;"mary@TEST.org"&lt;/span&gt;    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;"mary@test.org"&lt;/span&gt;    &lt;span class="err"&gt;───┼─&lt;/span&gt; &lt;span class="n"&gt;Fast&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;lookup&lt;/span&gt; &lt;span class="k"&gt;instead&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt;
&lt;span class="nv"&gt;"Bob@demo.NET"&lt;/span&gt;     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;"bob@demo.net"&lt;/span&gt;     &lt;span class="err"&gt;───┘&lt;/span&gt;  &lt;span class="n"&gt;scanning&lt;/span&gt; &lt;span class="n"&gt;entire&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This scenario commonly occurs when email contacts are imported into your database from external sources with inconsistent casing. While forcing lowercase storage during import with lowercase comparison would be a cleaner and more efficient approach, expression indexes provide a powerful solution when you need to work with existing inconsistent data or when data normalization isn't feasible.&lt;/p&gt;

&lt;p&gt;Now that you understand what expression indexes accomplish, let's examine the technical mechanism that makes this optimization possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Precomputing for Performance
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0rovlt9o5u1xtizjvrqm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0rovlt9o5u1xtizjvrqm.png" alt="Expression indexes" width="800" height="315"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Expression indexes work by precomputing and storing the results of specified functions or expressions during index creation. When Postgres encounters a query with a &lt;code&gt;WHERE&lt;/code&gt; clause that exactly matches the indexed expression, it can use this precomputed index for lightning-fast lookups instead of applying the function to every row during a sequential scan:&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="c1"&gt;-- Problem: This query forces a sequential scan on every row&lt;/span&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;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'john@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Solution: Create an expression index on the lowercased email&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_users_lower_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;-- Now this query uses the index for millisecond performance&lt;/span&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;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'john@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Identifying Expression Index Candidates in Supabase
&lt;/h3&gt;

&lt;p&gt;Your &lt;code&gt;pg_stat_statements&lt;/code&gt; data reveals queries with high execution times that consistently apply functions in &lt;code&gt;WHERE&lt;/code&gt; clauses. Look for patterns involving &lt;code&gt;LOWER()&lt;/code&gt;, &lt;code&gt;UPPER()&lt;/code&gt;, date functions like &lt;code&gt;EXTRACT()&lt;/code&gt;, mathematical calculations, or &lt;code&gt;jsonb&lt;/code&gt; path extractions:&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="c1"&gt;-- High-impact candidate: User search by normalized phone numbers&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_users_normalized_phone&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;REGEXP_REPLACE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[^0-9]'&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="s1"&gt;'g'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Optimizes queries like:&lt;/span&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;users&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;REGEXP_REPLACE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[^0-9]'&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="s1"&gt;'g'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'1234567890'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Critical Implementation Requirements
&lt;/h3&gt;

&lt;p&gt;Expression indexes demand &lt;em&gt;immutable&lt;/em&gt; functions—those guaranteed to return identical results for identical inputs without side effects. Postgres enforces this restriction to maintain index consistency. Here's how it works in practice:&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="c1"&gt;-- Valid: Date extraction from timestamps&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_orders_year&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;YEAR&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;-- Optimizes year-based reporting queries&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&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;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;YEAR&lt;/span&gt; &lt;span class="k"&gt;FROM&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;AS&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;YEAR&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2024&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Invalid: NOW() is not immutable (changes over time)&lt;/span&gt;
&lt;span class="c1"&gt;-- CREATE INDEX invalid_idx ON events (created_at - NOW()); -- This fails&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  jsonb Path Extraction for Supabase Applications
&lt;/h3&gt;

&lt;p&gt;For applications storing flexible data structures in &lt;code&gt;jsonb&lt;/code&gt; columns, expression indexes on frequently accessed paths provide dramatic performance improvements for equality and range queries. The following example demonstrates two common patterns for optimizing &lt;code&gt;jsonb&lt;/code&gt; queries:&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="c1"&gt;-- SaaS application: Index user preference values&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_user_preferences_theme&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;user_profiles&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;preferences&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'theme'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Fast lookups for users with specific preferences&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_profiles&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;preferences&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'theme'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'dark'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- E-commerce: Index calculated discount percentages&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_products_discount_rate&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="n"&gt;original_price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;sale_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;original_price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)::&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;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;sale_price&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;original_price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expression indexes transform function-heavy queries from performance bottlenecks into optimized operations, but they require careful consideration of write overhead and exact query matching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partial Indexes: Targeting Specific Data Subsets
&lt;/h2&gt;

&lt;p&gt;Partial indexes represent a surgical approach to database optimization, addressing the fundamental inefficiency of indexing data you rarely query. By including only rows that satisfy a specific &lt;code&gt;WHERE&lt;/code&gt; condition in the index, partial indexes deliver dramatically smaller index sizes, reduced maintenance overhead, and laser-focused performance for your most critical query patterns.&lt;/p&gt;

&lt;p&gt;This diagram illustrates the dramatic size reduction when only specific rows are indexed based on query patterns:&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;Full&lt;/span&gt; &lt;span class="k"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                    &lt;span class="k"&gt;Partial&lt;/span&gt; &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="err"&gt;┌─────────────────────────┐&lt;/span&gt;   &lt;span class="err"&gt;┌─────────────────┐&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;Row&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;  &lt;span class="err"&gt;│──&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;Row&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;indexed&lt;/span&gt;  &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;Row&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'inactive'&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="err"&gt;│&lt;/span&gt;                 &lt;span class="err"&gt;│&lt;/span&gt;  &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;smaller&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;Row&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt; &lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="err"&gt;│&lt;/span&gt;                 &lt;span class="err"&gt;│&lt;/span&gt;  &lt;span class="n"&gt;Faster&lt;/span&gt; &lt;span class="n"&gt;scans&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;Row&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;  &lt;span class="err"&gt;│──&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;Row&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;indexed&lt;/span&gt;  &lt;span class="err"&gt;│&lt;/span&gt;  &lt;span class="n"&gt;Reduced&lt;/span&gt; &lt;span class="n"&gt;maintenance&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;Row&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'canceled'&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="err"&gt;│&lt;/span&gt;                 &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;Row&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;  &lt;span class="err"&gt;│──&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;Row&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;indexed&lt;/span&gt;  &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="err"&gt;└─────────────────┘&lt;/span&gt;
&lt;span class="err"&gt;└─────────────────────────┘&lt;/span&gt;   &lt;span class="k"&gt;Only&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt; &lt;span class="n"&gt;indexed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The following sections demonstrate how to implement this selective indexing approach, starting with the core benefits and progressing through identification strategies, technical requirements, and advanced patterns for complex scenarios.&lt;/p&gt;

&lt;h3&gt;
  
  
  Precision Over Breadth
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flp9gpf3lesbxz0yqou27.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flp9gpf3lesbxz0yqou27.png" alt="Partial indexes" width="800" height="315"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Traditional indexes include every row in a table, but partial indexes target specific subsets that align with your application's access patterns. This precision yields indexes that are orders of magnitude smaller—and correspondingly faster—while consuming fewer resources during write operations.&lt;/p&gt;

&lt;p&gt;Here's a practical comparison showing the difference:&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="c1"&gt;-- Problem: Indexing all orders when you primarily query active ones&lt;/span&gt;
&lt;span class="c1"&gt;-- Full index includes millions of completed/cancelled orders&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_orders_customer_full&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Solution: Partial index targets only operationally relevant orders&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_orders_active_customer&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- This query now uses a dramatically smaller, faster index&lt;/span&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;orders&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'user_123'&lt;/span&gt; 
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&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;order_date&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Identifying Partial Index Opportunities
&lt;/h3&gt;

&lt;p&gt;Analyze your query patterns for consistent filtering conditions that significantly reduce the result set. Common patterns include status-based filtering (active/inactive), temporal constraints (recent records), and priority-based queries (high-priority items). The following are examples of status-based and temporal filtering patterns:&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="c1"&gt;-- SaaS application: Active subscriptions represent a tiny fraction of the total&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_subscriptions_active_user&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- E-commerce: Recent orders for customer service and fulfillment&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_orders_recent_processing&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_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;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="k"&gt;CURRENT_DATE&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;'30 days'&lt;/span&gt; 
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Query Planner Predicate Matching
&lt;/h3&gt;

&lt;p&gt;For the Postgres query planner to utilize a partial index, it must determine that your query's &lt;code&gt;WHERE&lt;/code&gt; clause is logically implied by the index's predicate. This requires exact matches or mathematically provable implications:&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="c1"&gt;-- Partial index predicate&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_high_value_transactions&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- These queries CAN use the index:&lt;/span&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;transactions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'abc'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;-- Exact match&lt;/span&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;transactions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'abc'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;-- Implies amount &amp;gt; 1000&lt;/span&gt;

&lt;span class="c1"&gt;-- This query CANNOT use the index:&lt;/span&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;transactions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'abc'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;-- Doesn't imply amount &amp;gt; 1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Advanced Partial Index Patterns
&lt;/h3&gt;

&lt;p&gt;You can also combine partial indexes with expressions for maximum optimization impact, targeting both data subsets and computed values simultaneously. Here are three advanced patterns:&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="c1"&gt;-- Multi-tenant SaaS: Index active tenant data with normalized identifiers&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_tenant_data_active_normalized&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;tenant_data&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_slug&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Unique constraints on subsets: One active subscription per user&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_unique_active_subscription&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Error tracking: Index only failed events with extracted error codes&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_events_error_codes&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'error_code'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;occurred_at&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'error_code'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IS&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Partial indexes transform broad, resource-intensive indexing strategies into focused, high-performance solutions that align database resources with actual application usage patterns, setting the foundation for exploring additional advanced indexing techniques.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other Advanced Indexing Techniques in Postgres
&lt;/h2&gt;

&lt;p&gt;Beyond expression and partial indexes, Postgres offers specialized indexing methods that address specific data types and query patterns common in modern Supabase applications.&lt;/p&gt;

&lt;h3&gt;
  
  
  GIN for Composite Data
&lt;/h3&gt;

&lt;p&gt;GIN indexes excel at indexing composite data types where individual items contain multiple searchable elements. Unlike B-tree indexes, which store complete values, GIN employs an inverted index approach that maps content (such as words, elements, or keys) to the locations (row IDs) where that content appears. This makes them essential for &lt;code&gt;jsonb&lt;/code&gt; document queries, array operations, and full-text search scenarios that B-tree indexes cannot handle efficiently.&lt;/p&gt;

&lt;p&gt;Here are two common GIN index patterns:&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="c1"&gt;-- JSONB containment queries&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_user_preferences_gin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;user_profiles&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;preferences&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Optimizes: WHERE preferences @&amp;gt; '{"theme": "dark"}'&lt;/span&gt;

&lt;span class="c1"&gt;-- Array overlap operations  &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_product_tags_gin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;products&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;tags&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Optimizes: WHERE tags &amp;amp;&amp;amp; ARRAY['electronics', 'mobile']&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  GiST for Complex Types
&lt;/h3&gt;

&lt;p&gt;GiST indexes provide a flexible framework for indexing geometric data and range types and enabling nearest-neighbor searches. They're particularly valuable for Supabase applications using &lt;a href="https://postgis.net/" rel="noopener noreferrer"&gt;PostGIS&lt;/a&gt; for geospatial functionality.&lt;/p&gt;

&lt;p&gt;Here are two GiST patterns for geospatial and temporal data:&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="c1"&gt;-- Geospatial queries with PostGIS&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_locations_geom&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;locations&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GiST&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Optimizes: WHERE geom &amp;amp;&amp;amp; ST_MakeEnvelope(lng1, lat1, lng2, lat2, 4326)&lt;/span&gt;

&lt;span class="c1"&gt;-- Range type overlaps&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_events_timerange&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GiST&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time_period&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Optimizes: WHERE time_period &amp;amp;&amp;amp; '[2024-01-01, 2024-01-31]'::tsrange&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  HNSW Indexes for Vector Similarity Search
&lt;/h3&gt;

&lt;p&gt;For AI and machine learning applications storing vector embeddings, Postgres's &lt;code&gt;pgvector&lt;/code&gt; extension provides HNSW indexes optimized for high-dimensional similarity searches.&lt;/p&gt;

&lt;p&gt;HNSW indexes work by creating a multilayered graph structure where each layer contains increasingly fewer nodes, allowing for efficient navigation from coarse to fine-grained similarity matches. This hierarchical approach enables fast approximate nearest-neighbor searches in high-dimensional vector spaces. Here's the basic implementation pattern:&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="c1"&gt;-- Vector embeddings for semantic search&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_documents_embedding&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Optimizes: ORDER BY embedding  &amp;lt;=&amp;gt; query_vector LIMIT 10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HNSW indexes excel at k-nearest neighbor queries but &lt;a href="https://opensearch.org/blog/a-practical-guide-to-selecting-hnsw-hyperparameters/" rel="noopener noreferrer"&gt;require careful consideration of key parameters&lt;/a&gt;. The &lt;code&gt;m&lt;/code&gt; parameter controls the number of bidirectional links each node maintains, affecting the recall-performance balance—higher values improve search quality but increase memory usage and build time. The &lt;code&gt;ef_construction&lt;/code&gt; parameter determines the size of the candidate list during index construction, where larger values create higher-quality indexes at the cost of longer build times.&lt;/p&gt;

&lt;h3&gt;
  
  
  Performance Trade-Offs
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/current/textsearch-indexes.html" rel="noopener noreferrer"&gt;GIN indexes offer faster lookups&lt;/a&gt; but require longer build times and consume more storage. They're optimal for static data with frequent reads. GiST indexes provide faster updates and a smaller storage footprint, making them suitable for dynamic data scenarios.&lt;/p&gt;

&lt;p&gt;HNSW indexes deliver excellent performance for vector similarity searches but involve trade-offs between search accuracy and speed. Higher &lt;code&gt;m&lt;/code&gt; and &lt;code&gt;ef_construction&lt;/code&gt; values improve recall but significantly increase index size and build time, making parameter tuning essential for production deployments.&lt;/p&gt;

&lt;p&gt;These advanced indexing strategies &lt;a href="https://supabase.com/docs/guides/database/debugging-performance" rel="noopener noreferrer"&gt;require manual implementation and validation&lt;/a&gt; using &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; as Supabase's Index Advisor currently focuses only on B-tree recommendations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing Your Indexing Strategy
&lt;/h2&gt;

&lt;p&gt;Having explored the various advanced indexing techniques and their practical applications, you need to understand how to choose the right strategy for your specific use case. Here's a simple decision framework that can help you determine which index is best suited for your needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expression:&lt;/strong&gt; When queries consistently apply functions (&lt;code&gt;LOWER&lt;/code&gt;, &lt;code&gt;EXTRACT&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial:&lt;/strong&gt; When over 80 percent of queries target specific data subsets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GIN:&lt;/strong&gt; When working with &lt;code&gt;jsonb&lt;/code&gt;, arrays, or full-text search&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GiST:&lt;/strong&gt; When dealing with geospatial data or range types&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With you having explored the full spectrum of advanced indexing options, the question becomes this: How do you systematically implement and validate these techniques?&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Practices for Indexing in Postgres
&lt;/h2&gt;

&lt;p&gt;Effective indexing requires a strategic approach that balances query performance gains against write overhead and maintenance costs. These best practices guide you through systematic index evaluation, performance measurement, and overhead management to ensure your Supabase application scales efficiently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using EXPLAIN ANALYZE to Measure Performance
&lt;/h3&gt;

&lt;p&gt;Before creating any index, capture baseline performance using &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; to document current execution plans, costs, and actual execution times. This baseline enables accurate measurement of indexing impact. Here is an example of capturing baseline performance for a typical query:&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="c1"&gt;-- Capture baseline performance&lt;/span&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12345&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After you establish this baseline, follow a systematic process to validate the effectiveness of your new index. Below is an example:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;em&gt;Create the index&lt;/em&gt; (use &lt;code&gt;CONCURRENTLY&lt;/code&gt; in production):
&lt;/li&gt;
&lt;/ol&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;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_status&lt;/span&gt; 
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;em&gt;Update table statistics&lt;/em&gt; to ensure the planner recognizes the new index:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;em&gt;Rerun &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;&lt;/em&gt; on the same query and compare results:
&lt;/li&gt;
&lt;/ol&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12345&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&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're comparing performance before and after index creation, focus on these critical metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Actual execution time:&lt;/strong&gt; Look for significant reductions in total query time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan type changes:&lt;/strong&gt; Sequential scans should become index or bitmap heap scans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rows examined:&lt;/strong&gt; Verify that the index reduces the number of rows processed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buffer activity:&lt;/strong&gt; A lower &lt;code&gt;shared_blks_read&lt;/code&gt; indicates reduced I/O.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Successful indexing typically shows noticeable execution time reductions for well-targeted queries, with scan types changing from &lt;code&gt;Seq Scan&lt;/code&gt; to &lt;code&gt;Index Scan&lt;/code&gt; or &lt;code&gt;Bitmap Heap Scan&lt;/code&gt; using your new index.&lt;/p&gt;

&lt;h3&gt;
  
  
  Criteria for Creating Indexes
&lt;/h3&gt;

&lt;p&gt;Effective indexing requires strategic prioritization and alignment with query patterns and data types. Here are some best practices to guide your indexing decisions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Target high-impact queries first:&lt;/strong&gt; Focus indexing efforts on queries identified through &lt;code&gt;pg_stat_statements&lt;/code&gt; that exhibit high &lt;code&gt;total_exec_time&lt;/code&gt;, frequent &lt;code&gt;calls&lt;/code&gt;, or excessive &lt;code&gt;shared_blks_read&lt;/code&gt; values. Prioritize queries that combine high frequency with slow execution times—a query executed ten thousand times daily with a fifty-millisecond average latency has a greater impact than one executed ten times with a five-hundred-millisecond latency.&lt;/p&gt;

&lt;p&gt;To identify these high-impact queries, you can run the following SQL:&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="c1"&gt;-- Identify high-impact queries using pg_stat_statements&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_exec_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mean_exec_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shared_blks_read&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&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;total_exec_time&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;&lt;strong&gt;Data type and query pattern alignment:&lt;/strong&gt; Match index types to data characteristics and query patterns. Use B-tree indexes for scalar equality and range queries, GIN indexes for &lt;code&gt;jsonb&lt;/code&gt; containment and array operations, GiST indexes for geospatial queries and full-text search on dynamic data, and partial indexes when queries consistently target specific data subsets.&lt;/p&gt;

&lt;p&gt;The following is an example of index creation for common query patterns:&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="c1"&gt;-- JSONB queries: Use GIN indexes&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_user_preferences_gin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;user_profiles&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;preferences&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Geospatial queries: Use GiST indexes  &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_locations_geom&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;locations&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GiST&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Frequent subset queries: Use partial indexes&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_active_subscriptions&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Selectivity and cardinality considerations:&lt;/strong&gt; Create indexes on columns with high selectivity (many distinct values) for equality queries and moderate selectivity for range queries. Avoid indexing columns with extremely low cardinality (like Boolean flags) unless combined with other columns or used in partial indexes targeting minority cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Managing Write Overhead from Excessive Indexing
&lt;/h3&gt;

&lt;p&gt;Every index introduces write overhead because Postgres must update the index structure for each &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, or &lt;code&gt;DELETE&lt;/code&gt; operation that affects indexed columns. The &lt;code&gt;pganalyze&lt;/code&gt; model estimates this overhead as follows:&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="n"&gt;write_overhead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index_entry_size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;row_size&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;partial_index_selectivity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This represents additional bytes written to maintain indexes per byte written to the table.&lt;/p&gt;

&lt;p&gt;To understand the practical consequences of excessive indexing, let's take a look at some real-world benchmark data that illustrates why careful index management is essential:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quantifying overindexing impact:&lt;/strong&gt; Real-world benchmarks demonstrate severe performance degradation from excessive indexing. &lt;a href="https://www.percona.com/blog/benchmarking-postgresql-the-hidden-cost-of-over-indexing/" rel="noopener noreferrer"&gt;One study&lt;/a&gt; showed that increasing indexes from seven to thirty-nine across a schema resulted in &lt;em&gt;a 58 percent reduction in transactions per second&lt;/em&gt; (1,400 TPS to 600 TPS) and &lt;em&gt;a transaction-latency increase&lt;/em&gt; from eleven milliseconds to twenty-six milliseconds average.&lt;/p&gt;

&lt;p&gt;This degradation compounds in write-heavy Supabase applications, making selective indexing critical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identifying and removing unused indexes:&lt;/strong&gt; Regularly audit for unused indexes that provide no query benefit but continue imposing write overhead:&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="c1"&gt;-- Using Supabase CLI&lt;/span&gt;
&lt;span class="n"&gt;supabase&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="n"&gt;unused&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;indexes&lt;/span&gt;

&lt;span class="c1"&gt;-- Or query pg_stat_user_indexes directly&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indexname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indexname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For applications with high write volumes, consider the following strategies to manage indexing effectively and reduce write overhead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Prioritize partial indexes&lt;/em&gt; to minimize the subset of writes requiring index updates.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Combine multiple query needs&lt;/em&gt; into a single multicolumn index rather than creating multiple single-column indexes.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Consider deferred indexing&lt;/em&gt; for batch processing scenarios where indexes can be dropped during bulk operations and recreated afterward.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Monitor pg_stat_statements&lt;/em&gt; for queries with high &lt;code&gt;total_plan_time&lt;/code&gt;, which can indicate excessive index evaluation overhead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is to achieve surgical precision by creating indexes that provide substantial query performance improvements while minimizing unnecessary write overhead that could degrade overall application throughput.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementation-Priority Framework
&lt;/h3&gt;

&lt;p&gt;Here's a simple framework that can help you to prioritize your optimization efforts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;High impact, low risk:&lt;/strong&gt; Partial indexes on status columns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium impact, medium risk:&lt;/strong&gt; Expression indexes for case-insensitive searches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High impact, high complexity:&lt;/strong&gt; GIN indexes for &lt;code&gt;jsonb&lt;/code&gt; queries&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Advanced Postgres indexing strategies transform Supabase applications from performance bottlenecks into high-speed, scalable systems. Expression indexes eliminate sequential scan penalties for function-based queries, while partial indexes provide surgical precision while reducing size and write overhead. GIN indexes unlock JSONB and array operations, GiST indexes enable geospatial queries, and HNSW indexes power AI applications with vector similarity search.&lt;/p&gt;

&lt;p&gt;While Supabase's Index Advisor handles basic B-tree optimization, real-world performance demands the manual implementation of these advanced techniques. Strategic indexing decisions—knowing when a partial index on active records outperforms a full table index or when a GIN index eliminates &lt;code&gt;jsonb&lt;/code&gt; query bottlenecks—separate applications that struggle under load from those that scale effortlessly.&lt;/p&gt;

&lt;p&gt;Mastering these techniques delivers compound benefits—faster queries improve user experience, reduced resource consumption controls costs, and scalable architecture prevents technical debt accumulation.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgressql</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Data Integrity First: Mastering Transactions in Supabase SQL for Reliable Applications</title>
      <dc:creator>Damaso Sanoja</dc:creator>
      <pubDate>Tue, 23 Sep 2025 11:42:39 +0000</pubDate>
      <link>https://dev.to/damasosanoja/data-integrity-first-mastering-transactions-in-supabase-sql-for-reliable-applications-2dbb</link>
      <guid>https://dev.to/damasosanoja/data-integrity-first-mastering-transactions-in-supabase-sql-for-reliable-applications-2dbb</guid>
      <description>&lt;p&gt;Transferring $500 between bank accounts, reserving the last seat on a flight, updating inventory after a flash-sale checkout—all of these operations require multiple SQL statements that must execute as a single, indivisible unit, and any glitch can corrupt your data. Database transactions exist to stop that from happening. By wrapping related statements into an all-or-nothing unit, Postgres ensures that balances, orders, and records remain consistent, regardless of the traffic or network conditions.&lt;/p&gt;

&lt;p&gt;But relying on these safeguards isn't as simple as sprinkling &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;COMMIT&lt;/code&gt; into your code. You still have to address challenges like &lt;a href="https://www.linkedin.com/pulse/race-condition-database-trong-luong-van-9fsuc/" rel="noopener noreferrer"&gt;race conditions&lt;/a&gt;, &lt;a href="https://www.postgresql.org/docs/current/ddl-constraints.html" rel="noopener noreferrer"&gt;constraint violations&lt;/a&gt;, and mid-transaction failures across API layers. &lt;a href="https://supabase.com/" rel="noopener noreferrer"&gt;Supabase&lt;/a&gt; helps solve these issues by building on Postgres and handling the transaction logic directly at the database itself. It exposes the logic through streamlined interfaces that preserve data integrity without the usual middleware complexity.&lt;/p&gt;

&lt;p&gt;In this guide, I'll explain how transaction-consistency guarantees in Postgres actually work, show you manual and programmatic transaction patterns I've used, how to handle concurrency with isolation controls and row-level locks, and teach you how to build data integrity into your application using &lt;a href="https://github.com/orgs/supabase/discussions/526#discussioncomment-12190267" rel="noopener noreferrer"&gt;Supabase's database-first approach&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding Transactions in Postgres
&lt;/h2&gt;

&lt;p&gt;In Postgres, a &lt;a href="https://www.postgresql.org/docs/current/tutorial-transactions.html" rel="noopener noreferrer"&gt;transaction&lt;/a&gt; is a logical unit of work that groups one or more database operations together to represent a complete business process or workflow. For example, transferring money between bank accounts involves multiple operations (debiting one account, crediting another) that logically belong together as a single business transaction.&lt;/p&gt;

&lt;p&gt;To ensure reliable and consistent data processing, Postgres provides specific guarantees for the execution of transactions through "&lt;a href="https://www.postgresql.org/docs/17/glossary.html#GLOSSARY-ACID" rel="noopener noreferrer"&gt;ACID&lt;/a&gt; compliance." This means that every transaction automatically follows four properties: &lt;em&gt;atomicity&lt;/em&gt;, &lt;em&gt;consistency&lt;/em&gt;, &lt;em&gt;isolation&lt;/em&gt;, and &lt;em&gt;durability&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Atomicity&lt;/em&gt; ensures that all operations within a transaction either complete successfully together or fail together as a single unit. In our bank-transfer example, if a transfer of $500 from one account to another encounters any failure (such as insufficient funds, invalid account numbers, or system errors), the entire transaction rolls back, ensuring that money is never debited from the sender's account without being credited to the receiver's account.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Consistency&lt;/em&gt; ensures data-integrity rules and business constraints are maintained throughout the transaction. In our bank-transfer scenario, consistency ensures that account balances never become negative, account numbers remain valid, and the total money in the system stays the same—if $500 leaves one account, exactly $500 must arrive in another account, preserving the fundamental accounting principle that debits must equal credits.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Isolation&lt;/em&gt; prevents concurrent transactions from interfering with each other during execution. In our bank-transfer example, if multiple transfers involving the same accounts happen simultaneously, isolation ensures that each transaction sees a consistent view of account balances and prevents race conditions where concurrent transfers might result in incorrect final balances or overdrafts.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Durability&lt;/em&gt; guarantees that once a transaction is committed, the changes persist permanently even in the face of system failures. In our bank-transfer scenario, once the transfer completes successfully, the updated account balances are permanently stored and will survive power outages, system crashes, or hardware failures—ensuring that the financial transaction cannot be lost or reversed due to technical issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  ACID Rigor in Practice: When Full Compliance Matters
&lt;/h3&gt;

&lt;p&gt;While Postgres is inherently designed to provide robust ACID compliance for all transactions, the &lt;em&gt;degree&lt;/em&gt; of transactional rigor, particularly concerning &lt;em&gt;isolation&lt;/em&gt;, can be tailored to specific application needs. This flexibility allows developers to balance strong consistency guarantees with performance and concurrency requirements.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/17/transaction-iso.html" rel="noopener noreferrer"&gt;Postgres offers several isolation levels&lt;/a&gt; to achieve this balance, with &lt;code&gt;READ COMMITTED&lt;/code&gt; providing a good default for many applications and &lt;code&gt;SERIALIZABLE&lt;/code&gt; offering the highest level of strictness; we will delve into these specific isolation levels and their implications in detail later in this guide.&lt;/p&gt;

&lt;p&gt;For now, all you need to know is that choosing the appropriate isolation level within Postgres depends on your specific use case and its tolerance for certain types of temporary inconsistencies.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Highest Isolation Required (&lt;em&gt;eg&lt;/em&gt; SERIALIZABLE)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Relaxed Isolation Acceptable (&lt;em&gt;eg&lt;/em&gt; READ COMMITTED)&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Financial Systems:&lt;/strong&gt; Money transfers require complete isolation to prevent phenomena like phantom reads (new rows appearing in repeated queries) or nonrepeatable reads (same query returning different results) during complex calculations or audits.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Social Media Feeds:&lt;/strong&gt; Displaying like counts or follower numbers can tolerate slight delays or inconsistencies in real time as long as the data eventually settles.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Healthcare Records:&lt;/strong&gt; Patient charts need absolute isolation to prevent simultaneous updates from overwriting critical medication dosages or treatment notes, ensuring data integrity across a session.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Content Management:&lt;/strong&gt; Blog-post view counts or comment threads can tolerate brief inconsistencies during high traffic periods, where exact real-time accuracy isn't paramount.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Inventory Management:&lt;/strong&gt; Order processing requires the highest consistency and isolation to prevent accepting orders for nonexistent items, avoiding unfulfillable orders in highly concurrent environments.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Analytics Dashboards:&lt;/strong&gt; Metrics aggregation can use data that might be slightly stale or experience minor inconsistencies from concurrent writes, as exact real-time precision isn't critical for trend analysis.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Booking Systems:&lt;/strong&gt; Hotel or flight reservations need strict serializable consistency to prevent overbooking scenarios, ensuring that concurrent booking attempts behave as if they happened one after another.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Recommendation Engines:&lt;/strong&gt; Product suggestions can work with slightly stale user-preference data without significantly degrading user experience, as long as updates eventually propagate.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For applications that fall into the "highest isolation required" category, implementing these strict transactional guarantees becomes paramount to system reliability and data integrity within Postgres.&lt;/p&gt;

&lt;h3&gt;
  
  
  Simplifying ACID: Supabase's Database-First Approach
&lt;/h3&gt;

&lt;p&gt;Effectively using Postgres's native ACID capabilities for complex business logic in modern applications often introduces significant architectural and development challenges. This is because developers typically need to implement extensive &lt;em&gt;middleware solutions&lt;/em&gt;—intricate application-level code to manually orchestrate transaction boundaries, handle errors, and ensure atomicity across multiple database operations or API calls.&lt;/p&gt;

&lt;p&gt;Here, you could use something like Supabase, an open source Firebase alternative, to extend Postgres capabilities with a "database-first architecture."&lt;/p&gt;

&lt;p&gt;Common business logic is encapsulated as &lt;a href="https://www.ibm.com/docs/en/aix/7.3.0?topic=concepts-remote-procedure-call" rel="noopener noreferrer"&gt;remote procedure calls (RPCs)&lt;/a&gt; directly within the database (&lt;em&gt;eg&lt;/em&gt; as Postgres functions). Postgres functions execute atomically by design, while Supabase's role is to provide an RPC mechanism to invoke these functions as single, indivisible transactions. This means developers no longer need to write cumbersome application-level code. Instead, the robust ACID guarantees of Postgres are fully utilized directly at the data layer, significantly simplifying application architecture, reducing potential failure points, and inherently ensuring data integrity, allowing developers to fully rely on the database's native transactional power.&lt;/p&gt;

&lt;p&gt;In the next section, I'll explore how to implement these transaction controls through Supabase and see the database-first approach in action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing and Executing Transactions in Supabase
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://supabase.com/docs/guides/database/overview" rel="noopener noreferrer"&gt;Supabase's Postgres foundation&lt;/a&gt; provides direct access to transaction control through three fundamental commands: &lt;a href="https://www.postgresql.org/docs/current/sql-begin.html" rel="noopener noreferrer"&gt;&lt;code&gt;BEGIN&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://www.postgresql.org/docs/current/sql-commit.html" rel="noopener noreferrer"&gt;&lt;code&gt;COMMIT&lt;/code&gt;&lt;/a&gt;, and &lt;a href="https://www.postgresql.org/docs/current/sql-rollback.html" rel="noopener noreferrer"&gt;&lt;code&gt;ROLLBACK&lt;/code&gt;&lt;/a&gt;. While the examples in this guide demonstrate these concepts using banking scenarios, the patterns apply universally—whether you're managing e-commerce inventory, healthcare records, social media content, or any application requiring data consistency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Basic Transaction Structure
&lt;/h3&gt;

&lt;p&gt;Every manual Postgres transaction follows this pattern in Supabase's SQL editor:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Marks the start of a new transaction&lt;/span&gt;
&lt;span class="c1"&gt;-- Your SQL operations here (these changes are temporary until committed)&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Makes all changes permanent and ends the transaction&lt;/span&gt;
&lt;span class="c1"&gt;-- OR&lt;/span&gt;
&lt;span class="c1"&gt;-- ROLLBACK; -- Cancels all changes made since BEGIN and ends the transaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This structure creates a transaction boundary that treats all enclosed operations as a single unit. The &lt;code&gt;BEGIN&lt;/code&gt; statement opens the transaction, operations execute within this protected context, and &lt;code&gt;COMMIT&lt;/code&gt; makes all changes permanent. If any operation fails, &lt;code&gt;ROLLBACK&lt;/code&gt; cancels everything, returning the database to its pretransaction state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Simple Transfer Example
&lt;/h3&gt;

&lt;p&gt;Here's a simple money-transfer scenario that demonstrates the core transaction workflow:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Start the transaction&lt;/span&gt;

&lt;span class="c1"&gt;-- Debit the sender's account&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Credit the receiver's account&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-002'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Finalize both operations together&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This transaction performs two critical operations: It debits one account and credits another.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Crucially, this explicit transaction wrapper is vital when multiple operations are logically interdependent.&lt;/em&gt; Without grouping these two &lt;a href="https://www.postgresql.org/docs/current/sql-update.html" rel="noopener noreferrer"&gt;&lt;code&gt;UPDATE&lt;/code&gt;&lt;/a&gt; statements into a single transaction, a system failure &lt;em&gt;between&lt;/em&gt; them could lead to data inconsistency—money might disappear from the first account without ever reaching the second, as each &lt;code&gt;UPDATE&lt;/code&gt; would commit independently.&lt;/p&gt;

&lt;p&gt;The same principle applies to any application requiring coordinated updates, such as inventory transfers between warehouses, moving tasks between project phases, or updating user profiles across multiple tables. The transaction ensures either all related changes succeed together or none occur at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Controlled-Rollback Example
&lt;/h3&gt;

&lt;p&gt;Transactions provide manual control over when to cancel operations:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Begin a new transaction&lt;/span&gt;

&lt;span class="c1"&gt;-- Attempt to deduct money&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-003'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Check the hypothetical new balance (for illustrative purposes; typically, logic would be in application)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-003'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- If the business logic determines this update is invalid (e.g., overdraft), cancel it&lt;/span&gt;
&lt;span class="k"&gt;ROLLBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Explicitly cancels the UPDATE operation and ends the transaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern demonstrates conditional transaction control. After performing an operation within the transaction, you can inspect the results and decide whether to &lt;code&gt;COMMIT&lt;/code&gt; or &lt;code&gt;ROLLBACK&lt;/code&gt; based on business logic.&lt;/p&gt;

&lt;p&gt;In e-commerce applications, this might involve checking inventory levels after a reservation; in content management, verifying user permissions after access changes; and in healthcare systems, validating dosage calculations after prescription updates. The ability to cancel transactions based on intermediate results prevents invalid data states from persisting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multitable Transaction Coordination
&lt;/h3&gt;

&lt;p&gt;Complex business operations often require coordinating changes across multiple 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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Initiate a transaction for interdependent operations&lt;/span&gt;

&lt;span class="c1"&gt;-- Transfer money between accounts in the 'accounts' table&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-004'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Log the transaction details in a separate 'transactions' audit table&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transaction_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;-- Get sender's ID&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-004'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;-- Get receiver's ID&lt;/span&gt;
  &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'transfer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'completed'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Commit all three operations as one atomic unit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This example coordinates three distinct operations: two balance updates and one audit log insertion.&lt;/p&gt;

&lt;p&gt;The transaction ensures that if the audit logging fails for any reason, the financial transfer also gets cancelled, maintaining perfect synchronization between your primary data and supporting records. This pattern is essential in any application where maintaining data relationships across tables is critical—order-processing systems that update inventory, customer records, and shipping tables simultaneously; user management systems that modify permissions, log changes, and update caches together; or content publishing workflows that update articles, search indexes, and notification queues as atomic units.&lt;/p&gt;

&lt;p&gt;The direct SQL approach shown above works excellently for straightforward scenarios, but what happens when operations fail unexpectedly and you need sophisticated automatic rollback handling?&lt;/p&gt;

&lt;h3&gt;
  
  
  Automatic Rollback on Constraint Violations
&lt;/h3&gt;

&lt;p&gt;When operations violate database constraints, Postgres automatically cancels the entire transaction:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Start the transaction&lt;/span&gt;

&lt;span class="c1"&gt;-- Attempt to debit an account (this line will likely violate a CHECK constraint like 'positive_balance')&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-004'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- This update will NOT execute if the previous one fails and rolls back the transaction&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- This COMMIT will never be reached if an earlier error occurred&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This transaction attempts to withdraw $1,500 from an account with $0 balance. The first &lt;code&gt;UPDATE&lt;/code&gt; violates our &lt;code&gt;positive_balance&lt;/code&gt; constraint (assuming one exists), triggering an automatic rollback that prevents both updates from executing. Without this protection, the second account would receive money that never left the first account, creating phantom funds in your system.&lt;/p&gt;

&lt;p&gt;The same principle protects any application with data-validation rules—e-commerce systems preventing overselling inventory, healthcare applications blocking invalid dosage combinations, or content management systems enforcing publishing workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manual Rollback for Business Logic Validation
&lt;/h3&gt;

&lt;p&gt;Sometimes, business rules require custom validation that database constraints cannot enforce:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Start a new transaction&lt;/span&gt;

&lt;span class="c1"&gt;-- Attempt the transfer operations&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-002'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-003'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Check a custom business rule (e.g., if this exceeds a daily transfer limit for ACC-002)&lt;/span&gt;
&lt;span class="c1"&gt;-- Note: This SELECT would typically be part of a larger function/application logic.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;COALESCE&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;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&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;daily_total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;from_account_id&lt;/span&gt; &lt;span class="o"&gt;=&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-002'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nb"&gt;DATE&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Assume application logic determines that the daily_total (if retrieved) exceeds $1000.&lt;/span&gt;
&lt;span class="c1"&gt;-- Based on that external check, we manually cancel the transaction.&lt;/span&gt;
&lt;span class="k"&gt;ROLLBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Explicitly cancels the two UPDATE operations and ends the transaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This example performs the financial transfer first and then facilitates validation against business rules. If a custom business rule (like a daily transfer limit) is exceeded, &lt;code&gt;ROLLBACK&lt;/code&gt; cancels both balance updates, preventing the transaction from completing. This pattern is required for complex business logic that requires examining multiple data points—for example, subscription services validating usage limits after resource allocation, project management systems checking capacity constraints after task assignments, or social platforms enforcing interaction limits after engagement tracking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cascading Error Prevention
&lt;/h3&gt;

&lt;p&gt;Transactions prevent cascading failures across related operations:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Begin the transaction for all interdependent steps&lt;/span&gt;

&lt;span class="c1"&gt;-- Primary financial transfer operations&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;750&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;750&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-002'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Secondary operation: Log the transaction details&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transaction_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;),&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-002'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="mi"&gt;750&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'transfer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'completed'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Tertiary operation: Update 'updated_at' timestamps on affected accounts&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ACC-002'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Commit all three operations together as one atomic unit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any operation in this chain fails—whether the balance updates, transaction logging, or timestamp updates—the entire sequence rolls back. This prevents scenarios where your primary data changes but supporting operations fail, leaving your system in an inconsistent state.&lt;/p&gt;

&lt;p&gt;Applications managing complex workflows depend on this all-or-nothing behavior: order processing systems that must update inventory, payment records, and shipping tables together; user registration flows that create accounts, set permissions, and send notifications atomically; or content-publishing pipelines that update articles, search indexes, and cache layers as coordinated units.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connection-Failure Recovery
&lt;/h3&gt;

&lt;p&gt;Network interruptions during transactions automatically trigger rollbacks, protecting against partial updates when client connections drop unexpectedly. This built-in protection ensures that even infrastructure failures cannot corrupt your data through incomplete operations.&lt;/p&gt;

&lt;p&gt;While single-user scenarios benefit significantly from error handling, the real complexity emerges when multiple users access your database simultaneously, creating race conditions that require more sophisticated transaction management.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing Race Conditions and Concurrency Issues
&lt;/h2&gt;

&lt;p&gt;Race conditions occur when multiple transactions attempt to read and modify the same data simultaneously, creating unpredictable results that corrupt data integrity. These issues manifest most commonly in high-traffic applications where users compete for limited resources—duplicate bookings in event systems, oversold inventory in e-commerce platforms, or conflicting account updates in financial applications.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Classic Race-Condition Scenario
&lt;/h3&gt;

&lt;p&gt;Consider two users simultaneously transferring money from the same account:&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="c1"&gt;-- User A's transaction: Wants to withdraw $800&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&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;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- User A reads balance: $1000&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- User A calculates new balance: $200&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- User B's transaction (simultaneously): Wants to withdraw $300&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&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;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- User B also reads balance: $1000 (before User A's commit)&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- User B calculates new balance: $700&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both transactions read the same initial balance of $1,000, but the final result depends on which transaction commits last.&lt;/p&gt;

&lt;p&gt;If user B commits after user A, user B's update (setting balance to $700) will overwrite user A's change (which would have set it to $200). The account would end up with $700 when it should have $200 ($1000 − $800) minus $300, or −$100.&lt;/p&gt;

&lt;p&gt;This "lost update" causes money to appear or disappear incorrectly. This same pattern destroys data integrity in inventory systems where multiple customers purchase the last item, booking platforms where seats get double-reserved, or content-management systems where collaborative editing overwrites changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution: Transaction-Isolation Levels
&lt;/h3&gt;

&lt;p&gt;Postgres accepts four isolation-level settings that control how transactions interact with concurrent operations: &lt;code&gt;READ UNCOMMITTED&lt;/code&gt;, &lt;code&gt;READ COMMITTED&lt;/code&gt;, &lt;code&gt;REPEATABLE READ&lt;/code&gt;, and &lt;code&gt;SERIALIZABLE&lt;/code&gt;. However, Postgres doesn't actually implement &lt;code&gt;READ UNCOMMITTED&lt;/code&gt; as a distinct isolation level—it silently upgrades any &lt;code&gt;READ UNCOMMITTED&lt;/code&gt; transaction to &lt;code&gt;READ COMMITTED&lt;/code&gt; for consistency. This means Postgres effectively provides three distinct isolation behaviors, with &lt;code&gt;READ COMMITTED&lt;/code&gt; serving as both the default and the lowest functional isolation level.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;READ COMMITTED&lt;/code&gt; allows transactions to see committed changes from other concurrent transactions. While this prevents "dirty reads" (reading uncommitted data), it can lead to "nonrepeatable reads," where a repeated query within the same transaction returns different results because another transaction committed changes in between:&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;SET&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt; &lt;span class="k"&gt;ISOLATION&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;READ&lt;/span&gt; &lt;span class="k"&gt;COMMITTED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Postgres's default isolation level&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Start Transaction A&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Transaction A reads balance: $1000&lt;/span&gt;

&lt;span class="c1"&gt;-- At this point, another transaction (Transaction B) might commit a $200 withdrawal from ACC-001.&lt;/span&gt;
&lt;span class="c1"&gt;-- The balance in the database is now $800.&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Transaction A reads balance again: $800 (a non-repeatable read)&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This behavior suits applications where seeing the most recent data is more important than strict consistency within a single transaction's multiple reads, such as social media feeds displaying ever-updating like counts, news websites where articles are frequently revised, or real-time analytics dashboards where the latest metrics are prioritized over a perfectly frozen historical view within a short session.&lt;/p&gt;

&lt;p&gt;For higher guarantees, &lt;code&gt;REPEATABLE READ&lt;/code&gt; ensures that repeated reads return the same values throughout a transaction, preventing nonrepeatable reads, but it can still allow "phantom reads" (where new rows appear in a result set that was previously empty or smaller).&lt;/p&gt;

&lt;p&gt;Finally, &lt;code&gt;SERIALIZABLE&lt;/code&gt; provides the strongest isolation by preventing all concurrency anomalies, including dirty reads, nonrepeatable reads, and phantom reads. It effectively makes concurrent transactions appear to execute sequentially, guaranteeing that the outcome is the same as if there were no concurrency at all.&lt;/p&gt;

&lt;p&gt;For applications where the highest degree of data integrity and consistency is paramount, such as financial and booking systems, &lt;code&gt;SERIALIZABLE&lt;/code&gt; isolation is often the preferred choice to eliminate complex race conditions and ensure predictable outcomes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Row-Level Locking with SELECT FOR UPDATE
&lt;/h3&gt;

&lt;p&gt;You can also prevent race conditions in a read-modify-write scenario by explicitly locking rows during the operation:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Select the row and place an exclusive lock on it&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Perform the update; other transactions attempting to FOR UPDATE this row will now wait&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- The lock is released when the transaction commits or rolls back&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;FOR UPDATE&lt;/code&gt; clause creates an exclusive lock on the selected row, forcing other transactions attempting the same operation to wait until the current transaction commits. This eliminates race conditions by serializing access to contested resources.&lt;/p&gt;

&lt;p&gt;Event-booking systems use this technique to prevent double reservations by locking seat records during the booking process. E-commerce platforms lock inventory records during purchase transactions to prevent overselling. Social media applications lock user profiles during complex update operations to prevent conflicting modifications.&lt;/p&gt;

&lt;p&gt;However, while &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; offers a targeted solution by making conflicting transactions wait, &lt;code&gt;SERIALIZABLE&lt;/code&gt; provides a broader isolation level that ensures complete transactional correctness across all operations by preventing any concurrency anomalies.&lt;/p&gt;

&lt;p&gt;Which to use depends on your specific use case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; is ideal for explicit "read-modify-write" patterns on known, frequently contested rows, offering predictable blocking behavior.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SERIALIZABLE&lt;/code&gt; provides the strongest guarantee against all concurrency issues for an entire transaction, but it requires your application to handle transaction retries (re-executing the transaction when conflicts are detected) when Postgres detects a serialization conflict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Summing up, use &lt;code&gt;SERIALIZABLE&lt;/code&gt; for complex business logic where absolute data integrity across diverse operations is paramount, even at the cost of occasional retries.&lt;/p&gt;

&lt;p&gt;Understanding these concurrency control mechanisms becomes crucial when implementing transactions through Supabase's various interfaces, where different approaches offer distinct advantages for different use cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing Transactions in Supabase
&lt;/h2&gt;

&lt;p&gt;Supabase offers multiple approaches for implementing transactions, each suited to different architectural patterns and complexity requirements. Understanding when to use manual SQL transactions versus programmatic approaches ensures you choose the optimal strategy for your application's needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manual Transactions via SQL Editor
&lt;/h3&gt;

&lt;p&gt;The SQL Editor provides direct access to Postgres's transaction capabilities for administrative tasks, data migrations, or one-off operations:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcrx4ctux0sk6qamuuhg9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcrx4ctux0sk6qamuuhg9.png" alt="Demo transaction in Supabase SQL editor" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This direct SQL approach to transactions is ideal for scenarios requiring precise, one-off control over your database, such as administrative tasks like manually correcting a corrupted record after an incident, performing data migrations where a set of changes must be applied atomically, or executing ad hoc operations that need strong transactional guarantees outside of your application's regular workflow.&lt;/p&gt;

&lt;p&gt;For instance, in an e-commerce system, you might use this approach to manually reverse a fraudulent order's inventory update and credit. In healthcare, it could be used for a critical, one-time data cleanup of patient records. However, integrating this level of transactional control into your application's regular, user-facing features typically requires programmatic solutions that integrate seamlessly with your frontend code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database Functions with RPC Calls
&lt;/h3&gt;

&lt;p&gt;Supabase recommends defining business logic directly within Postgres as &lt;a href="https://supabase.com/docs/guides/database/functions" rel="noopener noreferrer"&gt;functions&lt;/a&gt; (also known as stored procedures) and then executing them using RPC.&lt;/p&gt;

&lt;p&gt;This method encapsulates the entire transaction logic within the database itself, ensuring atomicity and data integrity regardless of client-side or network failures. You interact with these powerful server-side functions using Supabase's client libraries, such as &lt;code&gt;supabase-js&lt;/code&gt; for JavaScript, enabling seamless communication from your frontend code.&lt;/p&gt;

&lt;p&gt;Here's a sample JavaScript snippet demonstrating how a client-side application initiates a complex database operation with a single RPC call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Example of invoking a pre-defined Postgres function named `transfer_money`&lt;/span&gt;
&lt;span class="c1"&gt;// using Supabase's JavaScript client library (`supabase.rpc`).&lt;/span&gt;
&lt;span class="c1"&gt;// This function on the database server would contain the SQL operations for a money transfer.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transfer_money&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sender_account_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ACC-001&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;receiver_account_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ACC-002&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;transfer_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;150.00&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Handle the response from the RPC call&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Transaction failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Log any error returned by the database function&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Transfer successful:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Confirm successful completion&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The advantage of this approach lies in how Postgres handles these function executions: It automatically wraps the entire function's logic in a single, robust transaction. This means if any operation within the &lt;code&gt;transfer_money&lt;/code&gt; function fails due to connection interruptions between individual SQL commands originating from the client, all changes roll back automatically. &lt;/p&gt;

&lt;h3&gt;
  
  
  Edge Functions for Complex Transaction Logic
&lt;/h3&gt;

&lt;p&gt;For sophisticated business logic requiring external API calls, advanced data validation, or complex conditional operations that cannot reside solely within the database, &lt;a href="https://supabase.com/docs/guides/functions" rel="noopener noreferrer"&gt;Supabase Edge Functions&lt;/a&gt; provide the ideal environment. They act as server-side handlers that can connect directly to your database, giving you programmatic control over transaction flow.&lt;/p&gt;

&lt;p&gt;The following TypeScript code demonstrates an Edge Function handling a transfer request. It includes custom validation and orchestrates the core database transaction via an RPC call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[https://esm.sh/@supabase/supabase-js@2](https://esm.sh/@supabase/supabase-js@2)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Retrieve Supabase URL from environment variables&lt;/span&gt;
  &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="c1"&gt;// Use a service role key for elevated privileges&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleComplexTransfer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Complex validation logic that might go beyond SQL constraints, executed server-side&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;suspicious&lt;/span&gt;&lt;span class="dl"&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Transfer blocked for suspicious reason&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Execute the core atomic database transaction via an RPC call to a Postgres function&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transfer_money&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;sender_account_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;receiver_account_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transfer_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Return the result of the database operation to the client&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&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;Edge Functions excel in scenarios where your transactional logic must extend beyond the database's direct capabilities.&lt;/p&gt;

&lt;p&gt;For example, in a payment processing system, an Edge Function could validate a credit card with an external payment gateway API before committing the transaction to the database. In a user-onboarding workflow, it might create a user record in Postgres and then call a third-party email service to send a welcome email, ensuring both steps are coordinated. For complex real-time bidding platforms, an Edge Function could enforce elaborate pricing logic or integrate with external analytics services before finalizing a bid in the database. They provide the flexibility of server-side code while maintaining core transaction integrity by delegating atomic database operations to Postgres RPC calls.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing the Right Approach
&lt;/h3&gt;

&lt;p&gt;Database functions via RPC suit most transaction scenarios—financial transfers, inventory updates, and user registration workflows. Edge Functions are needed when business logic extends beyond database operations to include external API interactions, complex validation requiring multiple data sources, or custom authentication flows.&lt;/p&gt;

&lt;p&gt;Crucially, both approaches maintain ACID properties while offering different levels of flexibility for your application architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Practices for Transactions
&lt;/h2&gt;

&lt;p&gt;Effective transaction management requires balancing data integrity with performance considerations. Here are some practices to ensure your applications maintain consistency while avoiding common pitfalls that can degrade system performance or create deadlock scenarios.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Transactions Short and Focused
&lt;/h3&gt;

&lt;p&gt;Minimize transaction duration by performing only essential operations within transaction boundaries. Long-running transactions hold locks longer, increasing contention and reducing overall system throughput:&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="c1"&gt;-- Good: Focused transaction, only includes critical database operations&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-002'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transaction_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Avoid: Including unrelated, non-database operations within the transaction&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Do NOT include operations like sending emails, uploading files to S3, or making external API calls here.&lt;/span&gt;
&lt;span class="c1"&gt;-- These operations are slow and do not require transactional atomicity with the database.&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC-002'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Performing business logic, external API calls, or complex calculations outside transaction boundaries prevents unnecessary lock retention. Reserve transactions exclusively for database operations that must execute atomically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Database Functions for Complex Logic
&lt;/h3&gt;

&lt;p&gt;Encapsulate multistep transaction logic within Postgres functions called via RPC. This approach minimizes network round-trip times and ensures atomic execution regardless of client-side failures.&lt;/p&gt;

&lt;p&gt;As explained, database functions also automatically wrap their contents in transactions, eliminating the risk of partial updates due to network interruptions between separate SQL commands.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implement Robust Error Handling
&lt;/h3&gt;

&lt;p&gt;Always include comprehensive error handling that accounts for both constraint violations and unexpected failures. Use try-catch blocks in Edge Functions and proper error checking with RPC calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Attempt to execute a complex database operation via RPC&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;complex_operation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Check for specific database errors returned by the RPC&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Operation failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Based on error type, implement retry logic, roll back other application state, or notify the user&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Handle successful operation and continue application flow&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Operation successful:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Catch and handle unexpected network errors, Deno runtime errors in Edge Functions, etc.&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unexpected error during operation:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Ensure application state is consistent or user is informed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Choose Appropriate Isolation Levels
&lt;/h3&gt;

&lt;p&gt;As discussed before, carefully select the appropriate transaction isolation level for your operations. While Postgres's default &lt;code&gt;READ COMMITTED&lt;/code&gt; suits many scenarios, consider &lt;code&gt;SERIALIZABLE&lt;/code&gt; for operations requiring stronger consistency guarantees to prevent specific concurrency anomalies. Remember that higher isolation levels may increase transaction retry requirements in high-contention scenarios.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Savepoints for Complex Scenarios
&lt;/h3&gt;

&lt;p&gt;For sophisticated business logic requiring partial rollbacks, use &lt;a href="https://www.postgresql.org/docs/current/sql-savepoint.html" rel="noopener noreferrer"&gt;Postgres's savepoint functionality&lt;/a&gt; within database functions. Savepoints allow rolling back to specific points without canceling entire transactions, providing fine-grained control over complex multistep operations.&lt;/p&gt;

&lt;p&gt;These practices ensure your transaction handling remains performant, reliable, and maintainable as your application scales to handle increasing concurrent users and complex business requirements.&lt;/p&gt;

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

&lt;p&gt;In this article, I explored the critical role of database transactions in preserving data integrity, from understanding Postgres's foundational ACID properties to mastering advanced concurrency control with isolation levels and row-level locking. I also explained how to implement these robust transactional patterns effectively, whether through Supabase's SQL editor, powerful database functions (RPCs), or flexible Edge Functions for complex logic.&lt;/p&gt;

&lt;p&gt;If you apply these principles, you can build applications that ensure data remains consistent and reliable, even in the most demanding, high-traffic scenarios.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgressql</category>
      <category>database</category>
      <category>sql</category>
    </item>
  </channel>
</rss>
