Audits optimizer table statistics for staleness, missing column coverage, and row count drift to diagnose poor query performance caused by outdated or incomplete statistics. Uses SHOW STATISTICS for read-only SQL analysis of table-level and column-level statistics freshness, entirely without requiring DB Console access.
Complement to profiling-statement-fingerprints: This skill diagnoses optimizer statistics issues; for identifying historically slow queries, see profiling-statement-fingerprints.
Prerequisites
- SQL connection with any privilege on target tables
- Automatic statistics collection enabled (default):
sql.stats.automatic_collection.enabled = true
Related skills: profiling-statement-fingerprints for historical query analysis, triaging-live-sql-activity for live triage.
Core Concepts
CockroachDB-specific defaults:
- Automatic collection triggers at ~20% row count change (
sql.stats.automatic_collection.fraction_stale_rows) - Auto-collection covers index column groups (when
sql.stats.multi_column_collection.enabled = true, the default); ad-hoc multi-column stats on non-indexed columns require manualCREATE STATISTICS - Large tables (>10M rows) may have delayed auto-collection
- Staleness thresholds: refresh if >7 days (OLTP) or >30 days (OLAP), or >20-30% row count drift
See references/statistics-thresholds.md for workload-specific guidance.
Core Diagnostic Queries
Query 1: Identify Tables with Stale or Missing Statistics
Finds tables with outdated statistics or no statistics at all, ranked by staleness.
WITH table_stats AS (
SELECT
table_catalog,
table_schema,
table_name,
column_names,
row_count,
created,
now() - created AS stats_age
FROM [SHOW STATISTICS FOR TABLE database_name.*] -- Replace database_name
WHERE column_names = '{}' -- Table-level stats only (empty array)
)
SELECT
table_schema || '.' || table_name AS full_table_name,
row_count,
created AS stats_created_at,
stats_age,
CASE
WHEN created IS NULL THEN 'Missing statistics'
WHEN stats_age > INTERVAL '30 days' THEN 'Very stale (>30d)'
WHEN stats_age > INTERVAL '7 days' THEN 'Stale (>7d)'
ELSE 'Fresh'
END AS staleness_status
FROM table_stats
WHERE stats_age > INTERVAL '7 days' OR created IS NULL -- Adjust threshold
ORDER BY stats_age DESC NULLS FIRST
LIMIT 50;
Customization:
- Replace
database_name.*with specific schema pattern (e.g.,mydb.public.*) - Adjust staleness threshold:
INTERVAL '7 days'for OLTP,'30 days'for OLAP - Increase
LIMITto see more tables
Key columns:
staleness_status: Quick classification of statistics freshnessstats_age: Exact time since last collectionrow_count: Last known table size
Query 2: Audit Statistics for Specific Table
Shows all statistics for a single table, including table-level and per-column details.
SELECT
column_names,
row_count,
distinct_count,
null_count,
created,
now() - created AS stats_age,
CASE
WHEN histogram_id IS NOT NULL THEN 'Yes'
ELSE 'No'
END AS has_histogram
FROM [SHOW STATISTICS FOR TABLE database_name.schema_name.table_name]
ORDER BY
CASE WHEN column_names = '{}' THEN 0 ELSE 1 END, -- Table-level first
created DESC;
Customization:
- Replace
database_name.schema_name.table_namewith fully-qualified table name
Key columns:
column_names: Empty{}= table-level, single element = column-leveldistinct_count: Cardinality for selectivity estimatesnull_count: NULL value count for IS NULL predicateshas_histogram: Distribution data availability
Interpretation:
- First row (column_names = '{}') shows table-level row_count
- Subsequent rows show per-column statistics
- Missing columns indicate no statistics collected yet
Query 3: Detect Row Count Drift
Compares current table row count against cached statistics to identify significant drift.
WITH current_count AS (
SELECT count(*) AS actual_rows
FROM database_name.schema_name.table_name -- Replace with target table
),
stats_count AS (
SELECT row_count, created
FROM [SHOW STATISTICS FOR TABLE database_name.schema_name.table_name]
WHERE column_names = '{}' -- Table-level stats
ORDER BY created DESC
LIMIT 1
)
SELECT
c.actual_rows,
s.row_count AS stats_rows,
s.created AS stats_created_at,
now() - s.created AS stats_age,
ABS(c.actual_rows - s.row_count) AS drift_absolute,
ROUND(
ABS(c.actual_rows - s.row_count)::NUMERIC /
NULLIF(s.row_count, 0) * 100,
2
) AS drift_pct,
CASE
WHEN ABS(c.actual_rows - s.row_count)::NUMERIC / NULLIF(s.row_count, 0) > 0.30 THEN 'High drift (>30%)'
WHEN ABS(c.actual_rows - s.row_count)::NUMERIC / NULLIF(s.row_count, 0) > 0.20 THEN 'Medium drift (>20%)'
WHEN ABS(c.actual_rows - s.row_count)::NUMERIC / NULLIF(s.row_count, 0) > 0.10 THEN 'Low drift (>10%)'
ELSE 'Minimal drift (<10%)'
END AS drift_status
FROM current_count c, stats_count s;
Customization:
- Replace table name in both CTEs
- Adjust drift thresholds (30%, 20%, 10%) based on workload tolerance
Key columns:
drift_pct: Percentage difference between current and cached row countdrift_status: Classification for prioritizationstats_age: Time since statistics last refreshed
Interpretation:
- >30% drift: Urgent refresh recommended, optimizer estimates likely very inaccurate
- 20-30% drift: Consider refresh if experiencing performance issues
- 10-20% drift: Monitor for trends, may trigger automatic collection soon
- <10% drift: Normal variance, no action needed
Query 4: Identify Missing Column-Level Statistics
Finds table columns without statistics, focusing on columns frequently used in WHERE/JOIN clauses.
WITH table_columns AS (
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'schema_name' -- Replace
AND table_name = 'table_name' -- Replace
AND is_hidden = 'NO' -- Exclude internal columns
),
stats_columns AS (
SELECT UNNEST(column_names) AS column_name
FROM [SHOW STATISTICS FOR TABLE database_name.schema_name.table_name]
WHERE column_names != '{}' -- Exclude table-level stats
)
SELECT
tc.column_name AS missing_column,
'No statistics available' AS status
FROM table_columns tc
WHERE tc.column_name NOT IN (SELECT column_name FROM stats_columns)
ORDER BY tc.column_name;
Customization:
- Replace schema_name, table_name, and database_name with target table
Interpretation:
- Columns returned have no optimizer statistics
- Prioritize creating statistics for columns used in:
- WHERE clause predicates (
WHERE user_id = 123) - JOIN conditions (
JOIN orders ON users.id = orders.user_id) - GROUP BY / ORDER BY expressions
- WHERE clause predicates (
Action: Generate CREATE STATISTICS commands (see Query 7)
Query 5: Histogram Coverage Analysis
Identifies columns with/without histogram data for range query optimization.
SELECT
UNNEST(column_names) AS column_name,
created,
now() - created AS stats_age,
CASE
WHEN histogram_id IS NOT NULL THEN 'Has histogram'
ELSE 'Missing histogram'
END AS histogram_status
FROM [SHOW STATISTICS FOR TABLE database_name.schema_name.table_name]
WHERE column_names != '{}' -- Exclude table-level stats
ORDER BY
CASE WHEN histogram_id IS NULL THEN 0 ELSE 1 END, -- Missing first
created DESC;
Customization:
- Replace database_name.schema_name.table_name
Key columns:
histogram_status: Indicates distribution data availabilitystats_age: Time since histogram last updated
Interpretation:
- Has histogram: Optimizer can estimate range scan selectivity (BETWEEN, >, <)
- Missing histogram: Optimizer uses uniform distribution assumption (less accurate)
- Automatic collection creates histograms; missing indicates very new column or disabled collection
Query 6: Multi-Column Statistics Detection
Identifies existing multi-column (composite) statistics for correlated columns.
SELECT
column_names,
created,
now() - created AS stats_age,
row_count,
ARRAY_LENGTH(column_names, 1) AS column_count
FROM [SHOW STATISTICS FOR TABLE database_name.schema_name.table_name]
WHERE ARRAY_LENGTH(column_names, 1) > 1 -- Multi-column only
ORDER BY created DESC;
Customization:
- Replace database_name.schema_name.table_name
Key columns:
column_names: Array of correlated columnscolumn_count: Number of columns in composite statistic
Interpretation:
- Present: Multi-column statistics exist — either auto-collected for an index column group or manually created
- Absent: No multi-column stats yet; may need manual creation for correlated non-indexed columns (e.g., city + state + zip)
- Common use case: Manual stats on correlated columns that aren't covered by an index
See references/create-statistics-examples.md for multi-column creation patterns.
Query 7: Generate CREATE STATISTICS Recommendations
Produces ready-to-run CREATE STATISTICS commands for tables with stale or missing statistics.
WITH stale_tables AS (
SELECT
table_schema,
table_name,
created,
now() - created AS stats_age
FROM [SHOW STATISTICS FOR TABLE database_name.*]
WHERE column_names = '{}'
AND (created IS NULL OR now() - created > INTERVAL '7 days') -- Adjust threshold
)
SELECT
table_schema || '.' || table_name AS full_table_name,
stats_age,
'CREATE STATISTICS __auto__ FROM ' || table_schema || '.' || table_name || ';' AS create_command
FROM stale_tables
ORDER BY stats_age DESC NULLS FIRST
LIMIT 50;
Customization:
- Replace
database_name.*with schema pattern - Adjust
INTERVAL '7 days'staleness threshold - Increase
LIMITfor more recommendations
Output:
create_command: Copy-paste ready SQL command__auto__: Uses automatic column selection (recommended default)
Execution:
- Review generated commands before execution
- Run during low-traffic periods for large tables (>10M rows)
- Monitor job progress (see Query 6 for job monitoring)
Common Workflows
Workflow 1: Post-Bulk-Load Statistics Audit
Scenario: After bulk INSERT/COPY/IMPORT operation, validate statistics are current.
Steps:
-
Identify affected tables:
-- List tables modified in last 24 hours SELECT DISTINCT table_schema || '.' || table_name AS full_table_name FROM [SHOW TABLES] WHERE table_schema = 'target_schema'; -- Replace -
Check row count drift (Query 3): Run drift detection query for each affected table.
-
Generate and execute refresh commands (Query 7):
CREATE STATISTICS __auto__ FROM schema_name.table_name; -- From Query 7 output -
Monitor collection job:
SELECT job_id, status, fraction_completed, running_status FROM [SHOW JOBS] WHERE job_type = 'CREATE STATS' AND created > now() - INTERVAL '1 hour' ORDER BY created DESC LIMIT 10; -
Verify refresh (Query 2): Re-run statistics audit to confirm
createdtimestamp updated.
Expected outcome: Statistics age <1 hour, drift_pct <5%.
Workflow 2: Diagnose Unexpected Query Plan Changes
Scenario: Query performance suddenly degrades; EXPLAIN shows different plan.
Steps:
-
Identify affected query from profiling-statement-fingerprints: Find query with latency spike or plan hash change.
-
Extract table references: Parse query text to identify tables in FROM/JOIN clauses.
-
Audit statistics for each table (Query 2): Check staleness and row count currency.
-
Compare historical vs current row counts:
-- Example: Check if table grew significantly SELECT row_count, created FROM [SHOW STATISTICS FOR TABLE users] WHERE column_names = '{}' ORDER BY created DESC LIMIT 5; -- Last 5 collections -
Refresh stale statistics (Query 7): Execute CREATE STATISTICS for tables with high drift.
-
Validate plan stability: Re-run EXPLAIN to verify plan returns to expected structure.
Expected outcome: Plan hash stabilizes, latency returns to baseline after statistics refresh.
Workflow 3: Routine Statistics Health Check
Scenario: Periodic audit to proactively identify statistics issues before performance degrades.
Steps:
-
Run cluster-wide staleness scan (Query 1):
-- All databases SHOW STATISTICS FOR TABLE *.*; -- Warning: May be slow on large clusters -
Prioritize critical tables: Focus on high-traffic tables from profiling-statement-fingerprints.
-
Check automatic collection is enabled:
SHOW CLUSTER SETTING sql.stats.automatic_collection.enabled; -- Should be true -
Review pending auto-collection jobs:
SELECT job_id, description, status, fraction_completed FROM [SHOW JOBS] WHERE job_type = 'AUTO CREATE STATS' AND status IN ('pending', 'running') ORDER BY created DESC; -
Generate batch refresh script (Query 7): Save output to file for scheduled execution.
-
Schedule refresh during maintenance window: Execute generated CREATE STATISTICS commands during low-traffic period.
Frequency: Weekly for OLTP, monthly for OLAP.
Safety Considerations
Production-Safe Operations
SHOW STATISTICS:
- Impact: Read-only, no cluster impact
- Safe for production: Yes, run anytime without restrictions
CREATE STATISTICS:
- Impact: CPU/IO-intensive, non-blocking table scans
- Safety:
- Does NOT lock table or block writes
- Consumes resources (CPU, network, disk I/O)
- May impact query performance during collection on large tables
- Best practices:
- Run during low-traffic periods for tables >10M rows
- Stagger execution (avoid creating statistics on many tables simultaneously)
- Monitor job progress and resource utilization
Resource Consumption
Small tables (<10K rows): Negligible impact, safe anytime
Medium tables (10K-10M rows): Seconds to minutes, minor impact
Large tables (>10M rows): Minutes to hours, plan accordingly:
- Schedule during maintenance windows
- Monitor cluster metrics (CPU, disk I/O) during collection
- Use
SHOW JOBSto track progress
Cancellation (if needed):
-- Find job ID
SELECT job_id, status, fraction_completed
FROM [SHOW JOBS]
WHERE job_type = 'CREATE STATS' AND status = 'running';
-- Cancel job (non-destructive, existing statistics remain)
CANCEL JOB 123456789012345678;
Batch Collection Best Practices
Avoid overwhelming cluster:
- Collect statistics for 3-5 tables concurrently maximum
- Wait for completion before starting next batch
- Monitor cluster health metrics between batches
Example staggered script:
# Collect statistics in batches with delays
for table in table1 table2 table3; do
cockroach sql -e "CREATE STATISTICS __auto__ FROM $table;" &
done
wait # Wait for batch to complete
sleep 60 # Delay between batches
for table in table4 table5 table6; do
cockroach sql -e "CREATE STATISTICS __auto__ FROM $table;" &
done
wait
See references/create-statistics-examples.md for detailed batch patterns.
Additional details
Further sections for this skill are in references/additional-details.md.