PostgreSQL may use an index for id < 11 and a sequential scan for id > 11 because the first condition matches very few rows while the second matches almost the entire table. Both operators support B-tree indexes; the planner compares their estimated costs.
This guide helps a developer working on a Mac reproduce that choice with synthetic article rows, read the plans, and check whether the difference needs attention. It follows the retrieved current PostgreSQL 18 documentation and current node-postgres documentation. Check your server version before comparing plan details; the examples do not promise identical plans across versions or machines. PostgreSQL: Using EXPLAIN
Why can the same index produce different scan choices?
A B-tree index supports both less-than and greater-than comparisons. Changing the comparison direction does not make the index unusable. What changes is the range of values—and therefore the number of rows—the planner expects to retrieve. PostgreSQL: Index Types
Consider an illustrative table containing exactly 100,000 article rows, with consecutive integer IDs from 1 through 100,000:
| Predicate | Matching rows | Share of table |
|---|---|---|
id < 11 |
10 | 0.01% |
id > 11 |
99,989 | 99.989% |
id = 11 |
1 | 0.001% |
These counts follow from the synthetic input, rather than measurements of an existing database. Neither range includes ID 11. An index can efficiently locate the ten matching IDs in the first range. For the second range, it would identify almost every row anyway.
An ordinary PostgreSQL index scan retrieves entries from the index and corresponding rows from the table’s main storage, called the heap. Those heap rows can be spread across many pages. When almost every page is needed, reading the table directly can cost less than traversing the index and fetching the rows it references. PostgreSQL: Index-Only Scans and Covering Indexes
The number 11 therefore has no special planner meaning. With different data, the greater-than condition could be the narrow range. PostgreSQL estimates the fraction of matching rows, known as selectivity, using table statistics. The numeric size of the boundary alone does not establish that fraction. PostgreSQL: Statistics Used by the Planner
How do you reproduce the comparison on a Mac?
You need an existing PostgreSQL connection and the psql command available in your Mac’s Terminal. Installation is outside this exercise. Open your usual database connection with psql; it accepts connection settings and provides an interactive SQL prompt. Run the following blocks in one fresh session. PostgreSQL: psql
The setup creates a temporary table containing only synthetic article text. Temporary tables disappear when the session ends. The ordinary integer primary key automatically creates a unique B-tree index, so a second index on id is unnecessary. PostgreSQL: CREATE TABLE
SHOW server_version;
CREATE TEMP TABLE article_scan_demo (
id integer PRIMARY KEY,
title text NOT NULL,
body text NOT NULL
);
INSERT INTO article_scan_demo (id, title, body)
SELECT
g,
'Article ' || g,
repeat('Sample article text. ', 20)
FROM generate_series(1, 100000) AS s(g);
ANALYZE article_scan_demo;
SELECT
count(*) AS total_rows,
count(*) FILTER (WHERE id < 11) AS below_11,
count(*) FILTER (WHERE id > 11) AS above_11
FROM article_scan_demo;
PostgreSQL’s integer generate_series includes both endpoints with the default step of one. The count query should therefore return 100,000 total rows, 10 below 11, and 99,989 above 11. PostgreSQL: Set Returning Functions
Run ANALYZE after loading the rows so the planner has statistics about the populated table. This command collects statistics; it serves a different purpose from the execution-measurement option in EXPLAIN ANALYZE. PostgreSQL: ANALYZE
Next, compare the two full-row queries:
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM article_scan_demo WHERE id < 11;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM article_scan_demo WHERE id > 11;
These commands execute each query and report its plan and actual row counts, without returning all the selected article rows. BUFFERS adds block activity; TIMING OFF disables timing of individual nodes. This is useful for studying execution, but it does not measure the time required to send all matching article data to a desktop application. PostgreSQL: EXPLAIN
Expect an index-based plan for the narrow range and a sequential scan for the broad range under ordinary settings. Treat those as expectations: PostgreSQL can choose a bitmap plan or another applicable plan based on its estimates. This example’s SQL was inspected, but no PostgreSQL server was available to execute it during preparation; the row-count arithmetic was checked separately.
Which parts of the plan explain the decision?
Start with the scan node, its estimated rows, and its actual rows. For the first query, look for Index Scan with an Index Cond, or a Bitmap Index Scan beneath a Bitmap Heap Scan. For the second, look for Seq Scan with a Filter. A bitmap plan still uses an index; it organizes matching row locations before fetching heap pages. PostgreSQL: Using EXPLAIN
Then check these observations:
| Observation in this example | What to investigate |
|---|---|
| Estimates roughly match 10 and 99,989 actual rows | The different match counts explain the different choices. |
| Estimated rows differ substantially from actual rows | Refresh statistics before changing planner settings. |
| Both predicates use sequential scans on a much smaller sample | Table size may make direct scanning cheaper. |
| A bitmap plan appears | Compare its row estimates and heap work as another index-based alternative. |
Planner costs are estimates in arbitrary units, not milliseconds. Compare them as part of PostgreSQL’s model rather than interpreting a cost of 100 as a duration. Because the example uses temporary tables, buffer activity appears under local blocks; regular table and index buffers appear under shared blocks. PostgreSQL: EXPLAIN
If estimates look surprising, continue in the same session with this diagnostic query:
SELECT
schemaname,
n_distinct,
histogram_bounds,
correlation
FROM pg_stats
WHERE schemaname = (
SELECT nspname
FROM pg_namespace
WHERE oid = pg_my_temp_schema()
)
AND tablename = 'article_scan_demo'
AND attname = 'id';
The histogram describes the value distribution used for range estimation. Correlation describes how closely the column’s logical order follows physical row order; values near either extreme can make index access to the heap look less expensive. Neither field establishes a universal percentage at which PostgreSQL must switch scans. PostgreSQL: pg_stats
How do you compare the queries through node-postgres?
node-postgres sends query text and parameter values to PostgreSQL, where planning occurs. Its query API accepts SQL followed by a values array, so both comparisons can use the same boundary value of 11. node-postgres: Queries
For an independent Node.js reproduction, use a working Node.js/npm installation and your existing PostgreSQL connection settings. In a new project directory, run npm init -y, then npm install pg. Save the complete program below as compare-scans.mjs and run node compare-scans.mjs. The client uses PostgreSQL environment settings such as PGHOST, PGPORT, and PGDATABASE; use the settings for your existing local database. node-postgres: Connecting
import pg from 'pg';
const client = new pg.Client();
try {
await client.connect();
await client.query(`
CREATE TEMP TABLE article_scan_demo (
id integer PRIMARY KEY,
title text NOT NULL,
body text NOT NULL
)
`);
await client.query(`
INSERT INTO article_scan_demo (id, title, body)
SELECT
g,
'Article ' || g,
repeat('Sample article text. ', 20)
FROM generate_series(1, 100000) AS s(g)
`);
await client.query('ANALYZE article_scan_demo');
const queries = [
'EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM article_scan_demo WHERE id < $1',
'EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM article_scan_demo WHERE id > $1'
];
for (const text of queries) {
const result = await client.query(text, [11]);
console.log(text);
console.log(
result.rows.map(row => row['QUERY PLAN']).join('\n')
);
}
} finally {
await client.end();
}
The program creates its own sample table and keeps every operation on one connection. It prints both plans and closes the connection afterward. Its JavaScript syntax was checked with Node.js; database execution was not performed during preparation. If connection setup fails, resolve that before interpreting scan behavior.
Can named prepared statements change the comparison?
Adding a name to a node-postgres query configuration creates a named prepared statement on each connection. That introduces a further distinction: PostgreSQL can use a custom plan based on parameter values or a generic plan shared across executions. node-postgres: Queries, PostgreSQL: PREPARE
Under PostgreSQL 18’s default automatic policy, the first five executions use custom plans. PostgreSQL then compares a generic plan’s estimated cost with the average custom-plan cost; it does not automatically switch to a generic plan on execution six. PostgreSQL: PREPARE
If an application’s named statement behaves differently from a literal SQL test, examine that statement on the same connection using EXPLAIN EXECUTE. A generic plan displays parameter symbols, while a custom plan substitutes the supplied values. Keep the two comparison operators in separate named statements because their SQL text differs. PostgreSQL: PREPARE
When should you change the query or planner settings?
First preserve the query’s actual task. Replacing SELECT * with SELECT id changes which data PostgreSQL must retrieve. When all required columns are available from an appropriate index, an index-only scan becomes possible. Avoiding heap fetches also depends on the visibility map, so an index-only plan does not guarantee zero heap access. PostgreSQL: Index-Only Scans and Covering Indexes
If the task needs every matching article, retain that requirement while comparing plans. If it needs only a small ordered subset, test the actual ORDER BY and LIMIT query instead: ordering and early termination can change the preferred plan. PostgreSQL: Using EXPLAIN
After checking statistics, you can compare an alternative plan in the existing SQL session:
BEGIN;
SET LOCAL enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM article_scan_demo WHERE id > 11;
ROLLBACK;
This setting discourages sequential scans; it cannot suppress them entirely. Use the result as a diagnostic comparison. PostgreSQL also models sequential and nonsequential page access through seq_page_cost and random_page_cost. Changing their relative values can change plan choices, but the documentation advises evaluating cost settings across the query workload rather than a few experiments. PostgreSQL: Query Planning
For this synthetic table, the decisive comparison is ten matching rows versus 99,989. Check the exact query, refresh statistics, compare estimated and actual rows, and inspect prepared-plan behavior when relevant. Those steps establish whether the sequential scan fits the work requested and give you evidence for any further change.
Sources
- Using EXPLAIN — PostgreSQL
- Index Types — PostgreSQL
- Statistics Used by the Planner — PostgreSQL
- Index-Only Scans and Covering Indexes — PostgreSQL
- psql — PostgreSQL
- CREATE TABLE — PostgreSQL
- Set Returning Functions — PostgreSQL
- ANALYZE — PostgreSQL
- EXPLAIN — PostgreSQL
- pg_stats — PostgreSQL
- Queries — node-postgres
- Connecting — node-postgres
- PREPARE — PostgreSQL
- Query Planning — PostgreSQL