PostgreSQL es el sistema de bases de datos relacional de código abierto más avanzado del mundo. No es solo una alternativa a MySQL: es una base de datos con características que MySQL todavía no tiene, como soporte nativo para JSON, arrays, tipos personalizados, full text search avanzado, y extensiones como pgvector para IA. Esta guía te enseña PostgreSQL desde la instalación hasta consultas avanzadas con ejemplos reales.
¿Por qué PostgreSQL?
PostgreSQL tiene más de 35 años de desarrollo activo y sigue siendo la base de datos favorita de desarrolladores según la encuesta de Stack Overflow. Sus ventajas clave son el cumplimiento estricto del estándar SQL, soporte para tipos de datos avanzados (JSONB, arrays, rangos, geometría), transacciones robustas con MVCC, extensibilidad mediante extensiones, y un rendimiento excelente tanto en lectura como en escritura.
Instalación de PostgreSQL
# Ubuntu/Debian
sudo apt update
sudo apt install postgresql postgresql-contrib
# macOS con Homebrew
brew install postgresql@16
brew services start postgresql@16
# Verificar instalación
psql --version
# Conectarse como superusuario postgres
sudo -u postgres psql
# Alternativa: usar Docker (la más cómoda para desarrollo)
docker run --name mi-postgres -e POSTGRES_PASSWORD=mipassword -e POSTGRES_DB=mi_base_datos -p 5432:5432 -d postgres:16
# Conectarse al contenedor Docker
docker exec -it mi-postgres psql -U postgres
Comandos básicos de psql
psql es la interfaz de línea de comandos de PostgreSQL. Aunque existen clientes gráficos como pgAdmin o DBeaver, saber moverte por psql es fundamental para trabajar en servidores remotos.
-- Comandos psql (con )
l -- Listar todas las bases de datos
c mi_base -- Conectarse a una base de datos
dt -- Listar tablas de la base de datos actual
d nombre_tabla -- Describir la estructura de una tabla
du -- Listar usuarios/roles
i archivo.sql -- Ejecutar un archivo SQL
iming -- Mostrar tiempo de ejecución de queries
e -- Abrir el editor externo
q -- Salir
-- Conectarse desde línea de comandos
psql -h localhost -p 5432 -U usuario -d mi_base_datos
-- Con URI de conexión
psql "postgresql://usuario:password@localhost:5432/mi_base_datos"
Crear bases de datos, usuarios y permisos
-- Crear una base de datos
CREATE DATABASE tienda
WITH ENCODING 'UTF8'
LC_COLLATE 'es_ES.UTF-8'
LC_CTYPE 'es_ES.UTF-8';
-- Crear un usuario
CREATE USER tienda_user WITH PASSWORD 'password_seguro';
-- Dar permisos
GRANT ALL PRIVILEGES ON DATABASE tienda TO tienda_user;
-- En la base de datos tienda:
c tienda
-- Dar permisos sobre esquema público
GRANT ALL ON SCHEMA public TO tienda_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO tienda_user;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO tienda_user;
-- Permisos para tablas futuras
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL ON TABLES TO tienda_user;
-- Eliminar base de datos o usuario
DROP DATABASE IF EXISTS tienda_test;
DROP USER IF EXISTS usuario_viejo;
Crear tablas: tipos de datos esenciales
PostgreSQL tiene un sistema de tipos muy rico. Conocer los tipos correctos para cada situación mejora el rendimiento y la integridad de los datos.
CREATE TABLE usuarios (
-- Identificadores
id BIGSERIAL PRIMARY KEY, -- Auto-incremento grande
uuid UUID DEFAULT gen_random_uuid(), -- UUID como alternativa
-- Texto
nombre VARCHAR(100) NOT NULL, -- Longitud máxima
email TEXT NOT NULL UNIQUE, -- Sin límite de longitud
bio TEXT, -- Nullable por defecto
-- Números
edad SMALLINT, -- 2 bytes (-32768 a 32767)
puntuacion INTEGER DEFAULT 0, -- 4 bytes
saldo NUMERIC(12, 2) DEFAULT 0.00, -- Precisión exacta (moneda)
altura REAL, -- Punto flotante 4 bytes
-- Booleanos y fechas
activo BOOLEAN NOT NULL DEFAULT TRUE,
fecha_nac DATE,
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Con zona horaria
ultima_vez TIMESTAMPTZ,
-- Tipos especiales de PostgreSQL
etiquetas TEXT[], -- Array de texto
metadatos JSONB, -- JSON binario (indexable)
ip_address INET, -- Dirección IP
-- Constraints
CHECK (edad IS NULL OR (edad >= 0 AND edad <= 150)),
CHECK (puntuacion >= 0)
);
-- Índices
CREATE INDEX idx_usuarios_email ON usuarios(email);
CREATE INDEX idx_usuarios_creado ON usuarios(creado_en DESC);
CREATE INDEX idx_usuarios_activo ON usuarios(activo) WHERE activo = TRUE;
-- Índice GIN para búsquedas en JSONB
CREATE INDEX idx_usuarios_meta ON usuarios USING GIN(metadatos);
El schema completo de una tienda online
-- Categorías con árbol recursivo
CREATE TABLE categorias (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
padre_id INTEGER REFERENCES categorias(id) ON DELETE SET NULL,
orden INTEGER DEFAULT 0
);
-- Productos
CREATE TABLE productos (
id SERIAL PRIMARY KEY,
sku VARCHAR(50) UNIQUE NOT NULL,
nombre VARCHAR(200) NOT NULL,
descripcion TEXT,
precio NUMERIC(10, 2) NOT NULL CHECK (precio >= 0),
precio_oferta NUMERIC(10, 2) CHECK (precio_oferta IS NULL OR precio_oferta < precio),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
categoria_id INTEGER REFERENCES categorias(id) ON DELETE SET NULL,
atributos JSONB DEFAULT '{}', -- Atributos variables del producto
imagenes TEXT[], -- Array de URLs
activo BOOLEAN DEFAULT TRUE,
creado_en TIMESTAMPTZ DEFAULT NOW(),
actualizado_en TIMESTAMPTZ DEFAULT NOW()
);
-- Trigger para actualizar fecha de modificación
CREATE OR REPLACE FUNCTION actualizar_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.actualizado_en = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_productos_actualizar
BEFORE UPDATE ON productos
FOR EACH ROW EXECUTE FUNCTION actualizar_timestamp();
-- Pedidos
CREATE TABLE pedidos (
id BIGSERIAL PRIMARY KEY,
usuario_id BIGINT NOT NULL REFERENCES usuarios(id),
estado VARCHAR(20) NOT NULL DEFAULT 'pendiente'
CHECK (estado IN ('pendiente', 'pagado', 'enviado', 'entregado', 'cancelado')),
total NUMERIC(12, 2) NOT NULL,
direccion JSONB NOT NULL,
creado_en TIMESTAMPTZ DEFAULT NOW()
);
-- Líneas de pedido
CREATE TABLE lineas_pedido (
id SERIAL PRIMARY KEY,
pedido_id BIGINT NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
producto_id INTEGER NOT NULL REFERENCES productos(id),
cantidad INTEGER NOT NULL CHECK (cantidad > 0),
precio_unitario NUMERIC(10, 2) NOT NULL,
subtotal NUMERIC(12, 2) GENERATED ALWAYS AS (cantidad * precio_unitario) STORED
);
Consultas SQL esenciales
-- SELECT básico con filtros
SELECT id, nombre, email, creado_en
FROM usuarios
WHERE activo = TRUE
AND creado_en > NOW() - INTERVAL '30 days'
ORDER BY creado_en DESC
LIMIT 20 OFFSET 0;
-- JOINs
SELECT
p.id,
p.nombre AS producto,
p.precio,
c.nombre AS categoria,
COUNT(lp.id) AS veces_vendido
FROM productos p
LEFT JOIN categorias c ON c.id = p.categoria_id
LEFT JOIN lineas_pedido lp ON lp.producto_id = p.id
WHERE p.activo = TRUE
GROUP BY p.id, p.nombre, p.precio, c.nombre
HAVING COUNT(lp.id) > 0
ORDER BY veces_vendido DESC
LIMIT 10;
-- Subconsultas y CTEs (Common Table Expressions)
WITH ventas_por_mes AS (
SELECT
DATE_TRUNC('month', pe.creado_en) AS mes,
SUM(pe.total) AS total_ventas,
COUNT(*) AS num_pedidos
FROM pedidos pe
WHERE pe.estado != 'cancelado'
AND pe.creado_en >= NOW() - INTERVAL '12 months'
GROUP BY DATE_TRUNC('month', pe.creado_en)
),
mes_anterior AS (
SELECT * FROM ventas_por_mes
ORDER BY mes DESC
LIMIT 1 OFFSET 1
)
SELECT
vm.mes,
vm.total_ventas,
vm.num_pedidos,
ROUND(
((vm.total_ventas - ma.total_ventas) / ma.total_ventas * 100)::NUMERIC,
2
) AS variacion_porcentual
FROM ventas_por_mes vm
CROSS JOIN mes_anterior ma
ORDER BY vm.mes DESC;
JSONB: el poder de los datos semi-estructurados
JSONB es una de las características más potentes de PostgreSQL. Te permite guardar datos semi-estructurados que se pueden indexar y consultar eficientemente, sin necesidad de definir el esquema de antemano.
-- Insertar con JSONB
UPDATE productos
SET atributos = '{"color": "rojo", "talla": ["S", "M", "L"], "peso_kg": 0.5}'
WHERE id = 1;
-- Consultar propiedades JSONB
SELECT nombre, atributos->>'color' AS color
FROM productos
WHERE atributos->>'color' = 'rojo';
-- Consultar arrays en JSONB
SELECT nombre, atributos->'talla' AS tallas
FROM productos
WHERE atributos->'talla' ? 'XL';
-- Buscar en propiedades anidadas
SELECT nombre, atributos#>>'{especificaciones, procesador}' AS cpu
FROM productos
WHERE atributos @> '{"categoria": "laptop"}';
-- Índice GIN para búsquedas eficientes
CREATE INDEX idx_productos_atributos ON productos USING GIN(atributos);
-- Funciones de JSONB
SELECT
jsonb_each_text(atributos) AS (clave, valor)
FROM productos
WHERE id = 1;
Full Text Search en PostgreSQL
PostgreSQL tiene búsqueda de texto completo integrada sin necesidad de Elasticsearch para casos de uso simples a medianos:
-- Añadir columna para búsqueda vectorizada
ALTER TABLE productos
ADD COLUMN busqueda_ts TSVECTOR
GENERATED ALWAYS AS (
setweight(to_tsvector('spanish', COALESCE(nombre, '')), 'A') ||
setweight(to_tsvector('spanish', COALESCE(descripcion, '')), 'B')
) STORED;
-- Índice GIN para búsqueda eficiente
CREATE INDEX idx_productos_busqueda ON productos USING GIN(busqueda_ts);
-- Buscar productos
SELECT
id,
nombre,
ts_rank(busqueda_ts, query) AS relevancia
FROM productos,
plainto_tsquery('spanish', 'zapatillas running') AS query
WHERE busqueda_ts @@ query
ORDER BY relevancia DESC
LIMIT 20;
-- Con resaltado de resultados
SELECT
nombre,
ts_headline('spanish', descripcion, query, 'MaxFragments=2') AS extracto
FROM productos,
plainto_tsquery('spanish', 'zapatillas running') AS query
WHERE busqueda_ts @@ query;
Transacciones y concurrencia
-- Transacción básica
BEGIN;
UPDATE productos SET stock = stock - 1 WHERE id = 42 AND stock > 0;
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio_unitario)
VALUES (1001, 42, 1, 29.99);
UPDATE pedidos SET total = total + 29.99 WHERE id = 1001;
COMMIT; -- O ROLLBACK si algo falla
-- Nivel de aislamiento
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;
-- SELECT FOR UPDATE: bloquear filas para actualización
BEGIN;
SELECT * FROM productos WHERE id = 42 FOR UPDATE;
-- Nadie más puede modificar este registro hasta que se haga COMMIT
UPDATE productos SET stock = stock - 1 WHERE id = 42;
COMMIT;
-- UPSERT (INSERT o UPDATE si ya existe)
INSERT INTO usuarios (email, nombre)
VALUES ('ana@ejemplo.com', 'Ana García')
ON CONFLICT (email) DO UPDATE
SET nombre = EXCLUDED.nombre,
ultima_vez = NOW();
Optimización de consultas: EXPLAIN ANALYZE
Cuando una consulta es lenta, el primer paso es entender qué está haciendo el planificador de PostgreSQL. EXPLAIN ANALYZE te muestra el plan de ejecución real con tiempos.
-- Ver el plan de ejecución
EXPLAIN ANALYZE
SELECT u.nombre, COUNT(p.id) AS total_pedidos
FROM usuarios u
LEFT JOIN pedidos p ON p.usuario_id = u.id
WHERE u.activo = TRUE
GROUP BY u.id, u.nombre
HAVING COUNT(p.id) > 5
ORDER BY total_pedidos DESC;
-- Buscar Sequential Scans en tablas grandes (señal de índice faltante)
-- Buscar tiempos altos en nodos específicos
-- Hash Join vs Nested Loop vs Merge Join
-- Índice parcial para queries frecuentes
CREATE INDEX idx_pedidos_pendientes
ON pedidos(usuario_id, creado_en DESC)
WHERE estado = 'pendiente';
-- Ver estadísticas de tablas
SELECT schemaname, tablename, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;
-- Forzar actualización de estadísticas
ANALYZE productos;
Backup y restauración
# Backup de una base de datos
pg_dump -h localhost -U usuario -d mi_base -Fc -f backup.dump
# Backup de todas las bases de datos
pg_dumpall -h localhost -U postgres > backup_total.sql
# Restaurar
pg_restore -h localhost -U usuario -d mi_base_nueva -Fc backup.dump
# Backup continuo / WAL archiving (producción)
# Usar herramientas como pgBackRest o Barman para backups en producción
# Ver tamaño de bases de datos
SELECT
datname,
pg_size_pretty(pg_database_size(datname)) AS tamaño
FROM pg_database
ORDER BY pg_database_size(datname) DESC;
# Ver tamaño de tablas e índices
SELECT
tablename,
pg_size_pretty(pg_total_relation_size(tablename::regclass)) AS tamaño_total,
pg_size_pretty(pg_relation_size(tablename::regclass)) AS tamaño_tabla,
pg_size_pretty(pg_indexes_size(tablename::regclass)) AS tamaño_indices
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(tablename::regclass) DESC;
PostgreSQL es una base de datos que cuanto más la conoces, más aprecias. Su robustez, su cumplimiento de estándares y sus características avanzadas como JSONB, full text search y las extensiones la convierten en la elección más sólida para la mayoría de proyectos. Si estás pensando en qué base de datos usar para tu próximo proyecto, PostgreSQL rara vez es la respuesta incorrecta.