Demystifying Postgres EXPLAIN Is Sequential Scan Always a Performance Blocker
Grace Collins
Solutions Engineer · Leapcell

Introduction
In the world of relational databases, performance optimization is a constant pursuit. Developers and database administrators frequently dive into query execution plans to identify bottlenecks and improve efficiency. Among the various operations revealed by EXPLAIN in PostgreSQL, the "Sequential Scan" often draws immediate concern, often perceived as an inefficient "full table scan" that must be eliminated at all costs. This widespread assumption, however, overlooks crucial nuances. Is a sequential scan always a bad omen? Does it invariably point to a performance killer that demands an index? This article delves into the intricacies of PostgreSQL's EXPLAIN output, specifically focusing on the sequential scan, to challenge this simplistic view and provide a more balanced understanding of its role in query execution.
The Nature of Sequential Scans
Before we dissect the "badness" of sequential scans, let's establish a common understanding of the core concepts involved in query planning and execution within PostgreSQL.
Key Terminology
EXPLAIN: A PostgreSQL command that displays the execution plan for a SQL statement. It shows how the database system will execute a query, including the operations it will perform and the order in which it will perform them.- Sequential Scan (Seq Scan): A database operation where PostgreSQL reads every row in a table from start to finish. It's often referred to as a "full table scan."
 - Index Scan: A database operation where PostgreSQL uses an index to locate specific rows in a table. Instead of reading the entire table, it navigates the index to find the data quickly.
 - Cost: A relative estimate of the expense of an operation in 
EXPLAINoutput. It's not a unit of time but a dimensionless value based on factors like disk I/O, CPU usage, and memory access. Lower costs generally indicate faster execution. - Rows: The estimated number of rows returned by an operation.
 - Width: The estimated average width (in bytes) of the rows returned by an operation.
 - Buffers: (Displayed with 
EXPLAIN (ANALYZE, BUFFERS)) This shows the number of shared and local buffers hit/read/dirtied during execution, indicating I/O activity. 
How Sequential Scan Works
At its core, a sequential scan in PostgreSQL involves reading data blocks from storage sequentially until the entire table (or the relevant part of it for a specific query) has been processed. The database system will examine each row to see if it meets the conditions specified in the WHERE clause. If a row satisfies the condition, it's included in the result set or passed to the next operation in the query plan.
When Sequential Scan is Not So Bad: Understanding the Context
The common knee-jerk reaction to a sequential scan is to immediately create an index. However, PostgreSQL's query planner is sophisticated. It understands that a sequential scan can be the most efficient strategy under certain circumstances.
Let's consider a practical example. Imagine a large products table with millions of rows.
CREATE TABLE products ( product_id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT, price NUMERIC(10, 2) NOT NULL, category VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Insert some sample data (millions of rows) INSERT INTO products (name, description, price, category) SELECT 'Product ' || generate_series, 'Description for product ' || generate_series, (random() * 1000)::numeric(10, 2), CASE (generate_series % 5) WHEN 0 THEN 'Electronics' WHEN 1 THEN 'Books' WHEN 2 THEN 'Clothing' WHEN 3 THEN 'Home Goods' ELSE 'Food' END FROM generate_series(1, 5000000); -- 5 million rows
Now, let's analyze some queries with EXPLAIN.
Scenario 1: Fetching a Large Percentage of Rows
If a query needs to retrieve a significant portion of the table, a sequential scan can be faster than an index scan. Why? An index scan involves two steps: first, traversing the index to find the row pointers (TIDs), and second, fetching the actual row data from the table using those pointers. This "random I/O" of jumping around the table can be more expensive than simply reading the table contiguously if many rows are needed.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM products WHERE price > 10;
If a large percentage of products have a price greater than 10 (which is very likely in our sample data, given random prices up to 1000), PostgreSQL might opt for a sequential scan. The EXPLAIN output would show something like this:
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------
 Seq Scan on products  (cost=0.00..109375.00 rows=4999999 width=472) (actual time=0.046..1237.498 rows=4999999 loops=1)
   Filter: (price > '10'::numeric)
   Rows Removed by Filter: 0
   Buffers: shared hit=43750 read=0 dirtied=0 written=0
 Planning Time: 0.160 ms
 Execution Time: 1251.234 ms
Here, the planner correctly estimated that almost all rows would satisfy the condition. In this case, creating an index on price would likely make the query slower because the overhead of using the index (reading index pages, then fetching data pages randomly) would outweigh the benefit of skipping rows.
Scenario 2: Small Tables
For very small tables, the overhead of reading and traversing an index might exceed the cost of a simple sequential scan. The query planner is smart enough to recognize this.
CREATE TABLE small_table ( id SERIAL PRIMARY KEY, data TEXT ); INSERT INTO small_table (data) SELECT 'Some data ' || generate_series FROM generate_series(1, 100); EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM small_table WHERE id = 50;
Even though id is a primary key (and thus indexed), the planner might still choose a sequential scan for a very small table if it determines it's faster to just read the whole table than to go through the index structure. However, for a single exact match on a PRIMARY KEY, an Index Scan is usually preferred, but the principle applies more generally to filters on non-indexed columns in small tables.
Scenario 3: Locality of Data and Cache Efficiency
Sequential scans often benefit from modern hardware architectures, particularly CPU caching and disk prefetching. When data is read sequentially, it's more likely to be in contiguous blocks on disk, which allows the operating system and storage devices to prefetch data. This can lead to very fast data transfer rates. If the data is frequently accessed, it might already reside in the operating system's file system cache or even the database's shared buffers, making the "disk read" effectively a "memory read."
Scenario 4: Lack of Useful Indexes
If no index exists that can efficiently satisfy the query's WHERE clause, a sequential scan is the only option. In such cases, while an index could improve performance, the sequential scan itself isn't "bad" – it's simply the necessary fallback.
EXPLAIN (ANALYZE, BUFFERS) SELECT name, price FROM products WHERE description ILIKE '%amazing%';
Unless you have a full-text search index (which is a different kind of index), searching text within a long description field with ILIKE will almost inevitably lead to a sequential scan because B-tree indexes are not designed for pattern matching within arbitrary text.
When to Worry About Sequential Scans
While not always bad, sequential scans are often a sign of a missed optimization opportunity, especially in these situations:
- Filtering a Small Percentage of Rows from a Large Table: If your query filters out 99.9% of rows and only returns a handful from a large table, an index is almost certainly the correct choice. The cost of reading millions of rows to find a few is prohibitive.
 - Order By / Group By without Index: If a query has an 
ORDER BYorGROUP BYclause on a column that isn't indexed, and the query is otherwise performing a sequential scan, PostgreSQL might have to sort the entire result set in memory or on disk (a "filesort"), which is very expensive for large datasets. An index could provide pre-sorted data, avoiding this extra step. - High I/O on Large Tables: If 
EXPLAIN (ANALYZE, BUFFERS)shows a high number ofreadbuffers for a sequential scan on a large table that retrieves few rows, it indicates that a lot of data is being pulled from disk unnecessarily. This is a prime candidate for indexing. 
Conclusion
The sequential scan, often simplistically labeled as inherently "bad," is in fact a highly effective and sometimes unavoidable operation in PostgreSQL. The key takeaway is context: for queries retrieving a large percentage of a table's data, for small tables, or when cache efficiency is high, a sequential scan can outperform an index scan. However, when filtering a tiny subset of a huge table, especially with ORDER BY or GROUP BY, or when experiencing excessive disk I/O, a sequential scan likely signals an opportunity for index optimization. A knowledgeable understanding of EXPLAIN and the underlying data distribution is paramount to making informed performance tuning decisions. Don't blindly fear the sequential scan; understand its purpose and optimize where it truly matters.
