Las rarezas de SQL más habituales, en quince consultas que no hacen lo que parece: algunas fallan en todos los motores y otras dan un resultado distinto en SQLite, PostgreSQL y MySQL. Para cada una, lo que devuelve de verdad, por qué y cómo evitarlo.
SQL parece un lenguaje sencillo, casi inglés: selecciona esto de aquí donde pase aquello. Pero detrás de esa sencillez hay reglas que sorprenden: el valor NULL, que no es igual ni a sí mismo; uniones que multiplican las filas sin avisar, y motores que responden distinto a la misma consulta. Aquí están las más habituales. Todas se ejecutaron sobre la base de ejemplo «tienda» del curso de SQL en SQLite 3.46.1, PostgreSQL 17.11 y MySQL 8.4.11, y los resultados y mensajes de error que ves están copiados de esa ejecución.
- NULL = NULL no es verdadero
- NOT IN con un NULL no devuelve nada
- COUNT(*) y COUNT(columna) no cuentan lo mismo
- La suma de nada es NULL, no 0
- Un WHERE convierte el LEFT JOIN en un JOIN normal
- Un JOIN multiplica las filas, y con ellas las cuentas
- GROUP BY con columnas sueltas: uno responde, dos se niegan
- 7 / 2 da 3 (salvo en MySQL)
- BETWEEN con fecha y hora se deja fuera el último día
- ¿Los NULL van primero o al final? Depende del motor
- «madrid» y «Madrid»: iguales o no según el motor
- SQLite guarda texto en una columna INTEGER
- estado = ‘cancelado’ OR ‘pendiente’ no hace lo que parece
- || concatena… salvo en MySQL, y un NULL lo borra todo
- Un UPDATE sin WHERE cambia todas las filas sin preguntar
NULL = NULL no es verdadero
Mateo Herrera es el único cliente sin ciudad. Parece lógico buscarlo así:
SELECT nombre, pais FROM clientes WHERE ciudad = NULL;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+--------+------+ | nombre | pais | +--------+------+ +--------+------+ (ninguna fila)
Ninguna fila, en los tres motores. La forma correcta es IS NULL:
SELECT nombre, pais FROM clientes WHERE ciudad IS NULL;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+---------------+-------+ | nombre | pais | +---------------+-------+ | Mateo Herrera | Chile | +---------------+-------+
Por qué: NULL no es un valor, sino la ausencia de valor: «no se sabe». Comparar cualquier cosa con algo desconocido da otro desconocido, y eso incluye a NULL consigo mismo. SQL trabaja con tres valores lógicos, TRUE, FALSE y NULL, y el WHERE solo deja pasar las filas en las que la condición da TRUE:
SELECT NULL = NULL AS igual, NULL <> NULL AS distinto, NULL IS NULL AS es_nulo;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+-------+----------+---------+ | igual | distinto | es_nulo | +-------+----------+---------+ | NULL | NULL | 1 | +-------+----------+---------+
PostgreSQL 17.11+-------+----------+---------+ | igual | distinto | es_nulo | +-------+----------+---------+ | NULL | NULL | true | +-------+----------+---------+
SQLite escribe el verdadero como 1 y PostgreSQL como true, pero los dos coinciden en lo importante: NULL = NULL y NULL <> NULL dan NULL.
IS NULL y IS NOT NULL, nunca = NULL. Para comparar dos columnas que pueden ser NULL como si fueran valores normales, PostgreSQL y SQLite tienen IS NOT DISTINCT FROM; MySQL, el operador <=>.NOT IN con un NULL no devuelve nada
Queremos los clientes que no viven en ninguna de las ciudades de los clientes de Chile. Con NOT IN y una subconsulta:
SELECT nombre, ciudad FROM clientes WHERE ciudad NOT IN (SELECT ciudad FROM clientes WHERE pais = 'Chile');
Ábrela en el editor y cámbiala →
SQLite 3.46.1+--------+--------+ | nombre | ciudad | +--------+--------+ +--------+--------+ (ninguna fila)
Ninguno, también en PostgreSQL y MySQL, aunque está claro que Ana, en Ciudad de México, no vive en Chile. El problema está en lo que devuelve la subconsulta:
SELECT ciudad FROM clientes WHERE pais = 'Chile';
Ábrela en el editor y cámbiala →
SQLite 3.46.1+----------+ | ciudad | +----------+ | NULL | | Santiago | +----------+
Por qué: Mateo es de Chile y no tiene ciudad, así que la lista lleva un NULL. x NOT IN (a, b) equivale a x <> a AND x <> b, y comparar con NULL da NULL. Una condición con un NULL unido por AND nunca llega a ser TRUE: el WHERE descarta todas las filas. Con IN no pasa, porque se convierte en una cadena de OR, y basta con que una comparación dé TRUE.
La alternativa segura es NOT EXISTS, que pregunta si existe una fila que cumpla la condición, sin listas de por medio:
SELECT c.nombre, c.ciudad FROM clientes c WHERE NOT EXISTS (SELECT 1 FROM clientes ch WHERE ch.pais = 'Chile' AND ch.ciudad = c.ciudad);
Ábrela en el editor y cámbiala →
Devuelve 11 clientes: todos menos Tomás, que vive en Santiago. Mateo sí aparece, porque su ciudad NULL no coincide con ninguna.
NOT IN con una subconsulta sobre una columna que admite NULL. Usa NOT EXISTS, o filtra los nulos dentro: NOT IN (SELECT ciudad FROM clientes WHERE pais = 'Chile' AND ciudad IS NOT NULL).COUNT(*) y COUNT(columna) no cuentan lo mismo
SELECT COUNT(*) AS filas, COUNT(ciudad) AS con_ciudad, COUNT(email) AS con_email,
COUNT(DISTINCT ciudad) AS ciudades
FROM clientes;Ábrela en el editor y cámbiala →
SQLite 3.46.1+-------+------------+-----------+----------+ | filas | con_ciudad | con_email | ciudades | +-------+------------+-----------+----------+ | 12 | 11 | 11 | 9 | +-------+------------+-----------+----------+
Por qué: COUNT(*) cuenta filas; COUNT(columna) cuenta los valores que no son NULL en esa columna. Hay 12 clientes, pero Mateo no tiene ciudad y Sofía no tiene email. Y COUNT(DISTINCT ciudad) cuenta las ciudades diferentes: Ciudad de México y Madrid se repiten, y el NULL no cuenta como ciudad. Lo mismo pasa con SUM, AVG, MIN y MAX: todas ignoran los NULL. Un AVG sobre una columna con huecos es la media de los datos que hay, no de todas las filas.
COUNT(*) para «cuántas filas» y COUNT(columna) para «cuántas tienen dato». Si un hueco debe contar como cero en una media, dilo: AVG(COALESCE(columna, 0)).La suma de nada es NULL, no 0
El pedido 13 está cancelado y no tiene ninguna línea. ¿Cuántas unidades lleva?
SELECT SUM(cantidad) AS unidades, COUNT(*) AS lineas FROM detalle WHERE pedido_id = 13;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+----------+--------+ | unidades | lineas | +----------+--------+ | NULL | 0 | +----------+--------+
Por qué: COUNT de un conjunto vacío es 0, pero SUM, AVG, MIN y MAX de un conjunto vacío son NULL: no hay ningún valor que sumar, así que el resultado es «no se sabe», no cero. Es lo mismo en los tres motores, y es fácil que ese NULL se propague: cualquier operación con él da NULL (por ejemplo, un total de pedido más los gastos de envío).
SELECT COALESCE(SUM(cantidad), 0) AS unidades FROM detalle WHERE pedido_id = 13;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+----------+ | unidades | +----------+ | 0 | +----------+
COALESCE(SUM(...), 0) cuando el cero es la respuesta correcta. COALESCE devuelve el primero de sus argumentos que no sea NULL, y funciona igual en los tres motores.Un WHERE convierte el LEFT JOIN en un JOIN normal
Queremos todos los clientes, con sus pedidos de octubre si los tienen. LEFT JOIN conserva a los clientes sin pedidos, así que parece la herramienta adecuada:
SELECT c.nombre, p.id AS pedido, p.fecha FROM clientes c LEFT JOIN pedidos p ON p.cliente_id = c.id WHERE p.fecha >= '2026-10-01' ORDER BY c.id;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+-------------+--------+------------+ | nombre | pedido | fecha | +-------------+--------+------------+ | Ana Torres | 22 | 2026-10-12 | | Lucía Gómez | 21 | 2026-10-02 | +-------------+--------+------------+
Solo dos clientes: los otros diez desaparecieron, como con un JOIN normal. Por qué: el WHERE se aplica después de unir las tablas. El LEFT JOIN rellena con NULL a los clientes sin pedidos, y NULL >= '2026-10-01' da NULL: el WHERE los descarta. A los que tienen pedidos, pero antiguos, los descarta por la fecha.
La condición sobre la tabla de la derecha tiene que ir en el ON, que decide qué filas se unen, no cuáles se quedan:
SELECT c.nombre, p.id AS pedido, p.fecha FROM clientes c LEFT JOIN pedidos p ON p.cliente_id = c.id AND p.fecha >= '2026-10-01' ORDER BY c.id;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+----------------+--------+------------+ | nombre | pedido | fecha | +----------------+--------+------------+ | Ana Torres | 22 | 2026-10-12 | | Luis Fernández | NULL | NULL | | Camila Rojas | NULL | NULL | | Diego Martínez | NULL | NULL | | Valentina Díaz | NULL | NULL | | Jorge Ramírez | NULL | NULL | | Lucía Gómez | 21 | 2026-10-02 | | Mateo Herrera | NULL | NULL | | Sofía Castro | NULL | NULL | | Andrés Navarro | NULL | NULL | | Paula Morales | NULL | NULL | | Tomás Ortega | NULL | NULL | +----------------+--------+------------+
LEFT JOIN, las condiciones sobre la tabla de la derecha van en el ON. En el WHERE solo tiene sentido preguntar por ella con IS NULL, por ejemplo para encontrar los clientes sin pedidos.Un JOIN multiplica las filas, y con ellas las cuentas
¿Cuántos pedidos ha hecho Ana y cuántas unidades ha comprado? Se unen las tres tablas y se cuenta:
SELECT c.nombre, COUNT(p.id) AS pedidos, SUM(d.cantidad) AS unidades FROM clientes c JOIN pedidos p ON p.cliente_id = c.id JOIN detalle d ON d.pedido_id = p.id WHERE c.id = 1 GROUP BY c.nombre;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+------------+---------+----------+ | nombre | pedidos | unidades | +------------+---------+----------+ | Ana Torres | 7 | 8 | +------------+---------+----------+
Las 8 unidades están bien, pero Ana no ha hecho 7 pedidos, sino 4. Por qué: el JOIN con detalle produce una fila por cada línea de pedido, y los pedidos con varias líneas se repiten. Cada pedido de Ana aparece tantas veces como líneas tiene:
SELECT p.id AS pedido, p.estado, COUNT(*) AS lineas_unidas FROM pedidos p JOIN detalle d ON d.pedido_id = p.id WHERE p.cliente_id = 1 GROUP BY p.id, p.estado ORDER BY p.id;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+--------+-----------+---------------+ | pedido | estado | lineas_unidas | +--------+-----------+---------------+ | 1 | entregado | 2 | | 4 | entregado | 1 | | 12 | entregado | 1 | | 22 | pendiente | 3 | +--------+-----------+---------------+
Para contar pedidos, COUNT(DISTINCT p.id):
SELECT c.nombre, COUNT(DISTINCT p.id) AS pedidos, SUM(d.cantidad) AS unidades FROM clientes c JOIN pedidos p ON p.cliente_id = c.id JOIN detalle d ON d.pedido_id = p.id WHERE c.id = 1 GROUP BY c.nombre;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+------------+---------+----------+ | nombre | pedidos | unidades | +------------+---------+----------+ | Ana Torres | 4 | 8 | +------------+---------+----------+
JOIN (aquí, una línea de pedido, no un pedido). DISTINCT arregla los recuentos, pero no las sumas: si sumas un importe de la tabla pedidos después de unirla con sus líneas, el importe se suma una vez por línea. En ese caso, calcula cada total en una subconsulta o una CTE y une después.GROUP BY con columnas sueltas: uno responde, dos se niegan
Esta consulta agrupa por país, pero también pide la ciudad, que no está en el GROUP BY ni dentro de una función de agregado. Cada país tiene varias ciudades: ¿cuál debería salir?
SELECT pais, ciudad, COUNT(*) AS clientes FROM clientes GROUP BY pais ORDER BY pais;
Ábrela en el editor y cámbiala →
SQLite 3.46.1+-----------+------------------+----------+ | pais | ciudad | clientes | +-----------+------------------+----------+ | Argentina | Buenos Aires | 1 | | Chile | NULL | 2 | | Colombia | Bogotá | 1 | | España | Madrid | 3 | | México | Ciudad de México | 4 | | Perú | Lima | 1 | +-----------+------------------+----------+
PostgreSQL 17.11column "clientes.ciudad" must appear in the GROUP BY clause or be used in an aggregate function
MySQL 8.4.11ERROR 1055: Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'f.clientes.ciudad' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
Por qué: SQLite responde, y elige una ciudad cualquiera de cada grupo: para España dice Madrid, aunque Lucía vive en Barcelona, y para Chile, NULL, aunque Tomás vive en Santiago. Un resultado que parece correcto y no lo es. PostgreSQL se niega, y MySQL también: desde la versión 5.7, el modo ONLY_FULL_GROUP_BY viene activado de fábrica. Las versiones antiguas de MySQL (y las instalaciones que desactivan ese modo) se comportan como SQLite.
SELECT de una consulta agrupada tiene que estar en el GROUP BY o dentro de una función de agregado (MIN(ciudad), COUNT(DISTINCT ciudad)…). Si escribes así, la consulta funcionará igual en los tres motores.7 / 2 da 3 (salvo en MySQL)
SELECT 7 / 2 AS entera, 7 / 2.0 AS con_decimales, SUM(stock) / COUNT(*) AS stock_medio FROM productos;
Ábrela en el editor y cámbiala →
| Motor | Resultado |
|---|---|
| SQLite 3.46.1 | 3 | 3.5 | 36 |
| PostgreSQL 17.11 | 3 | 3.5000000000000000 | 36 |
| MySQL 8.4.11 | 3.5000 | 3.5000 | 36.7333 |
Por qué: en SQLite y en PostgreSQL, dividir dos enteros da un entero: se descarta la parte decimal, no se redondea. Por eso 7 / 2 da 3 y el stock medio sale 36. Basta con que uno de los dos números sea decimal (2.0) para que el resultado lo sea. MySQL, en cambio, siempre devuelve un decimal con el operador / (el stock medio real es 36.7333); su división entera es otro operador, DIV. La misma consulta da números distintos según el motor.
SUM(stock) * 1.0 / COUNT(*), o directamente AVG(stock), que siempre devuelve la media con decimales.BETWEEN con fecha y hora se deja fuera el último día
Una tabla de visitas guarda la fecha y la hora en texto. Queremos las visitas del 1 al 12 de octubre, ambos incluidos:
WITH visitas AS ( SELECT '2026-10-11 18:40' AS momento UNION ALL SELECT '2026-10-12 00:00' UNION ALL SELECT '2026-10-12 09:15' UNION ALL SELECT '2026-10-12 21:30' ) SELECT COUNT(*) AS del_1_al_12 FROM visitas WHERE momento BETWEEN '2026-10-01' AND '2026-10-12';
Ábrela en el editor y cámbiala →
SQLite 3.46.1+-------------+ | del_1_al_12 | +-------------+ | 1 | +-------------+
Solo una de las cuatro, aunque todas son del 11 o del 12 de octubre. Por qué: BETWEEN incluye los extremos, pero el extremo es '2026-10-12', y '2026-10-12 09:15' es mayor: al comparar texto, la fecha con hora va después que la fecha sola. Incluso '2026-10-12 00:00' queda fuera. Solo pasa la del día 11. En PostgreSQL y MySQL, con valores de tipo fecha y hora de verdad, ocurre lo mismo: '2026-10-12' se interpreta como la medianoche al empezar el día 12, y TIMESTAMP '2026-10-12 09:15:00' BETWEEN '2026-10-01' AND '2026-10-12' da false en PostgreSQL y 0 en MySQL: falso en los dos.
momento >= '2026-10-01' AND momento < '2026-10-13'. Vale para cualquier precisión, de segundos a microsegundos, y en cualquier motor.¿Los NULL van primero o al final? Depende del motor
SELECT nombre, ciudad FROM clientes ORDER BY ciudad LIMIT 3;
Ábrela en el editor y cámbiala →
| Motor | Resultado |
|---|---|
| SQLite 3.46.1 | Mateo Herrera | NULL · Lucía Gómez | Barcelona · Camila Rojas | Bogotá |
| PostgreSQL 17.11 | Lucía Gómez | Barcelona · Camila Rojas | Bogotá · Valentina Díaz | Buenos Aires |
| MySQL 8.4.11 | Mateo Herrera | NULL · Lucía Gómez | Barcelona · Camila Rojas | Bogotá |
Por qué: el estándar SQL no dice dónde van los NULL al ordenar, así que cada motor decidió: SQLite y MySQL los consideran menores que cualquier valor y los ponen primero en un orden ascendente; PostgreSQL, mayores, y los pone al final. La misma consulta con LIMIT devuelve filas distintas.
ORDER BY ciudad NULLS LAST o NULLS FIRST. MySQL no lo tiene; ahí se ordena primero por la condición: ORDER BY ciudad IS NULL, ciudad, que funciona en los tres.«madrid» y «Madrid»: iguales o no según el motor
Tres búsquedas con el nombre de la ciudad mal escrito: en minúsculas con =, en minúsculas con LIKE y sin tilde:
SELECT COUNT(*) AS madrid FROM clientes WHERE ciudad = 'madrid';
Ábrela en el editor y cámbiala →
SELECT COUNT(*) AS madrid FROM clientes WHERE ciudad LIKE 'madrid';
Ábrela en el editor y cámbiala →
SELECT nombre, ciudad FROM clientes WHERE ciudad = 'Bogota';
Ábrela en el editor y cámbiala →
| Consulta | SQLite 3.46.1 | PostgreSQL 17.11 | MySQL 8.4.11 |
|---|---|---|---|
ciudad = 'madrid' | 0 clientes | 0 clientes | 2 clientes |
ciudad LIKE 'madrid' | 2 clientes | 0 clientes | 2 clientes |
ciudad = 'Bogota' | 0 clientes | 0 clientes | 1 cliente |
Por qué: cómo se comparan los textos lo decide la intercalación (collation). En SQLite, = distingue mayúsculas, pero LIKE no, al menos con las letras sin tilde. PostgreSQL distingue mayúsculas en los dos (para no distinguirlas tiene ILIKE). MySQL 8 usa por defecto utf8mb4_0900_ai_ci: ci es case insensitive (no distingue mayúsculas) y ai, accent insensitive, así que para MySQL «Bogota» y «Bogotá» son el mismo texto.
LOWER(ciudad) = 'madrid' (ojo: así no se usa un índice normal sobre la columna). Y si las tildes no deben importar, decídelo al crear la tabla, con la intercalación de la columna.SQLite guarda texto en una columna INTEGER
La columna stock es INTEGER. ¿Qué pasa si se intenta guardar un texto?
INSERT INTO productos (id, nombre, categoria_id, precio, stock) VALUES (16, 'Funda para portátil', 1, 19.90, 'muchas'); SELECT id, nombre, stock FROM productos WHERE id = 16;
Ábrela en el editor y cámbiala →
| Motor | Resultado |
|---|---|
| SQLite 3.46.1 | 1 filas modificadas |
| PostgreSQL 17.11 | Error: invalid input syntax for type integer: «muchas» |
| MySQL 8.4.11 | Error: ERROR 1366: Incorrect integer value: ‘muchas’ for column ‘stock’ at row 1 |
PostgreSQL y MySQL rechazan el INSERT. SQLite lo acepta, y el producto queda con muchas unidades:
SQLite 3.46.1+----+---------------------+--------+ | id | nombre | stock | +----+---------------------+--------+ | 16 | Funda para portátil | muchas | +----+---------------------+--------+
Por qué: en SQLite el tipo de una columna es una preferencia (lo llama afinidad), no una restricción. Si el valor se puede convertir, lo convierte; si no, lo guarda tal cual. La función typeof() muestra lo que hay de verdad en cada celda:
INSERT INTO productos (id, nombre, categoria_id, precio, stock) VALUES (16, 'Funda para portátil', 1, 19.90, 'muchas'); SELECT nombre, stock, typeof(stock) AS tipo, precio, typeof(precio) AS tipo_precio FROM productos WHERE id IN (3, 16);
Ábrela en el editor y cámbiala →
SQLite 3.46.1+---------------------+--------+---------+--------+-------------+ | nombre | stock | tipo | precio | tipo_precio | +---------------------+--------+---------+--------+-------------+ | Monitor 27" 4K | 12 | integer | 429.9 | real | | Funda para portátil | muchas | text | 19.9 | real | +---------------------+--------+---------+--------+-------------+
El precio tampoco es lo que parece: la columna es NUMERIC(10,2), pero SQLite no guarda decimales exactos, sino números de coma flotante. Por eso 429.90 se lee como 429.9, y una subida del 10 % deja precios como 27.489 (lo verás en la última rareza). Desde la versión 3.37, SQLite tiene tablas STRICT, que sí comprueban los tipos:
CREATE TABLE notas (id INTEGER PRIMARY KEY, puntos INTEGER) STRICT;
INSERT INTO notas (puntos) VALUES ('muchas');Ábrela en el editor y cámbiala →
SQLite 3.46.1Error: cannot store TEXT value in INTEGER column notas.puntos
STRICT o añade restricciones CHECK (typeof(stock) = 'integer'). Y para dinero, guarda centavos en un entero en vez de decimales: 1299.99 se guarda como 129999.estado = ‘cancelado’ OR ‘pendiente’ no hace lo que parece
Queremos los pedidos cancelados o pendientes. Leída en voz alta, la consulta parece correcta:
SELECT id, estado FROM pedidos WHERE estado = 'cancelado' OR 'pendiente';
Ábrela en el editor y cámbiala →
| Motor | Resultado |
|---|---|
| SQLite 3.46.1 | 13 | cancelado |
| PostgreSQL 17.11 | Error: invalid input syntax for type boolean: «pendiente» |
| MySQL 8.4.11 | 13 | canceladoWarning 1292: Truncated incorrect DOUBLE value: ‘pendiente’ |
Por qué: OR une dos condiciones completas, y 'pendiente' sola no es una condición: no la compara con nada. SQLite convierte el texto en número para usarlo como verdadero o falso; 'pendiente' se convierte en 0, que es falso, así que solo salen los cancelados, sin ningún aviso. MySQL hace lo mismo, pero al menos deja un aviso (que casi nadie mira). Solo PostgreSQL da error. Los pedidos 21 y 22, pendientes, no aparecen en ninguno.
estado = 'cancelado' OR estado = 'pendiente', o mejor, usa estado IN ('cancelado', 'pendiente'), que es más corto y no deja lugar a dudas.|| concatena… salvo en MySQL, y un NULL lo borra todo
SELECT nombre || ' (' || ciudad || ')' AS ficha
FROM clientes WHERE id IN (7, 8);Ábrela en el editor y cámbiala →
| Motor | Resultado |
|---|---|
| SQLite 3.46.1 | Lucía Gómez (Barcelona) · NULL |
| PostgreSQL 17.11 | Lucía Gómez (Barcelona) · NULL |
| MySQL 8.4.11 | 0 · NULLWarning 1287: ‘|| as a synonym for OR’ is deprecated and will be removed in a future release. Please use OR instead; Warning 1292: Truncated incorrect DOUBLE value: ‘Lucía Gómez’; Warning 1292: Truncated incorrect DOUBLE value: ‘ (‘; Warning 1292: Truncated incorrect DOUBLE value: ‘Barcelona’; Warning 1292: Truncated incorrect DOUBLE value: ‘)’; Warning 1292: Truncated incorrect DOUBLE value: ‘Mateo Herrera’ |
Dos sorpresas en una consulta. La primera: en SQLite y en PostgreSQL, Mateo, que no tiene ciudad, no sale como «Mateo Herrera ()», sino como NULL: cualquier concatenación con un NULL da NULL. La segunda: en MySQL, || no concatena, es el OR lógico. Convierte los textos en números y devuelve 0 o 1; el propio MySQL avisa de que ese uso está obsoleto. La función CONCAT existe en los tres, pero tampoco trata igual los NULL:
SELECT CONCAT(nombre, ' (', ciudad, ')') AS ficha
FROM clientes WHERE id IN (7, 8);Ábrela en el editor y cámbiala →
| Motor | Resultado |
|---|---|
| SQLite 3.46.1 | Lucía Gómez (Barcelona) · Mateo Herrera () |
| PostgreSQL 17.11 | Lucía Gómez (Barcelona) · Mateo Herrera () |
| MySQL 8.4.11 | Lucía Gómez (Barcelona) · NULL |
CONCAT(nombre, ' (', COALESCE(ciudad, 'sin ciudad'), ')') da el mismo resultado en los tres motores.Un UPDATE sin WHERE cambia todas las filas sin preguntar
La intención era subir un 10 % los precios de una categoría. Falta el WHERE:
UPDATE productos SET precio = precio * 1.10; SELECT COUNT(*) AS subidos FROM productos WHERE precio > 1000;
Ábrela en el editor y cámbiala →
| Motor | Resultado |
|---|---|
| SQLite 3.46.1 | 15 filas modificadas |
| PostgreSQL 17.11 | 15 filas modificadas |
| MySQL 8.4.11 | 15 filas modificadas |
Por qué: un UPDATE o un DELETE sin WHERE afecta a todas las filas, y ningún motor pregunta si estás seguro: las 15 filas cambian en los tres. MySQL solo añade unas notas de que redondeó algunos precios a dos decimales. En su consola existe una opción que bloquea estas sentencias, --safe-updates, pero no está activada por defecto.
La red de seguridad es la transacción: entre BEGIN y COMMIT, los cambios no son definitivos y se pueden revisar. Si algo no cuadra, ROLLBACK los deshace:
BEGIN; UPDATE productos SET precio = precio * 1.10; SELECT nombre, precio FROM productos WHERE id = 6; ROLLBACK; SELECT nombre, precio FROM productos WHERE id = 6;
Ábrela en el editor y cámbiala →
SQLite 3.46.1, dentro de la transacción+-------------------+--------+ | nombre | precio | +-------------------+--------+ | Ratón inalámbrico | 27.489 | +-------------------+--------+
SQLite 3.46.1, después del ROLLBACK+-------------------+--------+ | nombre | precio | +-------------------+--------+ | Ratón inalámbrico | 24.99 | +-------------------+--------+
Dentro de la transacción, el ratón ya cuesta 27.489 (el precio sin redondear del que hablábamos en la rareza de los tipos); tras el ROLLBACK, vuelve a costar 24.99.
UPDATE o un DELETE, ejecuta un SELECT con el mismo WHERE para ver qué filas vas a tocar. En producción, hazlo dentro de una transacción y comprueba el número de filas afectadas antes del COMMIT.¿Quieres comprobar cuánto te ha quedado? Pon a prueba lo que sabes con el test de SQL, o vuelve a las lecciones del curso.
Todas las consultas de esta página se ejecutaron sobre la base de ejemplo «tienda» en SQLite 3.46.1, PostgreSQL 17.11 y MySQL 8.4.11 antes de publicarla; los resultados y los mensajes de error están copiados de esa ejecución. ¿Quieres probar algo? Usa el editor de SQL, que ya trae la base cargada.
