Skip to content

PopSQL: limited support.

Migrate now →

Free tool

A Postgres health check in two minutes, without handing over the password.

Run one read-only query on your database and paste the result here. The page points out unused indexes, tables full of dead rows, stuck transactions, sequences near their limit and cache usage. The analysis runs in your browser and nothing is sent anywhere.

01

Run this query on your database

It only reads Postgres's own statistics views (pg_stat_*, pg_catalog), never touches your data, and works on Postgres 10 and later with no extension. Use a user that can see the tables: the database owner, or someone with the pg_monitor role.

postgres-health.sql
-- DataRunner · Postgres health check (read-only, Postgres 10+)
SELECT json_build_object(
  'v', 1,
  'version', current_setting('server_version'),
  'database', current_database(),
  'size_bytes', pg_database_size(current_database()),
  'max_connections', current_setting('max_connections')::int,
  'stats_reset', (SELECT stats_reset FROM pg_stat_database WHERE datname = current_database()),
  'cache_hit_pct', (
    SELECT round(100.0 * sum(blks_hit) / nullif(sum(blks_hit) + sum(blks_read), 0), 2)
    FROM pg_stat_database WHERE datname = current_database()),
  'connections', (
    SELECT coalesce(json_object_agg(state, n), '{}')
    FROM (SELECT coalesce(state, 'other') AS state, count(*) AS n
          FROM pg_stat_activity WHERE backend_type = 'client backend' GROUP BY 1) c),
  'dead_tuples', (
    SELECT coalesce(json_agg(t), '[]') FROM (
      SELECT schemaname || '.' || relname AS name, n_live_tup AS live, n_dead_tup AS dead,
             greatest(last_autovacuum, last_vacuum) AS last_vacuum
      FROM pg_stat_user_tables WHERE n_dead_tup > 1000
      ORDER BY n_dead_tup DESC LIMIT 10) t),
  'unused_indexes', (
    SELECT coalesce(json_agg(t), '[]') FROM (
      SELECT s.schemaname || '.' || s.indexrelname AS name, s.relname AS "table",
             pg_relation_size(s.indexrelid) AS bytes
      FROM pg_stat_user_indexes s JOIN pg_index i ON i.indexrelid = s.indexrelid
      WHERE s.idx_scan = 0 AND NOT i.indisunique AND NOT i.indisprimary
      ORDER BY pg_relation_size(s.indexrelid) DESC LIMIT 10) t),
  'no_primary_key', (
    SELECT coalesce(json_agg(t), '[]') FROM (
      SELECT n.nspname || '.' || c.relname AS name, c.reltuples::bigint AS rows
      FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
      WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
        AND n.nspname NOT LIKE 'pg_toast%'
        AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = c.oid AND k.contype = 'p')
      ORDER BY c.reltuples DESC LIMIT 10) t),
  'long_transactions', (
    SELECT coalesce(json_agg(t), '[]') FROM (
      SELECT pid, state, application_name AS app,
             extract(epoch FROM now() - xact_start)::int AS seconds
      FROM pg_stat_activity
      WHERE xact_start IS NOT NULL AND now() - xact_start > interval '5 minutes'
        AND backend_type = 'client backend' AND pid <> pg_backend_pid()
      ORDER BY xact_start LIMIT 10) t),
  'sequences', (
    SELECT coalesce(json_agg(t), '[]') FROM (
      SELECT schemaname || '.' || sequencename AS name, last_value, max_value,
             round(100.0 * last_value / max_value, 2) AS pct
      FROM pg_sequences
      WHERE last_value IS NOT NULL AND increment_by > 0 AND last_value::numeric / max_value > 0.5
      ORDER BY 4 DESC LIMIT 10) t),
  'largest_tables', (
    SELECT coalesce(json_agg(t), '[]') FROM (
      SELECT schemaname || '.' || relname AS name, pg_total_relation_size(relid) AS bytes
      FROM pg_statio_user_tables ORDER BY 2 DESC LIMIT 5) t)
) AS datarunner_health;

From a terminal, save the query to a file and run: psql "$DATABASE_URL" -XAt -f postgres-health.sql

02

Paste the result

The analysis happens in this browser. The result never leaves it.

This changes every week.

DataRunner runs these checks and more (locks, the most expensive queries, replica lag) on the schedule you choose, and tells you by email, Slack, Telegram or WhatsApp when something crosses a limit. The free plan needs no card.

Questions

Why not just ask for the database password?
Because nobody should hand a production password to a site they just met. The query is read-only, you see all of it before running it, and the result only holds table and index names and counts.
The numbers are zero or look odd.
Postgres statistics start over after a manual reset (pg_stat_reset), a server crash or a failover to a replica; a normal restart keeps them. Right after one of those, "never used" means "not used since then". Run it again after a few days of normal traffic.
Can I drop the indexes it lists?
First check that no read replica uses the index (statistics are per server) and that it does not back a constraint. When in doubt, run the query again in a week.
Does it work on RDS, Cloud SQL, Supabase, Neon?
Yes, they are all Postgres. On some managed services the default user cannot see other users' activity; then the connections and transactions part comes back incomplete.