Accidental Full Table Scan — The Query That Brought Down Production
Advertisement
Introduction
Why This Matters
Full table scans on large tables don't fail immediately — they succeed, slowly, while consuming all available disk I/O and blocking every other query behind them. The problem is always invisible in development: with 10,000 rows the query takes 3ms; with 50 million rows it takes 42 seconds and locks your database.
The causes are predictable and preventable: function calls wrapping indexed columns, implicit type casts, missing WHERE clause guards, and OR clauses that disable index usage. Every one of these can be caught by running EXPLAIN ANALYZE before deploying.
The Symptoms
When a full table scan hits production, the symptom pattern is unmistakable:
Database CPU: 100% (sustained)
pg_stat_activity: 1 query, state "active", duration 4+ minutes
pg_locks: multiple queries waiting on "relation" lock
Application: HTTP 504 timeouts on all endpoints
Alert: "Database connection pool exhausted"
Root cause query:
SELECT * FROM orders WHERE DATE(created_at) = '2026-03-14'This exact query — wrapping created_at in DATE() — reads every single row in the table because the function call makes the index on created_at unusable. With 50 million orders, that is 50 million disk page reads.
Cause 1: Function Wrapping an Indexed Column
The most common cause. Any function applied to an indexed column prevents the index from being used:
-- Index exists:
CREATE INDEX idx_orders_created_at ON orders(created_at);
-- WRONG: DATE() wraps the column — index is ignored, full scan
EXPLAIN ANALYZE
SELECT * FROM orders WHERE DATE(created_at) = '2026-03-14';
-- Seq Scan on orders (rows=50000000, actual time=42187ms)
-- Filter: (date(created_at) = '2026-03-14')
-- Rows Removed by Filter: 49998753
-- CORRECT: Range query on the indexed column directly
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE created_at >= '2026-03-14 00:00:00'
AND created_at < '2026-03-15 00:00:00';
-- Index Scan using idx_orders_created_at on orders
-- (rows=1247, actual time=2.1ms)
-- Other common function-wrapping traps:
-- WRONG:
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';
SELECT * FROM products WHERE UPPER(sku) = 'ABC-123';
SELECT * FROM logs WHERE TO_CHAR(event_time, 'YYYY-MM') = '2026-03';
-- CORRECT: Use a functional index that matches the query exactly
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';
-- Now the functional index is usedCause 2: Implicit Type Cast
PostgreSQL performs an implicit cast when the query parameter type doesn't match the column type. This cast is applied to the column, which disables index usage:
-- Column definition: users.phone VARCHAR(20)
-- Index: CREATE INDEX idx_users_phone ON users(phone);
-- WRONG: Integer parameter, VARCHAR column — PostgreSQL casts the column
SELECT * FROM users WHERE phone = 1234567890;
-- Seq Scan: PostgreSQL cannot use the index because it must cast every row
-- CORRECT: Match the column type exactly
SELECT * FROM users WHERE phone = '1234567890';
-- Index Scan: types match, index is used// The same trap in Node.js application code
// WRONG: req.user.id is a number, but user_id column is UUID (string)
const sessions = await db.query(
'SELECT * FROM sessions WHERE user_id = $1',
[req.user.id] // number being passed for a string column
)
// CORRECT: Ensure the parameter type matches the column type
const sessions = await db.query(
'SELECT * FROM sessions WHERE user_id = $1',
[String(req.user.id)]
)
// With Prisma — this is handled automatically because the schema defines types
// But raw SQL queries require manual type management
const sessions = await prisma.$queryRaw`
SELECT * FROM sessions WHERE user_id = ${req.user.id}::text
`Cause 3: Missing WHERE Clause From Undefined Parameters
A variable being undefined silently produces WHERE column = NULL — which matches nothing but still reads the entire table:
// WRONG: Optional parameter, undefined produces full scan
async function getOrdersByUser(userId?: string) {
// If userId is undefined, this becomes: WHERE user_id = NULL
// PostgreSQL reads every row to find NULL matches (finds none)
const result = await db.query(
'SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
)
return result.rows
}
// CORRECT: Validate required parameters before querying
async function getOrdersByUser(userId: string) {
if (!userId || typeof userId !== 'string') {
throw new Error('userId is required and must be a string')
}
const result = await db.query(
'SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 100',
[userId]
)
return result.rows
}
// Dynamic WHERE clauses require extra care
function buildOrdersQuery(
filters: { userId?: string; status?: string; startDate?: string }
) {
const conditions: string[] = []
const params: unknown[] = []
let paramIndex = 1
if (filters.userId) {
conditions.push(`user_id = $${paramIndex++}`)
params.push(filters.userId)
}
if (filters.status) {
conditions.push(`status = $${paramIndex++}`)
params.push(filters.status)
}
if (filters.startDate) {
conditions.push(`created_at >= $${paramIndex++}`)
params.push(filters.startDate)
}
// Require at least one filter on large tables
if (conditions.length === 0) {
throw new Error('At least one filter is required when querying orders')
}
const where = conditions.join(' AND ')
return { sql: `SELECT * FROM orders WHERE ${where} LIMIT 100`, params }
}Cause 4: OR Clause Spanning Non-Indexed Columns
An OR condition that includes a non-indexed column forces a sequential scan of the entire table:
-- Index exists on user_id but not on notes
-- WRONG: OR forces full scan because 'notes' is not indexed
SELECT * FROM orders
WHERE user_id = '123'
OR notes LIKE '%refund%';
-- PostgreSQL must evaluate EVERY row because of the OR with notes
-- CORRECT option 1: UNION (each branch can use its own index)
SELECT * FROM orders WHERE user_id = '123'
UNION
SELECT * FROM orders WHERE to_tsvector('english', notes) @@ to_tsquery('refund');
-- CORRECT option 2: Add a GIN index for full-text search on notes
CREATE INDEX idx_orders_notes_fts ON orders USING GIN (to_tsvector('english', notes));
SELECT * FROM orders
WHERE user_id = '123'
OR to_tsvector('english', notes) @@ to_tsquery('english', 'refund');Diagnosing With EXPLAIN ANALYZE
Always run EXPLAIN ANALYZE before deploying any query against a table that has or will have more than 100,000 rows:
-- Run EXPLAIN ANALYZE before deploying
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE DATE(created_at) = '2026-03-14';
-- Reading the output:
-- "Seq Scan" = full table scan (investigate this)
-- "Index Scan" = good, index is being used
-- "Index Only Scan" = best, reads only the index
-- "Rows Removed by Filter: 49998753" = 50M rows read, only 1247 returned
-- "Execution Time: 42187.3 ms" = 42 seconds, unacceptable
-- After fix:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE created_at >= '2026-03-14' AND created_at < '2026-03-15';
-- "Index Scan using idx_orders_created_at"
-- "Rows Removed by Filter: 0"
-- "Execution Time: 2.1 ms" -- 20,000x fasterKey signals in EXPLAIN output:
| Signal | Meaning | Action |
|---|---|---|
Seq Scan | Full table scan | Find index or rewrite query |
Rows Removed by Filter large | Reading most rows | Index is being bypassed |
cost=0.00..289432.00 | High estimated cost | PostgreSQL expects this to be slow |
Index Scan | Index is used | Good — verify estimated vs actual rows |
Index Only Scan | Only index accessed | Optimal |
Catching Slow Queries in Production
-- Enable slow query logging in postgresql.conf
-- log_min_duration_statement = 500 -- log queries slower than 500ms
-- log_statement = 'none'
-- Find top slow queries using pg_stat_statements
SELECT
left(query, 120) AS query,
calls,
round(mean_exec_time::numeric, 2) AS avg_ms,
round(total_exec_time::numeric, 2) AS total_ms,
round(rows / calls::numeric, 0) AS avg_rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;// Node.js: automatic slow query logging via pg pool
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
// Wrap all queries with timing
const originalQuery = pool.query.bind(pool)
pool.query = async function (...args: any[]) {
const start = Date.now()
const query = typeof args[0] === 'string' ? args[0] : args[0]?.text ?? 'unknown'
try {
const result = await originalQuery(...args)
const duration = Date.now() - start
if (duration > 500) {
console.warn({
query: query.substring(0, 200),
duration_ms: duration,
rows: result.rowCount,
}, 'Slow query detected')
}
return result
} catch (err) {
const duration = Date.now() - start
console.error({ query: query.substring(0, 200), duration_ms: duration, err }, 'Query failed')
throw err
}
} as anyCommon Mistakes
- Testing queries in development with small datasets — always test with production-scale row counts before deploying
- Wrapping indexed columns in
DATE(),LOWER(),UPPER(),TO_CHAR(), or any function in WHERE clauses - Passing JavaScript numbers to queries where columns are UUIDs or VARCHAR — type mismatch disables index
- Not guarding against
undefinedparameters in dynamic queries — producesWHERE col = NULL(full scan, zero results) - Using
ORwith non-indexed columns — splits the execution plan and forces a sequential scan - Not running
EXPLAIN ANALYZEbefore deploying new queries against tables expected to grow
Best Practices
- Run
EXPLAIN ANALYZEon every new query before deployment — look forSeq ScanandRows Removed by Filter - Rewrite function-wrapping conditions as range conditions (
created_at >= X AND created_at < Yinstead ofDATE(created_at) = X) - Create functional indexes when you must query by a derived value:
CREATE INDEX ON users (LOWER(email)) - Enable
pg_stat_statementsextension in production and review it weekly for newly slow queries - Set
log_min_duration_statement = 500in postgresql.conf to log all queries taking more than 500ms - Add explicit NOT NULL and type checks before any query parameter is used in a WHERE clause
Key Takeaways
- A full table scan on a 50-million row table takes 30-60 seconds and blocks all other queries — it looks like an outage
- Wrapping an indexed column in any function (
DATE(),LOWER(),CAST()) forces a sequential scan — always rewrite as a range condition - Implicit type casts disable indexes — ensure query parameter types exactly match column types, especially for UUID and VARCHAR columns
undefinedquery parameters produceWHERE col = NULLwhich triggers a full scan with zero resultsEXPLAIN ANALYZEalways showsSeq ScanvsIndex Scan— run it before every new query deploymentpg_stat_statementsreveals all slow queries in production — reviewtotal_exec_timeweekly- The gap between dev (10K rows, 3ms) and production (50M rows, 42 seconds) is where these bugs hide
Advertisement