Snowflake World Tour hits your city

See how leading teams deploy agents at scale. Find a stop near you. Register free.

Reading EXPLAIN Plans

Performance tuning in Postgres always starts in the same place: reading the query plan. You can't fix what you don't understand, and EXPLAIN is how Postgres tells you exactly what it's doing with your query — and why it's slow.

There are two forms, and the difference matters:

-- Estimated plan only (does NOT execute the query)
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;

-- Actually executes and shows real timing (use this!)
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

EXPLAIN shows what the planner thinks will happen. EXPLAIN ANALYZE shows what actually happened. When they disagree, that's a clue — the planner is working with bad estimates.

Here's an annotated plan from a real query:

                                                          QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
 Hash Join  (cost=1204.52..5765.83 rows=1248 width=164) (actual time=8.412..42.891 rows=1195 loops=1)
   Hash Cond: (o.customer_id = c.id)
   ->  Seq Scan on orders o  (cost=0.00..4230.00 rows=50000 width=96) (actual time=0.015..18.234 rows=50000 loops=1)
         Filter: (created_at > '2025-01-01'::date)
         Rows Removed by Filter: 150000
   ->  Hash  (cost=1102.00..1102.00 rows=8202 width=68) (actual time=8.102..8.102 rows=8200 loops=1)
         Buckets: 16384  Batches: 1  Memory Usage: 892kB
         ->  Seq Scan on customers c  (cost=0.00..1102.00 rows=8202 width=68) (actual time=0.012..4.521 rows=8200 loops=1)
               Filter: (region = 'us-west')
               Rows Removed by Filter: 41800
 Planning Time: 0.285 ms
 Execution Time: 43.124 ms

The key fields to understand:

  • cost (e.g., 0.00..4230.00): Startup cost and total cost in arbitrary planner units. The first number is the cost to return the first row; the second is total cost for all rows.
  • rows: Estimated (in the parenthetical) vs actual row count. Large differences signal stale statistics.
  • actual time: Real milliseconds. First number is time to first row; second is total time.
  • loops: How many times this node executed. Multiply actual time × loops for true cost.
  • Rows Removed by Filter: Rows read but discarded — a high number means wasted work.

Join Types in Plans

Postgres chooses between three join strategies:

-- Nested Loop: best for small inner tables or index-driven lookups
EXPLAIN ANALYZE
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = 12345;
 Nested Loop  (cost=0.71..16.75 rows=1 width=42) (actual time=0.038..0.041 rows=1 loops=1)
   ->  Index Scan using orders_pkey on orders o  (cost=0.42..8.44 rows=1 width=8) (actual time=0.021..0.022 rows=1 loops=1)
         Index Cond: (id = 12345)
   ->  Index Scan using customers_pkey on customers c  (cost=0.29..8.31 rows=1 width=42) (actual time=0.012..0.013 rows=1 loops=1)
         Index Cond: (id = o.customer_id)
 Planning Time: 0.195 ms
 Execution Time: 0.068 ms
  • Nested Loop: Iterates outer rows, looks up each in the inner table. Fast when inner table has an index or few rows.
  • Hash Join: Builds a hash table from the smaller relation, probes it with the larger. Good for joining large sets without indexes.
  • Merge Join: Sorts both inputs and merges. Efficient when both inputs are already sorted or when the join is very large.

Spotting Planner Misestimates

When "rows" (estimated) and "actual rows" differ by 10x or more, the planner is working with bad data:

 Index Scan using idx_orders_status on orders  (cost=0.42..85.10 rows=12 width=96) (actual time=0.025..145.882 rows=48500 loops=1)
   Index Cond: (status = 'pending')

The planner expected 12 rows but got 48,500. This usually means:

  1. Statistics are stale — run ANALYZE orders;
  2. Correlation between columns that the planner can't model
  3. A recently loaded batch that changed the data distribution

Understanding Scan Types

The scan type in your EXPLAIN output tells you how Postgres is accessing the table data. Each type has a sweet spot, and understanding when Postgres chooses each one helps you know whether you need to intervene.

Sequential Scan (Seq Scan)

Reads the entire table from start to finish. This is the baseline — no index needed, no random I/O, just read everything.

EXPLAIN ANALYZE SELECT count(*) FROM events WHERE event_type = 'page_view';
 Aggregate  (cost=28450.00..28450.01 rows=1 width=8) (actual time=187.234..187.235 rows=1 loops=1)
   ->  Seq Scan on events  (cost=0.00..26200.00 rows=900000 width=0) (actual time=0.021..142.891 rows=892410 loops=1)
         Filter: (event_type = 'page_view')
         Rows Removed by Filter: 107590
 Execution Time: 187.301 ms

Seq Scan is the correct choice when:

  • The table is small (a few thousand rows)
  • You're reading most of the table anyway (low selectivity)
  • No useful index exists

Seq Scan is a problem when:

  • You're selecting a tiny fraction of a large table
  • It's in a hot path that runs thousands of times per second

Index Scan

Uses an index to find matching rows, then fetches full row data from the heap (table) pages.

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 8842;
 Index Scan using idx_orders_customer_id on orders  (cost=0.42..52.18 rows=45 width=96) (actual time=0.028..0.185 rows=42 loops=1)
   Index Cond: (customer_id = 8842)
 Planning Time: 0.082 ms
 Execution Time: 0.214 ms

Index Scan involves two steps: traverse the index B-tree, then jump to heap pages. It's fast for highly selective queries but involves random I/O to the heap.

Index Only Scan

The holy grail — Postgres satisfies the query entirely from the index without touching the heap table at all.

-- With a covering index: CREATE INDEX idx_orders_cust_covering ON orders(customer_id) INCLUDE (status, total);
EXPLAIN ANALYZE SELECT status, total FROM orders WHERE customer_id = 8842;
 Index Only Scan using idx_orders_cust_covering on orders  (cost=0.42..48.12 rows=45 width=18) (actual time=0.022..0.078 rows=42 loops=1)
   Index Cond: (customer_id = 8842)
   Heap Fetches: 0
 Planning Time: 0.075 ms
 Execution Time: 0.102 ms

"Heap Fetches: 0" means zero trips to the table. This only works when:

  1. All columns in SELECT, WHERE, and ORDER BY are in the index (via INCLUDE)
  2. The visibility map shows all pages are all-visible (meaning VACUUM has run recently)

Bitmap Index Scan + Bitmap Heap Scan

Bitmap scans bridge the gap between seq scan and index scan. They're chosen for medium selectivity — too many rows for an index scan (random I/O gets expensive), but too few for a seq scan to be optimal.

EXPLAIN ANALYZE SELECT * FROM orders WHERE created_at BETWEEN '2025-03-01' AND '2025-03-31';
 Bitmap Heap Scan on orders  (cost=285.42..8924.15 rows=15200 width=96) (actual time=2.841..28.412 rows=14892 loops=1)
   Recheck Cond: ((created_at >= '2025-03-01') AND (created_at <= '2025-03-31'))
   Heap Blocks: exact=4218
   ->  Bitmap Index Scan on idx_orders_created_at  (cost=0.00..281.62 rows=15200 width=0) (actual time=2.124..2.124 rows=14892 loops=1)
         Index Cond: ((created_at >= '2025-03-01') AND (created_at <= '2025-03-31'))
 Planning Time: 0.108 ms
 Execution Time: 29.891 ms

The two-phase approach:

  1. Bitmap Index Scan: Walks the index and builds an in-memory bitmap of which heap pages contain matching rows
  2. Bitmap Heap Scan: Reads those heap pages sequentially (converting random I/O into sequential I/O)

This is especially powerful when combining multiple indexes with BitmapAnd/BitmapOr:

EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending' AND region = 'eu-west';
 Bitmap Heap Scan on orders  (cost=412.85..2180.42 rows=820 width=96) (actual time=1.892..5.421 rows=795 loops=1)
   Recheck Cond: ((status = 'pending') AND (region = 'eu-west'))
   ->  BitmapAnd
         ->  Bitmap Index Scan on idx_orders_status  (cost=0.00..205.12 rows=12500 width=0) (actual time=0.845..0.845 rows=12500 loops=1)
         ->  Bitmap Index Scan on idx_orders_region  (cost=0.00..198.42 rows=8200 width=0) (actual time=0.712..0.712 rows=8200 loops=1)
 Execution Time: 5.612 ms

When Does Postgres Choose Each?

The decision comes down to selectivity — what fraction of the table you're reading:

SelectivityTypical ScanReasoning
< 1-2% of rowsIndex ScanFew rows; random I/O is acceptable
2-15% of rowsBitmap ScanToo many for index, too few for seq scan
> 15-20% of rowsSeq ScanSequential I/O wins for large fractions
All indexed columns availableIndex Only ScanAvoids heap entirely

These thresholds shift depending on table size, correlation, and whether data is cached in shared_buffers.

Memory and I/O Configuration

Postgres ships with conservative defaults designed to run on a 1990s-era shared hosting server with 128 MB of RAM. If you haven't tuned these settings, you're leaving enormous performance on the table. The good news: a handful of parameters cover 90% of what matters.

shared_buffers

This is Postgres's own cache — the pool of 8KB pages kept in shared memory. Every read goes through this cache first before hitting the OS page cache or disk.

Rule of thumb: 25% of total RAM, but with diminishing returns past about 8 GB on most workloads.

-- Check current setting
SHOW shared_buffers;

-- Recommended: 25% of RAM
-- 16 GB server → set shared_buffers = '4GB'
-- 64 GB server → set shared_buffers = '16GB'

effective_cache_size

This doesn't allocate memory — it tells the planner how much data is likely cached between shared_buffers and the OS page cache. A higher value makes the planner more willing to choose index scans (since it expects the data is already in memory).

Rule of thumb: 50-75% of total RAM.

SHOW effective_cache_size;
-- 64 GB server → set effective_cache_size = '48GB'

work_mem

Memory available for each sort, hash, or merge operation within a query. A single complex query might use work_mem multiple times (once per sort node, once per hash join). Set this too high and a burst of concurrent queries can exhaust RAM.

Rule of thumb: Start at 16-64 MB, increase for specific sessions running analytical queries.

-- Check current setting
SHOW work_mem;

-- For an analytical session that needs large sorts:
SET work_mem = '256MB';
-- Run your big query...
RESET work_mem;

Signs that work_mem is too low: you see "Sort Method: external merge Disk" or "Batches: 4" (multiple batches) in hash joins in your EXPLAIN ANALYZE output.

maintenance_work_mem

Used by VACUUM, CREATE INDEX, and ALTER TABLE operations. These run single-threaded so you can safely set this much higher than work_mem.

SHOW maintenance_work_mem;
-- Set to 1-2 GB for faster index builds and vacuums
-- ALTER SYSTEM SET maintenance_work_mem = '1GB';

random_page_cost

The planner's estimate of how expensive a random page read is relative to a sequential read. The default of 4.0 assumes spinning disks. On SSDs, random reads are nearly as fast as sequential reads.

For SSDs: set to 1.1 - 1.5. This makes the planner more willing to use index scans.

SHOW random_page_cost;
-- ALTER SYSTEM SET random_page_cost = 1.1;

Monitoring Your Cache Hit Ratio

This is the single most important metric for memory tuning. You want shared_buffers cache hit ratio above 99%:

SELECT
  sum(heap_blks_read) AS heap_read,
  sum(heap_blks_hit) AS heap_hit,
  round(
    sum(heap_blks_hit)::numeric /
    nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) * 100, 2
  ) AS cache_hit_ratio
FROM pg_statio_user_tables;
 heap_read | heap_hit   | cache_hit_ratio
-----------+------------+-----------------
    285412 | 89421058   |           99.68

If your cache hit ratio drops below 99%, you're hitting disk too often. Either increase shared_buffers or reduce working set size (better indexes, partitioning, archiving old data).

Checkpoint Tuning

Checkpoints flush dirty pages from shared_buffers to disk. Poorly configured checkpoints cause I/O spikes that stall queries.

-- Check current checkpoint settings
SELECT name, setting, unit FROM pg_settings
WHERE name IN ('max_wal_size', 'checkpoint_completion_target', 'checkpoint_timeout');
           name            | setting | unit
---------------------------+---------+------
 checkpoint_completion_target | 0.9    |
 checkpoint_timeout           | 300    | s
 max_wal_size                 | 2048   | MB

Key principles:

  • max_wal_size: Controls how much WAL accumulates before forcing a checkpoint. Higher values = less frequent checkpoints = smoother I/O. Set to 4-16 GB for write-heavy workloads.
  • checkpoint_completion_target: Spread checkpoint I/O over this fraction of the checkpoint interval. Default 0.9 is good (spreads over 90% of the interval).

Understanding Your Workload Profile

Before tuning, know whether you're read-heavy or write-heavy — the strategies differ:

SELECT
  datname,
  tup_returned + tup_fetched AS reads,
  tup_inserted + tup_updated + tup_deleted AS writes,
  round(
    (tup_returned + tup_fetched)::numeric /
    nullif(tup_inserted + tup_updated + tup_deleted, 1), 1
  ) AS read_write_ratio
FROM pg_stat_database
WHERE datname = current_database();
  datname  |   reads    |  writes  | read_write_ratio
-----------+------------+----------+------------------
 myapp     | 284521000  | 28120000 |             10.1

A 10:1 ratio is starting to get write-heavy. For context:

  • Read-heavy (100:1+): Focus on caching, effective_cache_size, covering indexes, read replicas
  • Balanced (10:1 to 100:1): Focus on shared_buffers, index optimization, connection pooling
  • Write-heavy (<10:1): Focus on WAL tuning, checkpoint spreading, fillfactor, HOT updates

HOT Updates and Fill Factor

When Postgres updates a row, it normally must update every index on the table (because the new row version lives at a new heap location). HOT (Heap Only Tuple) updates avoid this by storing the new version on the same heap page — but only when there's room.

-- Reserve 20% of each page for HOT updates on a frequently-updated table
ALTER TABLE sessions SET (fillfactor = 80);

-- After changing fillfactor, VACUUM FULL to rewrite the table (or wait for natural turnover)
VACUUM FULL sessions;

Monitor HOT update effectiveness:

SELECT
  relname,
  n_tup_upd,
  n_tup_hot_upd,
  round(n_tup_hot_upd::numeric / nullif(n_tup_upd, 0) * 100, 1) AS hot_update_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 1000
ORDER BY n_tup_upd DESC;
   relname   | n_tup_upd | n_tup_hot_upd | hot_update_pct
-------------+-----------+---------------+----------------
 sessions    |    892410 |        845100 |           94.7
 orders      |    245000 |         42100 |           17.2
 user_prefs  |     85200 |         81400 |           95.6

The orders table at 17.2% HOT updates is a candidate for a lower fillfactor — something is preventing in-page updates (likely an indexed column being updated, or pages already full).

aside positive Snowflake Postgres automatically tunes shared_buffers, work_mem, and checkpoint settings based on your instance size and workload patterns. For advanced tuning, Snowflake Postgres Insights surfaces your top queries by execution time and recommends index improvements.

pg_stat_statements and Query Analysis

If you only install one Postgres extension for performance, make it pg_stat_statements. It tracks execution statistics for every normalized query — how many times it ran, total time, mean time, rows returned, and buffer usage. This is how you find the queries that actually matter.

Enabling pg_stat_statements

-- Requires adding to shared_preload_libraries (needs restart)
ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';
-- After restart:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

Finding Your Most Expensive Queries

The queries that consume the most total execution time are your highest-impact optimization targets:

SELECT
  substring(query, 1, 80) AS short_query,
  calls,
  round(total_exec_time::numeric, 1) AS total_time_ms,
  round(mean_exec_time::numeric, 1) AS mean_time_ms,
  rows,
  round((shared_blks_hit::numeric / nullif(shared_blks_hit + shared_blks_read, 0)) * 100, 1) AS cache_hit_pct
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
                    short_query                     |  calls  | total_time_ms | mean_time_ms |   rows   | cache_hit_pct
----------------------------------------------------+---------+---------------+--------------+----------+---------------
 SELECT o.*, c.name FROM orders o JOIN customers c  |  284521 |     892451.2  |         3.1  | 14218050 |          99.8
 UPDATE sessions SET last_active = $1 WHERE id = $2 | 1842100 |     645210.8  |         0.4  |  1842100 |          99.9
 SELECT * FROM events WHERE user_id = $1 AND crea   |   42810 |     428100.5  |        10.0  |  4185200 |          87.2
 INSERT INTO audit_log (action, user_id, details)   |  892410 |     215420.1  |         0.2  |   892410 |          99.9
 SELECT count(*) FROM orders WHERE status = $1      |   28450 |     185210.4  |         6.5  |    28450 |          92.1

Key insight: that UPDATE sessions query is individually cheap (0.4ms) but runs 1.8 million times — making it the second-most expensive overall. The events query at 87.2% cache hit rate is thrashing the buffer pool.

Total Time vs Mean Time

Don't just look at mean time. A query running 0.5ms × 2 million calls consumes more resources than one running 500ms × 100 calls:

-- Frequent but individually fast (death by a thousand cuts)
SELECT
  substring(query, 1, 60) AS query,
  calls,
  round(mean_exec_time::numeric, 2) AS mean_ms,
  round(total_exec_time::numeric / 1000, 1) AS total_seconds
FROM pg_stat_statements
WHERE calls > 10000
ORDER BY total_exec_time DESC
LIMIT 5;
-- Infrequent but individually slow (long-running reports)
SELECT
  substring(query, 1, 60) AS query,
  calls,
  round(mean_exec_time::numeric, 1) AS mean_ms,
  round(total_exec_time::numeric / 1000, 1) AS total_seconds
FROM pg_stat_statements
WHERE mean_exec_time > 1000
ORDER BY mean_exec_time DESC
LIMIT 5;

Both need attention, but through different approaches — high-frequency queries benefit from micro-optimizations (covering indexes, connection pooling), while slow queries need plan analysis.

Buffer Hit Ratio Per Query

The per-query buffer hit ratio reveals which specific queries are causing disk reads:

SELECT
  substring(query, 1, 60) AS query,
  calls,
  shared_blks_hit,
  shared_blks_read,
  round(
    shared_blks_hit::numeric /
    nullif(shared_blks_hit + shared_blks_read, 0) * 100, 1
  ) AS buffer_hit_pct
FROM pg_stat_statements
WHERE shared_blks_read > 1000
ORDER BY shared_blks_read DESC
LIMIT 5;

Queries with buffer hit rates below 95% are candidates for:

  • Better indexes to reduce data scanned
  • Increased shared_buffers if the working set doesn't fit
  • Query rewrites to access less data

Resetting Statistics

Reset periodically to get fresh data — stats are cumulative since the last reset:

-- Reset all pg_stat_statements data
SELECT pg_stat_statements_reset();

-- Check when stats were last reset (useful for context)
SELECT stats_reset FROM pg_stat_bgwriter;

A good practice: reset after deployments or configuration changes so you can see the impact clearly.

Parallel Queries

Postgres can split expensive operations across multiple CPU cores. When a single query needs to read millions of rows, parallel execution can cut wall-clock time dramatically — but it's not magic, and not every query benefits.

When Postgres Goes Parallel

The planner considers parallel execution for:

  • Large sequential scans (reading millions of rows)
  • Hash joins on large datasets
  • Aggregates (COUNT, SUM, AVG) over large tables
  • Merge joins with sorted inputs
EXPLAIN ANALYZE SELECT count(*), avg(total) FROM orders WHERE created_at > '2025-01-01';
 Finalize Aggregate  (cost=15842.52..15842.53 rows=1 width=40) (actual time=48.125..50.412 rows=1 loops=1)
   ->  Gather  (cost=15842.10..15842.51 rows=2 width=40) (actual time=45.812..50.385 rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate  (cost=14842.10..14842.11 rows=1 width=40) (actual time=42.125..42.126 rows=1 loops=3)
               ->  Parallel Seq Scan on orders  (cost=0.00..13592.10 rows=250000 width=8) (actual time=0.024..28.412 rows=166420 loops=3)
                     Filter: (created_at > '2025-01-01'::date)
                     Rows Removed by Filter: 33580
 Planning Time: 0.125 ms
 Execution Time: 50.512 ms

Key indicators: "Workers Planned: 2", "Workers Launched: 2", and "loops=3" (the leader process plus 2 workers). The "Parallel Seq Scan" distributes the table scan across all workers.

Configuration Parameters

-- Maximum parallel workers available system-wide
SHOW max_parallel_workers;           -- Default: 8

-- Maximum workers per single query operation (Gather node)
SHOW max_parallel_workers_per_gather; -- Default: 2

-- Minimum table size before parallel is considered
SHOW min_parallel_table_scan_size;   -- Default: 8MB

-- Cost thresholds that affect planner decisions
SHOW parallel_setup_cost;            -- Default: 1000
SHOW parallel_tuple_cost;            -- Default: 0.1

To allow more parallelism for analytical workloads:

-- Allow up to 4 workers per query for heavy analytical work
SET max_parallel_workers_per_gather = 4;

EXPLAIN ANALYZE
SELECT region, count(*), avg(total)
FROM orders
WHERE created_at > '2024-01-01'
GROUP BY region;
 Finalize GroupAggregate  (cost=18425.12..18512.84 rows=12 width=48) (actual time=32.412..32.845 rows=12 loops=1)
   Group Key: region
   ->  Gather Merge  (cost=18425.12..18510.84 rows=48 width=48) (actual time=32.125..32.712 rows=60 loops=1)
         Workers Planned: 4
         Workers Launched: 4
         ->  Sort  (cost=17425.06..17425.09 rows=12 width=48) (actual time=28.412..28.415 rows=12 loops=5)
               Sort Key: region
               ->  Partial HashAggregate  (cost=17424.50..17424.62 rows=12 width=48) (actual time=28.125..28.132 rows=12 loops=5)
                     ->  Parallel Seq Scan on orders  (cost=0.00..14925.00 rows=499900 width=16) (actual time=0.018..15.421 rows=399920 loops=5)
 Execution Time: 33.012 ms

With 4 workers, a 2-million row aggregation completes in 33ms instead of the ~130ms it would take single-threaded.

When Parallel Doesn't Help

Not everything benefits from parallelism:

-- Index scans are already fast — parallel doesn't help
EXPLAIN SELECT * FROM orders WHERE id = 12345;
-- Result: plain Index Scan, no parallelism (correct!)

-- Small tables don't trigger parallel (below min_parallel_table_scan_size)
EXPLAIN SELECT count(*) FROM status_codes;
-- Result: plain Seq Scan, no parallelism (table is tiny)

Parallel execution is also avoided when:

  • The function called in the query is marked PARALLEL UNSAFE (e.g., writes data, uses cursors)
  • The query is inside a transaction that has written data
  • The query uses FOR UPDATE/FOR SHARE
  • The table is below min_parallel_table_scan_size
  • Workers aren't available (all max_parallel_workers slots are in use)

Parallel-Safe vs Parallel-Unsafe Functions

Custom functions must be marked as parallel-safe for the planner to parallelize queries that use them:

-- This function CAN run in parallel workers
CREATE FUNCTION calculate_discount(price numeric, tier text)
RETURNS numeric
LANGUAGE sql
PARALLEL SAFE
AS $$
  SELECT CASE tier
    WHEN 'gold' THEN price * 0.20
    WHEN 'silver' THEN price * 0.10
    ELSE price * 0.05
  END;
$$;

-- This function CANNOT (it writes data)
CREATE FUNCTION log_access(user_id int)
RETURNS void
LANGUAGE sql
PARALLEL UNSAFE
AS $$
  INSERT INTO access_log(user_id, accessed_at) VALUES (user_id, now());
$$;

If a query uses a PARALLEL UNSAFE function, the entire query runs single-threaded — even parts that don't involve that function.

Conclusion

Performance optimization is an iterative process: measure with EXPLAIN ANALYZE and pg_stat_statements, identify the bottleneck (bad plan? missing index? insufficient memory? I/O bound?), fix it, and measure again. Resist the urge to tune everything at once — start with the highest-impact query from pg_stat_statements, understand its plan, and work from there.

Related Resources

Visual guide to understanding scan types and when they appear.


Understand your workload profile to guide tuning decisions.


Performance implications of large JSONB columns and TOAST.


Reduce index maintenance overhead with HOT updates.

Updated 2026-07-08

This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances