Postgres es todo lo que necesitas. Sí, todo.

Postgres es todo lo que necesitas. Sí, todo.

Hubo un momento en mi carrera donde mi stack "moderno" se veía más o menos así: Postgres para los datos relacionales, Redis para el cache, otro Redis para las sessions, Kafka para los eventos, Elasticsearch para el search, y MongoDB porque alguien del equipo insistió en que "los documentos son más flexibles". Seis servicios. Seis cosas que monitorear, pagar, versionar, y eventualmente, debuggear a las 2am.

Hoy uso Postgres. Solo Postgres.

Y no es que me haya rendido, ni que esté siendo flojo. Es que Postgres ganó. Silenciosamente, versión tras versión, extensión tras extensión, se convirtió en la base de datos más versátil que existe. Y la mayoría de los developers siguen sin darse cuenta.

Déjame mostrarte por qué ya no necesitas nada más.


El problema con "usar la herramienta correcta para el trabajo"

Este consejo suena bien en teoría. En práctica, significa que tu equipo de tres personas está operando seis servicios diferentes en producción, cada uno con su propio modelo de consistencia, su propio cliente, y sus propias formas de fallar.

La "herramienta correcta" tiene un costo que nadie menciona: complejidad operacional, latencia de red entre servicios, y la pesadilla de mantener datos sincronizados entre sistemas que no se hablan.

Postgres no es un compromiso. Es una decisión de arquitectura madura.


Postgres como cache (bye Redis)

El caso de Redis más común que he visto no es un sistema de cache complejo. Es esto:

value = redis.get("user:123:profile")
if not value:
    value = db.query("SELECT * FROM users WHERE id = 123")
    redis.set("user:123:profile", value, ex=3600)

Un cache básico con TTL. Dos sistemas. Dos conexiones. Dos puntos de falla.

Postgres tiene esto nativo:

-- Materializa resultados costosos y refresca cada hora
CREATE MATERIALIZED VIEW user_profile_cache AS
SELECT 
    u.id,
    u.name,
    u.email,
    count(o.id) as total_orders,
    sum(o.total) as lifetime_value
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name, u.email;

CREATE UNIQUE INDEX ON user_profile_cache (id);

-- Refresca de forma concurrente, sin bloquear reads
REFRESH MATERIALIZED VIEW CONCURRENTLY user_profile_cache;

¿Necesitas cache a nivel de aplicación con TTL real? pg_cron + una tabla con timestamp es todo:

CREATE TABLE cache (
    key TEXT PRIMARY KEY,
    value JSONB NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL
);

-- Tu app hace esto:
INSERT INTO cache (key, value, expires_at)
VALUES ('user:123:profile', '{"name": "Rafael"}', NOW() + INTERVAL '1 hour')
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
    expires_at = EXCLUDED.expires_at;

-- Cleanup automático con pg_cron
SELECT cron.schedule('cleanup-cache', '*/15 * * * *', 
    'DELETE FROM cache WHERE expires_at < NOW()');

¿Y el performance? Con UNLOGGED TABLES en Postgres obtienes escrituras significativamente más rápidas porque no escribes al WAL. Para cache, eso es perfectamente aceptable:

CREATE UNLOGGED TABLE session_cache (
    key TEXT PRIMARY KEY,
    value JSONB,
    expires_at TIMESTAMPTZ
);

Postgres como Session Store

La razón por la que usamos Redis para sessions es velocidad de lectura y escritura. Pero resulta que Postgres con el índice correcto es perfectamente rápido para esto, especialmente si tus sessions viven en una tabla simple:

CREATE UNLOGGED TABLE sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
    data JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '30 days'
);

CREATE INDEX ON sessions (user_id);
CREATE INDEX ON sessions (expires_at);

Tu middleware de sessions hace un simple SELECT por id. Un query con Primary Key lookup. Eso es O(1). No necesitas Redis para eso.

-- Renovar session (touch)
UPDATE sessions 
SET last_active_at = NOW(),
    expires_at = NOW() + INTERVAL '30 days'
WHERE id = $1 AND expires_at > NOW()
RETURNING *;

Postgres como NoSQL (JSONB es una bestia)

MongoDB existe porque los developers querían flexibilidad de schema. Postgres escuchó y respondió con JSONB, que no solo almacena JSON sino que lo indexa, lo consulta, y te permite hacer operaciones sobre él que MongoDB envidiaría.

-- Guarda cualquier estructura sin definir schema
CREATE TABLE events (
    id BIGSERIAL PRIMARY KEY,
    type TEXT NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- GIN index para queries sobre el JSON completo
CREATE INDEX ON events USING GIN (payload);

-- Consultas que "no deberían funcionar en SQL" pero funcionan
SELECT * FROM events
WHERE payload @> '{"user_id": 123}'  -- contiene este objeto
  AND payload ? 'metadata'           -- tiene esta key
  AND type = 'purchase';

-- Extraer y agregar campos anidados
SELECT 
    payload->>'user_id' as user_id,
    SUM((payload->>'amount')::numeric) as total
FROM events
WHERE type = 'purchase'
  AND created_at > NOW() - INTERVAL '30 days'
GROUP BY payload->>'user_id';

¿Quieres schema flexible pero con algunos campos garantizados? Mezclas columnas con JSONB:

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    sku TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    price NUMERIC(10,2) NOT NULL,
    -- Todo lo demás va aquí, sin schema fijo
    attributes JSONB DEFAULT '{}'
);

-- Un producto puede tener "color" y "talla"
-- Otro puede tener "peso" y "dimensiones"
-- Sin migraciones para cada variación

Postgres como Message Queue (bye Kafka, bye RabbitMQ)

Este es el que más gente no cree. "¿Kafka reemplazado por Postgres? No seas ridículo."

Escúchame.

El 90% de los casos de uso de Kafka en startups y empresas medianas no necesitan Kafka. Necesitan una queue confiable. Y Postgres hace eso perfectamente con SKIP LOCKED:

CREATE TABLE job_queue (
    id BIGSERIAL PRIMARY KEY,
    type TEXT NOT NULL,
    payload JSONB NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    attempts INT NOT NULL DEFAULT 0,
    run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX ON job_queue (status, run_at) WHERE status = 'pending';

El truco está en cómo los workers hacen el SELECT:

-- Worker que toma un job sin que otro worker lo tome también
BEGIN;

SELECT * FROM job_queue
WHERE status = 'pending'
  AND run_at <= NOW()
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;  -- 🔑 Esta es la magia

-- Marca como processing
UPDATE job_queue SET status = 'processing' WHERE id = $1;

COMMIT;

SKIP LOCKED significa que si un worker ya tomó ese row, el siguiente worker simplemente lo salta en lugar de esperar. Eso te da workers concurrentes sin race conditions, sin mensajes duplicados, sin coordinación externa.

¿Retry logic con backoff exponencial?

-- Si el job falla, reintenta en 2^attempts minutos
UPDATE job_queue
SET status = 'pending',
    attempts = attempts + 1,
    run_at = NOW() + (INTERVAL '1 minute' * power(2, attempts))
WHERE id = $1;

Para la mayoría de los casos, esto es todo lo que necesitas.


Postgres como Pub/Sub (LISTEN / NOTIFY)

¿Eventos en tiempo real entre servicios? Postgres tiene LISTEN y NOTIFY desde hace décadas y la mayoría no los usa.

-- Desde cualquier parte de tu app o un trigger:
NOTIFY user_events, '{"type": "signup", "user_id": 456}';

-- Desde otro proceso que escucha:
LISTEN user_events;

En código real, con Python por ejemplo:

import psycopg2
import select
import json

conn = psycopg2.connect(DATABASE_URL)
conn.autocommit = True
cursor = conn.cursor()
cursor.execute("LISTEN user_events;")

while True:
    # Espera hasta que llegue una notificación
    if select.select([conn], [], [], 5) != ([], [], []):
        conn.poll()
        while conn.notifies:
            notify = conn.notifies.pop(0)
            event = json.loads(notify.payload)
            print(f"Evento recibido: {event}")
            handle_event(event)

Y puedes disparar notificaciones automáticamente desde triggers:

CREATE OR REPLACE FUNCTION notify_user_signup()
RETURNS TRIGGER AS $$
BEGIN
    PERFORM pg_notify(
        'user_events',
        json_build_object(
            'type', 'signup',
            'user_id', NEW.id,
            'email', NEW.email
        )::text
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER on_user_signup
AFTER INSERT ON users
FOR EACH ROW EXECUTE FUNCTION notify_user_signup();

¿Cuándo sí necesitas Kafka? Cuando tienes millones de eventos por segundo, necesitas replay de eventos históricos, o tienes múltiples equipos con streams independientes. Para el otro 90% de los casos, LISTEN/NOTIFY + una tabla de eventos es suficiente.


Postgres como Search Engine

Elasticsearch es poderoso. También es caro, complejo de operar, y para la mayoría de los casos de search, overkill.

Postgres tiene full-text search nativo:

-- Agrega una columna de search vector
ALTER TABLE articles ADD COLUMN search_vector TSVECTOR;

-- Genera el vector desde múltiples columnas con pesos diferentes
UPDATE articles SET search_vector = 
    setweight(to_tsvector('spanish', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('spanish', coalesce(body, '')), 'B') ||
    setweight(to_tsvector('spanish', coalesce(tags::text, '')), 'C');

-- Index para que sea rápido
CREATE INDEX ON articles USING GIN (search_vector);

-- Query con ranking por relevancia
SELECT 
    id,
    title,
    ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('spanish', 'postgres & database') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;

¿Quieres búsqueda fuzzy (typos y similares)? pg_trgm:

CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX ON users USING GIN (name gin_trgm_ops);

-- Encuentra "Rafael" aunque el usuario escribió "Rafeal"
SELECT * FROM users
WHERE name % 'Rafeal'  -- similarity match
ORDER BY similarity(name, 'Rafeal') DESC;

El stack que ya no necesitas justificar

No estoy diciendo que Redis, Kafka, o Elasticsearch sean malas herramientas. Son excelentes en los problemas que resuelven. Lo que estoy diciendo es que la mayoría de los equipos los adoptan antes de necesitarlos, y pagan el costo operacional desde el día uno.

Postgres te da:

  • Storage relacional — lo que siempre supo hacer
  • Document storage — JSONB con índices GIN
  • Cache — Materialized Views + UNLOGGED tables
  • Session store — Una tabla simple con índices
  • Message queue — SKIP LOCKED
  • Pub/Sub — LISTEN/NOTIFY
  • Full-text search — tsvector + pg_trgm
  • Scheduled jobs — pg_cron
  • Time series — TimescaleDB (extensión)
  • Geospatial — PostGIS (extensión)

Todo en un solo sistema. Un solo backup. Un solo punto de monitoreo. Un solo psql para debuggear cualquier cosa.

La próxima vez que alguien en tu equipo proponga agregar Redis porque "necesitamos cache", pregunta primero: ¿realmente necesitamos otro servicio, o necesitamos un índice mejor y una Materialized View?

La respuesta casi siempre es: solo necesitamos usar mejor lo que ya tenemos.


¿Usas Postgres para algo que "no debería funcionar" en una base de datos relacional? Me encantaría escucharlo.