Rarezas de SQL: 15 consultas que no hacen lo que parece

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.

  1. NULL = NULL no es verdadero
  2. NOT IN con un NULL no devuelve nada
  3. COUNT(*) y COUNT(columna) no cuentan lo mismo
  4. La suma de nada es NULL, no 0
  5. Un WHERE convierte el LEFT JOIN en un JOIN normal
  6. Un JOIN multiplica las filas, y con ellas las cuentas
  7. GROUP BY con columnas sueltas: uno responde, dos se niegan
  8. 7 / 2 da 3 (salvo en MySQL)
  9. BETWEEN con fecha y hora se deja fuera el último día
  10. ¿Los NULL van primero o al final? Depende del motor
  11. «madrid» y «Madrid»: iguales o no según el motor
  12. SQLite guarda texto en una columna INTEGER
  13. estado = ‘cancelado’ OR ‘pendiente’ no hace lo que parece
  14. || concatena… salvo en MySQL, y un NULL lo borra todo
  15. Un UPDATE sin WHERE cambia todas las filas sin preguntar
Los enlaces «Ábrela en el editor» abren la consulta en el editor de SQL, que funciona con SQLite dentro de tu navegador y trae la misma base cargada. Las diferencias con PostgreSQL y MySQL de esta página no se pueden reproducir ahí: para eso están las tablas comparativas.

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.

Tabla con tres clientes: la condición ciudad = NULL da NULL en las tres filas, así que ninguna pasa el WHERE; ciudad IS NULL da TRUE solo para Mateo, que no tiene ciudadWHERE solo deja pasar las filas en las que la condición da TRUEclienteciudadciudad = NULLciudad IS NULLLuisMadridNULLFALSEMateoNULLNULLTRUEAnaCiudad de MéxicoNULLFALSE= NULL da NULL en todas las filas:ninguna pasa el WHEREIS NULL da TRUE o FALSE:pasa Mateo, el cliente sin ciudadNULL significa «no se sabe»: comparar algo con un valor desconocido da otro desconocido.

Con NULL, una comparación tiene tres resultados posibles, y el WHERE solo acepta uno. Esquema: decodigo.com.
Cómo evitarlo: para buscar valores que faltan, 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.

Cuatro pasos: ciudad NOT IN (NULL, Santiago) equivale a ciudad distinto de NULL AND ciudad distinto de Santiago; para Madrid da NULL AND TRUE, que es NULL, así que ninguna fila pasaNOT IN se convierte en una cadena de AND, y un NULL la estropeaciudad NOT IN (NULL, 'Santiago')la subconsulta dio NULL y Santiagociudad <> NULL AND ciudad <> 'Santiago'lo que significaNULL AND TRUEpara Luis, de MadridNULLla fila no pasa: ni Luis ni nadie

Un solo NULL en la lista convierte toda la condición en NULL. Esquema: decodigo.com.

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.

Cómo evitarlo: no uses 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.

Cómo evitarlo: elige a propósito: 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        |
+----------+
Cómo evitarlo: 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.

A la izquierda, cuatro filas del LEFT JOIN, entre ellas Tomás con pedido NULL; tras el WHERE sobre la fecha solo quedan Ana y Lucía, porque la comparación con NULL da NULLEl WHERE se aplica después del LEFT JOIN, y descarta sus filas con NULLLEFT JOIN: 4 de 23 filasclientepedidofechaAna12026-01-10Ana222026-10-12Lucía212026-10-02TomásNULLNULLWHERE fecha>= '2026-10-01'Resultado: 2 filasclientepedidofechaAna222026-10-12Lucía212026-10-02Para Tomás, NULL >= '2026-10-01' da NULL: el WHERE lo descarta, igual que a los pedidos antiguos.Con la condición en el ON, el filtro elige qué pedidos se unen y cada cliente sigue en el resultado.

El LEFT JOIN conserva a los clientes; el WHERE que viene detrás los vuelve a quitar. Esquema: decodigo.com.

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       |
+----------------+--------+------------+
Cómo evitarlo: en un 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             |
+--------+-----------+---------------+

Los cuatro pedidos de Ana se convierten en siete filas al unirlos con sus líneas de detalle, porque los pedidos 1 y 22 tienen varias; COUNT cuenta 7 y COUNT DISTINCT cuenta 4Al unir con las líneas, cada pedido aparece una vez por líneaPedidos de Ana: 4pedidoestado1entregado4entregado12entregado22pendienteCon detalle: 7 filaspedidoproducto1116481232252262212JOIN detalleCOUNT(p.id) cuenta filas, no pedidos: 7COUNT(DISTINCT p.id) cuenta pedidos: 4Los pedidos 1 y 22 tienen varias líneas: por eso se repiten (en azul).

Un JOIN de uno a muchos repite la fila del lado «uno» por cada fila del lado «muchos». Esquema: decodigo.com.

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        |
+------------+---------+----------+
Cómo evitarlo: antes de agrupar, piensa qué representa cada fila del resultado del 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.

Cómo evitarlo: todo lo que va en el 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 →

MotorResultado
SQLite 3.46.13 | 3.5 | 36
PostgreSQL 17.113 | 3.5000000000000000 | 36
MySQL 8.4.113.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.

Cómo evitarlo: si quieres decimales, conviértelo de forma explícita en los tres motores: 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.

Cómo evitarlo: con fechas que llevan hora, usa un intervalo abierto por arriba: 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 →

MotorResultado
SQLite 3.46.1Mateo Herrera | NULL · Lucía Gómez | Barcelona · Camila Rojas | Bogotá
PostgreSQL 17.11Lucía Gómez | Barcelona · Camila Rojas | Bogotá · Valentina Díaz | Buenos Aires
MySQL 8.4.11Mateo 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.

Cómo evitarlo: SQLite (desde la 3.30) y PostgreSQL aceptan 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 →

ConsultaSQLite 3.46.1PostgreSQL 17.11MySQL 8.4.11
ciudad = 'madrid'0 clientes0 clientes2 clientes
ciudad LIKE 'madrid'2 clientes0 clientes2 clientes
ciudad = 'Bogota'0 clientes0 clientes1 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.

Cómo evitarlo: no dependas de la intercalación por defecto. Para comparar sin mayúsculas en cualquier motor, 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 →

MotorResultado
SQLite 3.46.11 filas modificadas
PostgreSQL 17.11Error: invalid input syntax for type integer: «muchas»
MySQL 8.4.11Error: 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
Cómo evitarlo: en SQLite, crea las tablas nuevas con 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 →

MotorResultado
SQLite 3.46.113 | cancelado
PostgreSQL 17.11Error: invalid input syntax for type boolean: «pendiente»
MySQL 8.4.1113 | 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.

Cómo evitarlo: repite la columna, 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 →

MotorResultado
SQLite 3.46.1Lucía Gómez (Barcelona) · NULL
PostgreSQL 17.11Lucía Gómez (Barcelona) · NULL
MySQL 8.4.110 · 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 →

MotorResultado
SQLite 3.46.1Lucía Gómez (Barcelona) · Mateo Herrera ()
PostgreSQL 17.11Lucía Gómez (Barcelona) · Mateo Herrera ()
MySQL 8.4.11Lucía Gómez (Barcelona) · NULL
Cómo evitarlo: si una columna puede faltar, pon el valor de repuesto tú: 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 →

MotorResultado
SQLite 3.46.115 filas modificadas
PostgreSQL 17.1115 filas modificadas
MySQL 8.4.1115 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.

Cómo evitarlo: antes de un 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.