Ya tienes dónde poner los datos. Toca ponerlos. INSERT es la instrucción que añade filas a una tabla y, en apariencia, es la más simple del módulo: nombre de tabla, columnas, valores, punto. Pero detrás de esa simplicidad hay decisiones que separan un script que aguanta cinco años de uno que se rompe en la primera refactorización: listar o no listar las columnas, insertar una fila o cuarenta en una sola sentencia, dejar que el motor genere el id o forzarlo (y qué desastre silencioso provoca lo segundo), y cómo recuperar el id recién creado para poder insertar las líneas del pedido que acabas de registrar.
En esta lección aprenderás las cinco formas de INSERT, entenderás por fin qué hace el bloque de setval con el que termina tiendaverde.sql, sabrás diagnosticar de un vistazo cada uno de los cuatro errores que un INSERT puede darte, y terminarás registrando un pedido completo con sus tres líneas usando RETURNING, que es exactamente lo que hace una tienda online cada vez que alguien pulsa "Confirmar compra".
Contenido
- La sintaxis, y por qué hay que listar siempre las columnas
- Insertar varias filas en una sola sentencia
DEFAULTyDEFAULT VALUES- La columna de identidad: omitirla, forzarla y
setval RETURNING: recuperar lo que acabas de insertarINSERT ... SELECT: insertar el resultado de una consulta- Los cuatro errores de un
INSERTy su diagnóstico - El orden de inserción cuando hay claves foráneas
ON CONFLICT DO NOTHING, de pasada- Carga masiva:
COPYy\copyfrente aINSERT - Ejemplo completo: alta de producto y registro de un pedido
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- La sintaxis, y por qué hay que listar siempre las columnas
Un alta de proveedor:
INSERT INTO proveedores (nombre, pais, email, activo)
VALUES ('Cooperativa La Safor', 'España', '[email protected]', TRUE);Esa salida de psql merece una explicación, porque desconcierta a todo el mundo la primera vez:
| Parte | Significado |
|---|---|
INSERT |
El tipo de sentencia |
0 |
El OID de la fila insertada. Es una reliquia: hoy siempre vale 0 |
1 |
El número de filas insertadas. Este es el número que importa |
Cada vez que ejecutes un INSERT, mira el segundo número. Si esperabas 5 filas y pone INSERT 0 3, algo va mal.
La forma sin columnas, y por qué no debes usarla
SQL permite omitir la lista de columnas si das un valor para todas, en el orden exacto de la tabla:
-- ⚠️ INCORRECTA como práctica, aunque funcione hoy
INSERT INTO proveedores
VALUES (7, 'Cooperativa La Safor', 'España', '[email protected]', TRUE);Funciona. Y es una bomba de relojería, por cuatro motivos:
| Problema | Qué pasa |
|---|---|
| Dependes del orden físico | Si alguien reordena las columnas con un ALTER TABLE, tus INSERT empiezan a meter el país en el email |
| Se rompe al añadir columnas | El día que proveedores gane una columna telefono, todos estos INSERT fallarán con INSERT has more target columns than expressions… o peor, seguirán funcionando y desplazarán los datos |
| Ilegible | ('España', 'ventas@…', TRUE) no dice qué es qué. Con quince columnas es directamente indescifrable |
Obliga a dar el id |
Al listar todas las columnas incluyes la de identidad, y ahí empieza el problema del apartado 4 |
La regla, sin excepciones: lista siempre las columnas. Cuesta veinte caracteres y te ahorra una clase entera de bugs. Es, con diferencia, el hábito más rentable de esta lección.
Con la lista explícita puedes además omitir las columnas que tengan DEFAULT o admitan nulos, y ponerlas en el orden que quieras:
id lo genera la identidad, email queda NULL (es nulable) y activo toma su DEFAULT TRUE. Tres columnas rellenas sin escribirlas.
- Insertar varias filas en una sola sentencia
Basta separar las tuplas por comas:
INSERT INTO categorias (nombre, descripcion) VALUES
('Despensa a granel', 'Legumbres, cereales y frutos secos sin envase'),
('Bebés', 'Cuidado infantil con certificación ecológica'),
('Mascotas', 'Alimentación y accesorios para animales de compañía');Es la forma que usa tiendaverde.sql en sus nueve bloques de carga, y no es solo cuestión de comodidad: es muchísimo más rápido.
Por qué es más rápido
Un INSERT de N filas no cuesta lo mismo que N INSERT de una fila:
| Coste | N sentencias separadas | 1 sentencia con N tuplas |
|---|---|---|
| Viajes de red cliente↔servidor | N | 1 |
| Análisis y planificación de la consulta | N veces | 1 vez |
Transacciones implícitas (sin BEGIN) |
N confirmaciones a disco | 1 |
| Comprobación de restricciones | N (inevitable) | N (inevitable) |
Los tres primeros costes son fijos por sentencia y suelen dominar. En la práctica, insertar 1 000 filas con una sola sentencia frente a 1 000 sentencias sueltas puede ser entre 10 y 50 veces más rápido, y la mayor parte de esa diferencia viene del tercer punto: sin una transacción explícita, cada INSERT suelto se confirma a disco por su cuenta.
Límites prácticos: no hagas una sentencia con 100 000 tuplas. El texto de la consulta se vuelve enorme y el consumo de memoria del servidor también. Lotes de 500 a 5 000 filas son el punto dulce habitual. Y si hablamos de millones, la herramienta ya no es INSERT sino COPY (apartado 10).
DEFAULT y DEFAULT VALUES
DEFAULT y DEFAULT VALUESLa palabra clave DEFAULT puede aparecer como valor, y significa "usa el valor por omisión de esta columna":
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock, activo, fecha_alta)
VALUES ('Lentejas pardinas ecológicas 500 g', 1, 1, 2.45, 1.05, DEFAULT, DEFAULT, DEFAULT);stock toma 0, activo toma TRUE y fecha_alta toma CURRENT_DATE. Es idéntico a haber omitido las tres columnas, y de hecho omitirlas es preferible: se lee mejor. DEFAULT como valor solo resulta útil cuando construyes la sentencia desde un programa y te sale más cómodo mantener fija la lista de columnas.
DEFAULT VALUES inserta una fila con todos los valores por omisión:
CREATE TABLE demo_defaults (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
estado VARCHAR(20) NOT NULL DEFAULT 'pendiente',
fecha DATE NOT NULL DEFAULT CURRENT_DATE
);
INSERT INTO demo_defaults DEFAULT VALUES;| id | estado | fecha |
|---|---|---|
| 1 | pendiente | 2026-03-01 |
(La fecha será la del día en que lo ejecutes.) Es un caso raro, pero existe: tablas de "reserva de identificador" o de estado inicial que se rellenan después.
- La columna de identidad: omitirla, forzarla y
setval
setvalEste apartado explica, por fin, el bloque más críptico de tiendaverde.sql.
Lo normal: omitir el id
INSERT INTO categorias (nombre, descripcion)
VALUES ('Despensa a granel', 'Legumbres, cereales y frutos secos sin envase');Como no has dado id, PostgreSQL pide el siguiente valor a la secuencia asociada. Es lo correcto y lo que harás el 99 % de las veces.
Forzarlo: legal con BY DEFAULT, y peligroso
Como TiendaVerde declara sus identidades GENERATED BY DEFAULT, puedes dar el id a mano:
INSERT INTO categorias (id, nombre, descripcion)
VALUES (7, 'Bebés', 'Cuidado infantil con certificación ecológica');Funciona… y no toca la secuencia. La secuencia sigue creyendo que el último valor entregado es el 6. Así que el siguiente INSERT sin id:
INSERT INTO categorias (nombre, descripcion)
VALUES ('Mascotas', 'Alimentación y accesorios para animales');ERROR: duplicate key value violates unique constraint "categorias_pkey" DETAIL: Key (id)=(7) already exists.
La secuencia ha entregado el 7, que ya estaba ocupado. Ese es el desajuste de la secuencia, y es uno de los fallos más desconcertantes que existen: la sentencia que falla es correcta y el error apunta a un id que tú no has escrito.
flowchart TD
A["Secuencia en 6<br/>categorias tiene ids 1..6"] --> B["INSERT con id = 7<br/>explícito"]
B --> C["Tabla: ids 1..7<br/>Secuencia: sigue en 6 ❌"]
C --> D["INSERT sin id"]
D --> E["La secuencia entrega 7"]
E --> F["💥 duplicate key value<br/>violates categorias_pkey"]
El arreglo: setval
setval reposiciona la secuencia. La forma robusta, que no exige saber cómo se llama la secuencia:
| setval |
|---|
| 7 |
Ahora el siguiente INSERT sin id pedirá el 8 y funcionará. Y aquí está la explicación de las nueve líneas finales del script del curso:
SELECT setval(pg_get_serial_sequence('categorias', 'id'), (SELECT MAX(id) FROM categorias));
SELECT setval(pg_get_serial_sequence('proveedores', 'id'), (SELECT MAX(id) FROM proveedores));
SELECT setval(pg_get_serial_sequence('productos', 'id'), (SELECT MAX(id) FROM productos));
...tiendaverde.sql inserta todos los id a mano —para que las lecciones puedan hablar del "cliente 7" o del "pedido 12" con ids estables y reproducibles—, así que al terminar la carga las nueve secuencias están en 1. Sin ese bloque, el primer INSERT sin id de cualquier tabla fallaría por clave duplicada. Ese es el detalle que 01-06 dejó anunciado "para el módulo 5".
Tres funciones útiles alrededor de las secuencias:
| Función | Qué hace |
|---|---|
pg_get_serial_sequence('tabla','columna') |
Devuelve el nombre de la secuencia asociada ('public.categorias_id_seq') |
currval('secuencia') |
Último valor entregado en tu sesión. Falla si aún no has pedido ninguno |
setval('secuencia', n) |
Reposiciona: el siguiente valor será n + 1 |
Cómo evitar todo esto: declara las identidades
GENERATED ALWAYS AS IDENTITY(05-01) y no las fuerces nunca. TiendaVerde usaBY DEFAULTpor una razón didáctica concreta; tu esquema no tiene por qué.
Y un detalle que sorprende: las secuencias no se deshacen con un ROLLBACK. Si una transacción consume el valor 21 y luego aborta, ese 21 se pierde para siempre. Es deliberado —de lo contrario dos sesiones concurrentes tendrían que esperarse—, y significa que los id autogenerados tienen huecos. No son un contador de filas ni deben usarse como tal.
RETURNING: recuperar lo que acabas de insertar
RETURNING: recuperar lo que acabas de insertarUn INSERT normal solo te dice cuántas filas ha metido. RETURNING, extensión de PostgreSQL, te devuelve las filas insertadas, con sus valores generados incluidos:
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock)
VALUES ('Garbanzos ecológicos 500 g', 1, 1, 2.60, 1.15, 140)
RETURNING id, nombre, precio, stock, activo, fecha_alta;| id | nombre | precio | stock | activo | fecha_alta |
|---|---|---|---|---|---|
| 21 | Garbanzos ecológicos 500 g | 2.60 | 140 | true | 2026-03-01 |
Ahí tienes el id que ha generado el motor (21, el siguiente al 20), el activo que ha puesto el DEFAULT y la fecha_alta que ha puesto CURRENT_DATE. Todo eso lo habrías tenido que ir a buscar con un SELECT posterior… que, además, no sería fiable en un sistema con varios usuarios: entre tu INSERT y tu SELECT puede haber entrado otro pedido.
RETURNING admite lo mismo que un SELECT: columnas, expresiones, alias y *.
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock)
VALUES ('Quinoa real ecológica 500 g', 1, 3, 6.80, 3.40, 90)
RETURNING id,
nombre,
precio,
coste,
precio - coste AS margen,
ROUND((precio - coste) / precio, 4) AS margen_relativo;| id | nombre | precio | coste | margen | margen_relativo |
|---|---|---|---|---|---|
| 22 | Quinoa real ecológica 500 g | 6.80 | 3.40 | 3.40 | 0.5000 |
Por qué RETURNING es imprescindible
El caso canónico es insertar un padre y sus hijos: un pedido y sus líneas. Sin RETURNING, la secuencia sería:
INSERT INTO pedidos ...SELECT MAX(id) FROM pedidos← aquí está el falloINSERT INTO lineas_pedido (pedido_id, ...) VALUES (ese_id, ...)
El paso 2 es una condición de carrera: si otro cliente confirma su compra entre tu paso 1 y tu paso 2, MAX(id) te devuelve el pedido de otro y le cuelgas tus líneas. En una tienda con tráfico real esto no es una posibilidad teórica: pasa. RETURNING lo resuelve de raíz porque devuelve tu fila, no la última de la tabla.
Equivalentes por motor
| Motor | Cómo se recupera el id generado |
|---|---|
| PostgreSQL | INSERT ... RETURNING id (también en UPDATE y DELETE) |
| MySQL / MariaDB | SELECT LAST_INSERT_ID(); tras el INSERT, en la misma conexión |
| SQL Server | SCOPE_IDENTITY(), o la cláusula OUTPUT INSERTED.id en la propia sentencia |
| SQLite | SELECT last_insert_rowid(); o RETURNING desde la versión 3.35 |
| Oracle | INSERT ... RETURNING id INTO :variable (en PL/SQL o con parámetro de salida) |
Dos avisos sobre esta tabla. LAST_INSERT_ID() de MySQL es por conexión, así que es seguro frente a otros usuarios pero se pisa a sí mismo si haces dos INSERT seguidos. Y en SQL Server hay que preferir SCOPE_IDENTITY() a @@IDENTITY: el segundo devuelve el id generado por cualquier ámbito, incluidos los triggers, lo cual es una fuente clásica de errores.
RETURNINGfunciona igual enUPDATE(05-03) y enDELETE(05-04), y ahí es todavía más útil: te deja ver exactamente qué has cambiado o qué has borrado.
INSERT ... SELECT: insertar el resultado de una consulta
INSERT ... SELECT: insertar el resultado de una consultaEn lugar de VALUES, un INSERT puede alimentarse de una consulta. Es DML puro: no estás anidando una subconsulta (eso es el módulo 7), estás conectando la salida de un SELECT con la entrada de un INSERT.
Las columnas se emparejan por posición, no por nombre. Es la trampa clásica: si el SELECT devuelve (nombre, precio) y la lista de destino dice (precio, nombre), PostgreSQL se quejará por tipos incompatibles… o no se quejará y te dejará los datos cruzados.
Caso 1: poblar una tabla de histórico
Antes de archivar los pedidos entregados, guardamos una foto:
CREATE TABLE pedidos_historico (
id INTEGER PRIMARY KEY,
cliente_id INTEGER NOT NULL,
fecha_pedido DATE NOT NULL,
estado VARCHAR(20) NOT NULL,
gastos_envio NUMERIC(10,2) NOT NULL,
fecha_archivo DATE NOT NULL DEFAULT CURRENT_DATE
);INSERT INTO pedidos_historico (id, cliente_id, fecha_pedido, estado, gastos_envio)
SELECT pe.id,
pe.cliente_id,
pe.fecha_pedido,
pe.estado,
pe.gastos_envio
FROM pedidos AS pe
WHERE pe.estado = 'entregado';14 filas, los 14 pedidos entregados. fecha_archivo no aparece en la lista de destino, así que toma su DEFAULT CURRENT_DATE en todas.
Caso 2: duplicar el catálogo de un proveedor
El proveedor 5 (EcoNordic Supplies) está inactivo. Verde Atlántico (proveedor 3) se ofrece a suministrar los mismos artículos con un 8 % de recargo en el coste. En lugar de teclear cuatro altas:
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock, activo, fecha_alta)
SELECT p.nombre || ' (Verde Atlántico)',
p.categoria_id,
3,
p.precio,
ROUND(p.coste * 1.08, 2),
0,
TRUE,
DATE '2026-03-01'
FROM productos AS p
WHERE p.proveedor_id = 5
ORDER BY p.id;SELECT id, nombre, categoria_id, proveedor_id, precio, coste, stock
FROM productos
WHERE id > 20
ORDER BY id;| id | nombre | categoria_id | proveedor_id | precio | coste | stock |
|---|---|---|---|---|---|---|
| 21 | Detergente ecológico concentrado 1 L (Verde Atlántico) | 3 | 3 | 11.20 | 6.48 | 0 |
| 22 | Velas de cera de soja (pack 2) (Verde Atlántico) | 3 | 3 | 13.75 | 7.45 | 0 |
| 23 | Cepillo de dientes de bambú (Verde Atlántico) | 5 | 3 | 3.50 | 1.30 | 0 |
| 24 | Cápsulas de espirulina 120 uds (Verde Atlántico) | 6 | 3 | 16.40 | 9.40 | 0 |
Cuatro altas con una sentencia. Fíjate en lo que hace el SELECT: transforma mientras copia. Concatena un sufijo al nombre, sustituye el proveedor_id por una constante, recalcula el coste y pone el stock a cero. Ese es el verdadero valor de INSERT ... SELECT: no es copiar, es copiar transformando.
Comprobación de los costes, redondeados a dos decimales: 6,00 × 1,08 = 6,48; 6,90 × 1,08 = 7,452 → 7,45; 1,20 × 1,08 = 1,296 → 1,30; 8,70 × 1,08 = 9,396 → 9,40.
Caso 3: INSERT ... SELECT con LIMIT
Todo lo que sabes del módulo 2 sirve aquí, ORDER BY y LIMIT incluidos:
CREATE TEMP TABLE top_productos (
posicion INTEGER,
id INTEGER,
nombre VARCHAR(150),
precio NUMERIC(10,2)
);
INSERT INTO top_productos (posicion, id, nombre, precio)
SELECT ROW_NUMBER() OVER (ORDER BY p.precio DESC, p.id),
p.id,
p.nombre,
p.precio
FROM productos AS p
ORDER BY p.precio DESC, p.id
LIMIT 5;| posicion | id | nombre | precio |
|---|---|---|---|
| 1 | 15 | Té verde matcha ceremonial 30 g | 22.00 |
| 2 | 6 | Crema facial de aloe vera 50 ml | 18.90 |
| 3 | 20 | Cápsulas de espirulina 120 uds | 16.40 |
| 4 | 8 | Aceite corporal de almendras 200 ml | 14.25 |
| 5 | 13 | Velas de cera de soja (pack 2) | 13.75 |
(ROW_NUMBER() es una función de ventana del módulo 10; aquí solo numera las filas del ranking.)
Y ojo con un matiz: ORDER BY dentro de un INSERT ... SELECT no garantiza el orden físico de las filas en la tabla destino, porque en el modelo relacional una tabla es un conjunto sin orden (01-05). Solo determina qué filas entran cuando hay LIMIT. Para leerlas ordenadas necesitarás un ORDER BY en el SELECT, siempre.
- Los cuatro errores de un
INSERT y su diagnóstico
INSERT y su diagnósticoUn INSERT puede fallar por cuatro motivos, uno por cada tipo de restricción. Conocer los mensajes literales te ahorra horas.
7.1. Violación de NOT NULL
ERROR: null value in column "nombre" of relation "productos" violates not-null constraint DETAIL: Failing row contains (23, null, 1, null, 4.50, null, 0, t, 2026-03-01).
El DETAIL te muestra la fila completa tal como habría quedado, con los valores por omisión ya aplicados. Es muy útil: ahí ves qué columnas se rellenaron solas.
7.2. Violación de UNIQUE (o de la PK)
ERROR: duplicate key value violates unique constraint "categorias_nombre_key" DETAIL: Key (nombre)=(Bebidas) already exists.
El nombre de la restricción te dice qué columna. Si fuera categorias_pkey, sería el id: probablemente el desajuste de secuencia del apartado 4.
7.3. Violación de CHECK
INSERT INTO resenas (producto_id, cliente_id, puntuacion, comentario, fecha)
VALUES (1, 14, 7, 'Genial', '2026-03-01');ERROR: new row for relation "resenas" violates check constraint "resenas_puntuacion_check" DETAIL: Failing row contains (13, 1, 14, 7, Genial, 2026-03-01).
El nombre de la restricción identifica la regla. Aquí es la puntuación de 1 a 5, y por eso 05-01 insistía tanto en nombrarlas: chk_resenas_puntuacion habría sido igual de claro; resenas_check1 no lo habría sido.
7.4. Violación de FOREIGN KEY
INSERT INTO pedidos (cliente_id, fecha_pedido, estado, metodo_pago, gastos_envio)
VALUES (999, '2026-03-01', 'pendiente', 'tarjeta', 4.95);ERROR: insert or update on table "pedidos" violates foreign key constraint "pedidos_cliente_id_fkey" DETAIL: Key (cliente_id)=(999) is not present in table "clientes".
is not present in table es la firma inconfundible de una FK rota: has referenciado algo que no existe.
Tabla de diagnóstico rápido
| Fragmento del mensaje | Restricción rota | Qué mirar |
|---|---|---|
violates not-null constraint |
NOT NULL |
Falta un valor obligatorio; míralo en el DETAIL |
duplicate key value violates unique constraint "..._pkey" |
PRIMARY KEY |
¿Estás forzando el id? ¿Secuencia desajustada? → setval |
duplicate key value violates unique constraint "..._key" |
UNIQUE |
Ya existe una fila con ese valor. ¿Querías un UPSERT? → 05-05 |
violates check constraint |
CHECK |
Valor fuera de dominio o de rango. El nombre te dice cuál |
violates foreign key constraint + is not present in table |
FOREIGN KEY |
El padre no existe. ¿Orden de inserción? → apartado 8 |
violates foreign key constraint + is still referenced from table |
FOREIGN KEY |
Esto no es un INSERT: es un DELETE → 05-04 |
column "x" of relation "y" does not exist |
— | Errata en el nombre de columna. \d y |
INSERT has more expressions than target columns |
— | Descuadre entre la lista de columnas y la de valores |
invalid input syntax for type numeric: "12,50" |
— | Coma decimal en lugar de punto (01-03) |
Un detalle importante: cuando un INSERT multifila falla, no se inserta ninguna fila. Una sentencia es atómica: o entra entera o no entra nada. Si insertas 500 tuplas y la 337 viola un CHECK, las 499 restantes tampoco se guardan.
- El orden de inserción cuando hay claves foráneas
La integridad referencial impone el mismo orden que imponía a CREATE TABLE en 05-01: primero los padres, después los hijos.
flowchart TD
A["1 · categorias<br/>proveedores"] --> B["2 · productos"]
A2["1 · clientes<br/>empleados"] --> C["3 · pedidos"]
B --> D["4 · lineas_pedido"]
C --> D
B --> E["4 · resenas"]
A2 --> E
C --> F["4 · devoluciones"]
Intenta saltártelo y verás por qué:
-- ⚠️ INCORRECTA: la categoría 9 aún no existe
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock)
VALUES ('Pan de espelta congelado', 9, 1, 3.20, 1.60, 40);ERROR: insert or update on table "productos" violates foreign key constraint "productos_categoria_id_fkey" DETAIL: Key (categoria_id)=(9) is not present in table "categorias".
Primero la categoría, después el producto:
-- ✅ CORRECTA
INSERT INTO categorias (id, nombre, descripcion)
VALUES (9, 'Panadería', 'Pan y bollería ecológica congelada');
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock)
VALUES ('Pan de espelta congelado', 9, 1, 3.20, 1.60, 40);Las relaciones reflexivas tienen un matiz propio. clientes.referido_por_id apunta a la misma tabla, así que el referidor debe existir antes que el referido. En el script del curso funciona porque los clientes están ordenados por id y ninguno es referido por otro de id mayor. Si no fuera así, tendrías dos opciones: insertar primero con referido_por_id a NULL y actualizarlo después (05-03), o usar una restricción diferida (DEFERRABLE INITIALLY DEFERRED), que pospone la comprobación al final de la transacción. Esta última pertenece de lleno al modelo transaccional del módulo 9.
ON CONFLICT DO NOTHING, de pasada
ON CONFLICT DO NOTHING, de pasadaSi intentas insertar una fila que viola un UNIQUE, el INSERT falla. A veces lo que quieres es que no falle y simplemente no haga nada:
INSERT INTO categorias (nombre, descripcion)
VALUES ('Bebidas', 'Intento de duplicado')
ON CONFLICT DO NOTHING;Cero filas insertadas, cero errores. Es tremendamente útil para scripts que deben poder reejecutarse (cargas de datos maestros, semillas de desarrollo).
Su hermana mayor, ON CONFLICT ... DO UPDATE, resuelve el clásico "insértalo si no existe y actualízalo si existe". Eso es el UPSERT, y tiene lección propia: 05-05.
- Carga masiva:
COPY y \copy frente a INSERT
COPY y \copy frente a INSERTPara volúmenes grandes, INSERT no es la herramienta. PostgreSQL tiene COPY, diseñada específicamente para mover datos entre un fichero y una tabla:
-- COPY se ejecuta en el SERVIDOR: la ruta es del servidor
-- y hace falta ser superusuario o tener el rol pg_read_server_files
COPY productos (nombre, categoria_id, proveedor_id, precio, coste, stock)
FROM '/var/lib/postgresql/import/productos.csv'
WITH (FORMAT csv, HEADER true, DELIMITER ',');\copy es el comando equivalente de psql, que lee el fichero en tu máquina y lo envía por la conexión. No necesita permisos especiales y es el que usarás casi siempre:
tiendaverde=> \copy productos (nombre, categoria_id, proveedor_id, precio, coste, stock) FROM 'productos.csv' WITH (FORMAT csv, HEADER true)
También funciona en sentido inverso, para exportar:
tiendaverde=> \copy (SELECT id, nombre, precio FROM productos ORDER BY id) TO 'catalogo.csv' WITH (FORMAT csv, HEADER true)
Comparativa honesta:
INSERT multifila |
COPY / \copy |
|
|---|---|---|
| Origen de los datos | Texto SQL | Fichero CSV/texto o flujo |
| Velocidad relativa | Referencia | 5 a 20 veces más rápido |
| Análisis de la sentencia | Una vez por sentencia | Ninguno: es un protocolo binario/textual |
| Comprobación de restricciones | Sí | Sí, todas |
ON CONFLICT |
Sí | No |
| Transformación de datos | Sí (con INSERT ... SELECT) |
No: entra tal cual |
| Si falla una fila | Falla la sentencia entera | Falla la carga entera |
| Estándar SQL | Sí | No: es de PostgreSQL |
La regla: decenas o cientos de filas →
INSERTmultifila. Miles o millones →COPY. Y si necesitas transformar mientras cargas, el patrón profesional es de dos pasos:COPYa una tabla de staging sin restricciones, y de ahíINSERT ... SELECTtransformando hacia la tabla definitiva. Ese patrón reaparecerá en 05-05 y en el módulo 11.
Equivalentes por motor: MySQL tiene LOAD DATA INFILE, SQL Server tiene BULK INSERT y la utilidad bcp, Oracle tiene SQL*Loader y las external tables, y SQLite tiene el comando .import de su consola. Ninguno es compatible con los demás.
- Ejemplo completo: alta de producto y registro de un pedido
Cerramos con lo que hace una tienda de verdad. Trabajamos sobre la base recién cargada.
11.1. Dar de alta un producto nuevo
Huerta del Turia (proveedor 1) incorpora garbanzos a la categoría Alimentación:
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock)
VALUES ('Garbanzos ecológicos 500 g', 1, 1, 2.60, 1.15, 140)
RETURNING id, nombre, precio, coste, stock, activo, fecha_alta;| id | nombre | precio | coste | stock | activo | fecha_alta |
|---|---|---|---|---|---|---|
| 21 | Garbanzos ecológicos 500 g | 2.60 | 1.15 | 140 | true | 2026-03-01 |
Tres columnas omitidas y tres columnas rellenas solas: id por la identidad, activo y fecha_alta por sus DEFAULT. Exactamente el trabajo que 05-01 dejó declarado en el DDL.
11.2. Registrar un pedido con sus líneas
Pau Llorens Vidal (cliente 6) llama por teléfono. Le atiende Óscar Peris Blasco (empleado 4). Quiere dos aceites, una miel y tres infusiones, con portes de 4,95 €.
Paso 1: la cabecera, con RETURNING.
INSERT INTO pedidos (cliente_id, empleado_id, fecha_pedido, estado, metodo_pago, gastos_envio)
VALUES (6, 4, DATE '2026-03-02', 'pendiente', 'tarjeta', 4.95)
RETURNING id, cliente_id, empleado_id, fecha_pedido, estado, gastos_envio;| id | cliente_id | empleado_id | fecha_pedido | estado | gastos_envio |
|---|---|---|---|---|---|
| 21 | 6 | 4 | 2026-03-02 | pendiente | 4.95 |
El pedido es el 21. Ese número no lo hemos elegido nosotros: nos lo ha dicho el motor.
Paso 2: las tres líneas, en una sola sentencia.
Los precios se copian del catálogo en el momento de la venta —es la desnormalización deliberada de precio_unitario (01-05)— y aquí eso significa: 12,50 €, 9,75 € y 3,25 €.
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio_unitario, descuento) VALUES
(21, 1, 2, 12.50, 0.00),
(21, 3, 1, 9.75, 0.00),
(21, 14, 3, 3.25, 0.00)
RETURNING id, pedido_id, producto_id, cantidad, precio_unitario,
cantidad * precio_unitario * (1 - descuento) AS importe;| id | pedido_id | producto_id | cantidad | precio_unitario | importe |
|---|---|---|---|---|---|
| 48 | 21 | 1 | 2 | 12.50 | 25.0000 |
| 49 | 21 | 3 | 1 | 9.75 | 9.7500 |
| 50 | 21 | 14 | 3 | 3.25 | 9.7500 |
Las líneas 48, 49 y 50, a continuación de las 47 que ya había.
Paso 3: comprobar el pedido completo.
SELECT pe.id AS pedido,
c.nombre || ' ' || c.apellidos AS cliente,
e.nombre || ' ' || e.apellidos AS comercial,
COUNT(*) AS lineas,
SUM(lp.cantidad) AS unidades,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS productos,
pe.gastos_envio AS portes,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) + pe.gastos_envio, 2) AS total
FROM pedidos AS pe
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
JOIN clientes AS c ON pe.cliente_id = c.id
JOIN empleados AS e ON pe.empleado_id = e.id
WHERE pe.id = 21
GROUP BY pe.id, c.nombre, c.apellidos, e.nombre, e.apellidos, pe.gastos_envio;| pedido | cliente | comercial | lineas | unidades | productos | portes | total |
|---|---|---|---|---|---|---|---|
| 21 | Pau Llorens Vidal | Óscar Peris Blasco | 3 | 6 | 44.50 | 4.95 | 49.45 |
44,50 € de producto más 4,95 € de portes: 49,45 €. Todo el módulo 4 aplicado a una fila que has creado tú.
Lo que falta y por qué. Un sistema real haría estas dos inserciones dentro de una misma transacción, para que sea imposible que quede una cabecera sin líneas si algo falla en medio. Y descontaría el stock de los tres productos. Lo primero lo empezarás a usar en la próxima lección y lo estudiarás a fondo en el módulo 9; lo segundo es un
UPDATE, y también es de la próxima lección.
Recuerda recargar. Si has ejecutado estos ejemplos, tu base ya no coincide con la del curso: hay 21 o más productos, 21 pedidos y 50 líneas. Vuelve a lanzar
tiendaverde.sqlantes de continuar.
Errores Comunes y Consejos
- Omitir la lista de columnas. Funciona hoy y se rompe el día que alguien toque la tabla. Lista siempre las columnas.
- Insertar el
ida mano en una identidadBY DEFAULT. La secuencia se queda atrás y el siguienteINSERTautomático falla conduplicate key ... _pkey. Arréglalo consetvalo, mejor, no lo hagas. - Usar
SELECT MAX(id)para saber qué acabas de insertar. Es una condición de carrera. UsaRETURNING. - Emparejar mal las columnas en un
INSERT ... SELECT. Se emparejan por posición. Léelas dos veces. - Insertar hijos antes que padres.
is not present in table. Padres primero, siempre. - Creer que un
INSERTmultifila inserta lo que puede. Es atómico: si falla una tupla, no entra ninguna. - Confundir
NULLconDEFAULT.VALUES (NULL)guardaNULL(o falla conNOT NULL);VALUES (DEFAULT)aplica el valor por omisión. Omitir la columna equivale a lo segundo. - Escribir los decimales con coma.
12,50no es un número en SQL: esinvalid input syntax for type numeric(01-03). - Cargar un millón de filas con
INSERT. Para eso estáCOPY. Y si necesitas transformar,COPYa staging y luegoINSERT ... SELECT. - Copiar
productos.precioenprecio_unitariodesde la tabla en lugar de fijarlo. El precio de la línea es el del momento de la venta; si lo enlazas al catálogo, mañana cambiarán tus facturas antiguas. - Consejo: mira siempre el segundo número de
INSERT 0 N. Es el único que te dice si ha hecho lo que esperabas. - Consejo: usa
RETURNINGincluso cuando no lo necesites. Ver la fila resultante con susDEFAULTaplicados es la forma más rápida de comprobar que el DDL hace lo que crees. - Consejo: para datos maestros reejecutables,
ON CONFLICT DO NOTHING. Convierte un script frágil en uno idempotente.
Ejercicios
Ejercicio 1
TiendaVerde incorpora un proveedor y dos productos suyos. Escribe las sentencias necesarias, en el orden correcto, cumpliendo estos requisitos:
- Alta del proveedor Cooperativa La Safor, de España, con email
[email protected], activo. Recupera suidconRETURNING. - Alta de dos productos suyos en una sola sentencia, ambos de la categoría Alimentación (
id1):- Almendra marcona ecológica 250 g, precio 7,90 €, coste 4,20 €, stock 60.
- Naranjas de Valencia ecológicas 5 kg, precio 11,50 €, coste 6,00 €, stock 35.
- En los dos,
activoyfecha_altadeben quedar en sus valores por omisión, sin escribirlos.
- Una consulta que muestre el proveedor nuevo con sus dos productos y el margen de cada uno.
Ejercicio 2
Predice qué ocurre con cada una de estas sentencias sobre la base recién recargada, y si falla, di qué restricción se rompe y cuál sería el mensaje. Después compruébalo.
-- a)
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio_unitario)
VALUES (1, 5, 0, 1.95);
-- b)
INSERT INTO clientes (nombre, apellidos, email, pais)
VALUES ('Lucía', 'Martínez Soler', '[email protected]', 'España');
-- c)
INSERT INTO empleados (nombre, apellidos, puesto, jefe_id, fecha_contratacion)
VALUES ('Nerea', 'Blasco Tur', 'Comercial', 2, '2026-03-01');
-- d)
INSERT INTO pedidos (cliente_id, fecha_pedido, estado, metodo_pago)
VALUES (14, '2026-03-01', 'preparando', 'tarjeta');
-- e)
INSERT INTO devoluciones (pedido_id, motivo, fecha, importe)
VALUES (21, 'Producto defectuoso', '2026-03-05', 12.00);Ejercicio 3
El equipo de análisis quiere una tabla de resumen mensual de ventas de 2025 para no recalcularla en cada informe.
- Crea la tabla
ventas_mensualescon:mes(textoAAAA-MM, clave primaria),pedidos(entero, obligatorio),lineas(entero, obligatorio),unidades(entero, obligatorio),facturacion(NUMERIC(10,2), obligatorio, no negativo) yfecha_calculo(DATE, obligatorio, por omisión hoy). - Puéblala con un
INSERT ... SELECTa partir de los pedidos de 2025. - Comprueba el resultado y verifica que la suma de la columna
facturacioncoincide con la facturación de producto de 2025.
Soluciones
Solución 1
-- 1) El proveedor primero: es el padre
INSERT INTO proveedores (nombre, pais, email, activo)
VALUES ('Cooperativa La Safor', 'España', '[email protected]', TRUE)
RETURNING id, nombre, pais, email, activo;| id | nombre | pais | activo | |
|---|---|---|---|---|
| 6 | Cooperativa La Safor | España | [email protected] | true |
-- 2) Los dos productos, en una sola sentencia
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock) VALUES
('Almendra marcona ecológica 250 g', 1, 6, 7.90, 4.20, 60),
('Naranjas de Valencia ecológicas 5 kg', 1, 6, 11.50, 6.00, 35)
RETURNING id, nombre, precio, coste, stock, activo, fecha_alta;| id | nombre | precio | coste | stock | activo | fecha_alta |
|---|---|---|---|---|---|---|
| 21 | Almendra marcona ecológica 250 g | 7.90 | 4.20 | 60 | true | 2026-03-01 |
| 22 | Naranjas de Valencia ecológicas 5 kg | 11.50 | 6.00 | 35 | true | 2026-03-01 |
-- 3) Comprobación
SELECT pr.id AS proveedor_id,
pr.nombre AS proveedor,
p.id AS producto_id,
p.nombre AS producto,
p.precio,
p.coste,
p.precio - p.coste AS margen
FROM productos AS p
JOIN proveedores AS pr ON p.proveedor_id = pr.id
WHERE pr.nombre = 'Cooperativa La Safor'
ORDER BY p.id;| proveedor_id | proveedor | producto_id | producto | precio | coste | margen |
|---|---|---|---|---|---|---|
| 6 | Cooperativa La Safor | 21 | Almendra marcona ecológica 250 g | 7.90 | 4.20 | 3.70 |
| 6 | Cooperativa La Safor | 22 | Naranjas de Valencia ecológicas 5 kg | 11.50 | 6.00 | 5.50 |
Tres decisiones que había que tomar bien: el orden (proveedor antes que productos, porque es el padre de la FK), una sola sentencia para los dos productos, y omitir activo y fecha_alta en lugar de escribirlos. Y un detalle: el id 6 del proveedor no se ha escrito en ningún sitio; se ha recuperado con RETURNING y se ha usado en el INSERT siguiente. En una aplicación real, ese valor viajaría en una variable.
Solución 2
a) Falla. CHECK de cantidad:
ERROR: new row for relation "lineas_pedido" violates check constraint "lineas_pedido_cantidad_check" DETAIL: Failing row contains (48, 1, 5, 0, 1.95, 0.00).
CHECK (cantidad > 0): una línea de cero unidades no es una venta. Fíjate en que descuento sí se ha rellenado con su DEFAULT 0.00 antes de comprobar la restricción.
b) Falla. UNIQUE del email:
ERROR: duplicate key value violates unique constraint "clientes_email_key" DETAIL: Key (email)=([email protected]) already exists.
Es la protección de la clave natural de 01-05. El id sería distinto, pero el email es único: la base impide registrar dos veces a la misma persona. Este es exactamente el escenario que resolverá el UPSERT de 05-05.
c) Funciona.
Se crea la empleada 9, con jefe_id 2 (Andrés Company Talens, que existe), salario a NULL (la columna es nulable) y ciudad a NULL. Ninguna restricción se viola: salario no es obligatorio y su CHECK (salario >= 0) da UNKNOWN con un nulo, lo que se considera satisfecho (05-01).
d) Falla. CHECK del estado:
ERROR: new row for relation "pedidos" violates check constraint "pedidos_estado_check" DETAIL: Failing row contains (21, 14, null, 2026-03-01, preparando, tarjeta, 0.00).
'preparando' no está en el dominio ('pendiente','pagado','enviado','entregado','cancelado'). Observa el DETAIL: empleado_id ha quedado a NULL (pedido web) y gastos_envio ha tomado su DEFAULT 0.00. La fila estaba casi bien.
e) Falla. FOREIGN KEY:
ERROR: insert or update on table "devoluciones" violates foreign key constraint "devoluciones_pedido_id_fkey" DETAIL: Key (pedido_id)=(21) is not present in table "pedidos".
Sobre la base recién recargada solo hay 20 pedidos. El pedido 21 es el que creamos en el apartado 11, no el que hay ahora. Es un recordatorio de por qué conviene recargar el script entre bloques de ejercicios.
Solución 3
-- 1) La tabla
CREATE TABLE ventas_mensuales (
mes CHAR(7) NOT NULL,
pedidos INTEGER NOT NULL,
lineas INTEGER NOT NULL,
unidades INTEGER NOT NULL,
facturacion NUMERIC(10,2) NOT NULL,
fecha_calculo DATE NOT NULL DEFAULT CURRENT_DATE,
CONSTRAINT pk_ventas_mensuales PRIMARY KEY (mes),
CONSTRAINT chk_ventas_facturacion CHECK (facturacion >= 0)
);-- 2) La carga
INSERT INTO ventas_mensuales (mes, pedidos, lineas, unidades, facturacion)
SELECT TO_CHAR(pe.fecha_pedido, 'YYYY-MM'),
COUNT(DISTINCT pe.id),
COUNT(*),
SUM(lp.cantidad),
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2)
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
WHERE pe.fecha_pedido >= DATE '2025-01-01'
AND pe.fecha_pedido < DATE '2026-01-01'
GROUP BY TO_CHAR(pe.fecha_pedido, 'YYYY-MM');-- 3) Comprobación
SELECT mes, pedidos, lineas, unidades, facturacion
FROM ventas_mensuales
ORDER BY mes;| mes | pedidos | lineas | unidades | facturacion |
|---|---|---|---|---|
| 2025-03 | 2 | 5 | 10 | 68.80 |
| 2025-04 | 2 | 5 | 14 | 61.28 |
| 2025-05 | 2 | 5 | 6 | 58.85 |
| 2025-06 | 2 | 5 | 14 | 95.48 |
| 2025-07 | 1 | 3 | 9 | 44.60 |
| 2025-08 | 1 | 2 | 3 | 48.27 |
| 2025-09 | 1 | 2 | 13 | 32.76 |
| 2025-10 | 2 | 5 | 13 | 97.20 |
| 2025-11 | 1 | 3 | 6 | 31.70 |
| 2025-12 | 2 | 5 | 7 | 64.58 |
SELECT SUM(facturacion) AS total_2025,
SUM(pedidos) AS pedidos_2025,
SUM(lineas) AS lineas_2025,
SUM(unidades) AS unidades_2025
FROM ventas_mensuales;| total_2025 | pedidos_2025 | lineas_2025 | unidades_2025 |
|---|---|---|---|
| 603.52 | 16 | 40 | 95 |
Diez meses, 16 pedidos y 603,52 € de facturación de producto en 2025. Los otros 4 pedidos y 124,43 € corresponden a 2026: 603,52 + 124,43 = 727,95 €, la cifra canónica del módulo 4. Cuadra.
Dos observaciones sobre el diseño de esta tabla. La primera: mes como CHAR(7) en formato AAAA-MM es clave primaria natural y ordena alfabéticamente igual que cronológicamente, que es justo la razón por la que ese formato se usa tanto. La segunda, más importante: esta tabla es un agregado precalculado, es decir, desnormalización deliberada (01-05, sección 8). Su gran problema es la sincronización: si mañana entra un pedido con fecha de 2025, la tabla queda obsoleta y nadie se entera. Las soluciones —vistas materializadas y triggers— son del módulo 10.
Conclusión
INSERT es la puerta de entrada de los datos, y ya la dominas:
- La sintaxis
INSERT INTO tabla (columnas) VALUES (...), y la regla que no admite excepciones: lista siempre las columnas. La forma sin lista depende del orden físico de la tabla y se rompe en cuanto alguien la toca. - La salida
INSERT 0 N: el0es una reliquia, laNes lo que importa. - Varias filas en una sentencia, que es lo que hace el script del curso y lo que debes hacer tú: entre 10 y 50 veces más rápido, porque el coste dominante es fijo por sentencia.
DEFAULTcomo valor yDEFAULT VALUESpara una fila entera por omisión; y la equivalencia entre ponerDEFAULTy simplemente omitir la columna.- La columna de identidad: omitirla es lo normal, forzarla es legal con
BY DEFAULTy desajusta la secuencia, y el arreglo essetval(pg_get_serial_sequence(...), MAX(id))— que es exactamente lo que hacen las nueve líneas finales detiendaverde.sql. Las secuencias no se deshacen conROLLBACK: losidtienen huecos y no cuentan filas. RETURNING, la extensión que devuelve la fila insertada con sus valores generados, y que elimina la condición de carrera deSELECT MAX(id). Con sus equivalentes por motor:LAST_INSERT_ID(),SCOPE_IDENTITY(),OUTPUT.INSERT ... SELECTpara poblar históricos y duplicar catálogos transformando mientras copia; las columnas se emparejan por posición.- Los cuatro errores de un
INSERT, con su mensaje literal y su tabla de diagnóstico; y el hecho de que unINSERTmultifila es atómico. - El orden de inserción: padres antes que hijos, siempre.
COPYy\copypara carga masiva, de 5 a 20 veces más rápidos, y el patrón profesional de staging +INSERT ... SELECT.- Y el caso completo: alta de producto y registro del pedido 21 con sus tres líneas y sus 49,45 € de total, encadenando dos
INSERTconRETURNING.
Sabes crear tablas y llenarlas. Lo que aún no sabes es corregir. En la siguiente lección, Instrucción UPDATE, cambia el tono: hasta ahora, un error tuyo creaba una fila de más que podías borrar; a partir de ahora, un error tuyo puede sobrescribir veinte filas correctas y dejarte sin forma de saber qué había antes. Verás el protocolo profesional para que eso no ocurra —escribir primero el SELECT, contar las filas, y solo entonces convertirlo en UPDATE—, el hábito de trabajar con BEGIN y ROLLBACK como red de seguridad, y todo lo que UPDATE sabe hacer: varias columnas a la vez, cálculos sobre el valor anterior, actualizaciones a partir de otra tabla con FROM, y RETURNING para ver exactamente qué has cambiado.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
