| name | sql-templates |
| description | Templates SQL prontos para diagnóstico de performance, migrations zero-downtime, e operações comuns em PostgreSQL |
| allowed-tools | Read, Grep, Glob, Bash, Write, Edit |
| model | sonnet |
SQL Templates
Templates SQL para PostgreSQL, prontos para uso em operações sensíveis e
diagnóstico de performance. Todo exemplo assume PostgreSQL 12+.
Quando ativar
- Investigando query lenta ou regressão de performance
- Criando migration que altera tabelas em produção (especialmente >1M rows)
- Renomeando coluna, adicionando
NOT NULL, ou mudando tipo sem downtime
- Precisando de health check ou diagnóstico rápido do banco
Princípios
- Nunca trave escrita em tabelas grandes. Qualquer
ALTER que faz
rewrite é incidente em produção.
- Sempre
CONCURRENTLY em index. E valide que não ficou INVALID.
- Backfill em batches com
SKIP LOCKED. Transação única em 10M rows
explode WAL e trava replicação.
- Expand-Migrate-Contract. Adiciona novo, migra dados, remove antigo
— nunca tudo numa migration só.
Diagnóstico de Query Lenta
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
SELECT ...;
SELECT
substring(query, 1, 120) AS query_preview,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(total_exec_time::numeric, 2) AS total_ms,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
SELECT
relname,
seq_scan, idx_scan,
n_live_tup, n_dead_tup,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'sua_tabela';
SELECT
i.indexname,
pg_size_pretty(pg_relation_size(i.indexname::regclass)) AS size,
i.indexdef
FROM pg_indexes i
WHERE i.tablename = 'sua_tabela'
ORDER BY pg_relation_size(i.indexname::regclass) DESC;
SELECT
pid, usename, state,
wait_event_type, wait_event,
now() - query_start AS duration,
substring(query, 1, 120) AS query_preview
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;
SELECT
relname,
n_mod_since_analyze,
last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE n_mod_since_analyze > 10000
ORDER BY n_mod_since_analyze DESC;
Criação Segura de Index
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
SELECT indexrelid::regclass, indisvalid, indisready
FROM pg_index
WHERE indrelid = 'users'::regclass
AND indisvalid = false;
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email;
ANALYZE users;
CREATE INDEX CONCURRENTLY idx_orders_pending
ON orders (created_at)
WHERE status = 'pending';
CREATE INDEX CONCURRENTLY idx_events_user_time
ON events (user_id, created_at DESC);
CREATE INDEX CONCURRENTLY idx_events_time_brin
ON events USING BRIN (created_at)
WITH (pages_per_range = 32);
Migration Zero-Downtime: Adicionar Coluna com Backfill
Cenário: adicionar timezone NOT NULL em users (10M rows), default 'UTC'.
ALTER TABLE users ADD COLUMN timezone TEXT;
ALTER TABLE users ALTER COLUMN timezone SET DEFAULT 'UTC';
DO $$
DECLARE
batch_size INT := 10000;
affected INT;
BEGIN
LOOP
WITH batch AS (
SELECT id FROM users
WHERE timezone IS NULL
ORDER BY id
LIMIT batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE users u
SET timezone = 'UTC'
FROM batch b
WHERE u.id = b.id;
GET DIAGNOSTICS affected = ROW_COUNT;
EXIT WHEN affected = 0;
COMMIT;
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
SELECT
COUNT(*) FILTER (WHERE timezone IS NULL) AS pending,
COUNT(*) AS total
FROM users;
ALTER TABLE users
ADD CONSTRAINT users_timezone_not_null
CHECK (timezone IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_timezone_not_null;
ALTER TABLE users ALTER COLUMN timezone SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_timezone_not_null;
Migration Zero-Downtime: Renomear Coluna
ALTER TABLE ... RENAME COLUMN é instant no DB, mas quebra a aplicação
se nem todos os instances estão na versão nova. Faça dual-write.
ALTER TABLE orders ADD COLUMN placed_at TIMESTAMPTZ;
CREATE OR REPLACE FUNCTION sync_orders_placed_at() RETURNS TRIGGER AS $$
BEGIN
IF NEW.created_at IS DISTINCT FROM OLD.created_at THEN
NEW.placed_at := NEW.created_at;
ELSIF NEW.placed_at IS DISTINCT FROM OLD.placed_at THEN
NEW.created_at := NEW.placed_at;
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_orders_placed_at
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION sync_orders_placed_at();
UPDATE orders SET placed_at = created_at WHERE placed_at IS NULL;
DROP TRIGGER trg_sync_orders_placed_at ON orders;
DROP FUNCTION sync_orders_placed_at;
ALTER TABLE orders DROP COLUMN created_at;
Migration Zero-Downtime: Mudar Tipo de Coluna
ALTER COLUMN TYPE faz rewrite da tabela. Em tabela grande, use expand-contract.
ALTER TABLE products ADD COLUMN description_new TEXT;
CREATE OR REPLACE FUNCTION sync_products_description() RETURNS TRIGGER AS $$
BEGIN
NEW.description_new := NEW.description;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_products_description
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION sync_products_description();
UPDATE products SET description_new = description WHERE description_new IS NULL;
ALTER TABLE products DROP COLUMN description;
ALTER TABLE products RENAME COLUMN description_new TO description;
Adicionar Foreign Key sem Lock Longo
ALTER TABLE orders
ADD CONSTRAINT orders_customer_id_fkey
FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_id_fkey;
Reindex sem Travar
REINDEX INDEX CONCURRENTLY idx_orders_status;
REINDEX DATABASE CONCURRENTLY mydb;
CREATE INDEX CONCURRENTLY idx_orders_status_new ON orders (status);
DROP INDEX CONCURRENTLY idx_orders_status;
ALTER INDEX idx_orders_status_new RENAME TO idx_orders_status;
Partitioning de Tabela Grande
CREATE TABLE events_new (
id BIGSERIAL,
user_id BIGINT NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_04 PARTITION OF events_new
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE events_2026_05 PARTITION OF events_new
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE events_default PARTITION OF events_new DEFAULT;
INSERT INTO events_new SELECT * FROM events
WHERE created_at >= '2026-04-01' AND created_at < '2026-05-01'
ON CONFLICT DO NOTHING;
BEGIN;
ALTER TABLE events RENAME TO events_old;
ALTER TABLE events_new RENAME TO events;
COMMIT;
Detecção de Bloat e Cleanup
SELECT
relname,
n_live_tup, n_dead_tup,
ROUND(n_dead_tup::numeric / GREATEST(n_live_tup, 1) * 100, 2) AS dead_pct,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_cost_delay = 2
);
VACUUM (VERBOSE, ANALYZE) orders;
Health Check Rápido
SELECT
relname,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
pg_size_pretty(pg_relation_size(relid)) AS table_size,
pg_size_pretty(pg_total_relation_size(relid) - pg_relation_size(relid)) AS index_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;
SELECT state, COUNT(*) FROM pg_stat_activity GROUP BY state;
SELECT ROUND(
SUM(heap_blks_hit) / GREATEST(SUM(heap_blks_hit) + SUM(heap_blks_read), 1) * 100, 2
) AS cache_hit_ratio
FROM pg_statio_user_tables;
SELECT
pid,
now() - query_start AS duration,
state,
wait_event,
substring(query, 1, 200) AS query_preview
FROM pg_stat_activity
WHERE state != 'idle' AND now() - query_start > interval '30 seconds'
ORDER BY duration DESC;
Anti-patterns a Evitar
| Anti-pattern | Problema | Solução |
|---|
ALTER TABLE ... SET NOT NULL direto | Full scan com lock exclusivo | CHECK NOT VALID + VALIDATE |
CREATE INDEX sem CONCURRENTLY | Bloqueia writes até terminar | Sempre CONCURRENTLY em prod |
UPDATE em tabela inteira numa transação | Explode WAL, trava replicação | Batches com SKIP LOCKED |
ALTER COLUMN TYPE em tabela grande | Rewrite completo, lock exclusivo | Expand-contract com coluna nova |
SELECT COUNT(*) em tabela grande como check | Seq scan 10M+ rows | pg_class.reltuples (aproximado) |
| Migration de schema + data juntos | Rollback complexo | Separar: schema change → deploy → data migration |
DELETE em massa sem batch | WAL bloat + lock longo | DELETE ... WHERE id IN (SELECT id ... LIMIT N) em loop |