Crear y modificar datos en SQL: INSERT, UPDATE y DELETE

Lección 6 de 8. Hasta aquí, solo leer. Ahora toca escribir: crear tablas con restricciones que protejan los datos, y añadir, cambiar y borrar filas con INSERT, UPDATE y DELETE. Y la red de seguridad: las transacciones, para que una operación a medias no deje la base rota.

CREATE TABLE: definir una tabla

Hasta ahora solo hemos leído. Para guardar algo nuevo primero hace falta una tabla donde ponerlo. Vamos a añadir a la tienda una tabla de reseñas: qué cliente puntuó qué producto, con cuántas estrellas y qué dijo. CREATE TABLE da el nombre de la tabla y, para cada columna, su nombre, su tipo y sus restricciones, las reglas que tienen que cumplir los datos:

CREATE TABLE resenas (
  id          INTEGER PRIMARY KEY,
  producto_id INTEGER NOT NULL REFERENCES productos(id),
  cliente_id  INTEGER NOT NULL REFERENCES clientes(id),
  puntos      INTEGER NOT NULL CHECK (puntos BETWEEN 1 AND 5),
  texto       TEXT,
  fecha       DATE NOT NULL DEFAULT CURRENT_DATE
);

SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name;

Ábrela en el editor y cámbiala →

resultado+------------+
|    name    |
+------------+
| categorias |
| clientes   |
| detalle    |
| empleados  |
| pedidos    |
| productos  |
| resenas    |
+------------+

La consulta del final pregunta a sqlite_schema, la tabla interna donde SQLite guarda la definición de todas las demás, y la nueva ya aparece. Cada restricción de resenas tiene su propósito:

RestricciónQué impide
PRIMARY KEYdos filas con el mismo id; en SQLite, un INTEGER PRIMARY KEY se numera solo si no lo das
NOT NULLque falte el dato
CHECK (puntos BETWEEN 1 AND 5)valores fuera de la regla que tú pongas
REFERENCES productos(id)apuntar a un producto que no existe (clave foránea)
DEFAULT CURRENT_DATEnada: da un valor si no lo escribes (la fecha de hoy)
UNIQUEvalores repetidos en esa columna, como el email de clientes

INSERT: añadir filas

INSERT INTO añade filas. Se nombran las columnas y, en VALUES, una lista de valores por fila; se pueden insertar varias de una vez. Las columnas que no se nombran toman su valor por omisión: aquí el id, que se numera solo, y la fecha:

INSERT INTO resenas (producto_id, cliente_id, puntos, texto) VALUES
  (1, 1, 5, 'Rápido y ligero'),
  (3, 2, 4, 'Muy buena imagen, algo caro'),
  (6, 3, 3, NULL);

SELECT id, producto_id, cliente_id, puntos, texto
FROM resenas;

Ábrela en el editor y cámbiala →

resultado+----+-------------+------------+--------+-----------------------------+
| id | producto_id | cliente_id | puntos |            texto            |
+----+-------------+------------+--------+-----------------------------+
| 1  | 1           | 1          | 5      | Rápido y ligero             |
| 2  | 3           | 2          | 4      | Muy buena imagen, algo caro |
| 3  | 6           | 3          | 3      | NULL                        |
+----+-------------+------------+--------+-----------------------------+

Ahora, las restricciones en acción. Cada una de estas inserciones incumple una, y SQLite las rechaza con un mensaje que dice cuál:

INSERT INTO resenas (producto_id, cliente_id, puntos) VALUES (1, 1, 6);
INSERT INTO resenas (producto_id, cliente_id, puntos) VALUES (1, NULL, 4);
INSERT INTO clientes (nombre, pais, email, alta)
  VALUES ('Ana Torres', 'México', 'ana.torres@ejemplo.com', '2026-10-20');

Ábrela en el editor y cámbiala →

resultadoError: CHECK constraint failed: puntos BETWEEN 1 AND 5

Error: NOT NULL constraint failed: resenas.cliente_id

Error: UNIQUE constraint failed: clientes.email

La primera pone 6 estrellas, la segunda deja el cliente vacío y la tercera repite el correo de Ana, que en clientes es UNIQUE. Que la base rechace los datos malos es una ventaja: el error salta al guardar, no meses después en un informe.

La clave foránea que SQLite no comprueba

¿Y una reseña de un producto que no existe, el 99? Debería fallar por el REFERENCES, pero:

INSERT INTO resenas (producto_id, cliente_id, puntos) VALUES (99, 1, 4);
SELECT id, producto_id, puntos FROM resenas;

Ábrela en el editor y cámbiala →

resultado+----+-------------+--------+
| id | producto_id | puntos |
+----+-------------+--------+
| 1  | 99          | 4      |
+----+-------------+--------+

SQLite la acepta. Por compatibilidad con programas antiguos, SQLite no comprueba las claves foráneas salvo que se active en cada conexión con PRAGMA foreign_keys = ON. Con la comprobación activada, la misma inserción falla:

PRAGMA foreign_keys = ON;
INSERT INTO resenas (producto_id, cliente_id, puntos) VALUES (99, 1, 4);

Ábrela en el editor y cámbiala →

resultadoError: FOREIGN KEY constraint failed

Si usas SQLite en un programa, activa ese PRAGMA al abrir la conexión.

En PostgreSQL y MySQL

PostgreSQL comprueba las claves foráneas siempre, también escritas así, junto a la columna:

PostgreSQL 17ERROR:  insert or update on table "resenas" violates foreign key constraint "resenas_producto_id_fkey"
DETAIL:  Key (producto_id)=(99) is not present in table "productos".

MySQL tiene una trampa más escondida que la de SQLite: el REFERENCES escrito junto a la columna lo acepta sin error… y lo ignora. La tabla se crea sin ninguna clave foránea, y la reseña del producto 99 entra como si nada (con el id escrito a mano, porque la numeración automática de INTEGER PRIMARY KEY es cosa de SQLite):

MySQL 8.4+----+-------------+--------+
| id | producto_id | puntos |
+----+-------------+--------+
|  1 |          99 |      4 |
+----+-------------+--------+
+-----------------+
| claves_foraneas |
+-----------------+
|               0 |
+-----------------+

MySQL solo crea la clave foránea si se declara aparte, como una línea más de la tabla: FOREIGN KEY (producto_id) REFERENCES productos(id). Escrita así, la misma inserción falla:

MySQL 8.4ERROR 1452 (23000) at line 10: Cannot add or update a child row: a foreign key constraint fails (`t`.`resenas`, CONSTRAINT `resenas_ibfk_1` FOREIGN KEY (`producto_id`) REFERENCES `productos` (`id`))

La forma separada, FOREIGN KEY (...) REFERENCES ..., funciona en los tres motores, así que es la más segura de escribir.

Los tipos de SQLite son flexibles

En la lección 2 vimos que 1849.00 se guardaba como el entero 1849. Es parte de algo más general: en SQLite, el tipo de una columna es una preferencia, no una obligación. Si un valor no se puede convertir al tipo de la columna, se guarda tal cual. La función typeof dice qué se guardó:

CREATE TABLE medidas (n INTEGER);
INSERT INTO medidas VALUES (42), ('17'), ('cinco'), (3.5);
SELECT n, typeof(n) FROM medidas;

Ábrela en el editor y cámbiala →

resultado+-------+-----------+
|   n   | typeof(n) |
+-------+-----------+
| 42    | integer   |
| 17    | integer   |
| cinco | text      |
| 3.5   | real      |
+-------+-----------+

El texto '17' se convirtió en el número 17, pero 'cinco' no se puede convertir y se guardó como texto en una columna de enteros, sin ningún error. Desde la versión 3.37, SQLite tiene tablas estrictas, que se crean añadiendo STRICT al final y sí rechazan los tipos que no encajan:

CREATE TABLE medidas (n INTEGER) STRICT;
INSERT INTO medidas VALUES ('cinco');

Ábrela en el editor y cámbiala →

resultadoError: cannot store TEXT value in INTEGER column medidas.n

En PostgreSQL y MySQL

En los otros dos motores, los tipos se comprueban siempre. PostgreSQL:

CREATE TABLE medidas (n INTEGER);
INSERT INTO medidas VALUES ('cinco');
PostgreSQL 17CREATE TABLE
ERROR:  invalid input syntax for type integer: "cinco"
LINE 1: INSERT INTO medidas VALUES ('cinco');
                                    ^

MySQL, con su modo estricto, activado de fábrica desde la versión 5.7:

MySQL 8.4ERROR 1366 (HY000) at line 2: Incorrect integer value: 'cinco' for column 'n' at row 1

UPDATE: cambiar filas

UPDATE cambia valores de filas que ya existen: SET dice qué columnas y con qué valor, y WHERE, en qué filas. Subamos un 5 % los periféricos (categoría 3):

UPDATE productos
SET precio = ROUND(precio * 1.05, 2)
WHERE categoria_id = 3; -- @cambios

SELECT nombre, precio FROM productos WHERE categoria_id = 3;

Ábrela en el editor y cámbiala →

resultado(4 filas afectadas)

+---------------------------+--------+
|          nombre           | precio |
+---------------------------+--------+
| Teclado mecánico          | 93.98  |
| Ratón inalámbrico         | 26.24  |
| Auriculares con micrófono | 61.95  |
| Webcam 4K                 | 145.95 |
+---------------------------+--------+

La regla más importante de esta lección: un UPDATE o un DELETE sin WHERE afecta a todas las filas de la tabla. Sin preguntar. Antes de ejecutar uno, ejecuta un SELECT con el mismo WHERE y comprueba qué filas salen. Y si el cambio es delicado, hazlo dentro de una transacción.

DELETE: borrar filas

DELETE FROM borra las filas que cumplen el WHERE. Por ejemplo, el pedido cancelado, que no tiene líneas:

DELETE FROM pedidos WHERE estado = 'cancelado'; -- @cambios
SELECT COUNT(*) AS pedidos FROM pedidos;

Ábrela en el editor y cámbiala →

resultado(1 fila afectada)

+---------+
| pedidos |
+---------+
| 21      |
+---------+

Con las claves foráneas activadas, la base no deja borrar algo de lo que dependen otras filas. El portátil Aero 14 aparece en tres líneas de pedido:

DELETE FROM productos WHERE id = 1;

Ábrela en el editor y cámbiala →

resultadoError: FOREIGN KEY constraint failed

Es lo que quieres: borrar el producto dejaría esas líneas apuntando a nada. Lo habitual en una tienda real es no borrar nunca un producto vendido, sino marcarlo como descatalogado con una columna más.

UPSERT: insertar o actualizar

Un caso muy frecuente: «si la fila no existe, créala; si existe, actualízala». Hacerlo con un SELECT y después un INSERT o un UPDATE tiene un problema: entre la comprobación y la escritura, otro programa puede haber hecho lo mismo. El UPSERT lo hace en una sola sentencia, con ON CONFLICT:

INSERT INTO categorias (id, nombre) VALUES (5, 'Redes y Wi-Fi'), (6, 'Software')
ON CONFLICT (id) DO UPDATE SET nombre = excluded.nombre;

SELECT * FROM categorias;

Ábrela en el editor y cámbiala →

resultado+----+----------------+
| id |     nombre     |
+----+----------------+
| 1  | Portátiles     |
| 2  | Monitores      |
| 3  | Periféricos    |
| 4  | Almacenamiento |
| 5  | Redes y Wi-Fi  |
| 6  | Software       |
+----+----------------+

La categoría 5 ya existía y se ha renombrado; la 6 no existía y se ha creado. excluded es la fila que se intentaba insertar.

En PostgreSQL y MySQL

PostgreSQL usa la misma sintaxis, ON CONFLICT ... DO UPDATE, y da el mismo resultado. MySQL no la entiende:

MySQL 8.4ERROR 1064 (42000) at line 1: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'CONFLICT (id) DO UPDATE SET nombre = excluded.nombre' at line 2

MySQL tiene su propia forma, ON DUPLICATE KEY UPDATE. Las dos versiones están lado a lado en el ejemplo de insertar o actualizar.

Transacciones: todo o nada

Registrar una venta son varios cambios: crear el pedido, añadir sus líneas y descontar el stock. Si el programa falla a la mitad, la base no puede quedarse con un pedido sin líneas o con el stock descontado de una venta que no existe. Una transacción agrupa varias sentencias para que se apliquen todas o ninguna: empieza con BEGIN y termina con COMMIT, que las confirma, o con ROLLBACK, que las deshace.

Una transacción empieza con BEGIN, hace varios cambios provisionales y termina con COMMIT, que los guarda todos, o con ROLLBACK, que los deshace todosUna transacción: todo o nadaBEGINempiezaINSERT pedidoprovisionalINSERT detalleprovisionalUPDATE stockprovisionalCOMMITtodo se guardaROLLBACKnada ha pasadoMientras no hay COMMIT, los cambios son provisionales. Si algo falla a medias, ROLLBACK deja la baseexactamente como estaba antes del BEGIN: nunca queda un pedido sin sus líneas ni un stock descontado de más.

Las tres operaciones de una venta van juntas: o se guardan las tres o no se guarda ninguna. Esquema: decodigo.com.
BEGIN;
INSERT INTO pedidos (id, cliente_id, fecha, estado) VALUES (23, 12, '2026-10-20', 'pendiente');
INSERT INTO detalle (pedido_id, producto_id, cantidad, precio_unitario) VALUES (23, 9, 2, 119.00);
UPDATE productos SET stock = stock - 2 WHERE id = 9;
COMMIT;

SELECT p.id, c.nombre, pr.nombre AS producto, d.cantidad, pr.stock
FROM pedidos AS p
JOIN clientes  AS c  ON c.id = p.cliente_id
JOIN detalle   AS d  ON d.pedido_id = p.id
JOIN productos AS pr ON pr.id = d.producto_id
WHERE p.id = 23;

Ábrela en el editor y cámbiala →

resultado+----+--------------+--------------------+----------+-------+
| id |    nombre    |      producto      | cantidad | stock |
+----+--------------+--------------------+----------+-------+
| 23 | Tomás Ortega | Disco externo 4 TB | 2        | 13    |
+----+--------------+--------------------+----------+-------+

Tomás Ortega por fin ha comprado algo, y el stock del disco externo ha pasado de 15 a 13. Ahora lo contrario: un UPDATE sin WHERE que pone todos los precios a 1, y un ROLLBACK a tiempo:

BEGIN;
UPDATE productos SET precio = 1; -- @cambios
SELECT COUNT(*) AS a_un_dolar FROM productos WHERE precio = 1;
ROLLBACK;

SELECT COUNT(*) AS a_un_dolar FROM productos WHERE precio = 1;

Ábrela en el editor y cámbiala →

dentro de la transacción y después del ROLLBACK(15 filas afectadas)

+------------+
| a_un_dolar |
+------------+
| 15         |
+------------+

+------------+
| a_un_dolar |
+------------+
| 0          |
+------------+

Dentro de la transacción, los 15 productos cuestan 1 USD; después del ROLLBACK, ninguno. Fuera de una transacción, en cambio, cada sentencia se confirma sola en cuanto termina, y no hay vuelta atrás.

Cambiar y borrar tablas

ALTER TABLE cambia la definición de una tabla que ya existe; lo más habitual es añadir una columna, que se rellena con el valor por omisión en las filas que ya había. DROP TABLE borra la tabla entera, con sus datos:

ALTER TABLE productos ADD COLUMN descatalogado INTEGER NOT NULL DEFAULT 0;
SELECT id, nombre, descatalogado FROM productos WHERE id <= 3;

DROP TABLE IF EXISTS resenas;

Ábrela en el editor y cámbiala →

resultado+----+------------------+---------------+
| id |      nombre      | descatalogado |
+----+------------------+---------------+
| 1  | Portátil Aero 14 | 0             |
| 2  | Portátil Nova 16 | 0             |
| 3  | Monitor 27" 4K   | 0             |
+----+------------------+---------------+

SQLite puede añadir, renombrar y borrar columnas, pero no cambiar el tipo de una: para eso hay que crear una tabla nueva, copiar los datos y cambiarles el nombre. PostgreSQL y MySQL sí pueden hacerlo con ALTER TABLE ... ALTER COLUMN o MODIFY COLUMN.

Resumen

ParaEscribeRecuerda
Crear una tablaCREATE TABLE t (id INTEGER PRIMARY KEY, ...)pon restricciones: que la base rechace lo malo
Añadir filasINSERT INTO t (a, b) VALUES (1, 2), (3, 4)las columnas que no nombras toman su DEFAULT
Cambiar filasUPDATE t SET a = 1 WHERE ...sin WHERE cambia todas
Borrar filasDELETE FROM t WHERE ...sin WHERE borra todas
Insertar o actualizarINSERT ... ON CONFLICT (id) DO UPDATE SET ...en MySQL, ON DUPLICATE KEY UPDATE
Todo o nadaBEGIN; ... COMMIT; o ROLLBACK;fuera de una transacción no hay vuelta atrás
Claves foráneas en SQLitePRAGMA foreign_keys = ON;en cada conexión: si no, no se comprueban

Retos

Reto 1. Crea una tabla proveedores con un id, un nombre obligatorio y único y un país, e inserta dos proveedores. ¿Qué pasa si insertas un tercero con el nombre de uno que ya existe?

Ver la solución
CREATE TABLE proveedores (
  id     INTEGER PRIMARY KEY,
  nombre TEXT NOT NULL UNIQUE,
  pais   TEXT
);
INSERT INTO proveedores (nombre, pais) VALUES ('Distribuidora Norte', 'México'), ('Iberia Tech', 'España');
SELECT * FROM proveedores;
INSERT INTO proveedores (nombre, pais) VALUES ('Iberia Tech', 'Portugal');

Ábrela en el editor y cámbiala →

resultado+----+---------------------+--------+
| id |       nombre        |  pais  |
+----+---------------------+--------+
| 1  | Distribuidora Norte | México |
| 2  | Iberia Tech         | España |
+----+---------------------+--------+

Error: UNIQUE constraint failed: proveedores.nombre

La tabla se crea y se llena, y la tercera inserción falla por el UNIQUE.

Reto 2. Marca como descatalogados los productos con stock 0, dentro de una transacción, y comprueba el cambio antes de confirmarlo.

Ver la solución
ALTER TABLE productos ADD COLUMN descatalogado INTEGER NOT NULL DEFAULT 0;

BEGIN;
UPDATE productos SET descatalogado = 1 WHERE stock = 0; -- @cambios
SELECT id, nombre, stock, descatalogado FROM productos WHERE descatalogado = 1;
COMMIT;

Ábrela en el editor y cámbiala →

resultado(1 fila afectada)

+----+---------------------------+-------+---------------+
| id |          nombre           | stock | descatalogado |
+----+---------------------------+-------+---------------+
| 7  | Auriculares con micrófono | 0     | 1             |
+----+---------------------------+-------+---------------+

Reto 3. ¿Qué hace esta sentencia y por qué es peligrosa? DELETE FROM detalle; Compruébalo dentro de una transacción y deshazlo.

Ver la solución
BEGIN;
DELETE FROM detalle; -- @cambios
SELECT COUNT(*) AS lineas FROM detalle;
ROLLBACK;
SELECT COUNT(*) AS lineas FROM detalle;

Ábrela en el editor y cámbiala →

resultado(41 filas afectadas)

+--------+
| lineas |
+--------+
| 0      |
+--------+

+--------+
| lineas |
+--------+
| 41     |
+--------+

Sin WHERE, borra las 41 líneas de todos los pedidos. Dentro de la transacción se ve el destrozo, y el ROLLBACK lo deshace.

Todas las consultas de esta lección se ejecutaron en SQLite 3.46.1 sobre la base de ejemplo «tienda» antes de publicarla, y los resultados están copiados de esa ejecución; las diferencias con PostgreSQL 17 y MySQL 8.4 se comprobaron en esos motores. ¿Quieres probar algo? Usa el editor de SQL, que ya trae la base cargada.