Accidental Full Table Scan — The Query That Brought Down Production

Sanjeev SharmaSanjeev Sharma
9 min read

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 used

Cause 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 faster

Key signals in EXPLAIN output:

SignalMeaningAction
Seq ScanFull table scanFind index or rewrite query
Rows Removed by Filter largeReading most rowsIndex is being bypassed
cost=0.00..289432.00High estimated costPostgreSQL expects this to be slow
Index ScanIndex is usedGood — verify estimated vs actual rows
Index Only ScanOnly index accessedOptimal

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 any

Common 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 undefined parameters in dynamic queries — produces WHERE col = NULL (full scan, zero results)
  • Using OR with non-indexed columns — splits the execution plan and forces a sequential scan
  • Not running EXPLAIN ANALYZE before deploying new queries against tables expected to grow

Best Practices

  • Run EXPLAIN ANALYZE on every new query before deployment — look for Seq Scan and Rows Removed by Filter
  • Rewrite function-wrapping conditions as range conditions (created_at >= X AND created_at < Y instead of DATE(created_at) = X)
  • Create functional indexes when you must query by a derived value: CREATE INDEX ON users (LOWER(email))
  • Enable pg_stat_statements extension in production and review it weekly for newly slow queries
  • Set log_min_duration_statement = 500 in 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
  • undefined query parameters produce WHERE col = NULL which triggers a full scan with zero results
  • EXPLAIN ANALYZE always shows Seq Scan vs Index Scan — run it before every new query deployment
  • pg_stat_statements reveals all slow queries in production — review total_exec_time weekly
  • The gap between dev (10K rows, 3ms) and production (50M rows, 42 seconds) is where these bugs hide

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading