Manual teórico de SQL en MySQL / MariaDB

Explicaciones, sintaxis y ejemplos ejecutados de verdad: cada resultado que ves aquí lo produjo el servidor de base de datos al ejecutar el código mostrado. Cada tema avanza por niveles: Básico Intermedio Avanzado

0. Descargas e instalación

Para practicar todo lo del manual necesitas dos programas gratuitos: XAMPP, que trae el servidor de base de datos (MariaDB, compatible con MySQL), el servidor web Apache, PHP y phpMyAdmin; y MySQL Workbench 8.0, para diagramar las bases de datos (modelo entidad-relación) y trabajar con ellas de forma visual.

Sección de apoyo, sin retos evaluados. Las versiones y enlaces son los oficiales (Apache Friends y Oracle/MySQL). Las salidas de consola son reales, tomadas de un XAMPP 8.2.12 con MariaDB 10.4 en Windows.
Nivel básico
Descargar los programas correctos e instalarlos en tu sistema operativo.

0.1. Qué descargar y dónde

XAMPP 8.2

Apache + MariaDB + PHP + phpMyAdmin en un solo instalador. Versión usada en este manual: 8.2.12 (Windows y Linux) y 8.2.4 (macOS).

Página oficial de descargas
Windows (.exe, 149 MB) Linux (.run, 151 MB) macOS (.dmg, 150 MB)
MySQL Workbench 8.0

Para diagramar se usa la versión 8.0 (la última es la 8.0.47). Oracle cambió la numeración y la página principal ya ofrece versiones nuevas (26.x), por eso la 8.0 se descarga desde el archivo de versiones: en Product Version elige 8.0.47 y en Operating System tu sistema.

Workbench 8.0 (archivo oficial) Visual C++ Redistributable (requisito en Windows)

Descarga solo MySQL Workbench, no el "MySQL Installer" completo: ese instala además un servidor MySQL que ocupa el mismo puerto que XAMPP (0.10).

0.2. Instalar XAMPP

  1. Ejecuta xampp-windows-x64-8.2.12-0-VS16-installer.exe. Si aparece un aviso del antivirus o del Control de cuentas de usuario (UAC), acepta y continúa.
  2. En los componentes deja marcados al menos Apache, MySQL, PHP y phpMyAdmin.
  3. Carpeta de instalación: deja C:\xampp. No la instales en C:\Program Files: Windows protege esa carpeta y XAMPP no puede escribir en ella.
  4. Al terminar abre el XAMPP Control Panel y pulsa Start en Apache y en MySQL. Ambos deben ponerse en verde con su número de puerto (80 y 3306).
  5. Comprueba en el navegador: http://localhost/ (página de XAMPP) y http://localhost/phpmyadmin/.
  6. Si Windows pregunta por el Firewall, permite el acceso en redes privadas.

Tus proyectos web van en C:\xampp\htdocs; la consola de MySQL está en C:\xampp\mysql\bin (sección 1.2).

  1. Dale permiso de ejecución al instalador y ejecútalo como administrador:
cd ~/Descargas chmod +x xampp-linux-x64-8.2.12-0-installer.run sudo ./xampp-linux-x64-8.2.12-0-installer.run
  1. Se instala en /opt/lampp. Para iniciar, detener o ver el estado de los servicios:
sudo /opt/lampp/lampp start # inicia Apache y MySQL sudo /opt/lampp/lampp stop # los detiene sudo /opt/lampp/lampp status # muestra qué está corriendo sudo /opt/lampp/manager-linux-x64.run # panel gráfico (igual al de Windows)
  • Si al iniciar aparece netstat: command not found, instala las herramientas de red: sudo apt install net-tools (Ubuntu/Debian).
  • Si tu sistema ya tiene Apache o MySQL instalados, ocupan los mismos puertos: detenlos con sudo systemctl stop apache2 y sudo systemctl stop mysql (0.6).
  • Proyectos web: /opt/lampp/htdocs. Consola de MySQL: /opt/lampp/bin/mysql -u root -p.
  1. Abre xampp-osx-8.2.4-0-installer.dmg y ejecuta el instalador. Funciona en Mac con procesador Intel y Apple (M1, M2…).
  2. Si macOS dice que la aplicación "no se puede abrir porque es de un desarrollador no identificado", haz clic derecho sobre ella → Abrir → Abrir (o ve a Configuración del Sistema → Privacidad y seguridad y pulsa Abrir igualmente).
  3. Se instala en /Applications/XAMPP. Abre la aplicación XAMPP (manager-osx) → pestaña Manage Servers → inicia Apache Web Server y MySQL Database.
  4. Comprueba en el navegador http://localhost/ y http://localhost/phpmyadmin/.

Proyectos web: /Applications/XAMPP/htdocs. Consola de MySQL (en la Terminal): /Applications/XAMPP/bin/mysql -u root -p.

0.3. Instalar MySQL Workbench 8.0

En el archivo de Workbench elige Product Version: 8.0.47 y luego tu sistema:

  1. Instala primero el Visual C++ Redistributable (enlace en 0.1). Sin él, Workbench no inicia o el instalador lo pide.
  2. En Operating System elige Microsoft Windows y descarga Windows (x86, 64-bit), MSI Installer (mysql-workbench-community-8.0.47-winx64.msi).
  3. Ejecuta el .msi → Next → tipo de instalación Complete → Install.
  1. En Operating System elige Ubuntu Linux y en OS Version la versión de tu Ubuntu (por ejemplo 24.04 o 22.04). Descarga el paquete .deb.
  2. Instálalo con apt, que descarga también las dependencias:
cd ~/Descargas sudo apt install ./mysql-workbench-community_8.0.47*.deb mysql-workbench & # o búscalo en el menú de aplicaciones

Para Red Hat / Oracle Linux elige ese sistema y descarga el paquete .rpm (sudo dnf install ./mysql-workbench-community-8.0.47*.rpm).

  1. En Operating System elige macOS y descarga el .dmg de tu procesador: ARM, 64-bit para Mac con chip Apple (M1, M2…) o x86, 64-bit para Mac con Intel. (Para saberlo: menú Apple → Acerca de esta Mac.)
  2. Abre el .dmg y arrastra MySQLWorkbench a la carpeta Aplicaciones.
  3. La primera vez, si macOS lo bloquea: clic derecho → Abrir.
Nivel intermedio
Conectar Workbench al servidor de XAMPP y diseñar una base de datos con diagramas.

0.4. Conectar Workbench con XAMPP

  1. Inicia MySQL en el panel de XAMPP.
  2. En Workbench, junto a MySQL Connections, pulsa + y completa: Connection Name XAMPP local, Hostname 127.0.0.1, Port 3306, Username root.
  3. Pulsa Test Connection. En XAMPP recién instalado root no tiene contraseña: deja el campo vacío.
Workbench está hecho para MySQL y XAMPP trae MariaDB. Al conectar aparece un aviso de "Incompatible/nonstandard server version". Es normal: pulsa Continue Anyway. Consultas, tablas y diagramas funcionan; solo algunas pantallas de administración del servidor muestran datos incompletos.

0.5. Diagramar: modelo EER, Forward y Reverse Engineer

Crear un diagrama nuevo

File → New Model → doble clic en Add Diagram. Con el botón de tabla agregas tablas y columnas (marca PK, NN, AI); con los botones de relación (1:1, 1:N, N:M) unes las tablas y Workbench crea las llaves foráneas.

Forward Engineer: del diagrama a la base

Database → Forward Engineer → elige la conexión XAMPP local → Workbench genera el script CREATE TABLE y lo ejecuta en el servidor.

Reverse Engineer: de la base al diagrama

Database → Reverse Engineer → conexión → elige la base → Workbench dibuja el diagrama de las tablas que ya existen, con sus relaciones.

El diagrama se guarda como archivo .mwb. Para entregarlo como imagen: File → Export → Export as PNG (o PDF).

El script de Workbench 8.0 falla en MariaDB: la palabra VISIBLE

Workbench 8.0 genera el SQL pensando en MySQL 8. En los índices escribe la palabra VISIBLE, que existe solo en MySQL 8, y XAMPP trae MariaDB, que no la reconoce. Así se ve el script que genera el Forward Engineer (o la exportación del modelo):

CREATE TABLE IF NOT EXISTS `tienda`.`pedidos` ( `id` INT NOT NULL AUTO_INCREMENT, `fecha` DATE NOT NULL, `clientes_id` INT NOT NULL, PRIMARY KEY (`id`), INDEX `fk_pedidos_clientes_idx` (`clientes_id` ASC) VISIBLE, <-- esta palabra CONSTRAINT `fk_pedidos_clientes` FOREIGN KEY (`clientes_id`) REFERENCES `tienda`.`clientes` (`id`)) ENGINE = InnoDB;
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ' CONSTRAINT `fk_pedidos_clientes` FOREIGN KEY (`clientes_id`) REFER...' at line 6

Ojo: el error no menciona VISIBLE; señala la línea siguiente (CONSTRAINT), por eso parece que el problema es la llave foránea. Al quitar VISIBLE, el mismo script crea las tablas sin problema. A veces aparece también el cotejamiento de MySQL 8, que MariaDB tampoco tiene:

ERROR 1273 (HY000): Unknown collation: 'utf8mb4_0900_ai_ci'
Solución recomendada: bajar la versión destino del modelo

Con el modelo abierto: Model → Model Options → pestaña MySQL → desmarca Use defaults from global settings → Target MySQL Version = 5.7 → OK. Vuelve a hacer el Forward Engineer o la exportación: ya no aparece VISIBLE.

Sirve cualquier versión anterior a 8.0, pero usa 5.7: es la versión real que precede a la 8.0 (MySQL nunca publicó una 6.0 estable).

Si Workbench se cierra al cambiar la versión

Cambiar la versión en la configuración general (Edit → Preferences → Modeling → MySQL → Default Target MySQL Version) con un modelo abierto a veces hace que Workbench 8.0 se cierre solo. Para evitarlo:

  • Cámbiala por modelo (solución 1) en lugar de en Preferences, y guarda el modelo (Ctrl+S) antes de hacerlo.
  • O cierra todos los modelos, cámbiala en Preferences sin nada abierto, pulsa OK y reinicia Workbench.
Alternativa sin cambiar la versión: corregir el script

En el paso Review the SQL Script del Forward Engineer pulsa Save to Text File (o abre el .sql exportado) con el Bloc de notas → Edición → Reemplazar: VISIBLE (con el espacio de adelante) por nada, y utf8mb4_0900_ai_ci por utf8mb4_general_ci → Reemplazar todo. Luego ejecuta el script en phpMyAdmin o en la consola (SOURCE C:/ruta/script.sql).

Si exportas con Server → Data Export y falla con "Unknown table 'COLUMN_STATISTICS' in information_schema", Workbench está usando su propio mysqldump de MySQL 8. En Edit → Preferences → Administration → Path to mysqldump Tool indica el de XAMPP: C:\xampp\mysql\bin\mysqldump.exe. Para respaldos, la forma más segura sigue siendo mysqldump desde la consola (módulo 11).

Nivel avanzado
Resolver los problemas típicos de XAMPP: puertos ocupados, cambiar de puerto y reparar MySQL cuando no inicia.

0.6. Qué son los puertos y quién los ocupa

Un puerto es un número que identifica a un programa dentro del equipo: así el navegador sabe que debe hablar con Apache y Workbench con MySQL. Solo un programa puede escuchar en cada puerto. Si otro programa ya lo usa, XAMPP no puede iniciar ese servicio.

ServicioPuertoQuién suele ocuparlo
Apache (http)80IIS / World Wide Web Publishing Service de Windows, otro Apache, programas como VMware o Skype (versiones antiguas). En Linux, el Apache del sistema (apache2).
Apache (https)443Los mismos que el 80, y algunos programas de VPN o de máquinas virtuales.
MySQL / MariaDB3306Otro servidor MySQL instalado (por ejemplo el servicio MySQL80 que agrega el "MySQL Installer"), otra copia de XAMPP o un MySQL de Docker.

Cuando el puerto está ocupado, el panel de XAMPP muestra en rojo un aviso como "Problem detected! Port 80 in use by …" o, para MySQL, "Error: MySQL shutdown unexpectedly". Para saber qué programa ocupa un puerto (en CMD):

C:\> netstat -ano | findstr :3306 TCP 0.0.0.0:3306 0.0.0.0:0 LISTENING 5964 TCP [::]:3306 [::]:0 LISTENING 5964 C:\> tasklist /FI "PID eq 5964" Nombre de imagen PID Nombre de sesión Núm. de ses Uso de memor ========================= ======== ================ =========== ============ mysqld.exe 5964 Services 0 18.116 KB
  • La última columna de netstat es el PID (número del proceso); tasklist dice qué programa es. Aquí es mysqld.exe: si es el de XAMPP, MySQL ya está corriendo; si es de otra instalación (por ejemplo C:\Program Files\MySQL\…), es el que bloquea.
  • El botón Netstat del panel de XAMPP muestra la misma información en una ventana.
  • Si el PID es 4 (System) en el puerto 80, casi siempre es IIS: detén el servicio World Wide Web Publishing Service en services.msc y cámbialo a inicio Manual.
  • En Linux: sudo ss -ltnp | grep -E ':80|:3306'. En macOS: sudo lsof -i :3306.

Dos soluciones: detener el programa que ocupa el puerto (en Windows: services.msc → servicio → Detener y tipo de inicio Manual), o cambiar el puerto de XAMPP (0.7 y 0.8) si necesitas los dos programas.

0.7. Cambiar el puerto de Apache

  1. Detén Apache. En el panel: Apache → Config → Apache (httpd.conf). Cambia estas dos líneas (por ejemplo, a 8080):
Listen 80 → Listen 8080 ServerName localhost:80 → ServerName localhost:8080
  1. Si el ocupado es el 443: Config → Apache (httpd-ssl.conf) → Listen 443 → Listen 4433 y ServerName www.example.com:443 → :4433.
  2. Avísale al panel el puerto nuevo: botón Config (arriba a la derecha) → Service and Port Settings → pestaña Apache → Main Port 8080 (y SSL Port 4433) → Save.
  3. Inicia Apache. Desde ahora las direcciones llevan el puerto: http://localhost:8080/, http://localhost:8080/phpmyadmin/.

Si Apache no inicia después de editar: probablemente quedó un error de escritura en el archivo. Compruébalo antes de iniciar:

C:\> C:\xampp\apache\bin\httpd.exe -t Syntax OK

Si en lugar de Syntax OK muestra un error, indica el archivo y la línea. Para volver atrás, deja Listen 80 y ServerName localhost:80 como estaban.

0.8. Cambiar el puerto de MySQL

  1. Detén MySQL. En el panel: MySQL → Config → my.ini. Cambia el puerto en las dos secciones (por ejemplo, a 3307):
[client] port=3307 ← era 3306 [mysqld] port=3307 ← era 3306
  1. phpMyAdmin: en C:\xampp\phpMyAdmin\config.inc.php, debajo de la línea $cfg['Servers'][$i]['host'] = '127.0.0.1'; agrega: $cfg['Servers'][$i]['port'] = '3307';
  2. En el panel: Config → Service and Port Settings → MySQL → Main Port 3307 → Save. Inicia MySQL.
  3. Todo lo que se conecta a MySQL debe usar el puerto nuevo:
DóndeCómo
Consolamysql -u root -p -P 3307 (-P mayúscula = puerto)
WorkbenchEditar la conexión → Port 3307
PHP (PDO)new PDO('mysql:host=127.0.0.1;port=3307;dbname=mi_base', 'root', '')
Esta plataformaEn el archivo .env: DB_HOST=127.0.0.1;port=3307

Para volver al puerto normal, repite los pasos con 3306 (y borra la línea de port agregada en phpMyAdmin).

0.9. Reparar MySQL cuando se detiene inesperadamente

El problema más común de XAMPP: MySQL se pone verde un momento y se detiene con "Error: MySQL shutdown unexpectedly", aunque el puerto esté libre. Casi siempre pasa después de apagar el equipo o cerrar XAMPP sin detener MySQL: algunos archivos internos del servidor quedan dañados. XAMPP guarda una copia limpia de esos archivos en C:\xampp\mysql\backup.

Primero confirma la causa: pulsa Logs → mysql_error.log en el panel (archivo C:\xampp\mysql\data\mysql_error.log). Si el error habla del puerto ("Can't start server: Bind on TCP/IP port") el problema es el puerto (0.6), no los archivos.
  1. Haz una copia de seguridad de la carpeta C:\xampp\mysql\data completa (cópiala y pégala como data_respaldo). Si algo sale mal, puedes volver a dejarla como estaba.
  2. Abre C:\xampp\mysql\backup y selecciona todo excepto el archivo ibdata1.
  3. Cópialo y pégalo dentro de C:\xampp\mysql\data; cuando Windows pregunte, elige Reemplazar los archivos.
  4. Inicia MySQL en el panel y revisa en phpMyAdmin que tus bases y tablas estén.
Lo que se copia desde backup

Los archivos del propio servidor: aria_log.00000001, aria_log_control, ib_logfile0, ib_logfile1, ib_buffer_pool, ibtmp1, my.ini, multi-master.info y las carpetas mysql, performance_schema, phpmyadmin y test. Tus bases de datos no están en backup, así que sus carpetas no se tocan.

Por qué no se copia ibdata1

ibdata1 guarda información interna de todas tus tablas InnoDB. El de backup está vacío: si lo copias, MySQL inicia pero tus tablas aparecen con el error "Table doesn't exist in engine". Por eso se conserva el tuyo.

  • Usuarios y permisos vuelven a los de fábrica: la carpeta mysql contiene los usuarios. Después de reparar, root queda sin contraseña y se pierden los usuarios y roles creados (módulo 10). En esta plataforma hay que volver a importar database/03_seguridad_y_sandbox.sql para recrear el usuario sandbox_runner.
  • Los últimos cambios hechos justo antes del fallo pueden perderse. Por eso conviene tener copias de seguridad recientes (módulo 11).
  • Si después de reparar falta alguna tabla, restaura la copia data_respaldo o tu último respaldo .sql.
  • Para que no vuelva a pasar: antes de apagar el equipo o cerrar XAMPP, pulsa Stop en MySQL y espera a que se ponga gris.
  • En Linux y macOS no existe esa carpeta backup: revisa el registro (/opt/lampp/var/mysql/*.err o /Applications/XAMPP/xamppfiles/var/mysql/*.err) y restaura desde un respaldo.

0.10. Otros problemas frecuentes

SíntomaCausa probableSolución
Apache o MySQL no inician y no hay otro programa en el puertoEl panel no tiene permisos para escribir en la carpeta.Abre el panel con clic derecho → Ejecutar como administrador. Verifica que XAMPP esté en C:\xampp.
El antivirus elimina o bloquea httpd.exe o mysqld.exeFalso positivo.Agrega C:\xampp a las exclusiones del antivirus.
http://localhost/ muestra otra página o no cargaOtro servidor web en el puerto 80, o Apache en otro puerto.Revisa 0.6; si cambiaste el puerto, escribe http://localhost:8080/.
El script de Workbench falla con "error in your SQL syntax … near CONSTRAINT"La palabra VISIBLE de MySQL 8 en los índices.Bajar la versión destino del modelo a 5.7 o quitar VISIBLE del script (0.5).
Workbench no inicia en WindowsFalta el Visual C++ Redistributable.Instálalo (enlace en 0.1) y vuelve a abrir Workbench.
El puerto 3306 ya está ocupado justo después de instalar MySQLSe instaló el "MySQL Installer" con un servidor MySQL (servicio MySQL80).services.msc → MySQL80 → Detener y tipo de inicio Manual, o cambia el puerto (0.8).
phpMyAdmin dice "mysqli::real_connect(): (HY000/2002)"MySQL está detenido o en otro puerto.Inicia MySQL; si cambiaste el puerto, configúralo en phpMyAdmin (0.8).

1. Primeros pasos: la consola de MySQL

Sección de consulta y apoyo: no tiene retos evaluados. Aquí aprendes a abrir la consola, que se usa en todo el manual. Las herramientas ya vienen con XAMPP en C: mpp\mysql\bin. Las salidas que se muestran son reales, tomadas de un XAMPP con MariaDB 10.4.
Nivel básico
Saber qué programa usar y conectarse al servidor desde la consola.

1.1. Dos programas distintos: mysql y mysqldump

ProgramaPara qué sirveDónde se escribe
mysql.exeCliente de consola: conectarse al servidor, ejecutar consultas y cargar (restaurar) archivos .sql.En CMD. Al entrar, el indicador cambia a MariaDB [(none)]>
mysqldump.exeExportar (respaldar) una base de datos a un archivo .sql.Siempre en CMD, nunca dentro de MariaDB>

Error muy común: escribir mysqldump ... dentro de la consola MariaDB [(none)]>. Allí no existe y produce un error de sintaxis. Primero sal con exit.

1.2. Iniciar sesión en la base de datos desde la consola

Hay dos formas. Ambas requieren que MySQL esté iniciado (botón Start de MySQL en el panel de XAMPP).

Opción A: desde CMD, entrando a la carpeta de MySQL
  1. Abre CMD: tecla Windows, escribe cmd y presiona Enter.
  2. Ve a la carpeta donde están los programas de MySQL y conéctate:
C:\Users\aprendiz> cd C:\xampp\mysql\bin C:\xampp\mysql\bin> mysql -u root -p Enter password:
  • -u root: el usuario con el que entras (en XAMPP el administrador se llama root).
  • -p: pide la contraseña de forma oculta. En un XAMPP recién instalado root no tiene contraseña: presiona Enter sin escribir nada (o conéctate directamente con mysql -u root).
  • No escribas la contraseña pegada en el comando (-pMiClave): queda visible en pantalla y en el historial de la consola.

Si la conexión es correcta verás algo así:

Welcome to the MariaDB monitor. Commands end with ; or \g. Your MariaDB connection id is 8 Server version: 10.4.32-MariaDB mariadb.org binary distribution Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others. Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. MariaDB [(none)]>

(none) indica que todavía no has elegido una base de datos.

Opción B: con el botón «Shell» del panel de XAMPP

En el panel de control de XAMPP, el botón Shell abre una consola que ya tiene configurada la ruta de MySQL (agrega C:\xampp\mysql\bin a la variable PATH). Por eso puedes conectarte sin cambiar de carpeta:

Setting environment for using XAMPP for Windows. aprendiz@EQUIPO c:\xampp # mysql -u root -p Enter password:
Errores frecuentes al conectarse
MensajeCausa y solución
"mysql" no se reconoce como un comando interno o externoNo estás en C:\xampp\mysql\bin (usa cd C:\xampp\mysql\bin) o no abriste la consola con el botón Shell.
ERROR 2002 (HY000): Can't connect to MySQL server on 'localhost'El servicio MySQL está detenido: inícialo en el panel de XAMPP.
ERROR 1045 (28000): Access denied for user 'root'@'localhost'Contraseña incorrecta (o root sí tiene contraseña y no usaste -p).
Nivel intermedio
Moverse entre bases y tablas, y conocer los atajos de la consola.

1.3. Comandos básicos dentro de la consola

MariaDB [(none)]> SHOW DATABASES; -- lista las bases de datos MariaDB [(none)]> USE veterinaria; -- elige una base de datos Database changed MariaDB [veterinaria]> SHOW TABLES; -- tablas de la base actual +-----------------------+ | Tables_in_veterinaria | +-----------------------+ | duenos | | mascotas | +-----------------------+ 2 rows in set (0.001 sec) MariaDB [veterinaria]> DESCRIBE mascotas; -- estructura de una tabla +----------+-------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +----------+-------------+------+-----+---------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | nombre | varchar(30) | NO | | NULL | | | especie | varchar(20) | NO | | NULL | | | dueno_id | int(11) | YES | MUL | NULL | | +----------+-------------+------+-----+---------+----------------+ MariaDB [veterinaria]> SELECT * FROM mascotas; +----+----------+---------+----------+ | id | nombre | especie | dueno_id | +----+----------+---------+----------+ | 1 | Firulais | Perro | 1 | | 2 | Michi | Gato | 1 | | 3 | Rocky | Perro | 2 | +----+----------+---------+----------+ MariaDB [veterinaria]> exit -- salir de la consola Bye
  • Toda sentencia termina en ;. Si presionas Enter sin él, aparece -> esperando el resto: escribe ; y Enter.
  • Si te equivocas a mitad de una sentencia, escribe \c para cancelarla.
  • Terminar una consulta con \G en lugar de ; muestra cada fila en vertical (útil con tablas anchas).
  • En DESCRIBE, PRI = clave primaria y MUL = columna con índice que admite repetidos (como una clave foránea).

2. DDL: definir la estructura (CREATE, ALTER, DROP)

Sección de consulta y apoyo, sin retos evaluados. Todo se escribe en la consola de MySQL (sección 1.2: cd C:\xampp\mysql\bin y mysql -u root -p). Las salidas son reales, tomadas de un XAMPP con MariaDB 10.4.
Nivel básico
Crear bases de datos y tablas eligiendo bien los tipos de datos y las reglas que protegen la información.

2.1. Las familias de sentencias SQL

FamiliaPara qué sirveSentencias
DDL (Data Definition Language)Crear, modificar y eliminar la estructura: bases, tablas, columnas, índices, vistas.CREATE, ALTER, DROP, RENAME, TRUNCATE
DML (Data Manipulation Language)Trabajar con los datos guardados en las tablas (módulos 3 y 4).INSERT, UPDATE, DELETE, SELECT
TCL (Transaction Control)Confirmar o deshacer grupos de cambios (módulo 5).START TRANSACTION, COMMIT, ROLLBACK, SAVEPOINT
DCL (Data Control)Crear usuarios y roles, y dar o quitar permisos (módulo 10).GRANT, REVOKE

2.2. Bases de datos: crear, ver, usar y eliminar

MariaDB [(none)]> CREATE DATABASE colegio CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; Query OK, 1 row affected (0.001 sec) MariaDB [(none)]> CREATE DATABASE colegio; ERROR 1007 (HY000): Can't create database 'colegio'; database exists MariaDB [(none)]> CREATE DATABASE IF NOT EXISTS colegio; Query OK, 0 rows affected, 1 warning (0.000 sec) MariaDB [(none)]> SHOW DATABASES LIKE 'cole%'; +------------------+ | Database (cole%) | +------------------+ | colegio | +------------------+ MariaDB [(none)]> USE colegio; Database changed
  • CHARACTER SET utf8mb4 permite guardar tildes, eñes y emojis. Inclúyelo siempre al crear una base.
  • IF NOT EXISTS evita el error si ya existe (solo deja una advertencia). Es útil en scripts que se ejecutan varias veces.
  • Para eliminar una base y todo su contenido: DROP DATABASE colegio; (no pide confirmación ni se puede deshacer).

2.3. Crear tablas: CREATE TABLE

CREATE TABLE nombre_tabla ( columna1 TIPO [restricciones], columna2 TIPO [restricciones], ... [restricciones de tabla] );
Tipos de datos más usados
TipoGuardaEjemplo
INT / TINYINT / BIGINTEnteros (TINYINT: -128 a 127; INT: ±2.147 millones; BIGINT: mucho más)edad TINYINT
DECIMAL(p, d)Números exactos con p dígitos en total y d decimales. Úsalo para dinero.precio DECIMAL(10,2) → hasta 99.999.999,99
VARCHAR(n)Texto de hasta n caracteresnombre VARCHAR(60)
CHAR(n)Texto de longitud fijacodigo_pais CHAR(2)
TEXTTextos largos (hasta 65.535 bytes)observaciones TEXT
DATE / DATETIME / TIMESTAMPFecha / fecha y hora / fecha y hora con zona horaria del servidorfecha_nacimiento DATE
BOOLEANVerdadero o falso (se guarda como TINYINT(1): 1 o 0)activo BOOLEAN
ENUM('a','b')Solo uno de los valores de la listajornada ENUM('mañana','tarde','noche')
Restricciones (reglas que protegen los datos)
RestricciónQué garantiza
PRIMARY KEYIdentifica cada fila: única y nunca vacía. Una por tabla.
AUTO_INCREMENTMySQL asigna el número automáticamente (1, 2, 3…). Normalmente en la clave primaria.
NOT NULLLa columna es obligatoria.
DEFAULT valorValor que toma la columna si no se indica al insertar.
UNIQUENo se puede repetir el valor en otra fila (documento, correo).
CHECK (condición)El valor debe cumplir la condición (MariaDB 10.2+ y MySQL 8.0.16+ la hacen cumplir).
FOREIGN KEY ... REFERENCESEl valor debe existir en otra tabla (relación). Ver 2.6.
MariaDB [colegio]> CREATE TABLE cursos ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> nombre VARCHAR(50) NOT NULL UNIQUE, -> creditos TINYINT NOT NULL DEFAULT 3 CHECK (creditos BETWEEN 1 AND 10), -> activo BOOLEAN NOT NULL DEFAULT TRUE -> ); Query OK, 0 rows affected (0.030 sec) MariaDB [colegio]> CREATE TABLE aprendices ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> documento VARCHAR(15) NOT NULL UNIQUE, -> nombre VARCHAR(60) NOT NULL, -> fecha_nacimiento DATE NULL, -> jornada ENUM('mañana', 'tarde', 'noche') NOT NULL DEFAULT 'mañana', -> creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -> ); Query OK, 0 rows affected (0.017 sec) MariaDB [colegio]> CREATE TABLE inscripciones ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> aprendiz_id INT NOT NULL, -> curso_id INT NOT NULL, -> nota DECIMAL(3,1) NULL, -> CONSTRAINT fk_insc_aprendiz FOREIGN KEY (aprendiz_id) REFERENCES aprendices(id) ON DELETE CASCADE, -> CONSTRAINT fk_insc_curso FOREIGN KEY (curso_id) REFERENCES cursos(id) ON DELETE RESTRICT, -> CONSTRAINT uq_insc UNIQUE (aprendiz_id, curso_id) -> ); Query OK, 0 rows affected (0.018 sec)

CONSTRAINT nombre le da un nombre a la regla (útil para luego borrarla o entender un error). UNIQUE (aprendiz_id, curso_id) es una restricción de tabla: la combinación no se repite (un aprendiz no se inscribe dos veces al mismo curso). Las llaves foráneas y sus opciones ON DELETE se explican en la sección 2.6.

Revisar lo creado
MariaDB [colegio]> SHOW TABLES; +-------------------+ | Tables_in_colegio | +-------------------+ | aprendices | | cursos | | inscripciones | +-------------------+ MariaDB [colegio]> DESCRIBE aprendices; +------------------+---------------------------------+------+-----+---------------------+----------------+ | Field | Type | Null | Key | Default | Extra | +------------------+---------------------------------+------+-----+---------------------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | documento | varchar(15) | NO | UNI | NULL | | | nombre | varchar(60) | NO | | NULL | | | fecha_nacimiento | date | YES | | NULL | | | jornada | enum('mañana','tarde','noche') | NO | | mañana | | | creado_en | timestamp | NO | | current_timestamp() | | +------------------+---------------------------------+------+-----+---------------------+----------------+ MariaDB [colegio]> SHOW CREATE TABLE cursos\G *************************** 1. row *************************** Table: cursos Create Table: CREATE TABLE `cursos` ( `id` int(11) NOT NULL AUTO_INCREMENT, `nombre` varchar(50) NOT NULL, `creditos` tinyint(4) NOT NULL DEFAULT 3 CHECK (`creditos` between 1 and 10), `activo` tinyint(1) NOT NULL DEFAULT 1, PRIMARY KEY (`id`), UNIQUE KEY `nombre` (`nombre`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

SHOW CREATE TABLE muestra la sentencia completa tal como la guardó el servidor: observa que BOOLEAN se convirtió en tinyint(1) y TRUE en 1. El \G final muestra el resultado en vertical.

Errores y comprobaciones al crear
MariaDB [colegio]> CREATE TABLE cursos (id INT); ERROR 1050 (42S01): Table 'cursos' already exists MariaDB [colegio]> CREATE TABLE IF NOT EXISTS cursos (id INT); Query OK, 0 rows affected, 1 warning (0.000 sec) MariaDB [colegio]> INSERT INTO cursos (nombre, creditos) VALUES ('Bases de datos', 15); ERROR 4025 (23000): CONSTRAINT `cursos.creditos` failed for `colegio`.`cursos`

El CHECK rechazó 15 créditos porque la regla exige entre 1 y 10. Ojo: con IF NOT EXISTS la tabla existente no se modifica; si necesitas otra estructura, usa ALTER TABLE.

Nivel intermedio
Cambiar tablas que ya existen, acelerar búsquedas con índices y relacionar tablas con llaves foráneas.

2.4. Modificar tablas: ALTER TABLE

Quiero…Sentencia
Agregar una columna (al final)ALTER TABLE t ADD COLUMN col TIPO;
Agregarla en una posición... ADD COLUMN col TIPO AFTER otra; o ... FIRST;
Cambiar tipo o restricciones (mismo nombre)ALTER TABLE t MODIFY COLUMN col NUEVO_TIPO;
Cambiar el nombre (y el tipo)ALTER TABLE t CHANGE COLUMN viejo nuevo TIPO;
Cambiar solo el valor por defectoALTER TABLE t ALTER COLUMN col SET DEFAULT valor;
Eliminar una columnaALTER TABLE t DROP COLUMN col;
Agregar una restricciónALTER TABLE t ADD CONSTRAINT nombre UNIQUE (col);
Eliminar una restricción única / un índiceALTER TABLE t DROP INDEX nombre;
Eliminar una llave foráneaALTER TABLE t DROP FOREIGN KEY nombre;
Renombrar la tablaALTER TABLE t RENAME TO nuevo; o RENAME TABLE t TO nuevo;
MariaDB [colegio]> ALTER TABLE aprendices ADD COLUMN correo VARCHAR(80) NULL AFTER nombre; Query OK, 0 rows affected (0.005 sec) Records: 0 Duplicates: 0 Warnings: 0 MariaDB [colegio]> ALTER TABLE aprendices ADD COLUMN telefono VARCHAR(15) NULL; MariaDB [colegio]> ALTER TABLE aprendices MODIFY COLUMN nombre VARCHAR(100) NOT NULL; MariaDB [colegio]> ALTER TABLE aprendices CHANGE COLUMN telefono celular VARCHAR(20) NULL; MariaDB [colegio]> ALTER TABLE aprendices ALTER COLUMN jornada SET DEFAULT 'tarde'; MariaDB [colegio]> ALTER TABLE aprendices ADD CONSTRAINT uq_correo UNIQUE (correo); MariaDB [colegio]> ALTER TABLE cursos ADD CONSTRAINT chk_nombre CHECK (CHAR_LENGTH(nombre) >= 3); MariaDB [colegio]> ALTER TABLE aprendices DROP COLUMN celular; MariaDB [colegio]> DESCRIBE aprendices; +------------------+---------------------------------+------+-----+---------------------+----------------+ | Field | Type | Null | Key | Default | Extra | +------------------+---------------------------------+------+-----+---------------------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | documento | varchar(15) | NO | UNI | NULL | | | nombre | varchar(100) | NO | | NULL | | | correo | varchar(80) | YES | UNI | NULL | | | fecha_nacimiento | date | YES | | NULL | | | jornada | enum('mañana','tarde','noche') | NO | | tarde | | | creado_en | timestamp | NO | | current_timestamp() | | +------------------+---------------------------------+------+-----+---------------------+----------------+
  • MODIFY cambia el tipo conservando el nombre; CHANGE pide el nombre viejo, el nuevo y el tipo completo (aunque no cambie).
  • Con MODIFY y CHANGE hay que repetir todas las restricciones de la columna (NOT NULL, DEFAULT…): las que no escribas se pierden.
MariaDB [colegio]> ALTER TABLE aprendices RENAME COLUMN celular TO movil; ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'COLUMN celular TO movil' at line 1

RENAME COLUMN existe en MySQL 8 y MariaDB 10.5 o superior, pero no en el MariaDB 10.4 de XAMPP: allí se usa CHANGE COLUMN.

Reducir una columna con datos puede recortarlos sin preguntar. En el modo no estricto de XAMPP:
MariaDB [colegio]> INSERT INTO aprendices (documento, nombre) VALUES ('1001', 'Ana María Torres Gómez'); Query OK, 1 row affected (0.003 sec) MariaDB [colegio]> ALTER TABLE aprendices MODIFY COLUMN nombre VARCHAR(10) NOT NULL; Query OK, 1 row affected, 1 warning (0.054 sec) Records: 1 Duplicates: 0 Warnings: 1 MariaDB [colegio]> SHOW WARNINGS; +---------+------+---------------------------------------------+ | Level | Code | Message | +---------+------+---------------------------------------------+ | Warning | 1265 | Data truncated for column 'nombre' at row 1 | +---------+------+---------------------------------------------+ MariaDB [colegio]> SELECT documento, nombre FROM aprendices; +-----------+-------------+ | documento | nombre | +-----------+-------------+ | 1001 | Ana María | +-----------+-------------+

El nombre completo se perdió para siempre. Antes de cambiar tipos o tamaños en una tabla con datos, haz una copia de seguridad (sección 11.1) y revisa los datos más largos con SELECT MAX(CHAR_LENGTH(nombre)) FROM aprendices;.

2.5. Índices: búsquedas más rápidas

Un índice es como el índice de un libro: permite encontrar filas sin recorrer toda la tabla. Las claves primarias y las columnas UNIQUE ya tienen uno.

MariaDB [colegio]> CREATE INDEX idx_aprendices_nombre ON aprendices (nombre); Query OK, 0 rows affected (0.017 sec) MariaDB [colegio]> SHOW INDEX FROM aprendices; +------------+------------+-----------------------+--------------+-------------+----- | Table | Non_unique | Key_name | Seq_in_index | Column_name | ... +------------+------------+-----------------------+--------------+-------------+----- | aprendices | 0 | PRIMARY | 1 | id | ... | aprendices | 0 | documento | 1 | documento | ... | aprendices | 0 | uq_correo | 1 | correo | ... | aprendices | 1 | idx_aprendices_nombre | 1 | nombre | ... +------------+------------+-----------------------+--------------+-------------+----- MariaDB [colegio]> DROP INDEX idx_aprendices_nombre ON aprendices; Query OK, 0 rows affected (0.011 sec)

Non_unique = 0 indica índice único. Crea índices en columnas que se usan mucho en WHERE, JOIN u ORDER BY; no en todas: cada índice hace más lentos los INSERT y UPDATE. Para comprobar si una consulta realmente usa un índice se usa EXPLAIN (4.17).

2.6. Llaves foráneas (FOREIGN KEY): relacionar tablas

Una llave foránea es una regla que obliga a que el valor de una columna exista en otra tabla. La tabla que guarda la referencia es la hija (ciudades.departamento_id) y la tabla referenciada es la padre (departamentos.id). Así la base de datos impide, por sí misma, datos "huérfanos" o inconsistentes.

Sintaxis completa
CONSTRAINT nombre_de_la_llave FOREIGN KEY (columna_hija) REFERENCES tabla_padre (columna_padre) [ON DELETE RESTRICT | CASCADE | SET NULL | NO ACTION] [ON UPDATE RESTRICT | CASCADE | SET NULL | NO ACTION]
  • Se puede escribir dentro del CREATE TABLE (como restricción de tabla) o agregarse después con ALTER TABLE ... ADD CONSTRAINT.
  • CONSTRAINT nombre es opcional pero muy recomendado: sin nombre, MySQL inventa uno (sedes_ibfk_1) y es más difícil identificarla en los errores o borrarla.
  • Convención útil: fk_hija_padre, por ejemplo fk_ciudad_depto.
Crear tablas relacionadas: el padre primero
MariaDB [fk_demo]> CREATE TABLE ciudades ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> nombre VARCHAR(40) NOT NULL, -> departamento_id INT NOT NULL, -> CONSTRAINT fk_ciudad_depto FOREIGN KEY (departamento_id) REFERENCES departamentos(id) -> ); ERROR 1005 (HY000): Can't create table `fk_demo`.`ciudades` (errno: 150 "Foreign key constraint is incorrectly formed") MariaDB [fk_demo]> SHOW WARNINGS; +---------+------+------------------------------------------------------------------------------------------------------ | Level | Code | Message +---------+------+------------------------------------------------------------------------------------------------------ | Warning | 150 | Create table `fk_demo`.`ciudades` with foreign key constraint failed. Referenced table `fk_demo`.`departamentos` not found in the data dictionary near ' FOREIGN KEY (departamento_id) REFERENCES departamentos(id))'. | Error | 1005 | Can't create table `fk_demo`.`ciudades` (errno: 150 "Foreign key constraint is incorrectly formed") | Warning | 1215 | Cannot add foreign key constraint for `ciudades` +---------+------+------------------------------------------------------------------------------------------------------

El error 1005 con errno 150 es genérico ("la llave foránea está mal formada"). La causa real siempre aparece en SHOW WARNINGS: aquí, la tabla padre departamentos todavía no existe. Se crea primero el padre y luego la hija:

MariaDB [fk_demo]> CREATE TABLE departamentos (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(40) NOT NULL UNIQUE); Query OK, 0 rows affected (0.025 sec) MariaDB [fk_demo]> CREATE TABLE ciudades ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> nombre VARCHAR(40) NOT NULL, -> departamento_id INT NOT NULL, -> CONSTRAINT fk_ciudad_depto FOREIGN KEY (departamento_id) REFERENCES departamentos(id) -> ); Query OK, 0 rows affected (0.030 sec)
Sin nombre y con índice automático
MariaDB [fk_demo]> CREATE TABLE sedes ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> nombre VARCHAR(40) NOT NULL, -> ciudad_id INT NOT NULL, -> FOREIGN KEY (ciudad_id) REFERENCES ciudades(id) -> ); Query OK, 0 rows affected (0.029 sec) MariaDB [fk_demo]> SHOW CREATE TABLE sedes\G *************************** 1. row *************************** Table: sedes Create Table: CREATE TABLE `sedes` ( `id` int(11) NOT NULL AUTO_INCREMENT, `nombre` varchar(40) NOT NULL, `ciudad_id` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `ciudad_id` (`ciudad_id`), CONSTRAINT `sedes_ibfk_1` FOREIGN KEY (`ciudad_id`) REFERENCES `ciudades` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
  • Como no se le dio nombre, MySQL la llamó sedes_ibfk_1 (InnoDB foreign key número 1).
  • MySQL creó solo un índice (KEY `ciudad_id`) en la columna hija: la llave foránea lo necesita para verificar rápido.
Requisitos: cuándo NO se puede crear
RequisitoCausa que muestra SHOW WARNINGS si no se cumple
La tabla padre debe existirReferenced table ... not found in the data dictionary
Mismo tipo de dato en ambas columnas: INT con INT (tampoco se mezcla INT con INT UNSIGNED ni con BIGINT). En textos, también el mismo juego de caracteres.Field type or character set for column 'ciudad_id' does not mach referenced column 'id'
La columna padre debe tener índice (normalmente es la PRIMARY KEY o una UNIQUE)There is no index in the referenced table where the referenced columns appear as the first columns
Con ON DELETE SET NULL, la columna hija debe permitir NULLYou have defined a SET NULL condition but column 'ciudad_id' is defined as NOT NULL
Ambas tablas con motor InnoDB (el predeterminado)Ninguna: ver la advertencia de abajo.
-- Tipos distintos: ciudades.id es INT y aquí ciudad_id es BIGINT MariaDB [fk_demo]> CREATE TABLE barrios (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(40), ciudad_id BIGINT NOT NULL, -> CONSTRAINT fk_barrio_ciudad FOREIGN KEY (ciudad_id) REFERENCES ciudades(id)); ERROR 1005 (HY000): Can't create table `fk_demo`.`barrios` (errno: 150 "Foreign key constraint is incorrectly formed") -- Columna padre sin índice: ciudades.nombre no es PRIMARY KEY ni UNIQUE MariaDB [fk_demo]> CREATE TABLE barrios (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(40), ciudad_nombre VARCHAR(40), -> CONSTRAINT fk_barrio_ciudad FOREIGN KEY (ciudad_nombre) REFERENCES ciudades(nombre)); ERROR 1005 (HY000): Can't create table `fk_demo`.`barrios` (errno: 150 "Foreign key constraint is incorrectly formed") -- SET NULL sobre una columna NOT NULL MariaDB [fk_demo]> CREATE TABLE barrios (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(40), ciudad_id INT NOT NULL, -> CONSTRAINT fk_barrio_ciudad FOREIGN KEY (ciudad_id) REFERENCES ciudades(id) ON DELETE SET NULL); ERROR 1005 (HY000): Can't create table `fk_demo`.`barrios` (errno: 150 "Foreign key constraint is incorrectly formed")

Los tres errores son idénticos: solo SHOW WARNINGS; dice cuál requisito falló (columna derecha de la tabla de arriba).

Con el motor MyISAM la llave foránea se ignora en silencio.
MariaDB [fk_demo]> CREATE TABLE barrios_myisam (id INT AUTO_INCREMENT PRIMARY KEY, ciudad_id INT NOT NULL, -> CONSTRAINT fk_bm FOREIGN KEY (ciudad_id) REFERENCES ciudades(id)) ENGINE=MyISAM; Query OK, 0 rows affected (0.010 sec) MariaDB [fk_demo]> INSERT INTO barrios_myisam (ciudad_id) VALUES (999); Query OK, 1 row affected (0.011 sec) MariaDB [fk_demo]> SHOW CREATE TABLE barrios_myisam\G Create Table: CREATE TABLE `barrios_myisam` ( `id` int(11) NOT NULL AUTO_INCREMENT, `ciudad_id` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `fk_bm` (`ciudad_id`) ) ENGINE=MyISAM ...

No dio error, aceptó la ciudad 999 (que no existe) y en la estructura no hay ningún CONSTRAINT: solo quedó un índice. Usa siempre InnoDB, que es el motor predeterminado (no escribas ENGINE=MyISAM).

Agregar una llave foránea a una tabla que ya tiene datos
MariaDB [fk_demo]> CREATE TABLE clientes (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(40) NOT NULL, ciudad_id INT NULL); MariaDB [fk_demo]> INSERT INTO clientes (nombre, ciudad_id) VALUES ('Ana', 1), ('Luis', 2), ('Rosa', 7), ('Pedro', NULL); Query OK, 4 rows affected (0.004 sec) MariaDB [fk_demo]> ALTER TABLE clientes ADD CONSTRAINT fk_cliente_ciudad FOREIGN KEY (ciudad_id) REFERENCES ciudades(id); ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`fk_demo`.`#sql-1d70_56ff`, CONSTRAINT `fk_cliente_ciudad` FOREIGN KEY (`ciudad_id`) REFERENCES `ciudades` (`id`))

No se puede crear porque ya hay un dato que la viola: un cliente con la ciudad 7, que no existe (el nombre raro #sql-... es la copia temporal que MySQL usa durante el ALTER). Primero se buscan los huérfanos con un LEFT JOIN:

MariaDB [fk_demo]> SELECT c.id, c.nombre, c.ciudad_id -> FROM clientes c LEFT JOIN ciudades ci ON ci.id = c.ciudad_id -> WHERE c.ciudad_id IS NOT NULL AND ci.id IS NULL; +----+--------+-----------+ | id | nombre | ciudad_id | +----+--------+-----------+ | 3 | Rosa | 7 | +----+--------+-----------+ MariaDB [fk_demo]> UPDATE clientes SET ciudad_id = NULL WHERE id = 3; -- se corrige (o se asigna una ciudad válida) Query OK, 1 row affected (0.006 sec) MariaDB [fk_demo]> ALTER TABLE clientes ADD CONSTRAINT fk_cliente_ciudad -> FOREIGN KEY (ciudad_id) REFERENCES ciudades(id) ON DELETE SET NULL ON UPDATE CASCADE; Query OK, 4 rows affected (0.065 sec) Records: 4 Duplicates: 0 Warnings: 0

Un NULL en la columna hija sí está permitido: significa "sin ciudad" y no viola la llave foránea (Pedro no causó el error).

Opciones ON DELETE y ON UPDATE

Definen qué pasa con las filas hijas cuando se borra (ON DELETE) o se cambia el id (ON UPDATE) de su fila padre:

OpciónEfecto en las filas hijasCuándo usarla
RESTRICT (por defecto) / NO ACTIONSe impide borrar o cambiar el padre mientras tenga hijos (error 1451). En MySQL/MariaDB ambas se comportan igual.La opción más segura: obliga a decidir qué hacer con los hijos.
CASCADELos hijos se borran (ON DELETE) o se actualizan (ON UPDATE) junto con el padre.Hijos que no tienen sentido sin el padre: líneas de un pedido, inscripciones de un aprendiz.
SET NULLLos hijos se conservan con la columna en NULL.Relación opcional: el cliente sigue existiendo aunque se elimine su ciudad.
-- clientes → ciudades con ON DELETE SET NULL ON UPDATE CASCADE MariaDB [fk_demo]> SELECT * FROM clientes; +----+--------+-----------+ | id | nombre | ciudad_id | +----+--------+-----------+ | 1 | Ana | 1 | | 2 | Luis | 2 | | 3 | Rosa | NULL | | 4 | Pedro | NULL | +----+--------+-----------+ MariaDB [fk_demo]> DELETE FROM ciudades WHERE id = 2; -- se borra la ciudad de Luis Query OK, 1 row affected (0.001 sec) MariaDB [fk_demo]> UPDATE ciudades SET id = 10 WHERE id = 1; -- se cambia el id de la ciudad de Ana Query OK, 1 row affected (0.001 sec) Rows matched: 1 Changed: 1 Warnings: 0 MariaDB [fk_demo]> SELECT * FROM clientes; +----+--------+-----------+ | id | nombre | ciudad_id | +----+--------+-----------+ | 1 | Ana | 10 | | 2 | Luis | NULL | | 3 | Rosa | NULL | | 4 | Pedro | NULL | +----+--------+-----------+

Luis quedó sin ciudad (SET NULL) y Ana pasó automáticamente a la ciudad 10 (CASCADE). Con RESTRICT, en cambio:

-- sedes → ciudades no indicó opciones: RESTRICT por defecto MariaDB [fk_demo]> INSERT INTO sedes (nombre, ciudad_id) VALUES ('Sede norte', 3); Query OK, 1 row affected (0.002 sec) MariaDB [fk_demo]> DELETE FROM ciudades WHERE id = 3; ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`fk_demo`.`sedes`, CONSTRAINT `sedes_ibfk_1` FOREIGN KEY (`ciudad_id`) REFERENCES `ciudades` (`id`))

El efecto sobre los datos al usar INSERT, UPDATE y DELETE se profundiza en las secciones 3.2, 3.3 y 3.4.

Ver las llaves foráneas de una base

Además de SHOW CREATE TABLE tabla\G, el catálogo information_schema las lista todas:

MariaDB [fk_demo]> SELECT TABLE_NAME, COLUMN_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME -> FROM information_schema.KEY_COLUMN_USAGE -> WHERE TABLE_SCHEMA = 'fk_demo' AND REFERENCED_TABLE_NAME IS NOT NULL -> ORDER BY TABLE_NAME; +------------+-----------------+-------------------+-----------------------+------------------------+ | TABLE_NAME | COLUMN_NAME | CONSTRAINT_NAME | REFERENCED_TABLE_NAME | REFERENCED_COLUMN_NAME | +------------+-----------------+-------------------+-----------------------+------------------------+ | ciudades | departamento_id | fk_ciudad_depto | departamentos | id | | clientes | ciudad_id | fk_cliente_ciudad | ciudades | id | | sedes | ciudad_id | sedes_ibfk_1 | ciudades | id | +------------+-----------------+-------------------+-----------------------+------------------------+ MariaDB [fk_demo]> SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME, UPDATE_RULE, DELETE_RULE -> FROM information_schema.REFERENTIAL_CONSTRAINTS -> WHERE CONSTRAINT_SCHEMA = 'fk_demo' ORDER BY TABLE_NAME; +-------------------+------------+-----------------------+-------------+-------------+ | CONSTRAINT_NAME | TABLE_NAME | REFERENCED_TABLE_NAME | UPDATE_RULE | DELETE_RULE | +-------------------+------------+-----------------------+-------------+-------------+ | fk_ciudad_depto | ciudades | departamentos | RESTRICT | RESTRICT | | fk_cliente_ciudad | clientes | ciudades | CASCADE | SET NULL | | sedes_ibfk_1 | sedes | ciudades | RESTRICT | RESTRICT | +-------------------+------------+-----------------------+-------------+-------------+

La segunda consulta muestra las reglas de cada llave: las que no indicaron opciones quedaron en RESTRICT.

Modificar o eliminar una llave foránea

No existe un "ALTER CONSTRAINT": para cambiar una llave foránea (por ejemplo, sus opciones) se elimina y se vuelve a crear:

MariaDB [fk_demo]> ALTER TABLE ciudades DROP FOREIGN KEY fk_ciudad_depto; Query OK, 0 rows affected (0.008 sec) Records: 0 Duplicates: 0 Warnings: 0 MariaDB [fk_demo]> SHOW INDEX FROM ciudades WHERE Key_name = 'fk_ciudad_depto'; +----------+------------+-----------------+--------------+-----------------+----- | Table | Non_unique | Key_name | Seq_in_index | Column_name | ... +----------+------------+-----------------+--------------+-----------------+----- | ciudades | 1 | fk_ciudad_depto | 1 | departamento_id | ... +----------+------------+-----------------+--------------+-----------------+----- MariaDB [fk_demo]> ALTER TABLE ciudades ADD CONSTRAINT fk_ciudad_depto -> FOREIGN KEY (departamento_id) REFERENCES departamentos(id) ON DELETE CASCADE; Query OK, 2 rows affected (0.042 sec) Records: 2 Duplicates: 0 Warnings: 0 MariaDB [fk_demo]> ALTER TABLE ciudades DROP FOREIGN KEY fk_no_existe; ERROR 1091 (42000): Can't DROP FOREIGN KEY `fk_no_existe`; check that it exists
  • DROP FOREIGN KEY elimina la regla, pero el índice queda. Si ya no se necesita: ALTER TABLE ciudades DROP INDEX fk_ciudad_depto;
  • Para borrarla hay que conocer su nombre exacto (por eso conviene nombrarlas): búscalo con SHOW CREATE TABLE o en information_schema.
Casos especiales
-- Autorreferencia: una tabla que apunta a sí misma (empleado → jefe) MariaDB [fk_demo]> CREATE TABLE empleados ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> nombre VARCHAR(40) NOT NULL, -> jefe_id INT NULL, -> CONSTRAINT fk_emp_jefe FOREIGN KEY (jefe_id) REFERENCES empleados(id) ON DELETE SET NULL -> ); MariaDB [fk_demo]> INSERT INTO empleados (nombre, jefe_id) VALUES ('Laura', NULL); MariaDB [fk_demo]> INSERT INTO empleados (nombre, jefe_id) VALUES ('Andrés', 1), ('Sofía', 2); MariaDB [fk_demo]> SELECT e.nombre, j.nombre AS jefe FROM empleados e LEFT JOIN empleados j ON j.id = e.jefe_id; +---------+---------+ | nombre | jefe | +---------+---------+ | Laura | NULL | | Andrés | Laura | | Sofía | Andrés | +---------+---------+ -- Compuesta: varias columnas que referencian una clave primaria compuesta (mismo orden y tipos) MariaDB [fk_demo]> CREATE TABLE horarios (sede_id INT NOT NULL, dia TINYINT NOT NULL, PRIMARY KEY (sede_id, dia)); MariaDB [fk_demo]> CREATE TABLE turnos ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> sede_id INT NOT NULL, -> dia TINYINT NOT NULL, -> CONSTRAINT fk_turno_horario FOREIGN KEY (sede_id, dia) REFERENCES horarios(sede_id, dia) -> ); Query OK, 0 rows affected (0.016 sec)

En la autorreferencia, el primer registro (la jefa principal) se inserta con jefe_id en NULL; por eso la columna debe permitir NULL.

Orden correcto al crear y al borrar tablas relacionadas
CrearPrimero los padres, luego las hijas: departamentos → ciudades → sedes.
BorrarAl revés, primero las hijas: sedes → ciudades → departamentos. Si no, DROP TABLE da el error 1451 (sección 2.7).
Nivel avanzado
Eliminar y renombrar objetos con seguridad, crear vistas y otras formas de tablas, y entender por qué el DDL no tiene vuelta atrás.

2.7. Renombrar y eliminar: RENAME y DROP

MariaDB [colegio]> RENAME TABLE inscripciones TO matriculas; Query OK, 0 rows affected (0.010 sec) MariaDB [colegio]> DROP TABLE cursos; ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails MariaDB [colegio]> DROP TABLE tabla_que_no_existe; ERROR 1051 (42S02): Unknown table 'colegio.tabla_que_no_existe' MariaDB [colegio]> DROP TABLE IF EXISTS tabla_que_no_existe; Query OK, 0 rows affected, 1 warning (0.001 sec)
  • DROP TABLE elimina la tabla con su estructura y todos sus datos.
  • No se puede eliminar una tabla de la que dependen otras por llave foránea (cursos es usada por matriculas): primero se elimina la tabla dependiente o su llave foránea.
  • DROP DATABASE colegio; elimina la base entera: Query OK, 4 rows affected indica cuántos objetos borró.

2.8. Vistas: consultas guardadas con nombre

MariaDB [colegio]> CREATE VIEW vista_aprendices AS SELECT documento, nombre FROM aprendices; Query OK, 0 rows affected (0.006 sec) MariaDB [colegio]> SELECT * FROM vista_aprendices; +-----------+-------------+ | documento | nombre | +-----------+-------------+ | 1001 | Ana María | +-----------+-------------+ MariaDB [colegio]> DROP VIEW vista_aprendices; Query OK, 0 rows affected (0.001 sec)

Una vista no guarda datos: cada vez que se consulta, ejecuta su SELECT. Sirve para simplificar consultas largas o mostrar solo algunas columnas.

2.9. Otras formas de crear tablas y columnas

Columnas calculadas (generadas)

Una columna generada calcula su valor a partir de otras columnas de la misma fila, y se mantiene sola al cambiar los datos:

MariaDB [ddl2]> CREATE TABLE ventas ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> producto VARCHAR(30) NOT NULL, -> cantidad INT NOT NULL, -> precio DECIMAL(10,2) NOT NULL, -> total DECIMAL(12,2) AS (cantidad * precio) STORED -> ); MariaDB [ddl2]> INSERT INTO ventas (producto, cantidad, precio) VALUES ('Café', 3, 12000), ('Té', 2, 8000); MariaDB [ddl2]> UPDATE ventas SET cantidad = 10 WHERE id = 2; MariaDB [ddl2]> SELECT * FROM ventas; +----+----------+----------+----------+----------+ | id | producto | cantidad | precio | total | +----+----------+----------+----------+----------+ | 1 | Café | 3 | 12000.00 | 36000.00 | | 2 | Té | 10 | 8000.00 | 80000.00 | +----+----------+----------+----------+----------+ MariaDB [ddl2]> INSERT INTO ventas (producto, cantidad, precio, total) VALUES ('Agua', 1, 2000, 99); Query OK, 1 row affected, 1 warning (0.002 sec) MariaDB [ddl2]> SHOW WARNINGS; | Warning | 1906 | The value specified for generated column 'total' in table 'ventas' has been ignored |
  • El total del Té se recalculó solo al cambiar la cantidad (2 → 10). El valor 99 que se intentó escribir en total fue ignorado.
  • STORED guarda el resultado en disco (se puede indexar); VIRTUAL lo calcula cada vez que se consulta (no ocupa espacio).
Copiar la estructura o crear desde una consulta
-- LIKE: copia la estructura exacta (columnas, índices, AUTO_INCREMENT…) pero SIN datos MariaDB [ddl2]> CREATE TABLE ventas_copia LIKE ventas; MariaDB [ddl2]> SELECT COUNT(*) AS filas FROM ventas_copia; +-------+ | filas | +-------+ | 0 | +-------+ -- AS SELECT: crea la tabla con el resultado de una consulta (con datos) MariaDB [ddl2]> CREATE TABLE ventas_grandes AS SELECT producto, total FROM ventas WHERE total > 20000; Query OK, 2 rows affected (0.013 sec) Records: 2 Duplicates: 0 Warnings: 0 MariaDB [ddl2]> SHOW CREATE TABLE ventas_grandes\G Create Table: CREATE TABLE `ventas_grandes` ( `producto` varchar(30) NOT NULL, `total` decimal(12,2) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

CREATE TABLE ... AS SELECT copia las columnas y los datos, pero no copia la clave primaria, los índices ni las llaves foráneas (y la columna calculada pasó a ser una columna normal). Útil para respaldos rápidos de una tabla o tablas de reporte.

Tablas temporales
MariaDB [ddl2]> CREATE TEMPORARY TABLE carrito (producto VARCHAR(30), cantidad INT); MariaDB [ddl2]> INSERT INTO carrito VALUES ('Café', 2); MariaDB [ddl2]> SELECT * FROM carrito; +----------+----------+ | producto | cantidad | +----------+----------+ | Café | 2 | +----------+----------+ MariaDB [ddl2]> SHOW TABLES; +----------------+ | Tables_in_ddl2 | +----------------+ | ventas | | ventas_copia | | ventas_grandes | +----------------+

Una tabla TEMPORARY solo existe para tu conexión (otros usuarios no la ven) y se borra sola al cerrar la consola. Observa que no aparece en SHOW TABLES, aunque se puede consultar. Sirve para guardar resultados intermedios.

Reiniciar o adelantar el contador AUTO_INCREMENT
MariaDB [ddl2]> ALTER TABLE ventas AUTO_INCREMENT = 100; Query OK, 0 rows affected (0.004 sec) MariaDB [ddl2]> INSERT INTO ventas (producto, cantidad, precio) VALUES ('Jugo', 1, 5000); MariaDB [ddl2]> SELECT id, producto FROM ventas; +-----+----------+ | id | producto | +-----+----------+ | 1 | Café | | 2 | Té | | 3 | Agua | | 100 | Jugo | +-----+----------+

No se puede bajar el contador por debajo del id más alto que ya existe: MySQL lo ajusta solo al siguiente número libre. Para volver a 1 hay que vaciar la tabla con TRUNCATE (3.4).

2.10. Las sentencias DDL no se pueden deshacer

MariaDB [colegio]> START TRANSACTION; Query OK, 0 rows affected (0.000 sec) MariaDB [colegio]> CREATE TABLE temporal (id INT); Query OK, 0 rows affected (0.014 sec) MariaDB [colegio]> ROLLBACK; Query OK, 0 rows affected (0.000 sec) MariaDB [colegio]> SHOW TABLES LIKE 'temporal'; +------------------------------+ | Tables_in_colegio (temporal) | +------------------------------+ | temporal | +------------------------------+

CREATE, ALTER, DROP, RENAME y TRUNCATE hacen un COMMIT automático: el ROLLBACK no los revierte (y además confirma lo que hubiera pendiente en la transacción). La única protección real ante un DROP o un ALTER equivocado es una copia de seguridad.

2.11. Chuleta

Crear baseCREATE DATABASE IF NOT EXISTS b CHARACTER SET utf8mb4;
Crear tablaCREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY, col VARCHAR(50) NOT NULL);
Ver estructuraDESCRIBE t; · SHOW CREATE TABLE t\G
Agregar / cambiar / quitar columnaALTER TABLE t ADD COLUMN … · MODIFY COLUMN … · CHANGE COLUMN viejo nuevo … · DROP COLUMN …
ÍndiceCREATE INDEX idx ON t (col); · DROP INDEX idx ON t;
Llave foráneaALTER TABLE hija ADD CONSTRAINT fk_hija_padre FOREIGN KEY (col) REFERENCES padre(id) ON DELETE RESTRICT; · ALTER TABLE hija DROP FOREIGN KEY fk_hija_padre;
¿Por qué falló? (errno 150)SHOW WARNINGS;
Renombrar tablaRENAME TABLE t TO nuevo;
EliminarDROP TABLE IF EXISTS t; · DROP DATABASE b;

3. DML: manipular los datos

El DML (Data Manipulation Language) agrupa las sentencias que trabajan con los datos de las tablas ya creadas: INSERT (agregar), UPDATE (modificar) y DELETE (eliminar). A diferencia del DDL, sus cambios sí se pueden deshacer dentro de una transacción.

Sección de consulta y apoyo, sin retos evaluados. Todo se escribe en la consola de MySQL (sección 1.2). Las salidas son reales, tomadas de un XAMPP con MariaDB 10.4.

3.1. Insertar datos en una tabla

Nivel básico
Escribir un INSERT correcto para una o varias filas y entender los valores por defecto.
Sintaxis y reglas básicas
INSERT INTO nombre_tabla (columna1, columna2, columna3) VALUES (valor1, valor2, valor3);
  • Los valores van en el mismo orden que las columnas de la lista, y debe haber la misma cantidad.
  • Textos y fechas entre comillas simples: 'Cafetera', '2026-01-15'.
  • Números sin comillas, con punto decimal y sin separador de miles: 1450000.50 (no 1.450.000,50).
  • NULL sin comillas significa "sin valor"; 'NULL' con comillas sería el texto N-U-L-L.
  • Fechas: 'AAAA-MM-DD'; fecha y hora: 'AAAA-MM-DD HH:MM:SS'. Toda sentencia termina en ;.
Tabla de ejemplo
MariaDB [tienda_demo]> CREATE TABLE productos ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> codigo VARCHAR(10) NOT NULL UNIQUE, -> nombre VARCHAR(40) NOT NULL, -> categoria VARCHAR(20) NOT NULL DEFAULT 'General', -> precio DECIMAL(10,2) NOT NULL DEFAULT 0, -> stock INT NOT NULL DEFAULT 0, -> descuento DECIMAL(4,2) NULL, -> creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -> );

Fíjate en las reglas: codigo no se repite (UNIQUE), nombre es obligatorio (NOT NULL) y varias columnas tienen un valor por defecto (DEFAULT).

Una fila y varias filas
MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, categoria, precio, stock) -> VALUES ('P001', 'Mouse inalámbrico', 'Tecnología', 65000, 40); Query OK, 1 row affected (0.005 sec) MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, categoria, precio, stock) VALUES -> ('P002', 'Teclado mecánico', 'Tecnología', 230000, 15), -> ('P003', 'Cafetera', 'Hogar', 310000, 12), -> ('P004', 'Lámpara de escritorio', 'Hogar', 120000, 0); Query OK, 3 rows affected (0.004 sec) Records: 3 Duplicates: 0 Warnings: 0

Query OK, 1 row affected confirma que se guardó. En la inserción múltiple cada fila va entre paréntesis, separadas por comas: es más rápida que varios INSERT. Records = filas procesadas, Duplicates = repetidas, Warnings = advertencias (si no es 0, revísalas con SHOW WARNINGS;).

Columnas que se pueden omitir: AUTO_INCREMENT, DEFAULT y NULL
MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio) VALUES ('P005', 'Cuaderno argollado', 12000); Query OK, 1 row affected (0.003 sec) MariaDB [tienda_demo]> SELECT LAST_INSERT_ID(); +------------------+ | LAST_INSERT_ID() | +------------------+ | 5 | +------------------+ MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, categoria, precio, stock, descuento) -> VALUES ('P006', 'Balón de fútbol', 'Deportes', 95000, DEFAULT, NULL); Query OK, 1 row affected (0.002 sec)
  • Las columnas que no aparecen reciben su valor por defecto: id → siguiente número, categoria → 'General', stock → 0, creado_en → fecha y hora actuales.
  • DEFAULT pide explícitamente el valor por defecto y NULL deja la columna vacía (solo si la columna lo permite).
  • LAST_INSERT_ID() devuelve el id automático de la última fila que tú insertaste en esta conexión.
Nivel intermedio
Otras sintaxis, textos especiales y cómo leer los errores y advertencias.
Otras formas de escribir el INSERT
-- Columna = valor (más legible con muchas columnas; solo una fila) MariaDB [tienda_demo]> INSERT INTO productos SET codigo = 'P007', nombre = 'Bicicleta estática', -> categoria = 'Deportes', precio = 1450000, stock = 3; Query OK, 1 row affected (0.002 sec) -- Sin lista de columnas: un valor para TODAS, en el orden de la tabla (NULL en el id = automático) MariaDB [tienda_demo]> INSERT INTO productos VALUES (NULL, 'P010', 'Mancuernas 5 kg', 'Deportes', 180000, 0, NULL, NOW()); Query OK, 1 row affected (0.002 sec)

Sin lista de columnas funciona, pero no se recomienda: si mañana se agrega una columna a la tabla, la sentencia deja de funcionar.

Comillas, tildes, fechas y funciones
-- Comilla simple dentro del texto: se escribe doble ('') o con barra (\'). Las comillas dobles se escriben normal. MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio) VALUES ('P008', 'Libro "Cien años" de O''Brien', 45000); Query OK, 1 row affected (0.002 sec) -- Fecha y hora escritas explícitamente MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio, creado_en) VALUES ('P009', 'Resma de papel', 24000, '2026-01-15 08:30:00'); Query OK, 1 row affected (0.002 sec) MariaDB [tienda_demo]> SELECT id, codigo, nombre, categoria, precio, stock, descuento FROM productos ORDER BY id; +----+--------+-------------------------------+-------------+------------+-------+-----------+ | id | codigo | nombre | categoria | precio | stock | descuento | +----+--------+-------------------------------+-------------+------------+-------+-----------+ | 1 | P001 | Mouse inalámbrico | Tecnología | 65000.00 | 40 | NULL | | 2 | P002 | Teclado mecánico | Tecnología | 230000.00 | 15 | NULL | | 3 | P003 | Cafetera | Hogar | 310000.00 | 12 | NULL | | 4 | P004 | Lámpara de escritorio | Hogar | 120000.00 | 0 | NULL | | 5 | P005 | Cuaderno argollado | General | 12000.00 | 0 | NULL | | 6 | P006 | Balón de fútbol | Deportes | 95000.00 | 0 | NULL | | 7 | P007 | Bicicleta estática | Deportes | 1450000.00 | 3 | NULL | | 8 | P008 | Libro "Cien años" de O'Brien | General | 45000.00 | 0 | NULL | | 9 | P009 | Resma de papel | General | 24000.00 | 0 | NULL | | 10 | P010 | Mancuernas 5 kg | Deportes | 180000.00 | 0 | NULL | +----+--------+-------------------------------+-------------+------------+-------+-----------+ 10 rows in set (0.000 sec)

Se pueden usar funciones como valor: NOW(), CURDATE(), UPPER('texto'), ROUND(12.345, 2). Verifica siempre con SELECT ... ORDER BY: sin ORDER BY el orden de las filas no está garantizado. Si las tildes se ven mal, conéctate con mysql -u root -p --default-character-set=utf8mb4.

Errores al insertar
MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio) VALUES ('P001', 'Otro mouse', 50000); ERROR 1062 (23000): Duplicate entry 'P001' for key 'codigo' MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre) VALUES ('P011'); ERROR 1136 (21S01): Column count doesn't match value count at row 1 MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio) VALUES ('P011', 'Silla', 90000; ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '' at line 1 MariaDB [tienda_demo]> INSERT INTO productos (codig, nombre) VALUES ('P011', 'Silla'); ERROR 1054 (42S22): Unknown column 'codig' in 'field list'
ErrorQué significaSolución
1062 Duplicate entryEse valor ya existe en una columna PRIMARY KEY o UNIQUE.Usa otro valor o mira "si el registro ya existe" más abajo.
1136 Column count doesn't matchDistinta cantidad de columnas y de valores.Cuenta columnas y valores.
1064 SQL syntaxFalta una coma, comilla o paréntesis (aquí, el paréntesis final).Revisa cerca del texto que indica near '...'.
1054 Unknown columnNombre de columna mal escrito.Revisa con DESCRIBE productos;
1364 / 1406 / 1366Campo obligatorio omitido / texto demasiado largo / valor no numérico.Ver el modo estricto, a continuación.
¡Cuidado! Modo estricto y advertencias

XAMPP viene configurado sin modo estricto: algunos datos inválidos no producen error sino una simple advertencia, y se guardan "corregidos" a la fuerza:

MariaDB [tienda_demo]> INSERT INTO productos (codigo, precio) VALUES ('P012', 5000); Query OK, 1 row affected, 1 warning (0.002 sec) MariaDB [tienda_demo]> SHOW WARNINGS; +---------+------+---------------------------------------------+ | Level | Code | Message | +---------+------+---------------------------------------------+ | Warning | 1364 | Field 'nombre' doesn't have a default value | +---------+------+---------------------------------------------+ MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio) -> VALUES ('P013', 'Silla ergonómica con apoyabrazos y cabecero ajustable', 'abc'); Query OK, 1 row affected, 2 warnings (0.002 sec) MariaDB [tienda_demo]> SHOW WARNINGS; +---------+------+---------------------------------------------------------------------------------------+ | Level | Code | Message | +---------+------+---------------------------------------------------------------------------------------+ | Warning | 1265 | Data truncated for column 'nombre' at row 1 | | Warning | 1366 | Incorrect decimal value: 'abc' for column `tienda_demo`.`productos`.`precio` at row 1 | +---------+------+---------------------------------------------------------------------------------------+ MariaDB [tienda_demo]> SELECT codigo, nombre, precio FROM productos WHERE codigo IN ('P012', 'P013'); +--------+-------------------------------------------+---------+ | codigo | nombre | precio | +--------+-------------------------------------------+---------+ | P012 | | 5000.00 | | P013 | Silla ergonómica con apoyabrazos y cabec | 0.00 | +--------+-------------------------------------------+---------+

Quedó un producto sin nombre y otro con el nombre cortado y precio 0. Por eso: si la respuesta dice warning, ejecuta SHOW WARNINGS;. Con el modo estricto activado, esas sentencias se rechazan y no se guarda nada:

MariaDB [tienda_demo]> SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; Query OK, 0 rows affected (0.001 sec) MariaDB [tienda_demo]> INSERT INTO productos (codigo, precio) VALUES ('P014', 5000); ERROR 1364 (HY000): Field 'nombre' doesn't have a default value MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio) VALUES ('P014', 'Silla ergonómica con apoyabrazos y cabecero ajustable', 100); ERROR 1406 (22001): Data too long for column 'nombre' at row 1 MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio) VALUES ('P014', 'Silla', 'abc'); ERROR 1366 (22007): Incorrect decimal value: 'abc' for column `tienda_demo`.`productos`.`precio` at row 1

SET SESSION aplica solo a tu conexión. Para dejarlo permanente se edita la línea sql_mode= de C:\xampp\mysql\bin\my.ini y se reinicia MySQL. Modo actual: SELECT @@sql_mode;

Nivel avanzado
Manejar duplicados, entender el contador automático y cargar muchos datos de forma segura.
Si el registro ya existe: IGNORE, ON DUPLICATE KEY UPDATE y REPLACE

Los ejemplos de esta parte y la siguiente parten de una tabla productos nueva con tres filas: P001 (id 1, stock 40), P002 (id 2) y P005 (id 3, categoría Papelería, stock 50).

SentenciaSi el código ya existe…Respuesta
INSERTError, no guarda nada.ERROR 1062
INSERT IGNORELo omite (solo deja una advertencia).0 rows affected, 1 warning
INSERT ... ON DUPLICATE KEY UPDATEActualiza la fila existente.1 row = insertó · 2 rows = actualizó · 0 rows = no cambió nada
REPLACEBorra la fila vieja e inserta una nueva.2 rows affected (1 borrada + 1 insertada)
MariaDB [tienda_demo]> INSERT IGNORE INTO productos (codigo, nombre, precio) VALUES ('P001', 'Otro mouse', 50000); Query OK, 0 rows affected, 1 warning (0.001 sec) MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio, stock) VALUES ('P001', 'Mouse inalámbrico', 65000, 5) -> ON DUPLICATE KEY UPDATE stock = stock + VALUES(stock); Query OK, 2 rows affected (0.001 sec) MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio, stock) VALUES ('P011', 'Audífonos', 85000, 7) -> ON DUPLICATE KEY UPDATE stock = stock + VALUES(stock); Query OK, 1 row affected (0.002 sec) MariaDB [tienda_demo]> SELECT id, codigo, nombre, stock FROM productos WHERE codigo IN ('P001', 'P011'); +----+--------+--------------------+-------+ | id | codigo | nombre | stock | +----+--------+--------------------+-------+ | 1 | P001 | Mouse inalámbrico | 45 | | 8 | P011 | Audífonos | 7 | +----+--------+--------------------+-------+

P001 ya existía: se sumaron 5 a su stock (40 → 45). P011 no existía: se insertó. VALUES(stock) es el valor que se intentaba insertar.

MariaDB [tienda_demo]> SELECT id, codigo, nombre, categoria, precio, stock FROM productos WHERE codigo = 'P005'; +----+--------+--------------------+------------+----------+-------+ | id | codigo | nombre | categoria | precio | stock | +----+--------+--------------------+------------+----------+-------+ | 3 | P005 | Cuaderno argollado | Papelería | 12000.00 | 50 | +----+--------+--------------------+------------+----------+-------+ MariaDB [tienda_demo]> REPLACE INTO productos (codigo, nombre, precio) VALUES ('P005', 'Cuaderno cuadriculado', 15000); Query OK, 2 rows affected (0.001 sec) MariaDB [tienda_demo]> SELECT id, codigo, nombre, categoria, precio, stock FROM productos WHERE codigo = 'P005'; +----+--------+-----------------------+-----------+----------+-------+ | id | codigo | nombre | categoria | precio | stock | +----+--------+-----------------------+-----------+----------+-------+ | 10 | P005 | Cuaderno cuadriculado | General | 15000.00 | 0 | +----+--------+-----------------------+-----------+----------+-------+

REPLACE es peligroso: como borra la fila y crea otra, cambió el id (3 → 10) y la categoría y el stock volvieron a sus valores por defecto porque no se indicaron. Para modificar un registro existente, lo correcto es UPDATE (3.3) o ON DUPLICATE KEY UPDATE.

Los huecos en AUTO_INCREMENT son normales
MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio) VALUES ('P001', 'Otro mouse', 50000); ERROR 1062 (23000): Duplicate entry 'P001' for key 'codigo' MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio) VALUES ('P006', 'Lápiz', 1500); Query OK, 1 row affected (0.001 sec) MariaDB [tienda_demo]> SELECT id, codigo FROM productos ORDER BY id; +----+--------+ | id | codigo | +----+--------+ | 1 | P001 | | 2 | P002 | | 3 | P005 | | 5 | P006 | +----+--------+

El id 4 se "gastó" en el INSERT que falló. Lo mismo pasa con INSERT IGNORE, ON DUPLICATE KEY UPDATE, ROLLBACK o al borrar filas (por eso Audífonos quedó con el id 8). Un id automático no se reutiliza y no sirve para contar filas: para eso usa SELECT COUNT(*).

Varias inserciones como un bloque: transacciones

Cada INSERT se guarda de inmediato (autocommit). Con una transacción, un grupo de cambios se confirma (COMMIT) o se deshace (ROLLBACK) completo:

MariaDB [tienda_demo]> START TRANSACTION; MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre, precio) VALUES ('T001', 'Prueba 1', 1000), ('T002', 'Prueba 2', 2000); Query OK, 2 rows affected (0.000 sec) Records: 2 Duplicates: 0 Warnings: 0 MariaDB [tienda_demo]> SELECT ROW_COUNT(); +-------------+ | ROW_COUNT() | +-------------+ | 2 | +-------------+ MariaDB [tienda_demo]> ROLLBACK; -- deshace las dos filas (con COMMIT; se guardarían) Query OK, 0 rows affected (0.001 sec)

ROW_COUNT() dice cuántas filas afectó la sentencia anterior. Si cierras la consola sin COMMIT, la transacción se deshace sola.

Muchas inserciones desde un archivo .sql

Escribe los INSERT en un archivo de texto (Bloc de notas, guardado en UTF-8 con extensión .sql), por ejemplo C:\datos\productos.sql:

USE tienda_demo; INSERT INTO productos (codigo, nombre, categoria, precio, stock) VALUES ('P201', 'Grapadora', 'Papelería', 15000, 30), ('P202', 'Olla a presión', 'Hogar', 180000, 5);
-- Desde CMD (fuera de la consola de MySQL) C:\xampp\mysql\bin> mysql -u root -p --default-character-set=utf8mb4 tienda_demo < C:\datos\productos.sql -- Desde la consola de MySQL MariaDB [tienda_demo]> SOURCE C:/datos/productos.sql

Con < se detiene en el primer error e indica la línea (ERROR 1062 (23000) at line 2: ...). Escribir en un archivo evita perder un INSERT largo por un error de tecleo.

Cargar datos desde un archivo CSV (Excel)

Un CSV es texto con un registro por línea y los campos separados por comas. Por ejemplo C:\datos\productos.csv:

codigo,nombre,categoria,precio,stock P101,"Monitor 24 pulgadas",Tecnología,720000,8 P102,"Silla, ergonómica",Hogar,890000,6 P103,"Bicicleta estática",Deportes,1450000,3
C:\xampp\mysql\bin> mysql -u root -p --local-infile=1 --default-character-set=utf8mb4 tienda_demo MariaDB [tienda_demo]> LOAD DATA LOCAL INFILE 'C:/datos/productos.csv' -> INTO TABLE productos -> FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' -> LINES TERMINATED BY '\r\n' -> IGNORE 1 LINES -> (codigo, nombre, categoria, precio, stock); Query OK, 3 rows affected (0.001 sec) Records: 3 Deleted: 0 Skipped: 0 Warnings: 0 MariaDB [tienda_demo]> SELECT id, codigo, nombre, categoria, precio, stock FROM productos WHERE codigo LIKE 'P1%'; +----+--------+---------------------+-------------+------------+-------+ | id | codigo | nombre | categoria | precio | stock | +----+--------+---------------------+-------------+------------+-------+ | 11 | P101 | Monitor 24 pulgadas | Tecnología | 720000.00 | 8 | | 12 | P102 | Silla, ergonómica | Hogar | 890000.00 | 6 | | 13 | P103 | Bicicleta estática | Deportes | 1450000.00 | 3 | +----+--------+---------------------+-------------+------------+-------+
CláusulaSignificado
--local-infile=1Opción al abrir la consola que permite leer archivos del equipo. Sin ella: ERROR 1148 (42000): The used command is not allowed with this MariaDB version.
FIELDS TERMINATED BY ','Separador de campos. El Excel en español suele guardar con punto y coma: usa ';'.
OPTIONALLY ENCLOSED BY '"'Textos entre comillas dobles: así la coma de "Silla, ergonómica" no se confunde con un separador.
LINES TERMINATED BY '\r\n'Fin de línea de Windows (Excel, Bloc de notas). Para archivos de Linux: '\n'.
IGNORE 1 LINESSalta la fila de títulos.
(codigo, nombre, ...)Columna de la tabla para cada campo del archivo, en orden.
Trucos de la consola
  • ↑ / ↓ recuperan sentencias anteriores para corregirlas.
  • \c cancela la sentencia que estás escribiendo.
  • Si el indicador cambia a '> o ">, dejaste una comilla sin cerrar: ciérrala y escribe \c.
  • Clic derecho en la ventana de CMD pega el texto copiado.

3.2. Insertar datos en tablas relacionadas

Nivel básico
Insertar respetando el orden de las tablas relacionadas.

Cuando una tabla tiene una llave foránea (FOREIGN KEY), cada valor de esa columna debe existir en la tabla relacionada. La tabla referenciada se llama padre (por ejemplo categorias) y la que guarda la referencia se llama hija (articulos).

MariaDB [tienda_demo]> CREATE TABLE categorias (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(30) NOT NULL UNIQUE); MariaDB [tienda_demo]> CREATE TABLE articulos ( -> id INT AUTO_INCREMENT PRIMARY KEY, -> nombre VARCHAR(40) NOT NULL, -> categoria_id INT NOT NULL, -> CONSTRAINT fk_art_cat FOREIGN KEY (categoria_id) REFERENCES categorias(id) -> ON DELETE RESTRICT ON UPDATE CASCADE -> ); MariaDB [tienda_demo]> CREATE TABLE pedidos (id INT AUTO_INCREMENT PRIMARY KEY, cliente VARCHAR(40) NOT NULL, fecha DATE NOT NULL); MariaDB [tienda_demo]> CREATE TABLE detalle_pedido ( -> pedido_id INT NOT NULL, -> articulo_id INT NOT NULL, -> cantidad INT NOT NULL, -> PRIMARY KEY (pedido_id, articulo_id), -> CONSTRAINT fk_det_ped FOREIGN KEY (pedido_id) REFERENCES pedidos(id) ON DELETE CASCADE, -> CONSTRAINT fk_det_art FOREIGN KEY (articulo_id) REFERENCES articulos(id) -> );
Regla 1: primero el padre, después el hijo
MariaDB [tienda_demo]> INSERT INTO categorias (nombre) VALUES ('Tecnología'), ('Hogar'); Query OK, 2 rows affected (0.001 sec) Records: 2 Duplicates: 0 Warnings: 0 MariaDB [tienda_demo]> INSERT INTO articulos (nombre, categoria_id) VALUES ('Mouse', 1), ('Teclado', 1), ('Cafetera', 2); Query OK, 3 rows affected (0.002 sec) Records: 3 Duplicates: 0 Warnings: 0 MariaDB [tienda_demo]> SELECT a.id, a.nombre, c.nombre AS categoria FROM articulos a JOIN categorias c ON c.id = a.categoria_id; +----+----------+-------------+ | id | nombre | categoria | +----+----------+-------------+ | 1 | Mouse | Tecnología | | 2 | Teclado | Tecnología | | 3 | Cafetera | Hogar | +----+----------+-------------+

Si el padre no existe, el hijo se rechaza:

MariaDB [tienda_demo]> INSERT INTO articulos (nombre, categoria_id) VALUES ('Balón', 3); ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`tienda_demo`.`articulos`, CONSTRAINT `fk_art_cat` FOREIGN KEY (`categoria_id`) REFERENCES `categorias` (`id`) ON UPDATE CASCADE)

El mensaje dice qué tabla hija (articulos), qué restricción (fk_art_cat) y a qué tabla padre apunta (categorias). Solución: insertar primero la categoría 3, o usar un id que exista.

Cuidado con suponer los id. Un INSERT que falla también "gasta" un número de AUTO_INCREMENT. Si un artículo falla y luego se inserta otro, este no recibe el id que esperabas, y cualquier fila hija que use ese id fijo fallará o quedará relacionada con el registro equivocado. Por eso es mejor obtener el id real, como se muestra a continuación.
Nivel intermedio
Enlazar filas usando el id real y guardar pedido y detalle juntos.
Regla 2: usar el id real, no suponerlo
-- Buscar el id del padre por su nombre dentro del mismo INSERT MariaDB [tienda_demo]> INSERT INTO articulos (nombre, categoria_id) -> VALUES ('Licuadora', (SELECT id FROM categorias WHERE nombre = 'Hogar')); Query OK, 1 row affected (0.000 sec)
Maestro-detalle: LAST_INSERT_ID() y una transacción

Un pedido (maestro) y sus líneas (detalle) deben guardarse juntos: o todo, o nada.

MariaDB [tienda_demo]> START TRANSACTION; Query OK, 0 rows affected (0.000 sec) MariaDB [tienda_demo]> INSERT INTO pedidos (cliente, fecha) VALUES ('Ana Torres', '2026-10-12'); Query OK, 1 row affected (0.001 sec) MariaDB [tienda_demo]> SET @pedido = LAST_INSERT_ID(); -- guarda el id recién creado Query OK, 0 rows affected (0.000 sec) MariaDB [tienda_demo]> INSERT INTO detalle_pedido (pedido_id, articulo_id, cantidad) VALUES (@pedido, 1, 2), (@pedido, 3, 1); Query OK, 2 rows affected (0.000 sec) Records: 2 Duplicates: 0 Warnings: 0 MariaDB [tienda_demo]> COMMIT; Query OK, 0 rows affected (0.000 sec) MariaDB [tienda_demo]> SELECT p.id, p.cliente, a.nombre AS articulo, d.cantidad -> FROM pedidos p JOIN detalle_pedido d ON d.pedido_id = p.id JOIN articulos a ON a.id = d.articulo_id; +----+------------+----------+----------+ | id | cliente | articulo | cantidad | +----+------------+----------+----------+ | 1 | Ana Torres | Mouse | 2 | | 1 | Ana Torres | Cafetera | 1 | +----+------------+----------+----------+
  • @pedido es una variable de sesión: guarda el id del pedido para usarlo en las líneas de detalle sin escribirlo a mano.
  • Si alguna línea fallara, se ejecuta ROLLBACK; en lugar de COMMIT; y no queda un pedido sin líneas.
Nivel avanzado
Copiar datos entre tablas y conocer el riesgo de desactivar las llaves foráneas.
Copiar datos entre tablas: INSERT ... SELECT
MariaDB [tienda_demo]> CREATE TABLE articulos_hogar (nombre VARCHAR(40)); Query OK, 0 rows affected (0.003 sec) MariaDB [tienda_demo]> INSERT INTO articulos_hogar (nombre) -> SELECT a.nombre FROM articulos a JOIN categorias c ON c.id = a.categoria_id WHERE c.nombre = 'Hogar'; Query OK, 2 rows affected (0.001 sec) Records: 2 Duplicates: 0 Warnings: 0 MariaDB [tienda_demo]> SELECT * FROM articulos_hogar; +-----------+ | nombre | +-----------+ | Cafetera | | Licuadora | +-----------+

En lugar de VALUES va un SELECT (con JOIN si hace falta). Las columnas del SELECT deben coincidir en cantidad y orden con la lista del INSERT.

Desactivar la verificación de llaves foráneas (solo para cargas especiales)
MariaDB [tienda_demo]> SET FOREIGN_KEY_CHECKS = 0; Query OK, 0 rows affected (0.000 sec) MariaDB [tienda_demo]> INSERT INTO articulos (nombre, categoria_id) VALUES ('Huérfano', 555); Query OK, 1 row affected (0.001 sec) MariaDB [tienda_demo]> SET FOREIGN_KEY_CHECKS = 1; Query OK, 0 rows affected (0.000 sec) MariaDB [tienda_demo]> SELECT a.nombre, a.categoria_id, c.nombre AS categoria -> FROM articulos a LEFT JOIN categorias c ON c.id = a.categoria_id; +-----------+--------------+-------------+ | nombre | categoria_id | categoria | +-----------+--------------+-------------+ | Mouse | 1 | Tecnología | | Teclado | 1 | Tecnología | | Huérfano | 555 | NULL | +-----------+--------------+-------------+

Con FOREIGN_KEY_CHECKS = 0 se pudo guardar un artículo apuntando a una categoría que no existe (un registro huérfano), y volver a activarla no lo detecta. Los respaldos de mysqldump lo usan para cargar tablas en cualquier orden, pero en el trabajo diario no se debe usar.

Resumen
  1. Inserta primero las tablas padre y luego las hijas.
  2. No supongas los id: búscalos o guárdalos con LAST_INSERT_ID().
  3. Agrupa maestro y detalle en una transacción.
  4. El error 1452 siempre significa "el valor no existe en la tabla padre".

3.3. Modificar datos: UPDATE

Nivel básico
Modificar filas de forma controlada y comprobar el resultado.
UPDATE nombre_tabla SET columna1 = valor1, columna2 = valor2 WHERE condición;
Regla de oro: sin WHERE, el UPDATE modifica todas las filas de la tabla. Antes de un UPDATE, ejecuta un SELECT con el mismo WHERE para ver exactamente qué filas vas a cambiar.

Los ejemplos usan la tabla productos de la sección 3.1.

Revisar, actualizar y comprobar
MariaDB [tienda_demo]> SELECT codigo, nombre, precio, stock FROM productos WHERE categoria = 'Hogar'; +--------+------------------------+-----------+-------+ | codigo | nombre | precio | stock | +--------+------------------------+-----------+-------+ | P003 | Cafetera | 310000.00 | 12 | | P004 | Lámpara de escritorio | 120000.00 | 0 | +--------+------------------------+-----------+-------+ MariaDB [tienda_demo]> UPDATE productos SET stock = 25 WHERE codigo = 'P004'; Query OK, 1 row affected (0.009 sec) Rows matched: 1 Changed: 1 Warnings: 0 MariaDB [tienda_demo]> UPDATE productos SET precio = precio * 1.10 WHERE categoria = 'Hogar'; Query OK, 2 rows affected (0.002 sec) Rows matched: 2 Changed: 2 Warnings: 0 MariaDB [tienda_demo]> SELECT codigo, nombre, precio, stock FROM productos WHERE categoria = 'Hogar'; +--------+------------------------+-----------+-------+ | codigo | nombre | precio | stock | +--------+------------------------+-----------+-------+ | P003 | Cafetera | 341000.00 | 12 | | P004 | Lámpara de escritorio | 132000.00 | 25 | +--------+------------------------+-----------+-------+
  • El nuevo valor puede calcularse a partir del actual: precio = precio * 1.10 sube el precio un 10 %, stock = stock + 5 suma 5.
  • Rows matched = filas que cumplieron el WHERE. Changed = filas que realmente cambiaron.
Leer la respuesta: matched vs. changed
-- Se repite el mismo cambio: la fila coincide, pero ya tenía ese valor MariaDB [tienda_demo]> UPDATE productos SET stock = 25 WHERE codigo = 'P004'; Query OK, 0 rows affected (0.000 sec) Rows matched: 1 Changed: 0 Warnings: 0 -- Ninguna fila cumple el WHERE (no es un error) MariaDB [tienda_demo]> UPDATE productos SET stock = stock + 5 WHERE codigo = 'P999'; Query OK, 0 rows affected (0.001 sec) Rows matched: 0 Changed: 0 Warnings: 0

Rows matched: 0 no es un error: simplemente no había filas con ese código. Revisa el WHERE si esperabas cambios.

Nivel intermedio
Actualizar varias columnas, calcular valores distintos según condiciones y limitar las filas.
Varias columnas, varias condiciones y NULL
MariaDB [tienda_demo]> UPDATE productos SET descuento = 0.15, categoria = 'Oferta' WHERE codigo = 'P003'; Query OK, 1 row affected (0.001 sec) Rows matched: 1 Changed: 1 Warnings: 0 MariaDB [tienda_demo]> UPDATE productos SET stock = 0 WHERE categoria = 'Tecnología' OR codigo = 'P004'; Query OK, 3 rows affected (0.001 sec) Rows matched: 3 Changed: 3 Warnings: 0 MariaDB [tienda_demo]> UPDATE productos SET descuento = NULL WHERE descuento IS NOT NULL AND precio < 150000; Query OK, 1 row affected (0.001 sec) Rows matched: 1 Changed: 1 Warnings: 0

Para dejar una columna vacía se usa SET col = NULL; para buscar vacíos, WHERE col IS NULL (nunca = NULL).

Valores distintos según una condición: CASE
MariaDB [tienda_demo]> UPDATE productos SET descuento = CASE -> WHEN precio >= 1000000 THEN 0.20 -> WHEN precio >= 100000 THEN 0.10 -> ELSE NULL -> END; Query OK, 5 rows affected (0.001 sec) Rows matched: 10 Changed: 5 Warnings: 0 MariaDB [tienda_demo]> SELECT codigo, precio, descuento FROM productos ORDER BY precio DESC; +--------+------------+-----------+ | codigo | precio | descuento | +--------+------------+-----------+ | P007 | 1450000.00 | 0.20 | | P003 | 341000.00 | 0.10 | | P002 | 230000.00 | 0.10 | | P010 | 180000.00 | 0.10 | | P004 | 132000.00 | 0.10 | | P006 | 95000.00 | NULL | | P001 | 65000.00 | NULL | | P008 | 45000.00 | NULL | | P009 | 24000.00 | NULL | | P005 | 12000.00 | NULL | +--------+------------+-----------+

Este UPDATE no tiene WHERE a propósito: recorre las 10 filas (matched: 10) y solo cambian las 5 que no tenían ya el valor correcto (Changed: 5).

Limitar cuántas filas: ORDER BY + LIMIT
MariaDB [tienda_demo]> UPDATE productos SET stock = stock + 1 ORDER BY precio DESC LIMIT 2; Query OK, 2 rows affected (0.001 sec) Rows matched: 2 Changed: 2 Warnings: 0

Suma 1 al stock de los 2 productos más caros. Sin ORDER BY, "los 2 primeros" serían arbitrarios.

Errores al actualizar
MariaDB [tienda_demo]> UPDATE productos SET codigo = 'P002' WHERE codigo = 'P003'; ERROR 1062 (23000): Duplicate entry 'P002' for key 'codigo'

Las mismas reglas del INSERT se aplican al UPDATE: UNIQUE, NOT NULL, tipos de datos, modo estricto (si ves warning, usa SHOW WARNINGS;) y llaves foráneas (ver más abajo).

Nivel avanzado
Protegerse de errores graves y actualizar usando datos de otras tablas.
Protección: modo de actualizaciones seguras
MariaDB [tienda_demo]> SET SESSION sql_safe_updates = 1; Query OK, 0 rows affected (0.002 sec) MariaDB [tienda_demo]> UPDATE productos SET stock = 0; ERROR 1175 (HY000): You are using safe update mode and you tried to update a table without a WHERE that uses a KEY column MariaDB [tienda_demo]> UPDATE productos SET stock = 0 WHERE categoria = 'NoExiste'; ERROR 1175 (HY000): You are using safe update mode and you tried to update a table without a WHERE that uses a KEY column

Con sql_safe_updates = 1, MySQL solo permite UPDATE y DELETE cuyo WHERE use una columna con índice (como id o codigo) o que tengan LIMIT. Evita el clásico "olvidé el WHERE". Se puede abrir la consola así desde el inicio: mysql -u root -p --safe-updates.

Probar antes de confirmar: transacciones
MariaDB [tienda_demo]> START TRANSACTION; MariaDB [tienda_demo]> UPDATE productos SET precio = 1; -- ¡error: se olvidó el WHERE! Query OK, 10 rows affected (0.000 sec) Rows matched: 10 Changed: 10 Warnings: 0 MariaDB [tienda_demo]> SELECT codigo, precio FROM productos ORDER BY id LIMIT 3; +--------+--------+ | codigo | precio | +--------+--------+ | P001 | 1.00 | | P002 | 1.00 | | P003 | 1.00 | +--------+--------+ MariaDB [tienda_demo]> ROLLBACK; Query OK, 0 rows affected (0.001 sec) MariaDB [tienda_demo]> SELECT codigo, precio FROM productos ORDER BY id LIMIT 3; +--------+-----------+ | codigo | precio | +--------+-----------+ | P001 | 65000.00 | | P002 | 230000.00 | | P003 | 341000.00 | +--------+-----------+

Dentro de una transacción, un UPDATE equivocado se deshace con ROLLBACK. Sin transacción (autocommit), el cambio es inmediato y la única salida es un respaldo.

UPDATE con tablas relacionadas

Con las tablas de la sección 3.2 (categorias y articulos, con ON UPDATE CASCADE):

MariaDB [tienda_demo]> UPDATE articulos SET categoria_id = 99 WHERE nombre = 'Mouse'; ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (...) -- Cambiar el id del padre: ON UPDATE CASCADE actualiza también a los hijos MariaDB [tienda_demo]> UPDATE categorias SET id = 20 WHERE nombre = 'Hogar'; Query OK, 1 row affected (0.001 sec) Rows matched: 1 Changed: 1 Warnings: 0 MariaDB [tienda_demo]> SELECT nombre, categoria_id FROM articulos ORDER BY id; +-----------+--------------+ | nombre | categoria_id | +-----------+--------------+ | Mouse | 1 | | Teclado | 1 | | Cafetera | 20 | | Licuadora | 20 | +-----------+--------------+ -- UPDATE con JOIN: modificar una tabla usando datos de otra MariaDB [tienda_demo]> UPDATE articulos a JOIN categorias c ON c.id = a.categoria_id -> SET a.nombre = CONCAT(a.nombre, ' (', c.nombre, ')') -> WHERE c.nombre = 'Hogar'; Query OK, 2 rows affected (0.001 sec) Rows matched: 2 Changed: 2 Warnings: 0 MariaDB [tienda_demo]> SELECT nombre FROM articulos ORDER BY id; +-------------------+ | nombre | +-------------------+ | Mouse | | Teclado | | Cafetera (Hogar) | | Licuadora (Hogar) | +-------------------+
  • ON UPDATE CASCADE: al cambiar el id en el padre, los hijos se actualizan solos (Cafetera y Licuadora pasaron a la categoría 20).
  • Sin ON UPDATE CASCADE (o con RESTRICT), cambiar el id de un padre con hijos da el error 1451.
UPDATE con una subconsulta sobre la misma tabla
MariaDB [sq]> UPDATE productos SET precio = precio * 1.1 -> WHERE precio < (SELECT AVG(precio) FROM productos); Query OK, 1 row affected (0.006 sec) Rows matched: 1 Changed: 1 Warnings: 0

Sube el 10 % a los productos más baratos que el promedio. En el MariaDB de XAMPP (10.3 o superior) funciona, pero en MySQL la misma sentencia da ERROR 1093: You can't specify target table 'productos' for update in FROM clause. La forma que funciona en ambos es envolver la subconsulta en una tabla derivada: WHERE precio < (SELECT promedio FROM (SELECT AVG(precio) AS promedio FROM productos) AS t).

Resumen
  1. Primero un SELECT con el mismo WHERE; luego el UPDATE.
  2. Lee Rows matched y Changed.
  3. Para cambios grandes, usa START TRANSACTION y confirma con COMMIT solo después de revisar.
  4. Activa sql_safe_updates si trabajas sobre datos importantes.

3.4. Eliminar datos: DELETE y TRUNCATE

Nivel básico
Eliminar filas de forma controlada.
DELETE FROM nombre_tabla WHERE condición; -- elimina las filas que cumplen la condición TRUNCATE TABLE nombre_tabla; -- vacía la tabla completa
Igual que en UPDATE: sin WHERE, DELETE borra todas las filas. Primero ejecuta un SELECT con el mismo WHERE y revisa qué se va a borrar.
Revisar y eliminar
MariaDB [tienda_demo]> SELECT codigo, nombre FROM productos WHERE stock = 0; +--------+-------------------------------+ | codigo | nombre | +--------+-------------------------------+ | P001 | Mouse inalámbrico | | P002 | Teclado mecánico | | P004 | Lámpara de escritorio | | P005 | Cuaderno argollado | | P006 | Balón de fútbol | | P008 | Libro "Cien años" de O'Brien | | P009 | Resma de papel | | P010 | Mancuernas 5 kg | +--------+-------------------------------+ 8 rows in set (0.000 sec) MariaDB [tienda_demo]> DELETE FROM productos WHERE stock = 0; Query OK, 8 rows affected (0.001 sec) MariaDB [tienda_demo]> SELECT COUNT(*) FROM productos; +----------+ | COUNT(*) | +----------+ | 2 | +----------+ MariaDB [tienda_demo]> DELETE FROM productos WHERE codigo = 'P999'; Query OK, 0 rows affected (0.000 sec)

El SELECT previo mostró 8 filas y el DELETE confirmó 8 rows affected: coinciden, así que se borró exactamente lo esperado. 0 rows affected no es un error: ninguna fila cumplía la condición.

Nivel intermedio
Limitar lo que se borra, poder deshacerlo y conocer la diferencia con TRUNCATE.
Borrar solo algunas: ORDER BY + LIMIT
MariaDB [tienda_demo]> DELETE FROM productos ORDER BY creado_en LIMIT 1; -- el producto más antiguo Query OK, 1 row affected (0.002 sec)
Deshacer un DELETE: transacciones
MariaDB [tienda_demo]> START TRANSACTION; MariaDB [tienda_demo]> DELETE FROM productos; Query OK, 1 row affected (0.001 sec) MariaDB [tienda_demo]> SELECT COUNT(*) FROM productos; +----------+ | COUNT(*) | +----------+ | 0 | +----------+ MariaDB [tienda_demo]> ROLLBACK; Query OK, 0 rows affected (0.001 sec) MariaDB [tienda_demo]> SELECT COUNT(*) FROM productos; +----------+ | COUNT(*) | +----------+ | 1 | +----------+
Protección: modo de actualizaciones seguras
MariaDB [tienda_demo]> SET SESSION sql_safe_updates = 1; MariaDB [tienda_demo]> DELETE FROM productos; ERROR 1175 (HY000): You are using safe update mode and you tried to update a table without a WHERE that uses a KEY column
DELETE vs. TRUNCATE: el contador AUTO_INCREMENT
-- Después de DELETE FROM productos (todas las filas): MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre) VALUES ('N001', 'Nuevo después de DELETE'); MariaDB [tienda_demo]> SELECT id, codigo FROM productos; +----+--------+ | id | codigo | +----+--------+ | 12 | N001 | +----+--------+ MariaDB [tienda_demo]> TRUNCATE TABLE productos; Query OK, 0 rows affected (0.030 sec) MariaDB [tienda_demo]> INSERT INTO productos (codigo, nombre) VALUES ('N002', 'Nuevo después de TRUNCATE'); MariaDB [tienda_demo]> SELECT id, codigo FROM productos; +----+--------+ | id | codigo | +----+--------+ | 1 | N002 | +----+--------+

DELETE conserva el contador: el siguiente id siguió en 12. TRUNCATE vacía la tabla y reinicia el contador en 1. Observa también que TRUNCATE responde 0 rows affected aunque haya borrado filas: no las borra una por una, sino que recrea la tabla vacía.

Nivel avanzado
Borrar en tablas relacionadas, comparar las formas de eliminar y alternativas más seguras.
TRUNCATE no se puede deshacer
MariaDB [tienda_demo]> START TRANSACTION; MariaDB [tienda_demo]> TRUNCATE TABLE productos; Query OK, 0 rows affected (0.030 sec) MariaDB [tienda_demo]> ROLLBACK; Query OK, 0 rows affected (0.000 sec) MariaDB [tienda_demo]> SELECT COUNT(*) FROM productos; +----------+ | COUNT(*) | +----------+ | 0 | +----------+

TRUNCATE es técnicamente una sentencia DDL (sección 2.10): hace un COMMIT automático y el ROLLBACK no recupera los datos.

DELETE con tablas relacionadas

Con las tablas de la sección 3.2. Lo que ocurre al borrar un padre depende de la opción ON DELETE de la llave foránea:

OpciónAl borrar el padre…
RESTRICT / NO ACTION (por defecto)Se impide el borrado si tiene hijos (error 1451).
CASCADESe borran también sus hijos automáticamente.
SET NULLLos hijos se conservan con la columna en NULL (la columna debe permitir NULL).
-- articulos → categorias tiene ON DELETE RESTRICT MariaDB [tienda_demo]> DELETE FROM categorias WHERE nombre = 'Tecnología'; ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`tienda_demo`.`articulos`, CONSTRAINT `fk_art_cat` FOREIGN KEY (`categoria_id`) REFERENCES `categorias` (`id`) ON UPDATE CASCADE) -- detalle_pedido → pedidos tiene ON DELETE CASCADE MariaDB [tienda_demo]> SELECT COUNT(*) AS lineas_detalle FROM detalle_pedido; +----------------+ | lineas_detalle | +----------------+ | 2 | +----------------+ MariaDB [tienda_demo]> DELETE FROM pedidos WHERE id = 1; Query OK, 1 row affected (0.001 sec) MariaDB [tienda_demo]> SELECT COUNT(*) AS lineas_detalle FROM detalle_pedido; +----------------+ | lineas_detalle | +----------------+ | 0 | +----------------+

Con RESTRICT hay que borrar (o reasignar) primero los hijos. Con CASCADE se borraron las 2 líneas del pedido aunque la respuesta dijo 1 row affected: el borrado en cascada no se cuenta en el mensaje. Úsalo con cuidado.

-- TRUNCATE no se permite en una tabla que es padre de otra MariaDB [tienda_demo]> TRUNCATE TABLE categorias; ERROR 1701 (42000): Cannot truncate a table referenced in a foreign key constraint (`tienda_demo`.`articulos`, CONSTRAINT `fk_art_cat` FOREIGN KEY (`categoria_id`) REFERENCES `tienda_demo`.`categorias` (`id`)) -- DELETE con JOIN: borrar filas de una tabla según datos de otra MariaDB [tienda_demo]> DELETE a FROM articulos a JOIN categorias c ON c.id = a.categoria_id WHERE c.nombre = 'Hogar'; Query OK, 2 rows affected (0.001 sec) MariaDB [tienda_demo]> SELECT nombre FROM articulos ORDER BY id; +---------+ | nombre | +---------+ | Mouse | | Teclado | +---------+

En el DELETE con JOIN, el alias escrito después de DELETE (DELETE a) indica de qué tabla se borran las filas.

DELETE, TRUNCATE y DROP: comparación
DELETETRUNCATEDROP TABLE
FamiliaDMLDDLDDL
Qué eliminaLas filas que cumplen el WHERE (o todas)Todas las filasLa tabla completa: estructura y datos
Admite WHERESíNoNo
Contador AUTO_INCREMENTSe conservaVuelve a 1Desaparece con la tabla
Se deshace con ROLLBACKSí, dentro de una transacciónNoNo
Con llaves foráneasAplica RESTRICT / CASCADE / SET NULLError 1701 si es tabla padreError 1451 si es tabla padre
Velocidad en tablas grandesLento (fila por fila)Muy rápidoMuy rápido
Buena práctica: borrado lógico

En muchos sistemas no se borran los registros, sino que se marcan como inactivos para conservar el historial y poder recuperarlos:

ALTER TABLE productos ADD COLUMN activo BOOLEAN NOT NULL DEFAULT TRUE; -- una sola vez (DDL) UPDATE productos SET activo = FALSE WHERE codigo = 'P004'; -- "borrar" SELECT * FROM productos WHERE activo = TRUE; -- consultar solo los vigentes
DELETE con una subconsulta sobre la misma tabla
MariaDB [sq]> DELETE FROM productos WHERE precio = (SELECT MAX(precio) FROM productos); Query OK, 1 row affected (0.004 sec) -- Forma compatible también con MySQL: JOIN con una tabla derivada MariaDB [sq]> DELETE p FROM productos p JOIN (SELECT MAX(precio) AS maximo FROM productos) m ON p.precio = m.maximo; Query OK, 1 row affected (0.002 sec)

Igual que en UPDATE (3.3): la primera forma funciona en MariaDB 10.3+, pero en MySQL da el error 1093; la segunda funciona en ambos.

Resumen
  1. Antes de borrar: SELECT con el mismo WHERE y, si es importante, un respaldo (sección 11.1).
  2. Usa DELETE dentro de una transacción cuando quieras poder deshacerlo.
  3. Usa TRUNCATE solo para vaciar una tabla por completo, sabiendo que no tiene vuelta atrás.
  4. Conoce el ON DELETE de tus llaves foráneas antes de borrar un registro padre.

4. Consultas SQL: SELECT y JOIN

Una consulta le pide datos a la base de datos sin modificarlos. Siempre empieza con SELECT y su resultado es una tabla temporal (filas y columnas) que se muestra y luego desaparece. En este módulo aprenderás a filtrar, calcular, resumir y combinar información de una o varias tablas.

Base de ejemplo del manual: una veterinaria con 3 tablas relacionadas. Todos los resultados de esta sección se calcularon ejecutando las consultas sobre estos datos. (Los ejercicios usan otras bases: tienda, empresa, academia y biblioteca.)

duenos: personas dueñas de mascotas. Andrés no tiene mascotas.

idnombreciudadtelefono
1Marta RojasBogotá3001112233
2Jorge SilvaMedellínNULL
3Lucía PeñaBogotá3104445566
4Andrés MejíaCali3157778899

mascotas.dueno_id apunta a duenos.id. Toby no tiene dueño (NULL) y Luna no tiene raza.

idnombreespecierazaedadpesodueno_id
1FirulaisPerroLabrador528.501
2MichiGatoSiamés34.201
3RockyPerroBulldog722.002
4LunaGatoNULL13.103
5CocoAveLoro120.453
6TobyPerroBeagle211.30NULL
7NalaGatoPersa45.002

citas.mascota_id apunta a mascotas.id. Toby y Nala nunca han tenido cita.

idmascota_idfechamotivocosto
112024-01-10Vacunación45000.00
222024-01-15Control30000.00
312024-02-20Cirugía350000.00
432024-02-22Vacunación45000.00
542024-03-05Control30000.00
632024-03-18Desparasitación25000.00
752024-04-02Control30000.00
812024-04-25Control30000.00
Nivel básico
Leer datos de una tabla: elegir columnas, ordenar, limitar y filtrar filas.

4.1. Anatomía de una consulta y orden de ejecución

SELECT columnas o cálculos -- 5. qué mostrar FROM tabla -- 1. de dónde JOIN otra_tabla ON condición -- 1. (combinar tablas) WHERE condición sobre filas -- 2. qué filas conservar GROUP BY columnas -- 3. cómo agrupar HAVING condición sobre grupos -- 4. qué grupos conservar ORDER BY columnas ASC | DESC -- 6. en qué orden LIMIT n; -- 7. cuántas filas

Las cláusulas se escriben en ese orden, pero MySQL las ejecuta en este otro:

  1. FROM / JOIN
  2. WHERE
  3. GROUP BY
  4. HAVING
  5. SELECT
  6. ORDER BY
  7. LIMIT

Esto explica dos reglas que confunden al principio: WHERE no puede usar funciones de agregado (todavía no existen los grupos) y ORDER BY sí puede usar los alias definidos en el SELECT (ya se calcularon). Solo SELECT y FROM son obligatorias; las demás se usan cuando se necesitan.

4.2. SELECT: elegir columnas, alias y cálculos

Elige las columnas separadas por comas, en el orden en que quieres verlas. SELECT * trae todas, pero en los ejercicios debes mostrar exactamente las columnas pedidas.

SELECT nombre, especie, edad FROM mascotas;
Resultado (7 filas)
nombreespecieedad
FirulaisPerro5
MichiGato3
RockyPerro7
LunaGato1
CocoAve12
TobyPerro2
NalaGato4

Con AS renombras una columna en el resultado. También puedes calcular con + - * /.

SELECT nombre AS mascota, peso * 1000 AS peso_gramos FROM mascotas;
Resultado (7 filas)
mascotapeso_gramos
Firulais28500.00
Michi4200.00
Rocky22000.00
Luna3100.00
Coco450.00
Toby11300.00
Nala5000.00

4.3. ORDER BY, LIMIT y OFFSET

ASC (por defecto) ordena de menor a mayor; DESC de mayor a menor.

SELECT nombre, edad FROM mascotas ORDER BY edad DESC;
Resultado (7 filas)
nombreedad
Coco12
Rocky7
Firulais5
Nala4
Michi3
Toby2
Luna1

Con varias columnas, la segunda desempata a la primera: primero por especie y, dentro de cada especie, por nombre.

SELECT especie, nombre FROM mascotas ORDER BY especie, nombre;
Resultado (7 filas)
especienombre
AveCoco
GatoLuna
GatoMichi
GatoNala
PerroFirulais
PerroRocky
PerroToby

LIMIT n devuelve solo las primeras n filas. Úsalo SIEMPRE después de ORDER BY para que "los primeros" tengan sentido (aquí: las 3 mascotas más pesadas).

SELECT nombre, peso FROM mascotas ORDER BY peso DESC LIMIT 3;
Resultado (3 filas)
nombrepeso
Firulais28.50
Rocky22.00
Toby11.30

OFFSET n salta las primeras n filas: es la base de la paginación (página 2 de 3 en 3). También se escribe LIMIT 3, 3 (desde, cuántas).

SELECT nombre, peso FROM mascotas ORDER BY peso DESC LIMIT 3 OFFSET 3;
Resultado (3 filas)
nombrepeso
Nala5.00
Michi4.20
Luna3.10

Al ordenar de forma ascendente, los NULL salen primero (Luna no tiene raza). Con DESC salen al final.

SELECT nombre, raza FROM mascotas ORDER BY raza;
Resultado (7 filas)
nombreraza
LunaNULL
TobyBeagle
RockyBulldog
FirulaisLabrador
CocoLoro
NalaPersa
MichiSiamés

4.4. WHERE: filtrar filas

OperadorSignificadoEjemplo
= <> < > <= >=Comparaciones (<> o != = distinto)edad >= 5
AND OR NOTCombinar condiciones. AND se evalúa antes que OR: usa paréntesisa = 1 AND (b = 2 OR c = 3)
BETWEEN a AND bRango incluyendo ambos extremosprecio BETWEEN 10 AND 20
IN (...) / NOT INPertenece (o no) a una listaciudad IN ('Cali','Pasto')
LIKEPatrón de texto: % = cualquier cantidad de caracteres, _ = exactamente unonombre LIKE 'A%'
IS NULL / IS NOT NULLValor ausenteemail IS NULL
SELECT nombre, edad FROM mascotas WHERE edad >= 5;
Resultado (3 filas)
nombreedad
Firulais5
Rocky7
Coco12

Perros que sean muy jóvenes o muy viejos. Sin los paréntesis, la condición cambiaría de significado.

SELECT nombre, especie, edad FROM mascotas WHERE especie = 'Perro' AND (edad < 3 OR edad > 6);
Resultado (2 filas)
nombreespecieedad
RockyPerro7
TobyPerro2

Las fechas se escriben como texto 'AAAA-MM-DD'.

SELECT id, fecha, costo FROM citas WHERE fecha BETWEEN '2024-02-01' AND '2024-03-31';
Resultado (4 filas)
idfechacosto
32024-02-20350000.00
42024-02-2245000.00
52024-03-0530000.00
62024-03-1825000.00
SELECT nombre, especie FROM mascotas WHERE especie IN ('Gato', 'Ave');
Resultado (4 filas)
nombreespecie
MichiGato
LunaGato
CocoAve
NalaGato
SELECT nombre, especie FROM mascotas WHERE especie NOT IN ('Gato', 'Ave');
Resultado (3 filas)
nombreespecie
FirulaisPerro
RockyPerro
TobyPerro

'%o%': contiene una "o" en cualquier posición.

SELECT nombre FROM mascotas WHERE nombre LIKE '%o%';
Resultado (3 filas)
nombre
Rocky
Coco
Toby

'_o%': la segunda letra es "o" (el _ ocupa exactamente un carácter).

SELECT nombre FROM mascotas WHERE nombre LIKE '_o%';
Resultado (3 filas)
nombre
Rocky
Coco
Toby

NULL significa "sin valor". Se pregunta con IS NULL…

SELECT nombre, especie FROM mascotas WHERE raza IS NULL;
Resultado (1 fila)
nombreespecie
LunaGato

…y NUNCA con = NULL: cualquier comparación con NULL da "desconocido", por eso esta consulta no devuelve nada.

SELECT nombre FROM mascotas WHERE raza = NULL;
Resultado (0 filas)
nombre
(sin filas)

4.5. DISTINCT: quitar repetidos

Hay 7 mascotas, pero solo 3 especies distintas.

SELECT DISTINCT especie FROM mascotas ORDER BY especie;
Resultado (3 filas)
especie
Ave
Gato
Perro
Nivel intermedio
Transformar valores con funciones, tratar los NULL, resumir con agregados y grupos, y combinar dos tablas con JOIN.

4.6. Funciones de fila (se aplican a cada fila)

TipoFunciones más usadas
TextoUPPER(t), LOWER(t), CONCAT(a, b, ...), CHAR_LENGTH(t), SUBSTRING(t, inicio, largo), TRIM(t), REPLACE(t, buscar, poner)
NúmerosROUND(n, decimales), FLOOR(n) (hacia abajo), CEIL(n) (hacia arriba), ABS(n), MOD(a, b)
FechasYEAR(f), MONTH(f), DAY(f), DATEDIFF(f1, f2) (días), TIMESTAMPDIFF(unidad, inicio, fin), CURDATE(), NOW(), DATE_ADD(f, INTERVAL n DAY)
Condicionales y NULLVer la sección 4.7
SELECT UPPER(nombre) AS mayus, CHAR_LENGTH(nombre) AS letras, CONCAT(nombre, ' (', especie, ')') AS ficha FROM mascotas ORDER BY nombre;
Resultado (7 filas)
mayusletrasficha
COCO4Coco (Ave)
FIRULAIS8Firulais (Perro)
LUNA4Luna (Gato)
MICHI5Michi (Gato)
NALA4Nala (Gato)
ROCKY5Rocky (Perro)
TOBY4Toby (Perro)

ROUND redondea al más cercano; FLOOR siempre hacia abajo.

SELECT nombre, peso, ROUND(peso) AS redondeado, ROUND(peso, 1) AS un_decimal, FLOOR(peso) AS piso FROM mascotas;
Resultado (7 filas)
nombrepesoredondeadoun_decimalpiso
Firulais28.502928.528
Michi4.2044.24
Rocky22.002222.022
Luna3.1033.13
Coco0.4500.50
Toby11.301111.311
Nala5.0055.05

DATEDIFF(fin, inicio) devuelve la diferencia en días (ojo con el orden de los argumentos).

SELECT id, fecha, YEAR(fecha) AS anio, MONTH(fecha) AS mes, DATEDIFF('2024-12-31', fecha) AS dias_hasta_fin_de_anio FROM citas;
Resultado (8 filas)
idfechaaniomesdias_hasta_fin_de_anio
12024-01-1020241356
22024-01-1520241351
32024-02-2020242315
42024-02-2220242313
52024-03-0520243301
62024-03-1820243288
72024-04-0220244273
82024-04-2520244250

TIMESTAMPDIFF(MONTH | YEAR | DAY, inicio, fin) cuenta unidades completas: ideal para calcular edades o antigüedad.

SELECT id, fecha, TIMESTAMPDIFF(MONTH, fecha, '2024-12-31') AS meses_completos FROM citas WHERE mascota_id = 1;
Resultado (3 filas)
idfechameses_completos
12024-01-1011
32024-02-2010
82024-04-258

4.7. Manejo de NULL y funciones condicionales

NULL significa "valor desconocido o ausente". Casi cualquier operación con NULL da NULL, así que hay funciones específicas para tratarlo y para elegir valores según una condición:

FunciónDevuelveEjemplo → resultado
COALESCE(a, b, c, …)El primer valor que no sea NULL (estándar SQL, acepta muchos valores)COALESCE(NULL, NULL, 'c') → c
IFNULL(a, b)b si a es NULL; si no, a (solo 2 valores)IFNULL(NULL, 'x') → x
NVL(a, b) / NVL2(a, b, c)Igual que IFNULL / "si no es NULL b, si es NULL c". Solo MariaDB (vienen de Oracle; no existen en MySQL)NVL2(NULL, 'a', 'b') → b
NULLIF(a, b)NULL si a = b; si no, a. Convierte un valor "especial" en NULLNULLIF(5, 5) → NULL
IF(cond, si, no)Un CASE corto de dos opciones (propio de MySQL/MariaDB)IF(1 > 2, 'si', 'no') → no
CASE WHEN cond THEN … ENDEvalúa condiciones en orden (forma "buscada")ver ejemplo
CASE col WHEN valor THEN … ENDCompara una columna con valores fijos (forma "simple")ver ejemplo
GREATEST(…) / LEAST(…)El mayor / menor de varios valores de la misma fila (MAX y MIN comparan entre filas)LEAST(3, 7) → 3
CONCAT_WS(sep, …)Une textos con un separador saltando los NULLCONCAT_WS('-', 'a', NULL, 'b') → a-b
ISNULL(a)1 si es NULL, 0 si noISNULL(NULL) → 1
a <=> bIgualdad "segura": trata NULL como un valor comparableNULL <=> NULL → 1

COALESCE reemplaza los NULL por un valor legible.

SELECT nombre, COALESCE(telefono, 'Sin teléfono') AS contacto FROM duenos;
Resultado (4 filas)
nombrecontacto
Marta Rojas3001112233
Jorge SilvaSin teléfono
Lucía Peña3104445566
Andrés Mejía3157778899

IFNULL hace lo mismo con dos valores. Úsalo cuando solo hay un posible reemplazo.

SELECT nombre, IFNULL(raza, 'Mestizo') AS raza FROM mascotas ORDER BY id;
Resultado (7 filas)
nombreraza
FirulaisLabrador
MichiSiamés
RockyBulldog
LunaMestizo
CocoLoro
TobyBeagle
NalaPersa

CASE "buscado": evalúa las condiciones en orden y usa la primera que se cumpla. Por eso la condición más exigente va primero.

SELECT nombre, peso, CASE WHEN peso >= 20 THEN 'Grande' WHEN peso >= 5 THEN 'Mediano' ELSE 'Pequeño' END AS tamano FROM mascotas ORDER BY peso DESC;
Resultado (7 filas)
nombrepesotamano
Firulais28.50Grande
Rocky22.00Grande
Toby11.30Mediano
Nala5.00Mediano
Michi4.20Pequeño
Luna3.10Pequeño
Coco0.45Pequeño

CASE "simple": compara una sola columna con valores fijos. Es más corto cuando todas las condiciones son igualdades.

SELECT nombre, especie, CASE especie WHEN 'Perro' THEN 'Canino' WHEN 'Gato' THEN 'Felino' ELSE 'Otro' END AS familia FROM mascotas ORDER BY id;
Resultado (7 filas)
nombreespeciefamilia
FirulaisPerroCanino
MichiGatoFelino
RockyPerroCanino
LunaGatoFelino
CocoAveOtro
TobyPerroCanino
NalaGatoFelino

IF sirve cuando solo hay dos opciones.

SELECT nombre, edad, IF(edad >= 7, 'Senior', 'Adulto') AS etapa FROM mascotas ORDER BY id;
Resultado (7 filas)
nombreedadetapa
Firulais5Adulto
Michi3Adulto
Rocky7Senior
Luna1Adulto
Coco12Senior
Toby2Adulto
Nala4Adulto

NULLIF(motivo, 'Control') vuelve NULL las citas de control, y COUNT(columna) no cuenta los NULL: así se cuentan solo las que no son control.

SELECT COUNT(*) AS citas, COUNT(NULLIF(motivo, 'Control')) AS no_son_control FROM citas;
Resultado (1 fila)
citasno_son_control
84

Uso clásico de NULLIF: evitar dividir por cero. Para Luna (edad 1) el divisor sería 0; NULLIF(edad - 1, 0) lo convierte en NULL y el resultado es NULL en lugar de un error (en modo estricto) o una advertencia.

SELECT nombre, edad, ROUND(peso / NULLIF(edad - 1, 0), 2) AS kg_por_anio_extra FROM mascotas ORDER BY id;
Resultado (7 filas)
nombreedadkg_por_anio_extra
Firulais57.13
Michi32.10
Rocky73.67
Luna1NULL
Coco120.04
Toby211.30
Nala41.67

GREATEST fija un valor mínimo (nunca menos de 40.000) y LEAST un tope (nunca más de 100.000), fila por fila.

SELECT id, costo, GREATEST(costo, 40000) AS cobro_minimo, LEAST(costo, 100000) AS cobro_con_tope FROM citas ORDER BY id;
Resultado (8 filas)
idcostocobro_minimocobro_con_tope
145000.0045000.0045000.00
230000.0040000.0030000.00
3350000.00350000.00100000.00
445000.0045000.0045000.00
530000.0040000.0030000.00
625000.0040000.0025000.00
730000.0040000.0030000.00
830000.0040000.0030000.00

Jorge no tiene teléfono: CONCAT con un NULL devuelve todo NULL, mientras que CONCAT_WS simplemente lo salta.

SELECT nombre, CONCAT(nombre, ' - ', telefono) AS con_concat, CONCAT_WS(' - ', nombre, telefono) AS con_concat_ws FROM duenos;
Resultado (4 filas)
nombrecon_concatcon_concat_ws
Marta RojasMarta Rojas - 3001112233Marta Rojas - 3001112233
Jorge SilvaNULLJorge Silva
Lucía PeñaLucía Peña - 3104445566Lucía Peña - 3104445566
Andrés MejíaAndrés Mejía - 3157778899Andrés Mejía - 3157778899
Las trampas de NULL en una sola consulta

NULL = NULL no es verdadero sino NULL (desconocido); <=> e IS NULL sí responden 1. Un solo NULL vuelve NULL a GREATEST y hace que NOT IN (…, NULL) nunca sea verdadero: un NOT IN con un NULL en la lista no devuelve ninguna fila.

SELECT NULL = NULL AS igual, NULL <=> NULL AS igual_seguro, NULL IS NULL AS es_null, GREATEST(3, NULL, 9) AS greatest_con_null, 5 NOT IN (1, 2, NULL) AS not_in_con_null, NULL IS UNKNOWN AS es_desconocido;
Resultado (1 fila)
igualigual_seguroes_nullgreatest_con_nullnot_in_con_nulles_desconocido
NULL11NULLNULL1

4.8. Funciones de agregado (resumen muchas filas en una)

FunciónQué calculaCon NULL
COUNT(*)Número de filasCuenta todas las filas
COUNT(columna)Número de valoresIgnora los NULL
COUNT(DISTINCT columna)Número de valores distintosIgnora los NULL
SUM, AVGSuma y promedioIgnoran los NULL
MIN, MAXMenor y mayor valor (también con textos y fechas)Ignoran los NULL

Hay 7 mascotas, pero solo 6 tienen raza registrada (Luna tiene NULL).

SELECT COUNT(*) AS total, COUNT(raza) AS con_raza, COUNT(DISTINCT especie) AS especies FROM mascotas;
Resultado (1 fila)
totalcon_razaespecies
763

Usa ROUND(AVG(...), 2) cuando el enunciado pida 2 decimales.

SELECT SUM(costo) AS facturado, ROUND(AVG(costo), 2) AS promedio, MIN(costo) AS minimo, MAX(costo) AS maximo FROM citas;
Resultado (1 fila)
facturadopromediominimomaximo
585000.0073125.0025000.00350000.00

Primero WHERE filtra las filas y luego se agrega solo lo que quedó.

SELECT COUNT(*) AS controles, SUM(costo) AS total FROM citas WHERE motivo = 'Control';
Resultado (1 fila)
controlestotal
4120000.00

4.9. GROUP BY y HAVING

GROUP BY parte las filas en grupos (uno por cada valor distinto) y aplica las funciones de agregado a cada grupo. Regla de oro: toda columna del SELECT que no esté dentro de una función de agregado debe aparecer en el GROUP BY.

Una fila por especie, con su cantidad y su peso promedio.

SELECT especie, COUNT(*) AS cantidad, ROUND(AVG(peso), 2) AS peso_promedio FROM mascotas GROUP BY especie ORDER BY especie;
Resultado (3 filas)
especiecantidadpeso_promedio
Ave10.45
Gato34.10
Perro320.60

También se puede agrupar por un cálculo, por ejemplo el mes de una fecha.

SELECT MONTH(fecha) AS mes, COUNT(*) AS citas, SUM(costo) AS total FROM citas GROUP BY MONTH(fecha) ORDER BY mes;
Resultado (4 filas)
mescitastotal
1275000.00
22395000.00
3255000.00
4260000.00
WHEREHAVING
¿Qué filtra?Filas individualesGrupos ya formados
¿Cuándo actúa?Antes de GROUP BYDespués de GROUP BY
¿Puede usar COUNT, SUM, AVG…?NoSí

Solo las especies con 2 o más mascotas (el Ave queda fuera).

SELECT especie, COUNT(*) AS cantidad FROM mascotas GROUP BY especie HAVING COUNT(*) >= 2 ORDER BY especie;
Resultado (2 filas)
especiecantidad
Gato3
Perro3

Ambos a la vez: WHERE descarta las citas costosas y HAVING descarta los motivos que aparecen una sola vez.

SELECT motivo, COUNT(*) AS veces FROM citas WHERE costo < 100000 GROUP BY motivo HAVING COUNT(*) > 1 ORDER BY motivo;
Resultado (2 filas)
motivoveces
Control4
Vacunación2

4.10. JOIN: combinar dos tablas relacionadas

Las tablas se relacionan con claves foráneas: mascotas.dueno_id guarda el id de un dueño. Un JOIN junta cada fila de una tabla con las filas de la otra que cumplan la condición ON. Usa alias cortos (mascotas m) y antepón el alias a cada columna (m.nombre) para evitar ambigüedades.

Tipo¿Qué filas conserva?Si no hay pareja…
[INNER] JOINSolo las que tienen pareja en ambas tablasLa fila desaparece
LEFT JOINTODAS las de la tabla izquierda (la del FROM)Las columnas de la derecha quedan NULL
RIGHT JOINTODAS las de la tabla derecha (la del JOIN)Las columnas de la izquierda quedan NULL
INNER JOIN

Toby no aparece porque no tiene dueño (no hay pareja).

SELECT m.nombre AS mascota, d.nombre AS dueno FROM mascotas m JOIN duenos d ON m.dueno_id = d.id ORDER BY m.nombre;
Resultado (6 filas)
mascotadueno
CocoLucía Peña
FirulaisMarta Rojas
LunaLucía Peña
MichiMarta Rojas
NalaJorge Silva
RockyJorge Silva

Después de unir, WHERE puede filtrar por columnas de cualquiera de las tablas.

SELECT m.nombre, c.fecha, c.motivo FROM citas c JOIN mascotas m ON c.mascota_id = m.id WHERE c.motivo = 'Control' ORDER BY c.fecha;
Resultado (4 filas)
nombrefechamotivo
Michi2024-01-15Control
Luna2024-03-05Control
Coco2024-04-02Control
Firulais2024-04-25Control
LEFT JOIN

Todas las mascotas aparecen; las que no tienen citas muestran NULL en la fecha.

SELECT m.nombre, c.fecha FROM mascotas m LEFT JOIN citas c ON c.mascota_id = m.id ORDER BY m.nombre, c.fecha;
Resultado (10 filas)
nombrefecha
Coco2024-04-02
Firulais2024-01-10
Firulais2024-02-20
Firulais2024-04-25
Luna2024-03-05
Michi2024-01-15
NalaNULL
Rocky2024-02-22
Rocky2024-03-18
TobyNULL

Patrón "sin relación": LEFT JOIN + WHERE derecha.id IS NULL encuentra las mascotas que nunca han tenido cita.

SELECT m.nombre FROM mascotas m LEFT JOIN citas c ON c.mascota_id = m.id WHERE c.id IS NULL ORDER BY m.nombre;
Resultado (2 filas)
nombre
Nala
Toby
RIGHT JOIN

Conserva todos los dueños (la tabla de la derecha), incluso Andrés, que no tiene mascotas. Un RIGHT JOIN equivale a un LEFT JOIN con las tablas invertidas.

SELECT d.nombre AS dueno, m.nombre AS mascota FROM mascotas m RIGHT JOIN duenos d ON m.dueno_id = d.id ORDER BY d.nombre, m.nombre;
Resultado (7 filas)
duenomascota
Andrés MejíaNULL
Jorge SilvaNala
Jorge SilvaRocky
Lucía PeñaCoco
Lucía PeñaLuna
Marta RojasFirulais
Marta RojasMichi
Nivel avanzado
Combinar varias tablas, evitar los errores sutiles y conocer herramientas avanzadas. Las secciones 4.13 a 4.17 amplían tu conocimiento, pero no se usan en los retos de este módulo (no se permiten subconsultas ni UNION).

4.11. Varias tablas, agregados y auto-JOIN

Se encadenan los JOIN: dueños → mascotas → citas. Cada JOIN lleva su propia condición ON.

SELECT d.nombre AS dueno, m.nombre AS mascota, c.fecha, c.costo FROM duenos d JOIN mascotas m ON m.dueno_id = d.id JOIN citas c ON c.mascota_id = m.id ORDER BY c.fecha;
Resultado (8 filas)
duenomascotafechacosto
Marta RojasFirulais2024-01-1045000.00
Marta RojasMichi2024-01-1530000.00
Marta RojasFirulais2024-02-20350000.00
Jorge SilvaRocky2024-02-2245000.00
Lucía PeñaLuna2024-03-0530000.00
Jorge SilvaRocky2024-03-1825000.00
Lucía PeñaCoco2024-04-0230000.00
Marta RojasFirulais2024-04-2530000.00

JOIN + GROUP BY: cuánto ha gastado cada dueño. Agrupa por d.id, d.nombre por si hay dos personas con el mismo nombre.

SELECT d.nombre AS dueno, COUNT(c.id) AS citas, SUM(c.costo) AS gastado FROM duenos d JOIN mascotas m ON m.dueno_id = d.id JOIN citas c ON c.mascota_id = m.id GROUP BY d.id, d.nombre ORDER BY gastado DESC;
Resultado (3 filas)
duenocitasgastado
Marta Rojas4455000.00
Jorge Silva270000.00
Lucía Peña260000.00
SELECT d.nombre AS dueno, SUM(c.costo) AS gastado FROM duenos d JOIN mascotas m ON m.dueno_id = d.id JOIN citas c ON c.mascota_id = m.id GROUP BY d.id, d.nombre HAVING SUM(c.costo) > 100000 ORDER BY d.nombre;
Resultado (1 fila)
duenogastado
Marta Rojas455000.00

Auto-JOIN: con dos alias de la misma tabla se comparan filas entre sí (pares de mascotas del mismo dueño). a.id < b.id evita repetir pares e impide emparejar una mascota consigo misma.

SELECT a.nombre AS mascota_1, b.nombre AS mascota_2, a.dueno_id FROM mascotas a JOIN mascotas b ON a.dueno_id = b.dueno_id AND a.id < b.id ORDER BY a.nombre;
Resultado (3 filas)
mascota_1mascota_2dueno_id
FirulaisMichi1
LunaCoco3
RockyNala2

4.12. Trampas frecuentes (con prueba)

✗ COUNT(*) con LEFT JOIN

Andrés aparece con 1 mascota, ¡pero no tiene ninguna! COUNT(*) cuenta la fila de NULLs.

SELECT d.nombre, COUNT(*) AS mascotas FROM duenos d LEFT JOIN mascotas m ON m.dueno_id = d.id GROUP BY d.id, d.nombre ORDER BY d.nombre;
Resultado (4 filas)
nombremascotas
Andrés Mejía1
Jorge Silva2
Lucía Peña2
Marta Rojas2
✓ COUNT(columna_de_la_derecha)

COUNT(m.id) ignora los NULL: ahora Andrés tiene 0.

SELECT d.nombre, COUNT(m.id) AS mascotas FROM duenos d LEFT JOIN mascotas m ON m.dueno_id = d.id GROUP BY d.id, d.nombre ORDER BY d.nombre;
Resultado (4 filas)
nombremascotas
Andrés Mejía0
Jorge Silva2
Lucía Peña2
Marta Rojas2
✗ Filtrar la tabla derecha en WHERE

El WHERE elimina las filas con NULL: el LEFT JOIN se convierte en INNER JOIN y se pierden mascotas.

SELECT m.nombre, c.motivo FROM mascotas m LEFT JOIN citas c ON c.mascota_id = m.id WHERE c.motivo = 'Control' ORDER BY m.nombre;
Resultado (4 filas)
nombremotivo
CocoControl
FirulaisControl
LunaControl
MichiControl
✓ Filtrar la tabla derecha en el ON

Con la condición en el ON se conservan todas las mascotas.

SELECT m.nombre, c.motivo FROM mascotas m LEFT JOIN citas c ON c.mascota_id = m.id AND c.motivo = 'Control' ORDER BY m.nombre;
Resultado (7 filas)
nombremotivo
CocoControl
FirulaisControl
LunaControl
MichiControl
NalaNULL
RockyNULL
TobyNULL
✗ JOIN sin condición ON

Sin ON se combina cada mascota con cada dueño (7 × 4 = 28 filas): un producto cartesiano casi siempre incorrecto.

SELECT COUNT(*) AS filas FROM mascotas JOIN duenos;
Resultado (1 fila)
filas
28
  • Usar = NULL en vez de IS NULL: nunca encuentra nada.
  • Poner una función de agregado en WHERE (WHERE COUNT(*) > 2): es un error; va en HAVING.
  • Mostrar una columna que no está en el GROUP BY ni dentro de un agregado: el valor es arbitrario o da error.
  • Olvidar el desempate en ORDER BY cuando el enunciado lo pide ("en empate, por nombre").
  • Un NULL dentro de un NOT IN hace que la condición nunca se cumpla (ver 4.7).
  • Comillas: los textos y fechas van entre comillas simples ('Cali'), los números sin comillas.

4.13. GROUP_CONCAT y WITH ROLLUP

GROUP_CONCAT junta en un solo texto los valores de cada grupo (con su propio ORDER BY y separador).

SELECT d.nombre AS dueno, GROUP_CONCAT(m.nombre ORDER BY m.nombre SEPARATOR ', ') AS mascotas FROM duenos d JOIN mascotas m ON m.dueno_id = d.id GROUP BY d.id, d.nombre ORDER BY d.nombre;
Resultado (3 filas)
duenomascotas
Jorge SilvaNala, Rocky
Lucía PeñaCoco, Luna
Marta RojasFirulais, Michi

WITH ROLLUP agrega una fila final de total general (la que tiene NULL en la columna agrupada).

SELECT especie, COUNT(*) AS cantidad FROM mascotas GROUP BY especie WITH ROLLUP;
Resultado (4 filas)
especiecantidad
Ave1
Gato3
Perro3
NULL7

4.14. CROSS JOIN, FULL OUTER JOIN y UNION

CROSS JOIN combina todas las filas con todas, a propósito (aquí, el dueño de Cali con cada mascota). Útil para generar combinaciones.

SELECT d.nombre AS dueno, m.nombre AS mascota FROM duenos d CROSS JOIN mascotas m WHERE d.ciudad = 'Cali' ORDER BY m.nombre;
Resultado (7 filas)
duenomascota
Andrés MejíaCoco
Andrés MejíaFirulais
Andrés MejíaLuna
Andrés MejíaMichi
Andrés MejíaNala
Andrés MejíaRocky
Andrés MejíaToby

UNION ALL apila resultados de varias consultas (mismo número de columnas). UNION sin ALL además quita filas repetidas.

SELECT nombre, 'dueño' AS tipo FROM duenos WHERE ciudad = 'Bogotá' UNION ALL SELECT nombre, 'mascota' FROM mascotas WHERE especie = 'Ave';
Resultado (3 filas)
nombretipo
Marta Rojasdueño
Lucía Peñadueño
Cocomascota

MySQL/MariaDB no tiene FULL OUTER JOIN: se simula uniendo un LEFT y un RIGHT JOIN. Aparecen tanto Andrés (sin mascotas) como Toby (sin dueño).

SELECT d.nombre AS dueno, m.nombre AS mascota FROM duenos d LEFT JOIN mascotas m ON m.dueno_id = d.id UNION SELECT d.nombre, m.nombre FROM duenos d RIGHT JOIN mascotas m ON m.dueno_id = d.id ORDER BY dueno, mascota;
Resultado (8 filas)
duenomascota
NULLToby
Andrés MejíaNULL
Jorge SilvaNala
Jorge SilvaRocky
Lucía PeñaCoco
Lucía PeñaLuna
Marta RojasFirulais
Marta RojasMichi

4.15. Subconsultas y EXISTS

Una subconsulta es un SELECT dentro de otro. Puede ir en el WHERE, en el FROM o en el SELECT.

Subconsulta que devuelve un valor: mascotas más pesadas que el promedio.

SELECT nombre, peso FROM mascotas WHERE peso > (SELECT AVG(peso) FROM mascotas) ORDER BY peso DESC;
Resultado (3 filas)
nombrepeso
Firulais28.50
Rocky22.00
Toby11.30

Subconsulta que devuelve una lista para usar con IN: dueños que tienen gatos.

SELECT nombre FROM duenos WHERE id IN (SELECT dueno_id FROM mascotas WHERE especie = 'Gato') ORDER BY nombre;
Resultado (3 filas)
nombre
Jorge Silva
Lucía Peña
Marta Rojas

NOT EXISTS: mascotas para las que no existe ninguna cita (mismo resultado que el patrón LEFT JOIN … IS NULL de 4.10).

SELECT m.nombre FROM mascotas m WHERE NOT EXISTS (SELECT 1 FROM citas c WHERE c.mascota_id = m.id) ORDER BY m.nombre;
Resultado (2 filas)
nombre
Nala
Toby

Subconsulta en el FROM (tabla derivada): se le da un alias y se consulta como si fuera una tabla.

SELECT t.dueno, t.gastado FROM (SELECT d.nombre AS dueno, SUM(c.costo) AS gastado FROM duenos d JOIN mascotas m ON m.dueno_id = d.id JOIN citas c ON c.mascota_id = m.id GROUP BY d.id, d.nombre) AS t WHERE t.gastado > 50000 ORDER BY t.gastado DESC;
Resultado (3 filas)
duenogastado
Marta Rojas455000.00
Jorge Silva70000.00
Lucía Peña60000.00

4.16. CTE (WITH) y funciones de ventana

Una CTE (WITH nombre AS (…)) da nombre a una subconsulta y la deja al inicio: la misma consulta de 4.15, más fácil de leer.

WITH gasto AS (SELECT d.nombre AS dueno, SUM(c.costo) AS gastado FROM duenos d JOIN mascotas m ON m.dueno_id = d.id JOIN citas c ON c.mascota_id = m.id GROUP BY d.id, d.nombre) SELECT dueno, gastado FROM gasto WHERE gastado > 50000 ORDER BY gastado DESC;
Resultado (3 filas)
duenogastado
Marta Rojas455000.00
Jorge Silva70000.00
Lucía Peña60000.00

Una CTE recursiva genera filas que no existen en ninguna tabla (los meses 1 a 6). Con LEFT JOIN aparecen también los meses sin citas (5 y 6).

WITH RECURSIVE meses AS (SELECT 1 AS mes UNION ALL SELECT mes + 1 FROM meses WHERE mes < 6) SELECT m.mes, COUNT(c.id) AS citas FROM meses m LEFT JOIN citas c ON MONTH(c.fecha) = m.mes GROUP BY m.mes ORDER BY m.mes;
Resultado (6 filas)
mescitas
12
22
32
42
50
60

Las funciones de ventana (OVER) calculan sobre un grupo de filas sin juntarlas en una: aquí, el puesto de cada mascota por peso dentro de su especie.

SELECT nombre, especie, peso, RANK() OVER (PARTITION BY especie ORDER BY peso DESC) AS puesto FROM mascotas ORDER BY especie, puesto;
Resultado (7 filas)
nombreespeciepesopuesto
CocoAve0.451
NalaGato5.001
MichiGato4.202
LunaGato3.103
FirulaisPerro28.501
RockyPerro22.002
TobyPerro11.303

SUM(…) OVER (ORDER BY fecha) calcula un acumulado fila por fila.

SELECT id, fecha, costo, SUM(costo) OVER (ORDER BY fecha) AS acumulado FROM citas ORDER BY fecha;
Resultado (8 filas)
idfechacostoacumulado
12024-01-1045000.0045000.00
22024-01-1530000.0075000.00
32024-02-20350000.00425000.00
42024-02-2245000.00470000.00
52024-03-0530000.00500000.00
62024-03-1825000.00525000.00
72024-04-0230000.00555000.00
82024-04-2530000.00585000.00

4.17. EXPLAIN: ¿usa mi consulta un índice?

EXPLAIN antes de una consulta no la ejecuta: muestra el plan que seguirá el servidor para resolverla, es decir, qué tablas lee, en qué orden, si usa un índice (2.5) y cuántas filas calcula que tendrá que revisar. Es la forma de comprobar si un índice sirve. Ejemplo con una tabla pedidos de 50.000 filas:

MariaDB [banco]> EXPLAIN SELECT * FROM pedidos WHERE cliente_id = 25; +------+-------------+---------+------+---------------+------+---------+------+-------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +------+-------------+---------+------+---------------+------+---------+------+-------+-------------+ | 1 | SIMPLE | pedidos | ALL | NULL | NULL | NULL | NULL | 49071 | Using where | +------+-------------+---------+------+---------------+------+---------+------+-------+-------------+

type = ALL y key = NULL: recorre toda la tabla (unas 49.071 filas estimadas) para encontrar los 25 pedidos del cliente. Después de crear un índice:

MariaDB [banco]> CREATE INDEX idx_pedidos_cliente ON pedidos (cliente_id); Query OK, 0 rows affected (0.159 sec) MariaDB [banco]> EXPLAIN SELECT * FROM pedidos WHERE cliente_id = 25; +------+-------------+---------+------+---------------------+---------------------+---------+-------+------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +------+-------------+---------+------+---------------------+---------------------+---------+-------+------+-------+ | 1 | SIMPLE | pedidos | ref | idx_pedidos_cliente | idx_pedidos_cliente | 5 | const | 25 | | +------+-------------+---------+------+---------------------+---------------------+---------+-------+------+-------+

Ahora usa el índice (key) y lee solo 25 filas en lugar de casi 50.000.

ColumnaQué mirar
typeCómo busca, de peor a mejor: ALL (toda la tabla) → index (todo el índice) → range (un rango del índice) → ref (filas con un valor) → eq_ref / const (una sola fila).
possible_keys / keyÍndices que podría usar / el que realmente usa. key = NULL = no usa ninguno.
rowsFilas que calcula revisar (es una estimación). Cuanto menor, mejor.
ExtraUsing index = todo sale del índice (muy bueno). Using temporary o Using filesort = necesita una tabla temporal u ordenar aparte (costoso en tablas grandes).

Trampa frecuente: envolver la columna en una función impide usar el índice. Con un índice en fecha:

EXPLAIN SELECT * FROM pedidos WHERE YEAR(fecha) = 2024 AND MONTH(fecha) = 3; | type | key | rows | | ALL | NULL | 49071 | -- no usa el índice
EXPLAIN SELECT * FROM pedidos WHERE fecha BETWEEN '2024-03-01' AND '2024-03-31'; | type | key | rows | | range | idx_pedidos_fecha | 4247 | -- sí lo usa
  • Las dos consultas devuelven lo mismo, pero solo la segunda aprovecha el índice: compara la columna sola contra un rango.
  • Lo mismo pasa con LIKE '%texto' (comodín al inicio): no puede usar el índice. LIKE 'texto%' sí.
  • En un JOIN, EXPLAIN muestra una fila por tabla. La columna de la llave foránea debería tener índice (type = ref en la tabla hija); si aparece ALL en una tabla grande, falta un índice.
  • En tablas pequeñas (unas cientas de filas) el servidor puede preferir leer toda la tabla aunque exista un índice: es normal.

4.18. Cómo responder en la plataforma

  • Escribe una sola consulta que empiece con SELECT. No se permiten subconsultas (un SELECT dentro de otro) ni UNION.
  • Muestra exactamente las columnas pedidas y en ese orden. Los nombres o alias de las columnas no importan.
  • Si el enunciado pide un orden, debes escribir ORDER BY y el orden de las filas se evalúa; si no lo pide, el orden no importa. Aunque a veces las filas salgan ordenadas sin él (por ejemplo, después de un GROUP BY), la base de datos no lo garantiza, así que sin ORDER BY la respuesta no se acepta.
  • Si el enunciado pide redondear, usa ROUND(valor, 2).
  • Tu consulta se prueba con los datos que ves y con otros datos ocultos: si escribes valores fijos (por ejemplo, WHERE id IN (2, 5) en vez de la condición real), no aprobará.
  • Algunos retos exigen una palabra clave (por ejemplo LEFT JOIN, HAVING o COALESCE): aparece indicada en el reto.
  • Si fallas, verás cuántas filas y columnas devolvió tu consulta y una vista previa de tu resultado para comparar con lo pedido.

4.19. Chuleta: cómo armar una consulta paso a paso

  1. ¿De qué tablas salen los datos? → FROM y, si son varias, JOIN ... ON por sus claves.
  2. ¿Deben aparecer también los que no tienen relación? → LEFT/RIGHT JOIN. ¿Solo los que no la tienen? → LEFT JOIN ... WHERE derecha.id IS NULL.
  3. ¿Qué filas sirven? → WHERE.
  4. ¿Pide "por cada…", "cuántos por…", "total por…"? → GROUP BY + función de agregado.
  5. ¿Pide "solo los que tengan más de…" sobre un total? → HAVING.
  6. ¿Hay valores vacíos que mostrar de otra forma? → COALESCE / IFNULL.
  7. ¿Qué columnas y en qué orden? → SELECT.
  8. ¿Pide un orden o "los N primeros"? → ORDER BY (+ desempate) y LIMIT.

5. Transacciones: todo o nada

Una transacción es un grupo de sentencias que la base de datos trata como una sola unidad: o se guardan todas, o no se guarda ninguna. Sus sentencias forman la familia TCL (Transaction Control Language): START TRANSACTION, COMMIT, ROLLBACK y SAVEPOINT.

Sección de consulta y apoyo, sin retos evaluados. Todo se escribe en la consola de MySQL (sección 1.2). Las salidas son reales, tomadas de un XAMPP con MariaDB 10.4, usando esta tabla: CREATE TABLE cuentas (id INT PRIMARY KEY, titular VARCHAR(30), saldo DECIMAL(12,2));
Nivel básico
Agrupar varios cambios para que se guarden juntos o se deshagan juntos.

5.1. ¿Qué es una transacción?

Piensa en una transferencia bancaria de 100.000 de Ana a Luis. Son dos sentencias: restar a Ana y sumar a Luis. Si el servidor se apaga justo después de la primera, el dinero desaparece: a Ana se le restó y a Luis nunca se le sumó. Una transacción evita ese estado intermedio: hasta que no se confirma, ningún cambio es definitivo y, si algo falla, todos se deshacen.

START TRANSACTION

Inicia la transacción. Los cambios siguientes quedan "pendientes".

COMMIT

Confirma: todos los cambios pendientes se guardan de forma definitiva.

ROLLBACK

Deshace: todos los cambios pendientes se descartan, como si no hubieran ocurrido.

5.2. START TRANSACTION, COMMIT y ROLLBACK

La transferencia completa, confirmada con COMMIT:

MariaDB [banco]> START TRANSACTION; Query OK, 0 rows affected (0.000 sec) MariaDB [banco]> UPDATE cuentas SET saldo = saldo - 100000 WHERE id = 1; Query OK, 1 row affected (0.002 sec) Rows matched: 1 Changed: 1 Warnings: 0 MariaDB [banco]> UPDATE cuentas SET saldo = saldo + 100000 WHERE id = 2; Query OK, 1 row affected (0.000 sec) Rows matched: 1 Changed: 1 Warnings: 0 MariaDB [banco]> COMMIT; Query OK, 0 rows affected (0.001 sec) MariaDB [banco]> SELECT * FROM cuentas; +----+---------+-----------+ | id | titular | saldo | +----+---------+-----------+ | 1 | Ana | 400000.00 | | 2 | Luis | 300000.00 | +----+---------+-----------+

Un error grave (un UPDATE sin WHERE) deshecho con ROLLBACK:

MariaDB [banco]> START TRANSACTION; Query OK, 0 rows affected (0.000 sec) MariaDB [banco]> UPDATE cuentas SET saldo = 0; -- ¡olvidé el WHERE! Query OK, 2 rows affected (0.000 sec) Rows matched: 2 Changed: 2 Warnings: 0 MariaDB [banco]> SELECT * FROM cuentas; +----+---------+-------+ | id | titular | saldo | +----+---------+-------+ | 1 | Ana | 0.00 | | 2 | Luis | 0.00 | +----+---------+-------+ MariaDB [banco]> ROLLBACK; -- deshace todo desde START TRANSACTION Query OK, 0 rows affected (0.001 sec) MariaDB [banco]> SELECT * FROM cuentas; +----+---------+-----------+ | id | titular | saldo | +----+---------+-----------+ | 1 | Ana | 400000.00 | | 2 | Luis | 300000.00 | +----+---------+-----------+
  • Mientras la transacción está abierta, tú ves tus cambios pendientes; los demás usuarios todavía no (5.11).
  • Si cierras la consola o se pierde la conexión sin COMMIT, la transacción se deshace sola.
  • BEGIN; es un sinónimo de START TRANSACTION; en la consola (dentro de un procedimiento usa START TRANSACTION, porque allí BEGIN abre un bloque).
  • En el módulo de DML verás este mismo recurso aplicado: revisar un UPDATE (3.3) o un DELETE (3.4) antes de confirmarlo, y guardar un pedido con sus líneas (3.2).

5.3. autocommit: cada sentencia es su propia transacción

Por defecto autocommit está activado (1): cada sentencia que no está dentro de un START TRANSACTION se confirma sola e inmediatamente. Por eso un UPDATE escrito "suelto" ya no se puede deshacer.

MariaDB [banco]> SELECT @@autocommit; +--------------+ | @@autocommit | +--------------+ | 1 | +--------------+

Si se desactiva en la sesión, todas las sentencias quedan pendientes hasta un COMMIT o un ROLLBACK:

MariaDB [banco]> SET autocommit = 0; Query OK, 0 rows affected (0.000 sec) MariaDB [banco]> UPDATE cuentas SET saldo = saldo + 1 WHERE id = 1; Query OK, 1 row affected (0.002 sec) Rows matched: 1 Changed: 1 Warnings: 0 MariaDB [banco]> ROLLBACK; Query OK, 0 rows affected (0.001 sec) MariaDB [banco]> SET autocommit = 1; -- volver al modo normal

La forma recomendada es dejar autocommit = 1 y abrir una transacción con START TRANSACTION solo cuando se necesita: así queda claro dónde empieza y dónde termina.

Nivel intermedio
Entender las garantías ACID, deshacer solo una parte con SAVEPOINT y conocer qué no se puede deshacer.

5.4. Propiedades ACID

Una base de datos transaccional garantiza cuatro propiedades, conocidas por sus iniciales en inglés:

PropiedadSignificaEn la transferencia
AtomicidadTodo o nada: la transacción no se divide.No puede quedar el retiro de Ana sin el depósito a Luis.
ConsistenciaSe pasa de un estado válido a otro válido: se respetan las reglas (llaves foráneas, CHECK, UNIQUE).La suma de los saldos es la misma antes y después.
Islamiento (Isolation)Las transacciones simultáneas no se estorban: cada una ve los datos como si estuviera sola (5.8 a 5.11).Otro usuario no ve el dinero "en el aire" a mitad de la transferencia.
DurabilidadLo confirmado con COMMIT sobrevive a un apagón o a un reinicio.Después del COMMIT, la transferencia no se pierde aunque el servidor se apague.

5.5. Puntos de guardado: SAVEPOINT

Un SAVEPOINT marca un punto dentro de la transacción. ROLLBACK TO nombre deshace solo lo que se hizo después de ese punto; lo anterior sigue pendiente y se puede confirmar.

MariaDB [banco]> START TRANSACTION; MariaDB [banco]> UPDATE cuentas SET saldo = saldo - 50000 WHERE id = 1; -- retiro correcto Query OK, 1 row affected (0.000 sec) MariaDB [banco]> SAVEPOINT tras_retiro; Query OK, 0 rows affected (0.000 sec) MariaDB [banco]> UPDATE cuentas SET saldo = saldo + 5000000 WHERE id = 2; -- error: un cero de más Query OK, 1 row affected (0.000 sec) MariaDB [banco]> SELECT * FROM cuentas; +----+---------+------------+ | id | titular | saldo | +----+---------+------------+ | 1 | Ana | 350000.00 | | 2 | Luis | 5300000.00 | +----+---------+------------+ MariaDB [banco]> ROLLBACK TO tras_retiro; -- deshace solo el depósito Query OK, 0 rows affected (0.000 sec) MariaDB [banco]> SELECT * FROM cuentas; +----+---------+-----------+ | id | titular | saldo | +----+---------+-----------+ | 1 | Ana | 350000.00 | | 2 | Luis | 300000.00 | +----+---------+-----------+ MariaDB [banco]> COMMIT; -- se guarda el retiro Query OK, 0 rows affected (0.000 sec)
  • Puede haber varios savepoints con nombres distintos; RELEASE SAVEPOINT nombre elimina uno sin deshacer nada.
  • Un ROLLBACK sin TO sigue deshaciendo toda la transacción.

5.6. Lo que una transacción no puede deshacer

  • Sentencias DDL (CREATE, ALTER, DROP, RENAME, TRUNCATE): hacen un COMMIT automático antes y después de ejecutarse. Además de no poder deshacerse, confirman lo que estuviera pendiente. Ver el ejemplo en 2.10.
  • Tablas que no son InnoDB. Solo el motor InnoDB (el predeterminado) maneja transacciones. En una tabla MyISAM, el ROLLBACK no la toca y avisa con una advertencia:
MariaDB [banco]> CREATE TABLE bitacora_myisam (id INT AUTO_INCREMENT PRIMARY KEY, nota VARCHAR(30)) ENGINE=MyISAM; MariaDB [banco]> START TRANSACTION; MariaDB [banco]> INSERT INTO bitacora_myisam (nota) VALUES ('no se deshace'); Query OK, 1 row affected (0.001 sec) MariaDB [banco]> ROLLBACK; Query OK, 0 rows affected, 1 warning (0.000 sec) MariaDB [banco]> SHOW WARNINGS; +---------+------+---------------------------------------------------------------+ | Level | Code | Message | +---------+------+---------------------------------------------------------------+ | Warning | 1196 | Some non-transactional changed tables couldn't be rolled back | +---------+------+---------------------------------------------------------------+ MariaDB [banco]> SELECT * FROM bitacora_myisam; +----+---------------+ | id | nota | +----+---------------+ | 1 | no se deshace | +----+---------------+

Para saber el motor de cada tabla: SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'banco';. Tampoco se deshacen los valores AUTO_INCREMENT ya usados: el siguiente id salta el número "gastado" (3.1).

5.7. Transacciones en procedimientos, funciones y triggers

Objeto¿Puede usar START TRANSACTION, COMMIT y ROLLBACK?Dónde se explica
ProcedimientoSí. Es el lugar habitual para una operación de varios pasos; se combina con un HANDLER que hace ROLLBACK si algo falla.6.9
FunciónNo. Una función se usa dentro de otras sentencias y no puede confirmar ni deshacer.7.9
TriggerNo. Forma parte de la transacción de la sentencia que lo disparó: si el trigger falla, se deshace también esa sentencia.8.12
Nivel avanzado
Entender qué pasa cuando varios usuarios trabajan sobre los mismos datos al mismo tiempo: bloqueos, interbloqueos y niveles de aislamiento.

5.8. Dos usuarios a la vez: bloqueos de fila

Cuando una transacción modifica una fila, InnoDB la bloquea hasta el COMMIT o el ROLLBACK. Si otra sesión quiere modificar la misma fila, espera. Las demás filas siguen libres: el bloqueo es por fila, no por tabla. En el ejemplo, la sesión A empieza primero y la B un segundo después (dos consolas abiertas a la vez):

Sesión A
MariaDB [banco]> START TRANSACTION; MariaDB [banco]> UPDATE cuentas SET saldo = saldo - 10000 -> WHERE id = 1; -- bloquea la fila 1 Query OK, 1 row affected (0.003 sec) -- (A tarda unos segundos antes de confirmar) MariaDB [banco]> COMMIT; -- libera la fila 1 Query OK, 0 rows affected (0.002 sec)
Sesión B
MariaDB [banco]> UPDATE cuentas SET saldo = saldo + 1 -> WHERE id = 2; -- otra fila: no espera Query OK, 1 row affected (0.001 sec) MariaDB [banco]> UPDATE cuentas SET saldo = saldo + 20000 -> WHERE id = 1; -- misma fila: espera a A Query OK, 1 row affected (2.940 sec) MariaDB [banco]> SELECT * FROM cuentas WHERE id = 1; +----+---------+-----------+ | id | titular | saldo | +----+---------+-----------+ | 1 | Ana | 360000.00 | +----+---------+-----------+

B tardó 2.940 s: estuvo esperando a que A confirmara. Ningún cambio se perdió (350.000 − 10.000 + 20.000 = 360.000). Si la espera supera innodb_lock_wait_timeout (50 segundos por defecto), la sentencia de B se cancela:

MariaDB [banco]> SET SESSION innodb_lock_wait_timeout = 2; MariaDB [banco]> UPDATE cuentas SET saldo = saldo + 20000 WHERE id = 1; ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

Por eso una transacción debe ser corta: mientras está abierta, bloquea a los demás. Nunca dejes una transacción abierta mientras esperas a un usuario.

5.9. Leer para modificar: SELECT … FOR UPDATE

Un SELECT normal no bloquea. Si dos sesiones leen el mismo saldo, calculan el nuevo valor en la aplicación y lo guardan, la segunda escritura borra la primera: se produce una actualización perdida. SELECT ... FOR UPDATE lee la fila y la bloquea como si ya se fuera a modificar; la otra sesión espera y luego lee el valor actualizado:

Sesión A
MariaDB [banco]> START TRANSACTION; MariaDB [banco]> SELECT saldo FROM cuentas -> WHERE id = 1 FOR UPDATE; +-----------+ | saldo | +-----------+ | 360000.00 | +-----------+ MariaDB [banco]> UPDATE cuentas SET saldo = saldo - 60000 -> WHERE id = 1; MariaDB [banco]> COMMIT;
Sesión B (un segundo después)
MariaDB [banco]> START TRANSACTION; MariaDB [banco]> SELECT saldo FROM cuentas -> WHERE id = 1 FOR UPDATE; +-----------+ | saldo | +-----------+ | 300000.00 | +-----------+ 1 row in set (1.962 sec) -- esperó a A MariaDB [banco]> UPDATE cuentas SET saldo = saldo - 60000 -> WHERE id = 1; MariaDB [banco]> COMMIT; -- saldo final: 240000.00

B no leyó el saldo viejo (360.000) sino el ya descontado por A (300.000). Se usa siempre que se lee un valor para decidir con él (¿hay saldo suficiente?, ¿hay stock?) y luego modificarlo. LOCK IN SHARE MODE hace un bloqueo más débil: permite que otros lean, pero no que modifiquen.

5.10. Interbloqueos (deadlock)

Un interbloqueo ocurre cuando dos transacciones se esperan mutuamente: A tiene la fila 1 y quiere la 2; B tiene la 2 y quiere la 1. Ninguna puede seguir. InnoDB lo detecta de inmediato, cancela una de las dos (deshace toda su transacción) y deja continuar a la otra:

Sesión A
MariaDB [banco]> START TRANSACTION; MariaDB [banco]> UPDATE cuentas SET saldo = saldo - 1000 -> WHERE id = 1; -- 1. A bloquea la fila 1 MariaDB [banco]> UPDATE cuentas SET saldo = saldo + 1000 -> WHERE id = 2; -- 4. A pide la fila 2: interbloqueo ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
Sesión B
MariaDB [banco]> START TRANSACTION; MariaDB [banco]> UPDATE cuentas SET saldo = saldo - 500 -> WHERE id = 2; -- 2. B bloquea la fila 2 MariaDB [banco]> UPDATE cuentas SET saldo = saldo + 500 -> WHERE id = 1; -- 3. B pide la fila 1 (espera a A) Query OK, 1 row affected (0.937 sec) MariaDB [banco]> COMMIT; -- B termina normalmente
  • A recibió el error 1213 y toda su transacción se deshizo, incluido su primer UPDATE. La aplicación debe volver a intentarla.
  • Para evitarlos: accede a las filas siempre en el mismo orden (por ejemplo, de menor a mayor id) y mantén las transacciones cortas.

5.11. Niveles de aislamiento

El nivel de aislamiento decide qué cambios de otras transacciones puede ver la tuya mientras está abierta. Más aislamiento = menos sorpresas, pero más esperas. Cada nivel evita distintos problemas:

  • Lectura sucia: leer un cambio que otra transacción todavía no confirma (y que quizá deshaga).
  • Lectura no repetible: leer la misma fila dos veces en una transacción y obtener valores distintos porque otra la modificó y confirmó.
  • Fila fantasma: repetir una consulta y que aparezcan filas nuevas que otra transacción insertó.
NivelLectura suciaLectura no repetibleFila fantasma
READ UNCOMMITTEDPuede ocurrirPuede ocurrirPuede ocurrir
READ COMMITTEDSe evitaPuede ocurrirPuede ocurrir
REPEATABLE READ predeterminadoSe evitaSe evitaInnoDB la evita en lecturas normales
SERIALIZABLESe evitaSe evitaSe evita

En los tres ejemplos, la sesión A lee un saldo, espera dos segundos y lo vuelve a leer; mientras tanto, la sesión B lo modifica.

REPEATABLE READ: A ve siempre lo mismo
-- A START TRANSACTION; SELECT saldo FROM cuentas WHERE id = 2; | 299501.00 | -- B: UPDATE ... saldo + 1000 (confirmado) SELECT saldo FROM cuentas WHERE id = 2; | 299501.00 | -- igual que antes COMMIT;

A trabaja con una "foto" de los datos tomada en su primera lectura.

READ COMMITTED: A ve lo confirmado
-- A START TRANSACTION; SELECT saldo FROM cuentas WHERE id = 2; | 300501.00 | -- B: UPDATE ... saldo + 1000 (confirmado) SELECT saldo FROM cuentas WHERE id = 2; | 301501.00 | -- cambió: no repetible COMMIT;

Cada lectura ve lo último que se confirmó.

READ UNCOMMITTED: A ve lo no confirmado
-- B: START TRANSACTION; -- UPDATE cuentas SET saldo = 0 WHERE id = 1; -- A SELECT saldo FROM cuentas WHERE id = 1; | 0.00 | -- lectura sucia -- B: ROLLBACK; SELECT saldo FROM cuentas WHERE id = 1; | 240500.00 | -- el 0 nunca existió

A tomó una decisión con un valor que nunca se guardó.

MariaDB [banco]> SELECT @@tx_isolation; -- nivel actual +-----------------+ | @@tx_isolation | +-----------------+ | REPEATABLE-READ | +-----------------+ MariaDB [banco]> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- cambiarlo en esta sesión

Lo habitual es dejar el predeterminado (REPEATABLE READ) y usar SELECT ... FOR UPDATE (5.9) cuando se lee para modificar.

5.12. Buenas prácticas

  • Transacciones cortas: abre, haz los cambios y confirma. Nunca esperes a que un usuario responda con la transacción abierta.
  • Siempre termina con COMMIT o ROLLBACK; revisa los resultados (ROW_COUNT(), un SELECT) antes de confirmar.
  • Usa SELECT ... FOR UPDATE cuando leas un valor para decidir con él y luego modificarlo.
  • Accede a las filas en el mismo orden en todas las operaciones para evitar interbloqueos, y prepara la aplicación para reintentar ante un error 1213 o 1205.
  • No mezcles DDL dentro de una transacción: confirma lo pendiente sin avisar (5.6).
  • Usa tablas InnoDB; MyISAM no admite transacciones ni llaves foráneas.

5.13. Chuleta

Quiero…Sentencia
Iniciar una transacciónSTART TRANSACTION;
Guardar todos los cambiosCOMMIT;
Deshacer todos los cambiosROLLBACK;
Marcar un punto / volver a élSAVEPOINT p1; · ROLLBACK TO p1;
Ver / cambiar autocommitSELECT @@autocommit; · SET autocommit = 0;
Leer y bloquear para modificarSELECT ... FOR UPDATE;
Ver / cambiar el nivel de aislamientoSELECT @@tx_isolation; · SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Cambiar el tiempo máximo de esperaSET SESSION innodb_lock_wait_timeout = 10;

6. Procedimientos almacenados

Nivel básico
Crear un procedimiento, llamarlo con CALL, enviarle datos y obtener resultados.

6.1. ¿Qué es un procedimiento y cuándo usarlo?

Un procedimiento almacenado es un programa con nombre guardado dentro de la base de datos. Agrupa varias instrucciones SQL (consultas, inserciones, condiciones, ciclos, transacciones) y se ejecuta explícitamente con CALL nombre(argumentos).

Menos idas y vueltas

La aplicación envía un solo CALL en vez de muchas sentencias: todo el trabajo ocurre dentro del servidor.

Seguridad

Se puede dar permiso de EXECUTE sobre el procedimiento sin dar permisos directos sobre las tablas.

Reutilización

La regla de negocio vive en un solo lugar y todas las aplicaciones la usan igual.

6.2. Sintaxis general

CREATE PROCEDURE nombre_procedimiento( IN p_entrada TIPO, -- dato que recibe OUT p_salida TIPO, -- dato que devuelve INOUT p_ambos TIPO -- recibe y devuelve ) BEGIN DECLARE variable TIPO DEFAULT valor; -- las declaraciones van al inicio -- instrucciones... END; CALL nombre_procedimiento(valor, @variable_salida, @variable_ambos); SELECT @variable_salida;

Un procedimiento puede no tener parámetros: CREATE PROCEDURE sp_x() ... y se llama con CALL sp_x();. Usa prefijos como p_ (parámetros) y v_ (variables) para no confundirlos con columnas.

¿Qué es DELIMITER y cuándo se usa?

En la consola de MariaDB/MySQL, en la pestaña SQL de phpMyAdmin o en Workbench, el punto y coma (;) indica dónde termina una sentencia: al verlo, el programa envía al servidor todo lo escrito hasta ahí. El problema es que dentro de BEGIN ... END hay varias instrucciones, y cada una termina en ;. El programa corta el CREATE PROCEDURE en el primer ; y lo envía incompleto:

MariaDB [tienda]> CREATE PROCEDURE sp_corregir_precios() -> BEGIN -> UPDATE productos SET precio = 0 WHERE precio < 0; <-- aquí la consola cree que terminó la sentencia ERROR 1064 (42000): You have an error in your SQL syntax; ... near '' at line 3

DELIMITER cambia temporalmente el símbolo que marca el final de la sentencia. Así los ; del cuerpo dejan de cortarla, y el CREATE PROCEDURE completo se envía al llegar al nuevo símbolo:

MariaDB [tienda]> DELIMITER // -- 1. el final de sentencia ahora es // MariaDB [tienda]> CREATE PROCEDURE sp_corregir_precios() -> BEGIN -> UPDATE productos SET precio = 0 WHERE precio < 0; -- estos ; ya no cortan la sentencia -> SELECT COUNT(*) AS productos_revisados FROM productos; -> END // -- 2. aquí sí termina: se envía todo el procedimiento Query OK, 0 rows affected (0.004 sec) MariaDB [tienda]> DELIMITER ; -- 3. se restaura el ; para lo que sigue MariaDB [tienda]> CALL sp_corregir_precios(); +---------------------+ | productos_revisados | +---------------------+ | 2 | +---------------------+
  • Es una orden del programa cliente, no de SQL: el servidor nunca recibe la palabra DELIMITER. Por eso no aparece en la definición que se guarda.
  • El símbolo puede ser cualquiera que no aparezca dentro del cuerpo; los más usados son // y $$. Deja un espacio entre DELIMITER y el símbolo.
  • Siempre restáuralo con DELIMITER ; al terminar. Si lo olvidas, las siguientes sentencias no se ejecutan hasta que escribas //.
  • Solo hace falta cuando el cuerpo tiene varios ; (un bloque BEGIN ... END con varias instrucciones). Un cuerpo de una sola instrucción sin BEGIN ... END no lo necesita: CREATE PROCEDURE sp_contar() SELECT COUNT(*) FROM productos;
  • Vale igual para funciones (7), triggers (8) y eventos (9): cada vez que el cuerpo lleve BEGIN ... END con varias instrucciones, se escriben entre DELIMITER // y DELIMITER ;.
  • En phpMyAdmin, la pestaña SQL tiene un campo Delimitador debajo del editor: escribe ahí // y termina la sentencia con // (o usa DELIMITER en el texto, que phpMyAdmin también entiende).
  • En esta plataforma no hace falta: cada respuesta se envía como una sola sentencia. Si escribes DELIMITER (como lo harías en la consola), la plataforma lo reconoce y lo quita antes de evaluar.

6.3. Parámetros de entrada (IN)

Un parámetro IN (el modo por defecto) recibe un valor desde el CALL. Si el procedimiento lo cambia, quien llama no ve el cambio.

Código ejecutado, paso a paso
CREATE TABLE clientes (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(40), ciudad VARCHAR(30));
CREATE PROCEDURE sp_registrar_cliente(IN p_nombre VARCHAR(40), IN p_ciudad VARCHAR(30)) BEGIN INSERT INTO clientes (nombre, ciudad) VALUES (p_nombre, p_ciudad); END;
CALL sp_registrar_cliente('Ana', 'Cali');
CALL sp_registrar_cliente('Pedro', 'Bogotá');
CALL sp_registrar_cliente('Rosa', 'Cali');
SELECT * FROM clientes;
Resultado (3 filas)
idnombreciudad
1AnaCali
2PedroBogotá
3RosaCali

6.4. Procedimientos que devuelven resultados

Un SELECT sin INTO dentro del procedimiento devuelve sus filas a quien lo llama, como si fuera una consulta:

Código ejecutado, paso a paso
CREATE TABLE productos (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(30), precio INT);
INSERT INTO productos (nombre, precio) VALUES ('Lápiz', 1500), ('Cuaderno', 12000), ('Morral', 85000);
CREATE PROCEDURE sp_productos_desde(IN p_minimo INT) BEGIN SELECT nombre, precio FROM productos WHERE precio >= p_minimo ORDER BY precio; END;
CALL sp_productos_desde(10000);
Resultado (2 filas)
nombreprecio
Cuaderno12000
Morral85000
Nivel intermedio
Devolver valores en parámetros, guardar resultados en variables y escribir lógica con condiciones y ciclos.

6.5. Parámetros OUT e INOUT

ModoDirecciónComportamientoCómo se llama
INEntradaRecibe un valor. Si el procedimiento lo cambia, quien llama no ve el cambio. Es el modo por defecto.CALL sp(105);
OUTSalidaEmpieza en NULL dentro del procedimiento; el valor que se le asigne queda en la variable de quien llama.CALL sp(@total);
SELECT @total;
INOUTEntrada y salidaRecibe un valor inicial, lo transforma y lo devuelve en la misma variable.SET @x = 500;
CALL sp(@x);
SELECT @x;

Para asignar el resultado de una consulta a una variable o parámetro se usa SELECT ... INTO variable. Un SELECT sin INTO solo muestra un resultado (6.4): no guarda nada en el parámetro.

Parámetro OUT con SELECT ... INTO
Código ejecutado, paso a paso
CREATE TABLE clientes (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(40), ciudad VARCHAR(30));
INSERT INTO clientes (nombre, ciudad) VALUES ('Ana', 'Cali'), ('Pedro', 'Bogotá'), ('Rosa', 'Cali');
CREATE PROCEDURE sp_contar_por_ciudad(IN p_ciudad VARCHAR(30), OUT p_total INT) BEGIN SELECT COUNT(*) INTO p_total FROM clientes WHERE ciudad = p_ciudad; END;
CALL sp_contar_por_ciudad('Cali', @total_cali);
CALL sp_contar_por_ciudad('Bogotá', @total_bogota);
SELECT @total_cali, @total_bogota;
Resultado (1 fila)
@total_cali@total_bogota
21
Parámetro INOUT
Código ejecutado, paso a paso
CREATE PROCEDURE sp_aplicar_iva(INOUT p_valor DECIMAL(12,2)) BEGIN SET p_valor = p_valor * 1.19; -- lee el valor recibido y lo devuelve modificado END;
SET @precio = 100000;
CALL sp_aplicar_iva(@precio);
SELECT @precio;
Resultado (1 fila)
@precio
119000.00

6.6. Variables y control de flujo

  • Variables locales: DECLARE v_total INT DEFAULT 0; y se asignan con SET v_total = v_total + 1;. Solo existen dentro del procedimiento.
  • Variables de sesión (con @): SET @x = 5; viven mientras dure la conexión; se usan para recibir parámetros OUT.
  • Condicionales: IF cond THEN ... ELSEIF cond THEN ... ELSE ... END IF; o CASE ... END CASE;
  • Ciclos: WHILE cond DO ... END WHILE; (puede no ejecutarse nunca), REPEAT ... UNTIL cond END REPEAT; (al menos una vez) y etiqueta: LOOP ... LEAVE etiqueta; END LOOP; (ITERATE etiqueta salta a la siguiente vuelta).
Ciclo WHILE
Código ejecutado, paso a paso
CREATE TABLE tabla_multiplicar (factor INT, resultado INT);
CREATE PROCEDURE sp_tabla(IN p_numero INT, IN p_hasta INT) BEGIN DECLARE i INT DEFAULT 1; WHILE i <= p_hasta DO INSERT INTO tabla_multiplicar VALUES (i, p_numero * i); SET i = i + 1; END WHILE; END;
CALL sp_tabla(7, 5);
SELECT * FROM tabla_multiplicar;
Resultado (5 filas)
factorresultado
17
214
321
428
535

6.7. Administrar procedimientos

SHOW PROCEDURE STATUS WHERE Db = DATABASE(); -- lista los procedimientos SHOW CREATE PROCEDURE sp_transferir; -- muestra su código DROP PROCEDURE IF EXISTS sp_transferir; -- lo elimina (para cambiarlo: borrar y volver a crear)
Nivel avanzado
Validar y rechazar operaciones, garantizar "todo o nada" con transacciones, recorrer resultados fila por fila y construir consultas dinámicas.

6.8. Validaciones con SIGNAL

Igual que en los triggers, SIGNAL SQLSTATE '45000' detiene el procedimiento con un mensaje propio. Lo que se ejecutó antes del SIGNAL no se deshace solo (para eso, ver transacciones en 6.9):

Código ejecutado, paso a paso
CREATE TABLE inventario (producto VARCHAR(20) PRIMARY KEY, stock INT);
INSERT INTO inventario VALUES ('Café', 10);
CREATE PROCEDURE sp_retirar(IN p_producto VARCHAR(20), IN p_cantidad INT) BEGIN DECLARE v_stock INT; SELECT stock INTO v_stock FROM inventario WHERE producto = p_producto; IF v_stock IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'El producto no existe'; ELSEIF v_stock < p_cantidad THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Stock insuficiente'; END IF; UPDATE inventario SET stock = stock - p_cantidad WHERE producto = p_producto; END;
CALL sp_retirar('Café', 4);
CALL sp_retirar('Café', 50);
MySQL rechaza la operación: Stock insuficiente
CALL sp_retirar('Té', 1);
MySQL rechaza la operación: El producto no existe
SELECT * FROM inventario;
Resultado (1 fila)
productostock
Café6

El primer retiro (4 unidades) se hizo; los otros dos se rechazaron con su mensaje y el stock quedó en 6.

6.9. Transacciones y manejo de errores (HANDLER)

Una transacción agrupa varias operaciones que deben ocurrir todas o ninguna (explicadas en el módulo 5). Dentro de un procedimiento se combinan con un HANDLER, que define qué hacer si ocurre un error: EXIT sale del bloque y CONTINUE sigue con la siguiente instrucción. Lo habitual es un DECLARE EXIT HANDLER FOR SQLEXCEPTION que hace ROLLBACK, para que un error a mitad del proceso no deje cambios a medias.

Cuidado: un UPDATE ... WHERE saldo >= monto que no encuentra filas no es un error: simplemente afecta 0 filas y ningún HANDLER se activa. Por eso el ejemplo verifica ROW_COUNT() (filas afectadas por la última instrucción) antes de acreditar el dinero.

La primera transferencia se aprueba; la segunda se rechaza por fondos insuficientes y los saldos no se alteran.

Código ejecutado, paso a paso
CREATE TABLE cuentas (id INT PRIMARY KEY, titular VARCHAR(30), saldo DECIMAL(12,2));
INSERT INTO cuentas VALUES (1, 'Ana', 500000), (2, 'Luis', 100000);
CREATE PROCEDURE sp_transferir(IN p_origen INT, IN p_destino INT, IN p_monto DECIMAL(12,2), OUT p_estado VARCHAR(60)) BEGIN -- Si ocurre cualquier error SQL: se deshace todo y se informa DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_estado = 'ERROR: operación revertida'; END; START TRANSACTION; UPDATE cuentas SET saldo = saldo - p_monto WHERE id = p_origen AND saldo >= p_monto; IF ROW_COUNT() = 0 THEN -- 0 filas = no había fondos (no es un error SQL) ROLLBACK; SET p_estado = 'RECHAZADA: fondos insuficientes'; ELSE UPDATE cuentas SET saldo = saldo + p_monto WHERE id = p_destino; COMMIT; SET p_estado = 'APROBADA'; END IF; END;
CALL sp_transferir(1, 2, 200000, @estado1);
CALL sp_transferir(2, 1, 999999, @estado2);
SELECT @estado1, @estado2;
Resultado (1 fila)
@estado1@estado2
APROBADARECHAZADA: fondos insuficientes
SELECT * FROM cuentas;
Resultado (2 filas)
idtitularsaldo
1Ana300000.00
2Luis300000.00

6.10. Cursores: recorrer un resultado fila por fila

Se usan cuando cada fila requiere un tratamiento individual. Siempre siguen estos pasos:

  1. DECLARE cur CURSOR FOR SELECT ...; (después de las variables)
  2. DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_fin = 1; (después del cursor)
  3. OPEN cur; → ciclo con FETCH cur INTO ...; → CLOSE cur;

Si una sola sentencia SQL puede hacer el trabajo (por ejemplo, INSERT INTO bonos SELECT id, salario * 0.10 FROM empleados WHERE activo = 1), prefiérela: es más rápida que un cursor.

Código ejecutado, paso a paso
CREATE TABLE empleados (id INT PRIMARY KEY, nombre VARCHAR(30), salario DECIMAL(12,2), activo TINYINT);
CREATE TABLE bonos (empleado_id INT, monto DECIMAL(12,2));
INSERT INTO empleados VALUES (1, 'Ana', 3000000, 1), (2, 'Luis', 2000000, 0), (3, 'Eva', 4000000, 1);
CREATE PROCEDURE sp_generar_bonos() BEGIN -- 1) variables 2) cursor 3) handler (MySQL exige este orden) DECLARE v_id INT; DECLARE v_salario DECIMAL(12,2); DECLARE v_fin INT DEFAULT 0; DECLARE cur CURSOR FOR SELECT id, salario FROM empleados WHERE activo = 1; DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_fin = 1; OPEN cur; recorrido: LOOP FETCH cur INTO v_id, v_salario; IF v_fin = 1 THEN LEAVE recorrido; END IF; INSERT INTO bonos VALUES (v_id, v_salario * 0.10); END LOOP; CLOSE cur; END;
CALL sp_generar_bonos();
SELECT * FROM bonos;
Resultado (2 filas)
empleado_idmonto
1300000.00
3400000.00

6.11. SQL dinámico y seguridad

Cuando una parte de la consulta (por ejemplo, el nombre de una tabla) solo se conoce al ejecutar, se arma como texto y se ejecuta con PREPARE / EXECUTE:

Código ejecutado, paso a paso
CREATE TABLE clientes (id INT);
CREATE TABLE ventas (id INT);
INSERT INTO clientes VALUES (1), (2), (3);
INSERT INTO ventas VALUES (1);
CREATE PROCEDURE sp_contar_filas(IN p_tabla VARCHAR(64)) BEGIN SET @consulta = CONCAT('SELECT ''', p_tabla, ''' AS tabla, COUNT(*) AS filas FROM ', p_tabla); PREPARE sentencia FROM @consulta; -- se arma la consulta como texto EXECUTE sentencia; DEALLOCATE PREPARE sentencia; END;
CALL sp_contar_filas('clientes');
Resultado (1 fila)
tablafilas
clientes3
CALL sp_contar_filas('ventas');
Resultado (1 fila)
tablafilas
ventas1
  • Riesgo de inyección SQL: concatenar textos que vienen de un usuario permite que alguien "inyecte" SQL. Para valores, usa marcadores ? con EXECUTE sentencia USING @valor; para nombres de tablas, valida contra una lista de nombres permitidos.
  • SQL SECURITY: por defecto un procedimiento se ejecuta con los permisos de quien lo creó (SQL SECURITY DEFINER). Con SQL SECURITY INVOKER usa los permisos de quien lo llama. Se escribe antes de BEGIN.

6.12. Errores comunes

  • Usar SELECT COUNT(*) FROM t; esperando que llene un parámetro OUT: falta INTO p_total.
  • Declarar variables después de otras instrucciones: todos los DECLARE van al inicio del bloque, en el orden variables → cursores → handlers.
  • Llamar un procedimiento con OUT pasando un valor literal (CALL sp(5)): el parámetro de salida necesita una variable (@resultado).
  • Nombrar un parámetro igual que una columna (id): genera ambigüedad. Usa prefijos como p_ y v_.
  • Un ciclo WHILE sin SET i = i + 1 nunca termina.

6.13. Cómo responder en la plataforma

  • Escribe una sola sentencia CREATE PROCEDURE con el nombre y los parámetros exactos del enunciado. No hace falta DELIMITER (qué es y cuándo se usa en la consola: sección 6.2).
  • No incluyas el CALL: el sistema lo ejecuta por ti con valores de prueba y revisa el resultado.

7. Funciones almacenadas

Nivel básico
Crear una función que recibe datos y devuelve un valor, y usarla dentro de un SELECT.

7.1. ¿Qué es una función almacenada?

Una función almacenada recibe parámetros (solo de entrada) y devuelve exactamente un valor (un número, un texto o una fecha). Su gran ventaja es que se usa dentro de cualquier consulta, igual que UPPER() o ROUND(): en el SELECT, en WHERE, en ORDER BY o en GROUP BY.

7.2. Sintaxis general

CREATE FUNCTION nombre_funcion(p_param1 TIPO, p_param2 TIPO) RETURNS TIPO_DEL_RESULTADO -- obligatorio: qué tipo devuelve DETERMINISTIC -- características (ver 7.6) BEGIN DECLARE v_resultado TIPO; -- cálculos... RETURN v_resultado; -- obligatorio: devuelve el valor END; SELECT nombre_funcion(valor1, valor2);

Si el cálculo cabe en una línea, se puede omitir BEGIN ... END: CREATE FUNCTION fn_doble(n INT) RETURNS INT DETERMINISTIC RETURN n * 2;

7.3. Primera función: un cálculo (descuento + IVA)

Código ejecutado, paso a paso
CREATE FUNCTION fn_precio_final(p_precio DECIMAL(10,2), p_descuento INT) RETURNS DECIMAL(10,2) DETERMINISTIC NO SQL BEGIN DECLARE v_con_descuento DECIMAL(10,2); SET v_con_descuento = p_precio - (p_precio * p_descuento / 100); RETURN ROUND(v_con_descuento * 1.19, 2); -- + IVA 19 % END;
SELECT fn_precio_final(100000, 10) AS precio_final, fn_precio_final(50000, 0) AS sin_descuento;
Resultado (1 fila)
precio_finalsin_descuento
107100.0059500.00
Nivel intermedio
Diferenciar funciones y procedimientos, escribir lógica con condiciones y declarar correctamente las características de la función.

7.4. Función vs. procedimiento

CaracterísticaFunciónProcedimiento
ResultadoSiempre un único valor con RETURNS y RETURN.Ninguno directo; puede devolver varios valores con parámetros OUT/INOUT o mostrar consultas.
ParámetrosSolo de entrada (no se escribe IN).IN, OUT e INOUT.
Cómo se usaDentro de expresiones: SELECT fn_iva(precio) FROM productos;Con CALL sp_procesar(10);
Modificar datosPuede (MODIFIES SQL DATA), pero no la misma tabla que usa la consulta que la invoca, y no es buena práctica.Sin restricciones.
TransaccionesNo permite START TRANSACTION, COMMIT ni ROLLBACK.Sí.

7.5. IF dentro de la función y uso en consultas

Una función puede tener varios RETURN (uno por cada camino del IF) y se puede usar incluso en GROUP BY:

Código ejecutado, paso a paso
CREATE TABLE notas (aprendiz VARCHAR(30), nota DECIMAL(3,1));
INSERT INTO notas VALUES ('Ana', 4.6), ('Luis', 2.8), ('Eva', 3.0), ('Juan', 3.9);
CREATE FUNCTION fn_desempeno(p_nota DECIMAL(3,1)) RETURNS VARCHAR(15) DETERMINISTIC NO SQL BEGIN IF p_nota >= 4.5 THEN RETURN 'Superior'; ELSEIF p_nota >= 3.0 THEN RETURN 'Aprobado'; ELSE RETURN 'Reprobado'; END IF; END;
SELECT aprendiz, nota, fn_desempeno(nota) AS desempeno FROM notas ORDER BY nota DESC;
Resultado (4 filas)
aprendiznotadesempeno
Ana4.6Superior
Juan3.9Aprobado
Eva3.0Aprobado
Luis2.8Reprobado
SELECT fn_desempeno(nota) AS desempeno, COUNT(*) AS cantidad FROM notas GROUP BY fn_desempeno(nota) ORDER BY desempeno;
Resultado (3 filas)
desempenocantidad
Aprobado2
Reprobado1
Superior1

7.6. Características: determinismo y acceso a datos

DETERMINISTIC / NOT DETERMINISTIC

DETERMINISTIC: con los mismos parámetros siempre devuelve lo mismo (cálculos puros). NOT DETERMINISTIC (por defecto): puede variar, por ejemplo si usa NOW(), RAND() o lee tablas que cambian.

NO SQL / READS SQL DATA / MODIFIES SQL DATA

Indican si la función no usa tablas, si solo las lee (SELECT ... INTO) o si las modifica.

¿Por qué a veces son obligatorias? Cuando el servidor tiene activado el registro binario (usado para replicar datos a otros servidores), MySQL rechaza crear funciones sin declarar DETERMINISTIC, NO SQL o READS SQL DATA (error 1418), porque necesita saber si es seguro repetirlas en otro servidor. Declararlas correctamente es una buena práctica siempre.

7.7. Administrar funciones

SHOW FUNCTION STATUS WHERE Db = DATABASE(); -- lista las funciones SHOW CREATE FUNCTION fn_precio_final; -- muestra su código DROP FUNCTION IF EXISTS fn_precio_final; -- la elimina (para cambiarla: borrar y volver a crear)
Nivel avanzado
Consultar otras tablas desde una función, conocer sus límites y su impacto en el rendimiento.

7.8. Funciones que leen otras tablas (READS SQL DATA)

COALESCE(SUM(total), 0) evita devolver NULL cuando el cliente no tiene pedidos (Eva).

Código ejecutado, paso a paso
CREATE TABLE clientes (id INT PRIMARY KEY, nombre VARCHAR(30));
CREATE TABLE pedidos (id INT PRIMARY KEY, cliente_id INT, total DECIMAL(12,2));
INSERT INTO clientes VALUES (1, 'Ana'), (2, 'Luis'), (3, 'Eva');
INSERT INTO pedidos VALUES (1, 1, 8000000), (2, 1, 4000000), (3, 2, 3500000);
CREATE FUNCTION fn_categoria_cliente(p_cliente_id INT) RETURNS VARCHAR(20) READS SQL DATA BEGIN DECLARE v_total DECIMAL(12,2); SELECT COALESCE(SUM(total), 0) INTO v_total FROM pedidos WHERE cliente_id = p_cliente_id; RETURN CASE WHEN v_total >= 10000000 THEN 'PLATINO' WHEN v_total >= 3000000 THEN 'ORO' ELSE 'ESTÁNDAR' END; END;
SELECT nombre, fn_categoria_cliente(id) AS categoria FROM clientes;
Resultado (3 filas)
nombrecategoria
AnaPLATINO
LuisORO
EvaESTÁNDAR

7.9. Límites de las funciones

Una función no puede llamarse a sí misma. MySQL acepta crearla, pero falla al usarla:

Código ejecutado, paso a paso
CREATE FUNCTION fn_factorial(n INT) RETURNS INT DETERMINISTIC RETURN IF(n <= 1, 1, n * fn_factorial(n - 1));
SELECT fn_factorial(3);
MySQL rechaza la operación: Recursive stored functions and triggers are not allowed
  • Para cálculos repetitivos usa un ciclo (WHILE) dentro de la función, o una CTE recursiva en la consulta (sección 4.16).
  • Una función no puede devolver un conjunto de filas: un SELECT sin INTO dentro de ella da error 1415. Si necesitas devolver filas, usa un procedimiento (6.4) o una vista (2.8).
  • No admite START TRANSACTION, COMMIT, ROLLBACK ni SQL dinámico (PREPARE).

7.10. Rendimiento

  • Una función usada en el SELECT se ejecuta una vez por fila. Si además consulta otras tablas (7.8), en una tabla con miles de filas se ejecutan miles de consultas: a veces un JOIN con GROUP BY es mucho más rápido.
  • Una función aplicada a una columna en el WHERE (WHERE fn_categoria(id) = 'ORO') impide usar los índices de esa columna: MySQL debe calcularla para todas las filas.

7.11. Errores comunes

  • Olvidar RETURNS tipo (antes de BEGIN) o el RETURN valor; dentro del cuerpo.
  • Confundir RETURNS (declara el tipo, va en la cabecera) con RETURN (devuelve el valor, va en el cuerpo).
  • Escribir IN antes de los parámetros: en las funciones de MySQL (y de esta versión de MariaDB) es un error de sintaxis.
  • Un SELECT sin INTO dentro de la función produce error: una función no puede devolver un conjunto de resultados.
  • Tipo de retorno muy pequeño (DECIMAL(5,2) para valores grandes) provoca truncamiento o error.

7.12. Cómo responder en la plataforma

  • Escribe una sola sentencia CREATE FUNCTION con el nombre, parámetros y tipo de retorno exactos. No hace falta DELIMITER (qué es y cuándo se usa en la consola: sección 6.2).
  • Incluye DETERMINISTIC (o la característica que pida el reto). El sistema invoca tu función con valores de prueba y compara el resultado.

8. Disparadores (Triggers)

Nivel básico
Entender qué es un disparador, cuándo se ejecuta y cómo leer o cambiar la fila con NEW y OLD.

8.1. ¿Qué es un disparador y para qué sirve?

Un disparador (trigger) es un bloque de código SQL asociado a una tabla que el motor ejecuta automáticamente cuando ocurre un INSERT, UPDATE o DELETE sobre ella. Nadie lo llama: reacciona a los cambios. Se ejecuta una vez por cada fila afectada (FOR EACH ROW).

Validar

Hacer cumplir reglas que un CHECK o una clave foránea no pueden expresar (cupos, horarios, estados válidos).

Auditar

Guardar qué cambió, cuándo y cuáles eran los valores antes y después.

Sincronizar

Mantener totales o inventarios actualizados en otras tablas (descontar stock al vender).

Bloquear

Cancelar operaciones no permitidas con SIGNAL SQLSTATE '45000' (nivel avanzado).

8.2. Sintaxis general

CREATE TRIGGER nombre_del_trigger {BEFORE | AFTER} {INSERT | UPDATE | DELETE} ON nombre_tabla FOR EACH ROW BEGIN -- instrucciones; puede usar NEW.columna y/o OLD.columna END;

Si el cuerpo tiene una sola instrucción, BEGIN ... END es opcional. Convención útil para el nombre: tg_tabla_bi (before insert), _au (after update), _bd (before delete)…

Para escribir un trigger con BEGIN ... END en la consola hay que usar DELIMITER, igual que con los procedimientos (6.2).

8.3. Momento y evento: la matriz de activación

MomentoEventoDatos disponibles¿Puede cambiar la fila?Uso recomendado
BEFOREINSERTNEWSÍ con SET NEW.col = valorValidar, corregir o completar datos antes de guardarlos.
AFTERINSERTNEW (ya con su id autoincremental)NOAuditoría, actualizar otras tablas (stock, contadores).
BEFOREUPDATEOLD y NEWSÍ con SET NEW.col = valorValidar transiciones (no pasar de "Entregado" a "Pendiente").
AFTERUPDATEOLD y NEWNOHistorial de cambios.
BEFOREDELETEOLDNOImpedir el borrado (SIGNAL) o respaldar la fila.
AFTERDELETEOLDNOPapelera / bitácora, ajustar totales.

8.4. Pseudorregistros OLD y NEW

  • NEW: la fila como va a quedar. Existe en INSERT y UPDATE. Solo en BEFORE puedes modificarla con SET NEW.columna = valor;.
  • OLD: la fila como estaba. Existe en UPDATE y DELETE. Es de solo lectura.
  • Usar NEW en un trigger de DELETE (o OLD en uno de INSERT) produce error al crearlo: "There is no NEW row in on DELETE trigger".

8.5. Primer trigger: BEFORE INSERT para corregir datos

El precio negativo se convierte en 0 y el nombre se guarda en mayúsculas, sin que quien inserta tenga que hacerlo.

Código ejecutado, paso a paso
CREATE TABLE productos (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(40), precio DECIMAL(10,2));
CREATE TRIGGER tg_productos_bi BEFORE INSERT ON productos FOR EACH ROW BEGIN IF NEW.precio < 0 THEN SET NEW.precio = 0; -- corrige el valor ANTES de guardarlo END IF; SET NEW.nombre = UPPER(NEW.nombre); -- normaliza el texto END;
INSERT INTO productos (nombre, precio) VALUES ('teclado', 85000), ('mouse', -500);
SELECT * FROM productos;
Resultado (2 filas)
idnombreprecio
1TECLADO85000.00
2MOUSE0.00
Nivel intermedio
Usar los momentos AFTER para auditar y sincronizar otras tablas, escribir lógica con variables e IF, y administrar los triggers creados.

8.6. AFTER INSERT, AFTER UPDATE y BEFORE DELETE

AFTER INSERT: descontar inventario

Cada venta descuenta su cantidad del stock. El trigger se ejecuta una vez por fila insertada (3 veces aquí).

Código ejecutado, paso a paso
CREATE TABLE ventas (id INT AUTO_INCREMENT PRIMARY KEY, producto VARCHAR(40), cantidad INT);
CREATE TABLE inventario (producto VARCHAR(40) PRIMARY KEY, stock INT);
INSERT INTO inventario VALUES ('Café', 50), ('Té', 30);
CREATE TRIGGER tg_ventas_ai AFTER INSERT ON ventas FOR EACH ROW BEGIN UPDATE inventario SET stock = stock - NEW.cantidad WHERE producto = NEW.producto; END;
INSERT INTO ventas (producto, cantidad) VALUES ('Café', 5), ('Café', 3), ('Té', 10);
SELECT * FROM inventario;
Resultado (2 filas)
productostock
Café42
Té20
AFTER UPDATE: historial comparando OLD y NEW

Se actualizan dos empleados, pero solo a Ana le cambió el salario: por eso hay un único registro en el historial.

Código ejecutado, paso a paso
CREATE TABLE empleados (id INT PRIMARY KEY, nombre VARCHAR(40), salario DECIMAL(12,2));
CREATE TABLE historial_salarios (id INT AUTO_INCREMENT PRIMARY KEY, empleado_id INT, anterior DECIMAL(12,2), nuevo DECIMAL(12,2));
INSERT INTO empleados VALUES (1, 'Ana', 3000000), (2, 'Luis', 2500000);
CREATE TRIGGER tg_salario_au AFTER UPDATE ON empleados FOR EACH ROW BEGIN IF OLD.salario <> NEW.salario THEN -- solo si el salario cambió INSERT INTO historial_salarios (empleado_id, anterior, nuevo) VALUES (OLD.id, OLD.salario, NEW.salario); END IF; END;
UPDATE empleados SET salario = salario * 1.10 WHERE id = 1;
UPDATE empleados SET nombre = 'Luis F.' WHERE id = 2;
SELECT * FROM historial_salarios;
Resultado (1 fila)
idempleado_idanteriornuevo
113000000.003300000.00
BEFORE DELETE: respaldar la fila que se borra
Código ejecutado, paso a paso
CREATE TABLE clientes (id INT PRIMARY KEY, nombre VARCHAR(40));
CREATE TABLE clientes_eliminados (id INT, nombre VARCHAR(40));
INSERT INTO clientes VALUES (1, 'Carla'), (2, 'Mario');
CREATE TRIGGER tg_clientes_bd BEFORE DELETE ON clientes FOR EACH ROW BEGIN INSERT INTO clientes_eliminados VALUES (OLD.id, OLD.nombre); END;
DELETE FROM clientes WHERE id = 2;
SELECT * FROM clientes;
Resultado (1 fila)
idnombre
1Carla
SELECT * FROM clientes_eliminados;
Resultado (1 fila)
idnombre
2Mario

8.7. Variables locales e IF dentro del trigger

Dentro de BEGIN ... END se pueden declarar variables (DECLARE, siempre al inicio) y usar IF ... ELSEIF ... ELSE ... END IF o CASE, igual que en un procedimiento.

Código ejecutado, paso a paso
CREATE TABLE ventas (id INT AUTO_INCREMENT PRIMARY KEY, cantidad INT, precio DECIMAL(10,2), total DECIMAL(12,2));
CREATE TRIGGER tg_ventas_total_bi BEFORE INSERT ON ventas FOR EACH ROW BEGIN DECLARE v_subtotal DECIMAL(12,2); -- variable local del trigger SET v_subtotal = NEW.cantidad * NEW.precio; IF v_subtotal > 100000 THEN SET NEW.total = v_subtotal * 0.90; -- 10 % de descuento en compras grandes ELSE SET NEW.total = v_subtotal; END IF; END;
INSERT INTO ventas (cantidad, precio) VALUES (2, 30000), (5, 40000);
SELECT * FROM ventas;
Resultado (2 filas)
idcantidadpreciototal
1230000.0060000.00
2540000.00180000.00

La venta de 60.000 se guardó sin cambios; la de 200.000 superó el límite y quedó en 180.000 (10 % de descuento).

8.8. Administrar disparadores

SHOW TRIGGERS; -- lista los triggers de la base actual SHOW TRIGGERS LIKE 'productos'; -- solo los de una tabla SHOW CREATE TRIGGER tg_productos_bi; -- muestra su código DROP TRIGGER IF EXISTS tg_productos_bi; -- lo elimina (no existe ALTER TRIGGER: se borra y se vuelve a crear)
Nivel avanzado
Cancelar operaciones con errores propios, controlar el orden de varios triggers, conocer su relación con las llaves foráneas y sus límites.

8.9. Bloquear una operación con SIGNAL

SIGNAL SQLSTATE '45000' lanza un error definido por ti: la sentencia que activó el trigger se cancela y el mensaje llega a la aplicación. Observa que Tomás nunca se guardó.

Código ejecutado, paso a paso
CREATE TABLE inscripciones (id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(40), edad INT);
CREATE TRIGGER tg_inscripciones_bi BEFORE INSERT ON inscripciones FOR EACH ROW BEGIN IF NEW.edad < 18 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'No se permiten inscripciones de menores de edad'; END IF; END;
INSERT INTO inscripciones (nombre, edad) VALUES ('Sara', 20);
INSERT INTO inscripciones (nombre, edad) VALUES ('Tomás', 15);
MySQL rechaza la operación: No se permiten inscripciones de menores de edad
SELECT * FROM inscripciones;
Resultado (1 fila)
idnombreedad
1Sara20

8.10. Varios triggers en el mismo evento: FOLLOWS y PRECEDES

Una tabla puede tener varios triggers para el mismo momento y evento (MySQL 5.7+ y MariaDB 10.2+). Por defecto se ejecutan en el orden en que se crearon; con FOLLOWS otro (después de) o PRECEDES otro (antes de) se fija el orden:

Código ejecutado, paso a paso
CREATE TABLE pedidos (id INT AUTO_INCREMENT PRIMARY KEY, valor INT);
CREATE TABLE bitacora (paso VARCHAR(30));
CREATE TRIGGER tg_validar BEFORE INSERT ON pedidos FOR EACH ROW INSERT INTO bitacora VALUES ('1. validar');
CREATE TRIGGER tg_auditar BEFORE INSERT ON pedidos FOR EACH ROW FOLLOWS tg_validar INSERT INTO bitacora VALUES ('2. auditar');
CREATE TRIGGER tg_preparar BEFORE INSERT ON pedidos FOR EACH ROW PRECEDES tg_validar INSERT INTO bitacora VALUES ('0. preparar');
INSERT INTO pedidos (valor) VALUES (100);
SELECT * FROM bitacora;
Resultado (3 filas)
paso
0. preparar
1. validar
2. auditar

tg_preparar se creó de último, pero se ejecutó primero porque se declaró PRECEDES tg_validar.

8.11. Triggers y llaves foráneas en cascada

Un detalle importante: las acciones automáticas de una llave foránea (ON DELETE CASCADE, ON UPDATE CASCADE) no activan los triggers de la tabla hija.

Código ejecutado, paso a paso
CREATE TABLE pedidos (id INT PRIMARY KEY);
CREATE TABLE lineas (id INT PRIMARY KEY, pedido_id INT, FOREIGN KEY (pedido_id) REFERENCES pedidos(id) ON DELETE CASCADE);
CREATE TABLE lineas_borradas (linea_id INT);
CREATE TRIGGER tg_lineas_ad AFTER DELETE ON lineas FOR EACH ROW INSERT INTO lineas_borradas VALUES (OLD.id);
INSERT INTO pedidos VALUES (1);
INSERT INTO lineas VALUES (10, 1), (11, 1);
DELETE FROM lineas WHERE id = 10;
DELETE FROM pedidos WHERE id = 1;
SELECT COUNT(*) AS lineas_que_quedan FROM lineas;
Resultado (1 fila)
lineas_que_quedan
0
SELECT * FROM lineas_borradas;
Resultado (1 fila)
linea_id
10

Se borraron las dos líneas (10 directamente y 11 en cascada al borrar el pedido), pero la bitácora solo registró la 10. Si necesitas auditar también los borrados en cascada, haz el borrado de las filas hijas explícitamente (o desde un procedimiento) en lugar de depender de CASCADE.

8.12. Restricciones y errores comunes

  • Modificar la misma tabla del trigger (por ejemplo, un UPDATE productos dentro de un trigger de productos) produce el error 1442 "Can't update table ... because it is already used by statement which invoked this trigger". Para cambiar la fila actual usa SET NEW.columna en un trigger BEFORE.
  • No se permiten START TRANSACTION, COMMIT ni ROLLBACK dentro de un trigger: forma parte de la transacción de la sentencia que lo activó. Si el trigger falla, la sentencia completa se cancela.
  • Un trigger no puede llamarse a sí mismo en cadena (recursión) ni devolver resultados con un SELECT sin INTO.
  • Rendimiento: se ejecuta por cada fila. Un UPDATE que afecta 100.000 filas ejecuta el trigger 100.000 veces; mantén su lógica sencilla.
  • SET NEW.col en un trigger AFTER da error: la fila ya se guardó.
  • Olvidar END IF; o el ; al final de cada instrucción dentro de BEGIN ... END.

8.13. Cómo responder en la plataforma

  • Escribe una sola sentencia CREATE TRIGGER con el nombre exacto que pide el reto. No hace falta DELIMITER (si lo pones, se ignora).
  • Las tablas ya existen: no las crees. El sistema ejecuta tu trigger, realiza una operación de prueba y revisa el efecto en los datos.
  • En los retos de corrección, reescribe la sentencia completa ya corregida.

9. Eventos programados (Event Scheduler)

Nivel básico
Programar una tarea para que el servidor la ejecute solo, en una fecha y hora.

9.1. ¿Qué es un evento?

Un evento es una tarea SQL que el propio servidor ejecuta automáticamente según un horario: una sola vez en una fecha concreta o de forma repetida (cada hora, cada día…). Es como una alarma o un "cron" dentro de la base de datos: sirve para limpiar datos viejos, generar resúmenes nocturnos, cerrar periodos, etc.

TriggerProcedimientoEvento
¿Qué lo ejecuta?Un INSERT/UPDATE/DELETE en su tablaAlguien que hace CALLEl reloj del servidor

9.2. Activar el programador de eventos

SHOW VARIABLES LIKE 'event_scheduler'; -- ON = los eventos se ejecutan SET GLOBAL event_scheduler = ON; -- requiere privilegios de administrador
Para que quede activo después de reiniciar MySQL en XAMPP, agrega event_scheduler=ON en la sección [mysqld] del archivo my.ini. Si está en OFF, los eventos se crean pero nunca se ejecutan.

9.3. Sintaxis general

CREATE EVENT nombre_evento ON SCHEDULE {AT momento | EVERY cantidad unidad [STARTS inicio] [ENDS fin]} [ON COMPLETION [NOT] PRESERVE] [ENABLE | DISABLE] DO BEGIN -- instrucciones END;
Una sola vez: AT
ON SCHEDULE AT '2026-12-31 23:59:00'
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 3 HOUR
Repetido: EVERY
ON SCHEDULE EVERY 1 DAY
ON SCHEDULE EVERY 15 MINUTE

Unidades válidas: SECOND, MINUTE, HOUR, DAY, WEEK, MONTH, QUARTER, YEAR (en singular). EVERY siempre necesita cantidad y unidad: EVERY 1 DAY, nunca EVERY DAY.

9.4. Primer evento: una sola vez

Se programa para "ahora mismo"; unos instantes después, el servidor ya lo ejecutó y el valor cambió de 0 a 1.

Código ejecutado, paso a paso
CREATE TABLE config (id INT PRIMARY KEY, activo INT);
INSERT INTO config VALUES (1, 0);
CREATE EVENT ev_activar_config ON SCHEDULE AT CURRENT_TIMESTAMP DO UPDATE config SET activo = 1 WHERE id = 1;
SELECT * FROM config;
Resultado (1 fila)
idactivo
10
Nivel intermedio
Controlar cuándo empieza y termina un evento repetitivo, qué pasa al terminar y cómo pausarlo, cambiarlo o eliminarlo.

9.5. STARTS, ENDS y ON COMPLETION

ON SCHEDULE EVERY 15 MINUTE STARTS '2026-01-01 00:00:00' -- primera ejecución (si se omite: ahora) ENDS '2026-06-30 23:59:59' -- última posible (si se omite: nunca termina)
  • ON COMPLETION NOT PRESERVE (valor por defecto): cuando un evento AT se ejecuta, o un evento EVERY llega a su ENDS, MySQL lo elimina automáticamente.
  • ON COMPLETION PRESERVE: el evento se conserva (queda deshabilitado) para poder revisarlo o reactivarlo sin reescribirlo.

9.6. Eventos que se repiten: limpieza diaria

Con STARTS CURRENT_TIMESTAMP la primera ejecución es inmediata; luego se repetirá cada día. Las sesiones de hace 10 y 30 días se eliminaron.

Código ejecutado, paso a paso
CREATE TABLE sesiones (id INT PRIMARY KEY, usuario VARCHAR(20), ultima_actividad DATETIME);
INSERT INTO sesiones VALUES (1, 'ana', NOW()), (2, 'luis', NOW() - INTERVAL 10 DAY), (3, 'eva', NOW() - INTERVAL 30 DAY);
CREATE EVENT ev_limpiar_sesiones ON SCHEDULE EVERY 1 DAY STARTS CURRENT_TIMESTAMP ON COMPLETION PRESERVE DO BEGIN DELETE FROM sesiones WHERE ultima_actividad < NOW() - INTERVAL 7 DAY; END;
SELECT id, usuario FROM sesiones;
Resultado (3 filas)
idusuario
1ana
2luis
3eva

9.7. Administrar eventos

SHOW EVENTS; -- lista los eventos y su estado SHOW CREATE EVENT ev_limpiar_sesiones; -- muestra su código ALTER EVENT ev_limpiar_sesiones DISABLE; -- pausar ALTER EVENT ev_limpiar_sesiones ENABLE; -- reanudar ALTER EVENT ev_limpiar_sesiones ON SCHEDULE EVERY 12 HOUR; -- cambiar la frecuencia ALTER EVENT ev_limpiar_sesiones ON SCHEDULE AT CURRENT_TIMESTAMP; -- forzar una ejecución ya ALTER EVENT ev_limpiar_sesiones RENAME TO ev_limpieza; -- renombrar DROP EVENT IF EXISTS ev_limpieza; -- eliminar
Nivel avanzado
Organizar tareas complejas en procedimientos, supervisar los eventos y detectar fallas que nadie ve.

9.8. Eventos que llaman procedimientos

La práctica recomendada es dejar la lógica en un procedimiento y que el evento solo lo llame: así se puede probar con CALL y cambiar sin tocar el horario.

Código ejecutado, paso a paso
CREATE TABLE reservas (id INT PRIMARY KEY, estado VARCHAR(15), vence DATE);
INSERT INTO reservas VALUES (1, 'activa', CURDATE() - INTERVAL 2 DAY), (2, 'activa', CURDATE() + INTERVAL 5 DAY);
CREATE PROCEDURE sp_vencer_reservas() BEGIN UPDATE reservas SET estado = 'vencida' WHERE estado = 'activa' AND vence < CURDATE(); END;
CREATE EVENT ev_vencer_reservas ON SCHEDULE EVERY 1 DAY STARTS CURRENT_TIMESTAMP DO CALL sp_vencer_reservas();
SELECT * FROM reservas;
Resultado (2 filas)
idestadovence
1activa2026-10-12
2activa2026-10-19

La reserva que venció hace 2 días pasó a "vencida"; la que vence en 5 días sigue activa.

9.9. Consultar el estado de los eventos

El catálogo information_schema.EVENTS muestra el tipo, la frecuencia, la fecha programada y si están activos:

Código ejecutado, paso a paso
CREATE EVENT ev_limpieza ON SCHEDULE EVERY 1 HOUR DO DELETE FROM tabla_inexistente;
CREATE EVENT ev_cierre ON SCHEDULE AT '2030-12-31 23:59:00' ON COMPLETION PRESERVE DO SELECT 1;
ALTER EVENT ev_cierre DISABLE;
SELECT EVENT_NAME, EVENT_TYPE, INTERVAL_VALUE, INTERVAL_FIELD, EXECUTE_AT, STATUS, ON_COMPLETION FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE() ORDER BY EVENT_NAME;
Resultado (2 filas)
EVENT_NAMEEVENT_TYPEINTERVAL_VALUEINTERVAL_FIELDEXECUTE_ATSTATUSON_COMPLETION
ev_cierreONE TIMENULLNULL2030-12-31 23:59:00DISABLEDPRESERVE
ev_limpiezaRECURRING1HOURNULLENABLEDNOT PRESERVE

RECURRING = se repite; ONE TIME = una sola vez (AT). La columna LAST_EXECUTED indica cuándo se ejecutó por última vez.

9.10. Errores silenciosos y buenas prácticas

Cuando un evento falla no aparece ningún mensaje, porque no hay un usuario conectado que lo vea. El error solo queda en el registro de errores del servidor, en XAMPP: C:\xampp\mysql\data\mysql_error.log. Así se ve allí el evento ev_limpieza del ejemplo anterior, que borra de una tabla que no existe:

[ERROR] Event Scheduler: [usuario@localhost][base.ev_limpieza] Table 'base.tabla_inexistente' doesn't exist
  • Prueba la lógica antes como consulta normal o con CALL (por eso conviene usar procedimientos, 9.8).
  • Lleva una bitácora: que el procedimiento registre en una tabla cada ejecución (fecha, filas afectadas con ROW_COUNT(), y un HANDLER que guarde los errores).
  • Hora del servidor: los horarios usan la zona horaria de MySQL (SELECT NOW();), que puede ser distinta a la de tu equipo o a la de la aplicación.
  • Ejecuciones que se solapan: si una ejecución tarda más que el intervalo (por ejemplo, EVERY 1 MINUTE con una tarea de 2 minutos), puede comenzar otra antes de que termine la anterior.
  • Para crear eventos el usuario necesita el privilegio EVENT sobre la base de datos.

9.11. Errores comunes

  • EVERY DAY o EVERY 1 DAYS: la sintaxis correcta es EVERY 1 DAY.
  • Olvidar la palabra DO antes del cuerpo.
  • Programador apagado (event_scheduler = OFF): el evento existe pero nunca se ejecuta.
  • Una fecha en el pasado con AT (sin PRESERVE): MySQL descarta el evento en el momento de crearlo, sin ejecutarlo, y solo muestra una advertencia.

9.12. Cómo responder en la plataforma

  • Escribe una sola sentencia CREATE EVENT con el nombre y el horario que pide el reto. No hace falta DELIMITER (qué es y cuándo se usa en la consola: sección 6.2).
  • Para comprobarlo, el sistema revisa la programación de tu evento, ejecuta una vez su cuerpo (lo que va después de DO), como lo haría el programador de eventos, y revisa el efecto en los datos. No hace falta tener encendido event_scheduler.

10. Usuarios, roles y permisos (DCL)

El DCL (Data Control Language) decide quién puede entrar al servidor y qué puede hacer en cada base, tabla o columna. Sus sentencias principales son GRANT (dar permisos) y REVOKE (quitarlos); junto a ellas se usan CREATE USER y CREATE ROLE.

Sección de consulta y apoyo, sin retos evaluados. Todo se escribe en la consola de MySQL conectado como root (sección 1.2). Las salidas son reales, tomadas de un XAMPP con MariaDB 10.4.
Nivel básico
Entender el principio de mínimo privilegio y administrar las cuentas de usuario.

10.1. ¿Por qué no trabajar siempre con root?

root puede hacer cualquier cosa en cualquier base: borrar bases completas, ver todos los datos o crear usuarios. Si una aplicación se conecta como root y alguien encuentra una falla en ella, tiene el servidor completo. Lo correcto es aplicar el principio de mínimo privilegio: cada usuario o aplicación recibe solo los permisos que necesita.

Un reporte

Solo necesita SELECT sobre las tablas que consulta.

Una aplicación web

SELECT, INSERT, UPDATE y DELETE en su base, nunca permisos para borrar tablas.

Los respaldos

Permisos para leer y bloquear tablas, sin poder modificar datos (ver 11.4).

10.2. Crear, modificar y eliminar usuarios

Un usuario se identifica por su nombre y por el equipo desde donde se conecta: 'nombre'@'equipo'.

MariaDB [(none)]> CREATE USER 'ana'@'localhost' IDENTIFIED BY 'Clave_Ana_123'; Query OK, 0 rows affected (0.009 sec)
  • 'localhost' = solo desde el mismo equipo del servidor; '%' = desde cualquier equipo; '192.168.1.%' = desde la red local. 'ana'@'localhost' y 'ana'@'%' son dos usuarios distintos, cada uno con sus permisos.
  • Un usuario recién creado puede conectarse pero no puede hacer nada: no tiene permisos hasta que se le den con GRANT (10.3).
  • Para conectarse con él, desde CMD: mysql -u ana -p (pide la contraseña).
Quiero…Sentencia
Crear un usuarioCREATE USER 'ana'@'localhost' IDENTIFIED BY 'Clave_Ana_123';
Cambiar su contraseñaALTER USER 'ana'@'localhost' IDENTIFIED BY 'NuevaClave_456';
Bloquear / desbloquear la cuentaALTER USER 'ana'@'localhost' ACCOUNT LOCK; · ACCOUNT UNLOCK
Ver los usuarios del servidorSELECT user, host FROM mysql.user;
Saber con qué usuario estoy conectadoSELECT CURRENT_USER();
Eliminar un usuarioDROP USER 'ana'@'localhost';
En XAMPP, root viene sin contraseña. Está bien para practicar en tu equipo, pero en un servidor real hay que asignarle una: ALTER USER 'root'@'localhost' IDENTIFIED BY 'una_clave_fuerte'; (después, las aplicaciones que se conecten como root deben configurarse con esa clave).
Nivel intermedio
Dar y quitar permisos con GRANT y REVOKE, y elegir el nivel exacto al que se aplican.

10.3. Dar y quitar permisos: GRANT y REVOKE

MariaDB [(none)]> CREATE USER 'lector'@'localhost' IDENTIFIED BY 'Clave_Prueba_123'; Query OK, 0 rows affected (0.002 sec) MariaDB [(none)]> GRANT SELECT ON veterinaria.* TO 'lector'@'localhost'; Query OK, 0 rows affected (0.001 sec) MariaDB [(none)]> SHOW GRANTS FOR 'lector'@'localhost'; +------------------------------------------------------------------------------------------------------------------+ | Grants for lector@localhost | +------------------------------------------------------------------------------------------------------------------+ | GRANT USAGE ON *.* TO `lector`@`localhost` IDENTIFIED BY PASSWORD '*CD2B61A6F0104846CC3E7A8C097DF7033D43DCD7' | | GRANT SELECT ON `veterinaria`.* TO `lector`@`localhost` | +------------------------------------------------------------------------------------------------------------------+ MariaDB [(none)]> REVOKE SELECT ON veterinaria.* FROM 'lector'@'localhost'; Query OK, 0 rows affected (0.001 sec) MariaDB [(none)]> DROP USER 'lector'@'localhost'; Query OK, 0 rows affected (0.002 sec)
  • Estructura: GRANT permisos ON dónde TO quién; y REVOKE permisos ON dónde FROM quién;.
  • SHOW GRANTS muestra los permisos actuales. USAGE significa "puede conectarse, pero sin permisos": siempre aparece. La contraseña se guarda cifrada, nunca en texto.
  • Los cambios con GRANT y REVOKE se aplican de inmediato; no hace falta FLUSH PRIVILEGES (solo se necesita si se modifican a mano las tablas de mysql).
  • Si el usuario intenta algo sin permiso recibe un error y la operación no se hace. Por ejemplo, ana puede leer y modificar mascotas (10.5), pero no borrarlas:
MariaDB [(none)]> DELETE FROM veterinaria.mascotas WHERE id = 2; ERROR 1142 (42000): DELETE command denied to user 'ana'@'localhost' for table `veterinaria`.`mascotas`

10.4. Niveles de permiso: servidor, base, tabla, columna y rutina

La parte ON del GRANT decide a qué se aplica el permiso. Elige siempre el nivel más pequeño que sirva:

NivelSe escribeEjemplo
Todo el servidor evitarON *.*GRANT ALL PRIVILEGES ON *.* TO 'admin'@'localhost';
Una base de datosON base.*GRANT SELECT, INSERT, UPDATE, DELETE ON veterinaria.* TO 'app'@'localhost';
Una tablaON base.tablaGRANT SELECT ON veterinaria.mascotas TO 'lector'@'localhost';
Algunas columnasSELECT (col1, col2) ON base.tablaGRANT SELECT (id, nombre, especie) ON veterinaria.mascotas TO 'luis'@'localhost';
Un procedimiento o funciónON PROCEDURE base.nombreGRANT EXECUTE ON PROCEDURE veterinaria.sp_registrar_mascota TO 'web'@'localhost';

Con el permiso por columnas, luis puede ver el nombre de las mascotas, pero no el costo de la consulta:

MariaDB [(none)]> SELECT nombre, costo_consulta FROM veterinaria.mascotas; ERROR 1143 (42000): SELECT command denied to user 'luis'@'localhost' for column 'costo_consulta' in table 'mascotas'
Quiero que el usuario pueda…Permisos
Solo leer una baseGRANT SELECT ON base.* TO 'u'@'localhost';
Leer y modificar datos (una aplicación)GRANT SELECT, INSERT, UPDATE, DELETE ON base.* TO 'u'@'localhost';
Ejecutar procedimientos y funcionesGRANT EXECUTE ON base.* TO 'u'@'localhost';
Crear y modificar tablas (DDL)GRANT CREATE, ALTER, DROP, INDEX ON base.* TO 'u'@'localhost';
Hacer respaldos (11.4)GRANT SELECT, SHOW VIEW, TRIGGER, EVENT, LOCK TABLES ON base.* TO 'respaldo'@'localhost';
Todo sobre una base (no sobre el servidor)GRANT ALL PRIVILEGES ON base.* TO 'u'@'localhost';
Dar permisos a otros evitar... WITH GRANT OPTION
Nivel avanzado
Administrar permisos por grupos con roles y exponer solo vistas o procedimientos en lugar de las tablas.

10.5. Roles: permisos agrupados

Cuando varias personas hacen el mismo trabajo (por ejemplo, todo el personal de recepción), dar los mismos permisos uno por uno es repetitivo y fácil de equivocar. Un rol es un conjunto de permisos con nombre: se le dan los permisos al rol y luego se asigna el rol a cada usuario. Si cambian las tareas, se modifica el rol una sola vez.

MariaDB [(none)]> CREATE ROLE recepcion; -- 1. crear el rol Query OK, 0 rows affected (0.001 sec) MariaDB [(none)]> GRANT SELECT, INSERT, UPDATE ON veterinaria.mascotas TO recepcion; -- 2. permisos al rol Query OK, 0 rows affected (0.002 sec) MariaDB [(none)]> GRANT recepcion TO 'ana'@'localhost', 'luis'@'localhost'; -- 3. rol a los usuarios Query OK, 0 rows affected (0.001 sec) MariaDB [(none)]> SET DEFAULT ROLE recepcion FOR 'ana'@'localhost'; -- 4. activarlo al conectarse Query OK, 0 rows affected (0.001 sec) MariaDB [(none)]> SHOW GRANTS FOR recepcion; +-------------------------------------------------------------------------+ | Grants for recepcion | +-------------------------------------------------------------------------+ | GRANT USAGE ON *.* TO `recepcion` | | GRANT SELECT, INSERT, UPDATE ON `veterinaria`.`mascotas` TO `recepcion` | +-------------------------------------------------------------------------+ MariaDB [(none)]> SHOW GRANTS FOR 'ana'@'localhost'; +------------------------------------------------------------------------------------------------------------+ | Grants for ana@localhost | +------------------------------------------------------------------------------------------------------------+ | GRANT `recepcion` TO `ana`@`localhost` | | GRANT USAGE ON *.* TO `ana`@`localhost` IDENTIFIED BY PASSWORD '*1471494142F5E3A88FEED3A4BF027B16F25918F9' | | SET DEFAULT ROLE `recepcion` FOR `ana`@`localhost` | +------------------------------------------------------------------------------------------------------------+

Importante: tener un rol asignado no basta; el rol debe estar activo en la sesión. ana lo tiene como rol por defecto, así que se activa solo al conectarse. luis tiene el rol, pero no por defecto: hasta que no ejecuta SET ROLE solo cuenta con sus permisos propios.

Sesión de ana (rol por defecto)
MariaDB [(none)]> SELECT CURRENT_ROLE(); +----------------+ | CURRENT_ROLE() | +----------------+ | recepcion | +----------------+ MariaDB [(none)]> SELECT nombre, costo_consulta -> FROM veterinaria.mascotas; +----------+----------------+ | nombre | costo_consulta | +----------+----------------+ | Firulais | 45000.00 | | Michi | 38000.00 | +----------+----------------+ MariaDB [(none)]> DELETE FROM veterinaria.mascotas -> WHERE id = 2; ERROR 1142 (42000): DELETE command denied to user 'ana'@'localhost' for table `veterinaria`.`mascotas`

El rol no incluye DELETE, así que ana no puede borrar.

Sesión de luis (activa el rol a mano)
MariaDB [(none)]> SELECT CURRENT_ROLE(); +----------------+ | CURRENT_ROLE() | +----------------+ | NULL | +----------------+ MariaDB [(none)]> SELECT nombre, costo_consulta -> FROM veterinaria.mascotas; ERROR 1143 (42000): SELECT command denied to user 'luis'@'localhost' for column 'costo_consulta' in table 'mascotas' MariaDB [(none)]> SET ROLE recepcion; Query OK, 0 rows affected (0.000 sec) MariaDB [(none)]> SELECT nombre, costo_consulta -> FROM veterinaria.mascotas; +----------+----------------+ | nombre | costo_consulta | +----------+----------------+ | Firulais | 45000.00 | | Michi | 38000.00 | +----------+----------------+

Sin el rol activo, luis solo tiene su permiso por columnas (10.4).

Al cambiar los permisos del rol, el cambio llega a todos sus usuarios. Por ejemplo, para que recepción ya no pueda modificar:

MariaDB [(none)]> REVOKE INSERT, UPDATE ON veterinaria.mascotas FROM recepcion; Query OK, 0 rows affected (0.002 sec) MariaDB [(none)]> SHOW GRANTS FOR recepcion; +---------------------------------------------------------+ | Grants for recepcion | +---------------------------------------------------------+ | GRANT USAGE ON *.* TO `recepcion` | | GRANT SELECT ON `veterinaria`.`mascotas` TO `recepcion` | +---------------------------------------------------------+ MariaDB [(none)]> DROP ROLE recepcion; Query OK, 0 rows affected (0.000 sec)
Quiero…Sentencia
Crear un rolCREATE ROLE recepcion;
Dar permisos al rolGRANT SELECT ON base.tabla TO recepcion;
Asignar el rol a un usuarioGRANT recepcion TO 'ana'@'localhost';
Que el rol se active al conectarseSET DEFAULT ROLE recepcion FOR 'ana'@'localhost';
Activar un rol en la sesión actualSET ROLE recepcion; (y SET ROLE NONE; para desactivarlo)
Ver el rol activoSELECT CURRENT_ROLE();
Quitar el rol a un usuarioREVOKE recepcion FROM 'ana'@'localhost';
Eliminar el rolDROP ROLE recepcion;

10.6. Dar acceso solo a vistas o procedimientos

A veces un usuario no debe ver la tabla completa ni modificarla libremente. En lugar de darle permisos sobre la tabla:

  • Se le da SELECT sobre una vista (2.8) que muestra solo las columnas o filas permitidas.
  • Se le da EXECUTE sobre un procedimiento (módulo 6) que hace la operación con sus propias reglas. Con SQL SECURITY DEFINER (el valor por defecto) el procedimiento usa los permisos de quien lo creó, así que el usuario puede registrar datos sin tener permiso directo sobre la tabla.
MariaDB [veterinaria]> CREATE VIEW v_mascotas_publico AS SELECT id, nombre, especie FROM mascotas; MariaDB [veterinaria]> GRANT SELECT ON veterinaria.v_mascotas_publico TO 'web'@'localhost'; MariaDB [veterinaria]> GRANT EXECUTE ON PROCEDURE veterinaria.sp_registrar_mascota TO 'web'@'localhost';

Conectado como web:

MariaDB [(none)]> SELECT * FROM veterinaria.v_mascotas_publico; +----+----------+---------+ | id | nombre | especie | +----+----------+---------+ | 1 | Firulais | Perro | | 2 | Michi | Gato | +----+----------+---------+ MariaDB [(none)]> SELECT * FROM veterinaria.mascotas; ERROR 1142 (42000): SELECT command denied to user 'web'@'localhost' for table `veterinaria`.`mascotas` MariaDB [(none)]> CALL veterinaria.sp_registrar_mascota('Rocky', 'Perro'); Query OK, 1 row affected (0.002 sec) MariaDB [(none)]> INSERT INTO veterinaria.mascotas (nombre, especie) VALUES ('Luna', 'Gato'); ERROR 1142 (42000): INSERT command denied to user 'web'@'localhost' for table `veterinaria`.`mascotas`

web ve las mascotas sin el costo de la consulta y puede registrar una mascota solo a través del procedimiento, que fija el costo. No puede leer la tabla ni insertar directamente en ella.

10.7. Buenas prácticas

  • Un usuario por aplicación o persona, nunca una cuenta compartida: así se sabe quién hizo qué y se puede quitar el acceso a uno solo.
  • Mínimo privilegio: empieza sin permisos y agrega solo los necesarios, en el nivel más pequeño (tabla o columna antes que base; base antes que servidor).
  • Las aplicaciones nunca se conectan como root. Esta misma plataforma ejecuta el código de los aprendices con un usuario que solo puede trabajar en bases temporales de prueba.
  • Usa roles cuando varias personas comparten las mismas tareas, y revisa los permisos con SHOW GRANTS de vez en cuando.
  • Prefiere 'usuario'@'localhost' o una red concreta antes que '%', y contraseñas largas que no se escriban en archivos ni en el código.
  • Cuando alguien deja de necesitar el acceso, quítalo de inmediato (REVOKE o DROP USER).

10.8. Chuleta

TareaSentencia
Crear usuarioCREATE USER 'u'@'localhost' IDENTIFIED BY 'Clave_123';
Dar permisosGRANT SELECT, INSERT ON base.tabla TO 'u'@'localhost';
Quitar permisosREVOKE INSERT ON base.tabla FROM 'u'@'localhost';
Ver permisosSHOW GRANTS FOR 'u'@'localhost';
Crear y asignar un rolCREATE ROLE r; · GRANT ... TO r; · GRANT r TO 'u'@'localhost'; · SET DEFAULT ROLE r FOR 'u'@'localhost';
Cambiar contraseñaALTER USER 'u'@'localhost' IDENTIFIED BY 'Nueva_456';
Eliminar usuario / rolDROP USER 'u'@'localhost'; · DROP ROLE r;

11. Copias de seguridad y restauración

Sección de consulta y apoyo: no tiene retos evaluados. Todo se hace desde la consola de Windows (CMD), con las herramientas que ya trae XAMPP en C:\xampp\mysql\bin. Las salidas que se muestran aquí son reales, tomadas de un XAMPP con MariaDB 10.4.
Nivel básico
Sacar copias de seguridad completas o parciales y entender el archivo generado.

11.1. Sacar una copia de seguridad con mysqldump

Se ejecuta en CMD, fuera de la consola de MySQL, desde C:\xampp\mysql\bin (o desde el Shell de XAMPP):

mysqldump -u root -p [opciones] nombre_base > ruta\del\archivo.sql

El símbolo > le dice a CMD que guarde en un archivo lo que el programa mostraría en pantalla. Si el archivo ya existe, se reemplaza. La carpeta de destino debe existir (créala antes con mkdir C:\respaldos).

¿Qué necesitas?ComandoEl archivo contiene
Estructura y datos (copia completa) mysqldump -u root -p veterinaria > C:\respaldos\veterinaria_completo.sql DROP TABLE + CREATE TABLE + INSERT
Solo estructura mysqldump -u root -p --no-data veterinaria > C:\respaldos\veterinaria_estructura.sql Solo DROP TABLE + CREATE TABLE: tablas vacías, ideal para crear la base en otro equipo.
Solo datos mysqldump -u root -p --no-create-info veterinaria > C:\respaldos\veterinaria_datos.sql Solo INSERT: para llenar tablas que ya existen.
Solo algunas tablas mysqldump -u root -p veterinaria mascotas duenos > C:\respaldos\tablas.sql Solo las tablas nombradas después de la base.
Incluir la creación de la base mysqldump -u root -p --databases veterinaria > C:\respaldos\veterinaria_con_bd.sql Agrega CREATE DATABASE y USE al inicio.
Incluir procedimientos, funciones y eventos mysqldump -u root -p --routines --events veterinaria > C:\respaldos\veterinaria_total.sql Además de tablas y triggers (que se incluyen por defecto), los procedimientos, funciones y eventos.
Todas las bases del servidor mysqldump -u root -p --all-databases > C:\respaldos\servidor_completo.sql Todas las bases, cada una con su CREATE DATABASE.

Opciones útiles que se pueden combinar:

  • --routines --events: importante en este curso. Sin ellas, los procedimientos, funciones y eventos que creaste no quedan en la copia.
  • --single-transaction: copia consistente de tablas InnoDB sin bloquearlas mientras otros usuarios trabajan.
  • --skip-extended-insert: un INSERT por fila (archivo más largo, pero más fácil de leer y comparar).
  • --default-character-set=utf8mb4: conserva bien tildes y eñes.
  • --result-file=C:\respaldos\copia.sql: alternativa a >.

Ejemplo de una copia completa recomendada para el curso:

C:\xampp\mysql\bin> mkdir C:\respaldos C:\xampp\mysql\bin> mysqldump -u root -p --routines --events --single-transaction --default-character-set=utf8mb4 veterinaria > C:\respaldos\veterinaria_2026-10-12.sql Enter password: C:\xampp\mysql\bin> dir C:\respaldos 12/10/2026 10:25 p. m. 3.201 veterinaria_2026-10-12.sql

Si el comando no muestra nada después de la contraseña, terminó bien: mysqldump solo imprime mensajes cuando hay errores.

11.2. ¿Qué contiene el archivo .sql que se genera?

Un respaldo es un archivo de texto con las sentencias SQL necesarias para reconstruir la base. Este es el archivo real que genera mysqldump para la base veterinaria, con la explicación de cada parte:

-- MariaDB dump 10.19 Distrib 10.4.32-MariaDB, for Win64 (AMD64) -- -- Host: localhost Database: veterinaria -- ------------------------------------------------------ -- Server version 10.4.32-MariaDB
① Encabezado. Comentarios (--) con la versión de la herramienta, el servidor y la base respaldada. No se ejecutan.
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET NAMES utf8mb4 */; /*!40103 SET TIME_ZONE='+00:00' */; /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
② Preparación. Los /*!40101 ... */ son comentarios condicionales: MySQL/MariaDB los ejecuta solo si su versión es igual o mayor (40101 = versión 4.1.1); otros programas los ignoran. Guardan la configuración actual en variables @OLD_... y luego: fijan la codificación utf8mb4, desactivan las claves foráneas (para poder crear las tablas en cualquier orden) y respetan los id en 0.
-- -- Table structure for table `duenos` -- DROP TABLE IF EXISTS `duenos`; CREATE TABLE `duenos` ( `id` int(11) NOT NULL AUTO_INCREMENT, `nombre` varchar(40) NOT NULL, `ciudad` varchar(30) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
③ Estructura de cada tabla. DROP TABLE IF EXISTS borra la tabla si ya existe en el destino (¡cuidado al restaurar sobre una base con datos!) y CREATE TABLE la crea igual a la original. AUTO_INCREMENT=3 indica que el próximo id será 3. Esta parte no aparece en una copia hecha con --no-create-info.
-- -- Dumping data for table `duenos` -- LOCK TABLES `duenos` WRITE; /*!40000 ALTER TABLE `duenos` DISABLE KEYS */; INSERT INTO `duenos` VALUES (1,'Marta Rojas','Bogotá'),(2,'Jorge Silva','Medellín'); /*!40000 ALTER TABLE `duenos` ENABLE KEYS */; UNLOCK TABLES;
④ Datos. Bloquea la tabla mientras inserta, desactiva temporalmente los índices para cargar más rápido y agrega todas las filas en un solo INSERT (inserción extendida). Esta parte no aparece en una copia hecha con --no-data.
CREATE TABLE `mascotas` ( ... KEY `fk_mascota_dueno` (`dueno_id`), CONSTRAINT `fk_mascota_dueno` FOREIGN KEY (`dueno_id`) REFERENCES `duenos` (`id`) ) ENGINE=InnoDB ...; INSERT INTO `mascotas` VALUES (1,'Firulais','Perro',1),(2,'Michi','Gato',1),(3,'Rocky','Perro',2);
⑤ Las demás tablas, en orden alfabético, con sus claves foráneas. Por eso se desactivaron las claves foráneas en el paso ②.
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; ... -- Dump completed on 2026-10-12 22:25:10
⑥ Cierre. Restaura la configuración original guardada en el paso ②. La línea Dump completed confirma que la copia terminó completa: si falta, el archivo quedó cortado y no sirve.

Diferencias según la opción usada (misma base de ejemplo):

CopiaCREATE TABLEINSERTCREATE DATABASE / USELíneas
Completa (sin opciones)SíSíNo80
--no-dataSíNoNo60
--no-create-infoNoSíNo45
--databases veterinariaSíSíSí88

Lo que agrega --databases al inicio del archivo:

CREATE DATABASE /*!32312 IF NOT EXISTS*/ `veterinaria` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci */; USE `veterinaria`;
Nivel intermedio
Cargar una copia en una base de datos y comprobar que quedó completa.

11.3. Cargar (restaurar) una base de datos desde un archivo .sql

Se usa el cliente mysql y el símbolo <, que le entrega el contenido del archivo como si lo escribieras. Hay dos casos:

Caso 1: el archivo NO trae CREATE DATABASE (copia normal, --no-data o --no-create-info)

Primero crea la base vacía y luego carga el archivo indicando en qué base:

C:\xampp\mysql\bin> mysql -u root -p -e "CREATE DATABASE veterinaria_copia CHARACTER SET utf8mb4" Enter password: C:\xampp\mysql\bin> mysql -u root -p veterinaria_copia < C:\respaldos\veterinaria_completo.sql Enter password:

-e "..." ejecuta una sola sentencia sin entrar a la consola. Igual que mysqldump, si no muestra nada, terminó bien.

Caso 2: el archivo SÍ trae CREATE DATABASE (copia hecha con --databases o --all-databases)
C:\xampp\mysql\bin> mysql -u root -p < C:\respaldos\veterinaria_con_bd.sql

No se indica base de destino: el archivo la crea y la selecciona con USE. Ojo: siempre se restaura con el nombre original.

Alternativa: cargar desde dentro de la consola con SOURCE
MariaDB [(none)]> CREATE DATABASE veterinaria_copia; MariaDB [(none)]> USE veterinaria_copia; MariaDB [veterinaria_copia]> SOURCE C:/respaldos/veterinaria_completo.sql Query OK, 0 rows affected (0.000 sec) Query OK, 2 rows affected (0.002 sec) Records: 2 Duplicates: 0 Warnings: 0 ...

Con SOURCE la ruta va con barras / (o dobles \\) y se ve el resultado de cada sentencia, lo que ayuda a encontrar en qué línea falla.

Comprobar que la restauración quedó bien
C:\xampp\mysql\bin> mysql -u root -p -e "USE veterinaria_copia; SHOW TABLES; SELECT COUNT(*) AS mascotas FROM mascotas;" Enter password: +-----------------------------+ | Tables_in_veterinaria_copia | +-----------------------------+ | duenos | | mascotas | +-----------------------------+ +----------+ | mascotas | +----------+ | 3 | +----------+
Errores frecuentes al restaurar
MensajeCausa y solución
ERROR 1049 (42000): Unknown database 'veterinaria_copia'La base de destino no existe: créala primero (caso 1).
El sistema no puede encontrar el archivo especificado.Ruta o nombre del archivo incorrectos. Si la ruta tiene espacios, ponla entre comillas: < "C:\mis respaldos\copia.sql".
ERROR 1050 (42S01): Table 'x' already existsEl archivo no trae DROP TABLE y la tabla ya existe: restaura en una base vacía.
ERROR 1062 (23000): Duplicate entry '1' for key 'PRIMARY'Cargaste una copia de solo datos sobre tablas que ya tenían esas filas.
ERROR 1452 ... a foreign key constraint failsCopia de solo datos cargada en tablas cuyas filas relacionadas no existen.
Tildes y eñes salen como á o ?Codificación distinta: usa --default-character-set=utf8mb4 tanto al respaldar como al restaurar.
Usa CMD para estos comandos. En PowerShell, < no funciona para cargar archivos y > guarda el respaldo con otra codificación (UTF-16) que luego da errores al restaurar. Si estás en PowerShell, escribe cmd y presiona Enter para pasar a CMD.
Nivel avanzado
Programar respaldos automáticos y seguir las buenas prácticas que evitan perder información.

11.4. Automatizar los respaldos

Un archivo .bat guarda los comandos para no escribirlos cada vez. Crea con el Bloc de notas C:\respaldos\respaldo_diario.bat:

@echo off REM ===== Respaldo diario de una base de datos MySQL/MariaDB (XAMPP) ===== set BASE=veterinaria set CARPETA=C:\respaldos set MYSQL=C:\xampp\mysql\bin REM Fecha en formato AAAA-MM-DD para el nombre del archivo for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyy-MM-dd"') do set FECHA=%%i if not exist "%CARPETA%" mkdir "%CARPETA%" "%MYSQL%\mysqldump.exe" -u root --routines --events --single-transaction --default-character-set=utf8mb4 %BASE% > "%CARPETA%\%BASE%_%FECHA%.sql" if errorlevel 1 ( echo ERROR: el respaldo de %BASE% fallo >> "%CARPETA%\errores.log" ) else ( echo Respaldo creado: %CARPETA%\%BASE%_%FECHA%.sql ) REM Borrar respaldos de mas de 30 dias forfiles /p "%CARPETA%" /m %BASE%_*.sql /d -30 /c "cmd /c del @path" 2>nul
C:\respaldos> respaldo_diario.bat Respaldo creado: C:\respaldos\veterinaria_2026-10-13.sql
  • Cada ejecución crea un archivo con la fecha (veterinaria_2026-10-13.sql) y borra los de más de 30 días.
  • El ejemplo usa root sin contraseña (XAMPP recién instalado). Si root tiene contraseña, no la escribas en el .bat: crea un usuario solo para respaldos (10.4).
  • Programarlo: en Windows, abre el Programador de tareas → Crear tarea básica → frecuencia Diariamente → acción Iniciar un programa → selecciona el .bat.
  • Revisa de vez en cuando que el archivo termine en -- Dump completed y prueba restaurarlo (11.3).

11.5. Buenas prácticas de respaldo

  • Prueba tus copias: una copia que nunca restauraste no está comprobada. Restáurala en una base distinta (_copia) y revisa tablas y conteos.
  • Nombra los archivos con la fecha (tienda_2026-10-12.sql) y conserva varias versiones, no solo la última.
  • Guarda las copias fuera del equipo (memoria USB, nube): si el disco falla, la copia en el mismo disco se pierde con él.
  • Antes de un cambio grande (borrar datos, modificar tablas), haz una copia.
  • Revisa que el archivo termine en -- Dump completed.

11.6. Chuleta

TareaComando (en CMD, dentro de C:\xampp\mysql\bin)
Entrar a la consolamysql -u root -p
Respaldo completomysqldump -u root -p --routines --events base > C:\respaldos\base.sql
Solo estructuramysqldump -u root -p --no-data base > C:\respaldos\base_estructura.sql
Solo datosmysqldump -u root -p --no-create-info base > C:\respaldos\base_datos.sql
Crear la base de destinomysql -u root -p -e "CREATE DATABASE base_copia"
Restaurarmysql -u root -p base_copia < C:\respaldos\base.sql
Restaurar desde la consolaUSE base_copia; y luego SOURCE C:/respaldos/base.sql