
The classic SMB story: the app “feels slow”, so you spend a weekend moving the API to a bigger cloud instance. It still crawls. Users complain, support tickets pile up, and the owner starts asking uncomfortable questions about DevOps competence. Then you check the database and find a query that’s scanning two million rows on every page load.
The database is the bottleneck in most SMB applications — and the good news is you don’t need a full-time DBA to fix it. PostgreSQL ships with powerful built-in diagnostics, and in 2026 it’s still the default open-source choice for SMB SaaS. This guide walks through a measured, low-risk tuning sequence: find the slow query, fix it with the right index, fix the connection layer, then make the gains stick. No voodoo, no reboots, no downtime.
Paso 1 — Mide antes de tocar una sola configuración
Guessing at shared_buffers before you know your slowest query is how DB tuning becomes superstition. PostgreSQL 15+ includes pg_stat_statements — enable it once and you get per-query execution stats for free:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT queryid,
calls,
round(total_exec_time / calls)::int AS avg_ms,
round(total_exec_time / 1000)::int AS total_s,
left(query, 60) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Esa única consulta te dice exactamente qué consultas queman tu presupuesto de producción. Toma la peor y ejecuta EXPLAIN ANALYZE sobre ella:
EXPLAIN ANALYZE SELECT * FROM orders
WHERE status = 'pending' ORDER BY created_at DESC;
Seq Scan on orders (cost=0.00..81234.11 rows=2400000 width=84)
Filter: (status = 'pending')
Planning Time: 0.4 ms
Execution Time: 1842.3 ms
A Seq Scan on a table with thousands of rows is the smell you’re looking for: “1.8 seconds on a scan” is your smoking gun. Also switch on the slow-query log so you catch future offenders as they appear:
ALTER SYSTEM SET log_min_duration_statement = 250;
Now you have a baseline and a tripwire. Never tune blind — and never tune production data you can’t afford to lose: this is a good moment to confirm your backup strategy actually works before you start changing things.
Paso 2 — Indexa las consultas que importan
Most slow queries are fixed by one well-placed index — not by throwing CPU at PostgreSQL. The critical 2026 skill is knowing which index, because a wrong index costs you writes forever.
For our orders example, the filter is a boolean-like status plus a date sort. A plain B-tree index on status is almost useless (low cardinality). Better: a partial index that only covers the rows your query actually touches:
CREATE INDEX CONCURRENTLY idx_orders_pending_created
ON orders (created_at DESC)
WHERE status = 'pending';
Notice CONCURRENTLY — it builds the index without locking writes, so you can create it in production at 3 pm on a Tuesday (we covered zero-downtime change patterns before in zero-downtime database migrations). For dashboards that fetch a few wide columns, a covering index can make PostgreSQL answer entirely from the index:
CREATE INDEX CONCURRENTLY idx_orders_org_created
ON orders (org_id, created_at DESC)
INCLUDE (total, currency);
Re-run EXPLAIN ANALYZE and confirm the planner now picks an Index Scan:
Index Scan using idx_orders_pending_created on orders
Execution Time: 12.4 ms -- 1,842 ms → 12 ms
Two rules keep this safe: don’t index tables under ~10k rows (a scan is faster), and don’t stack five indexes per table. One partial index that matches the query shape beats three generic ones.
Paso 3 — Detén el agotamiento de conexiones con PgBouncer
You fixed the query, but the app still throws too many open connections around lunchtime. PostgreSQL is heavy per connection (roughly 5 MB each plus backend worker cost), so a default max_connections=100 will not survive 20 app instances with pooling disabled.
PgBouncer es la solución ligera: multiplexa unas pocas conexiones reales de base de datos sobre cientos de conexiones de clientes usando pooling a nivel de transacción:
# pgbouncer.ini
[databases]
acme = host=/var/run/postgresql
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
pool_mode = transaction
default_pool_size = 30
max_client_conn = 1000
pgbouncer /etc/pgbouncer/pgbouncer.ini
# app connection string: postgres://user:pass@db:6432/acme
Put PgBouncer in front of PostgreSQL, point your app pool at port 6432, and watch the “connection refused” alerts disappear. A good starting pool size is max_connections × 0.5 for the app, and leave headroom for maintenance connections. When you do catch a runaway query during a spike, knowing how to stop it calmly is half the battle:
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'active'
AND query ILIKE '%orders%'
AND now() - query_start > interval '30 seconds';
Paso 4 — Victorias baratas: memoria, autovacuum y bloat
Los índices arreglaron el síntoma; estas configuraciones previenen el siguiente. En un servidor de base de datos dedicado, empieza con memoria razonable:
ALTER SYSTEM SET shared_buffers = '2GB'; -- ~25% of RAM
ALTER SYSTEM SET effective_cache_size = '6GB'; -- ~75% of RAM
ALTER SYSTEM SET work_mem = '16MB'; -- don't max this out
work_mem is per-operation and per-session — a 1 GB setting across 50 connections is a memory bomb, not a speedup. Keep it modest.
Then handle bloat. PostgreSQL’s MVCC means updated rows leave dead tuples that only autovacuum collects. The 2026 defaults are reasonable, but on write-heavy tables you’ll see table size ballooning:
SELECT pg_size_pretty(pg_total_relation_size('orders'));
ALTER SYSTEM SET autovacuum_vacuum_scale_factor = 0.05; -- tighter than 0.2
ALTER SYSTEM SET autovacuum_naptime = '30s';
If a table is already bloated, a maintenance window with VACUUM (ANALYZE, VERBOSE) orders; (or pg_repack when it’s chronic) reclaims space and refreshes planner statistics. Schedule it during low traffic and watch your query plans improve.
Keep the Gains — and Know When It’s Not the Database
Performance tuning is a practice, not a one-off. Add a 15-minute weekly review: top 5 queries from pg_stat_statements, slow-log scan, bloat check. Feed those numbers into your observability layer so a regressing query alerts you before users do — the OpenTelemetry-based observability stack we recommend for SMBs makes this straightforward.
Also know your boundaries: if the app does N+1 queries in the code, no index will save you. If one hot table outgrows a single node, that’s a scale-out decision (read replicas, partitioning), not a tuning problem. The process above gets you 80% of the way for free — the last 20% is architecture, and that’s a conversation worth having with someone who’s seen your exact stack before.
Want us to audit your PostgreSQL before the next launch? Book a free 30-minute DevOps consultation at /reserva-cita.