# Texto y fechas, donde los cuatro se separan

URL canónica: https://missyera.com/guias/sql-desde-cero/texto-y-fechas/
Última actualización: 2026-11-14
Autora: Gera Flores (Miss Yera)

> **Respuesta corta**
>
> Aquí es donde los cuatro motores se separan de verdad. Pegar texto es || en PostgreSQL y SQLite, CONCAT() en MySQL y + en SQL Server. LIKE distingue mayúsculas solo en PostgreSQL. Y SQLite no tiene tipo fecha, guarda texto en formato AAAA-MM-DD, que es la razón de la mitad de los sustos con fechas. Todo lo que ves acá lo ejecuto yo sobre tienda.db.

Capítulo 5 de 23 del libro [SQL desde cero](https://missyera.com/guias/sql-desde-cero.md).

Un cliente me pidió el ranking de sus marcas. Doce locales, doce marcas, un Excel de media hora. Corrí la consulta y me salieron **veintitrés**.

No había veintitrés marcas. Había doce, y once estaban escritas con mayúsculas y con dos espacios delante porque alguien las cargó copiando de otro sistema. Para la base, `Market Central` y `MARKET CENTRAL` son dos cosas distintas. Y no te avisa. Te entrega el reporte con cara de que todo está bien 🙃

Este capítulo va de eso: de texto sucio y de fechas que no son fechas. Son los dos sitios donde los cuatro motores más se separan, así que aquí la tabla de dialectos vale doble.

Antes de seguir, una pregunta: **¿cuántos clientes distintos crees que tiene de verdad tu base?** Yo aprendí a no contestar eso de memoria nunca, porque la respuesta cambia según cómo esté escrito el nombre 🧼

## La tienda que aparecía dos veces

Vamos a ver la suciedad con nuestros ojos. El truco es meter el texto entre corchetes: los espacios de los bordes son invisibles hasta que pones algo al lado.

```
SELECT id, '[' || nombre || ']' AS entre_corchetes
FROM clientes
WHERE nombre <> TRIM(nombre)
ORDER BY id
LIMIT 5;
```

```text
id  entre_corchetes
--  ----------------------------
12  [  MARKET CENTRAL 012  ]
13  [  CAFE DEL PUERTO 013  ]
20  [  ALMACENES VEGA 020  ]
26  [  MINIMARKET EL SOL 026  ]
32  [  AUTOSERVICIO NORTE 032  ]
```

Dieciocho clientes de ciento veinte vienen así. Y fíjate que además están en mayúsculas, que es la otra mitad del problema.

Ahora la consulta que me dio veintitrés. La idea es quitarle los tres dígitos del final al nombre para quedarme con la marca.

```
SELECT COUNT(DISTINCT SUBSTR(nombre, 1, LENGTH(nombre) - 4)) AS marcas
FROM clientes;
```

```text
marcas
------
23
```

```
SELECT COUNT(DISTINCT UPPER(TRIM(SUBSTR(TRIM(nombre), 1, LENGTH(TRIM(nombre)) - 4)))) AS marcas
FROM clientes;
```

```text
marcas
------
12
```

Veintitrés contra doce. La misma pregunta, la misma base, el mismo día. Lo único que cambió es que la segunda limpia antes de contar.

Esa consulta se lee de adentro hacia afuera, como las muñecas rusas: `TRIM` quita los espacios, `SUBSTR` corta el código, `TRIM` otra vez limpia el espacio que quedó, `UPPER` pone todo en mayúsculas y recién ahí `COUNT(DISTINCT ...)` cuenta. Cinco funciones que vamos a ver una por una 🧼

Y guárdate la regla, que es la que de verdad importa: **antes de agrupar por un texto, límpialo**. Siempre. No importa qué tan confiable te digan que es la base.

## Juntar texto

```
SELECT nombre || ' - ' || ciudad AS ficha
FROM clientes
ORDER BY id
LIMIT 4;
```

```text
ficha
-----------------------------
Minimarket El Sol 001 - Piura
Minimarket El Sol 002 - Piura
Minimarket El Sol 003 - Piura
Mayorista Peru 004 - Lima
```

Esas dos barras verticales son el "pega esto con esto". Y son la primera cosa que cambia de motor a motor.

Pegar dos textos

| PostgreSQL | `nombre \|\| ' - ' \|\| ciudad` |
|---|---|
| MySQL | `CONCAT(nombre, ' - ', ciudad)` |
| SQL Server | `nombre + ' - ' + ciudad` |
| SQLite | `nombre \|\| ' - ' \|\| ciudad` |

MySQL usa || para el OR lógico, así que ahí las barras no pegan nada: te devuelven 0 o 1 y encima sin error. En SQL Server el + hace de suma y de pegamento según los tipos, que es cómo aparece el "conversión failed" cuando una de las dos columnas es un número.

Si quieres una sola forma que funcione en los cuatro, es `CONCAT()`: existe en PostgreSQL, en MySQL, en SQL Server desde 2012 y en SQLite desde la versión 3.44. Pero ojo, porque con nulos no se portan igual.

```
SELECT 'Lima' || NULL AS con_pipes, COALESCE('Lima' || NULL, 'se perdio') AS rescatado;
```

```text
con_pipes  rescatado
---------  ---------
           se perdio
```

```
SELECT CONCAT('Lima', NULL, 'Peru') AS con_concat;
```

```text
con_concat
----------
LimaPeru
```

Con `||` un solo nulo se come toda la frase, igual que en el capítulo 4. Con `CONCAT()` en SQLite el nulo se ignora y sale `LimaPeru`.

Qué hace CONCAT cuando uno de los textos es NULL

| PostgreSQL | `ignora el NULL` |
|---|---|
| MySQL | `devuelve NULL entero` |
| SQL Server | `trata el NULL como texto vacío` |
| SQLite | `ignora el NULL` |

Tres lo ignoran y MySQL no, así que la misma consulta que en Postgres te arma la ficha del cliente, en MySQL te deja la columna vacía en cuanto falte un dato. Si hay nulos posibles, envuelve cada trozo en COALESCE y deja de depender del motor.

![El mismo cliente escrito de tres formas, con distintas mayúsculas y con espacios sobrantes, agrupado sin limpiar da tres clientes, y agrupado con UPPER y TRIM da uno.](https://missyera.com/static/diagramas/webp/sql-04-texto.webp)

*Para la base son tres clientes distintos y no te lo va a decir. Por eso agrupar por un texto sin limpiarlo antes es la forma más rápida que conozco de inflar un conteo.*

## Mayúsculas, minúsculas y espacios que no ves

`UPPER` y `LOWER` hacen lo que suena y se escriben igual en los cuatro. `TRIM` quita los espacios de los dos bordes, y también en los cuatro.

```
SELECT id,
       LENGTH(nombre) AS largo,
       LENGTH(TRIM(nombre)) AS largo_limpio,
       '[' || TRIM(nombre) || ']' AS limpio
FROM clientes
WHERE id IN (1, 26)
ORDER BY id;
```

```text
id  largo  largo_limpio  limpio
--  -----  ------------  -----------------------
1   21     21            [Minimarket El Sol 001]
26  25     21            [MINIMARKET EL SOL 026]
```

El cliente 26 mide 25 caracteres pero su nombre real mide 21. Los cuatro sobrantes son aire, y ese aire es lo que hacía que apareciera como una marca aparte.

Contar cuántos caracteres tiene un texto

| PostgreSQL | `LENGTH(nombre)` |
|---|---|
| MySQL | `CHAR_LENGTH(nombre)` |
| SQL Server | `LEN(nombre)` |
| SQLite | `LENGTH(nombre)` |

Aquí hay dos trampas. En MySQL, LENGTH cuenta BYTES, así que "Ñaña" mide 6 y no 4: para caracteres es CHAR_LENGTH. Y el LEN de SQL Server ignora los espacios del final, así que el cliente 26 mediría 23 y no 25, y la suciedad se te esconde justo cuando la estás buscando.

Quitar los espacios de los bordes

| PostgreSQL | `TRIM(nombre)` |
|---|---|
| MySQL | `TRIM(nombre)` |
| SQL Server | `TRIM(nombre) -- desde 2017; antes LTRIM(RTRIM(nombre))` |
| SQLite | `TRIM(nombre)` |

De las pocas que se escriben igual en los cuatro. Si trabajas contra un SQL Server viejito, el LTRIM(RTRIM(...)) sigue funcionando en todas las versiones, así que es el que no falla nunca.

## Cortar y reemplazar

`SUBSTR(texto, desde, cuántos)` corta un pedazo. Y ojo con algo que confunde el primer día: **en SQL se empieza a contar en 1, no en 0**. Si vienes de Python, es al revés de lo que tienes en el dedo.

```
SELECT id,
       '[' || SUBSTR(nombre, -3) || ']' AS ultimos_tres,
       UPPER(TRIM(SUBSTR(TRIM(nombre), 1, LENGTH(TRIM(nombre)) - 4))) AS marca
FROM clientes
WHERE id IN (1, 26)
ORDER BY id;
```

```text
id  ultimos_tres  marca
--  ------------  -----------------
1   [001]         MINIMARKET EL SOL
26  [6  ]         MINIMARKET EL SOL
```

Mira el cliente 26: pedí los últimos tres caracteres y me trajo `6` y dos espacios, porque los últimos tres caracteres de verdad son espacios. La suciedad no se arregla sola en ningún paso, hay que limpiarla antes de cortar. Por eso la columna `marca` lleva el `TRIM` por dentro.

Cortar un pedazo de texto

| PostgreSQL | `SUBSTRING(nombre FROM 1 FOR 10) -- también SUBSTR(nombre, 1, 10)` |
|---|---|
| MySQL | `SUBSTRING(nombre, 1, 10)` |
| SQL Server | `SUBSTRING(nombre, 1, 10) -- los tres argumentos son obligatorios` |
| SQLite | `SUBSTR(nombre, 1, 10)` |

SUBSTRING(texto, desde, cuántos) con esos tres argumentos funciona en los cuatro y es la forma que yo escribo. Lo que no viaja es el número negativo para contar desde el final: en SQL Server no existe y hay que usar RIGHT(nombre, 3).

`REPLACE` cambia un texto por otro, y esta sí se escribe igual en todos.

```
SELECT id, direccion, REPLACE(direccion, 'nro', 'N.') AS normalizada
FROM direcciones
ORDER BY id
LIMIT 3;
```

```text
id  direccion        normalizada
--  ---------------  --------------
1   Av. 847 nro 331  Av. 847 N. 331
2   Av. 283 nro 161  Av. 283 N. 161
3   Av. 672 nro 178  Av. 672 N. 178
```

Reemplazar un texto por otro

| PostgreSQL, MySQL, SQL Server y SQLite | `REPLACE(dirección, 'nro', 'N.')` |
|---|---|

Una de las poquísimas que se escribe idéntica en los cuatro. Reemplaza TODAS las apariciones, no solo la primera.

## Buscar, y la trampa de las mayúsculas

En el capítulo 3 vimos `LIKE` y quedó dicho que se comporta distinto en cada motor. Ahora lo vamos a medir, que es otra cosa.

```
SELECT COUNT(*) AS con_like
FROM clientes
WHERE nombre LIKE '%Sol%';
```

```text
con_like
--------
11
```

```
SELECT COUNT(*) AS con_glob
FROM clientes
WHERE nombre GLOB '*Sol*';
```

```text
con_glob
--------
10
```

Once y diez. La diferencia es el cliente 26, el de `MINIMARKET EL SOL 026`: el `LIKE` de SQLite no distingue mayúsculas y lo encuentra, y el `GLOB` sí distingue y lo deja fuera.

Y aquí está lo que quiero que se te quede: **esa misma consulta con `LIKE` te devuelve 10 en PostgreSQL**, porque ahí el `LIKE` sí distingue mayúsculas. Mismo SQL, misma data, distinto número. Sin error, sin aviso, sin nada 😳

```
SELECT COUNT(*) AS a_prueba_de_motor
FROM clientes
WHERE UPPER(nombre) LIKE '%SOL%';
```

```text
a_prueba_de_motor
-----------------
11
```

Buscar sin que importen las mayúsculas

| PostgreSQL | `nombre ILIKE '%sol%' -- el LIKE normal SÍ distingue` |
|---|---|
| MySQL | `nombre LIKE '%sol%' -- el collation por defecto no distingue` |
| SQL Server | `nombre LIKE '%sol%' -- según el collation de la base` |
| SQLite | `nombre LIKE '%sol%' -- no distingue, pero solo en letras sin tilde` |

La única que da el mismo número en los cuatro es UPPER(columna) LIKE '%TEXTO%'. Cuesta seis letras más y te ahorra la conversación de por qué el reporte de la nube y el de tu laptop no coinciden.

Un detalle peruano que muerde: el `LIKE` de SQLite ignora mayúsculas solo en el alfabeto inglés. Con `Ñ` o con vocales con tilde vuelve a distinguir, así que `'%ÑAÑA%'` no encuentra `Ñaña`. Nuestros nombres de negocio están llenos de tildes, o sea que esto no es un caso raro de manual, es el martes 🇵🇪

## Las fechas no son fechas

Esta es la parte que hay que leer despacio, porque de aquí salen los errores más caros y ninguno da error.

```
SELECT id, fecha, TYPEOF(fecha) AS tipo, monto, TYPEOF(monto) AS tipo_monto
FROM pedidos
ORDER BY id
LIMIT 3;
```

```text
id  fecha       tipo  monto   tipo_monto
--  ----------  ----  ------  ----------
1   2025-05-30  text  892.06  real
2   2025-06-03  text  731.09  real
3   2025-09-23  text  407.39  real
```

`text`. La columna `fecha` no guarda fechas, guarda texto que a nosotras nos parece una fecha. Y no es que la base esté mal hecha: **SQLite no tiene tipo fecha**, punto. Los otros tres sí.

Cómo se guarda una fecha

| PostgreSQL | `DATE, TIMESTAMP` |
|---|---|
| MySQL | `DATE, DATETIME, TIMESTAMP` |
| SQL Server | `DATE, DATETIME2` |
| SQLite | `no existe: se guarda como TEXT en formato AAAA-MM-DD` |

Que SQLite no tenga tipo fecha suena a defecto y en la práctica funciona, porque el formato AAAA-MM-DD ordena y compara bien como texto: el 2025 va antes que el 2026 tanto en el calendario como en el diccionario. Lo que no funciona es cualquier otro formato.

Y ahí está la trampa. Mira lo que pasa si escribes la fecha como la escribimos en Perú.

```
SELECT COUNT(*) AS pedidos_de_2026
FROM pedidos
WHERE fecha >= '01/01/2026';
```

```text
pedidos_de_2026
---------------
900
```

Novecientos. O sea todos los pedidos de la tabla, incluidos los de 2025.

¿Por qué? Porque está comparando texto letra por letra, como el diccionario. El `0` de `01/01/2026` va antes que el `2` de `2025-05-30`, así que *todas* las fechas le parecen mayores. Cero errores, cero avisos, un número que está mal.

```
SELECT COUNT(*) AS pedidos_de_2026
FROM pedidos
WHERE fecha >= '2026-01-01';
```

```text
pedidos_de_2026
---------------
292
```

Doscientos noventa y dos. Ese es el bueno.

Y si intentas convertir la fecha peruana a fecha de verdad, tampoco te avisa.

```
SELECT DATE('24/06/2026') AS a_la_peruana,
       DATE('2026-06-24') AS en_iso;
```

```text
a_la_peruana  en_iso
------------  ----------
              2026-06-24
```

La primera sale vacía, que como vimos en el capítulo 4 es `NULL`. SQLite no entendió la fecha y en vez de reclamar, se encogió de hombros.

La regla, y va en mayúsculas porque me ha costado caro: **dentro de la base, las fechas se escriben AAAA-MM-DD.** El formato bonito se pone al final, cuando el número ya está calculado.

## El error que sí es error

Ahora prueba lo que hace todo el mundo que viene de MySQL o de SQL Server.

```
SELECT YEAR(fecha) AS anio
FROM pedidos
LIMIT 3;
```

```
OperationalError: no such function: YEAR
```

*no such function: YEAR*. Y este es de los buenos, en serio 🙌 Porque te lo dice de frente en vez de devolverte un número equivocado.

`YEAR()` existe en MySQL y en SQL Server, no existe en SQLite, y en PostgreSQL tampoco: ahí es `EXTRACT`. En SQLite todo lo de fechas pasa por una sola función, `STRFTIME`.

```
SELECT COUNT(*) AS pedidos_de_junio_2026
FROM pedidos
WHERE STRFTIME('%Y-%m', fecha) = '2026-06';
```

```text
pedidos_de_junio_2026
---------------------
50
```

Sacar el año de una fecha

| PostgreSQL | `EXTRACT(YEAR FROM fecha)` |
|---|---|
| MySQL | `YEAR(fecha) -- también EXTRACT(YEAR FROM fecha)` |
| SQL Server | `YEAR(fecha) -- también DATEPART(year, fecha)` |
| SQLite | `CAST(STRFTIME('%Y', fecha) AS INTEGER)` |

EXTRACT es la forma del estándar y funciona en PostgreSQL y en MySQL. SQL Server no la tiene. Para el mes es igual: MONTH() en dos, EXTRACT en dos, STRFTIME('%m', ...) en SQLite.

El `CAST` del final no es decoración: `STRFTIME` devuelve texto, así que sin él te llevas `'2026'` entre comillas y no puedes sumarlo ni restarlo.

## Hoy, hace 90 días y el primero del mes

Casi todo reporte de negocio empieza igual: "lo de los últimos 90 días". Para eso hay que saber hacerle cuentas a una fecha.

```
SELECT DATE('2026-06-24', '-90 days') AS hace_90_dias,
       DATE('2026-06-24', '+1 month') AS en_un_mes,
       DATE('2026-06-24', 'start of month') AS inicio_de_mes;
```

```text
hace_90_dias  en_un_mes   inicio_de_mes
------------  ----------  -------------
2026-03-26    2026-07-24  2026-06-01
```

```
SELECT COUNT(*) AS ultimos_90_dias
FROM pedidos
WHERE fecha >= DATE('2026-06-24', '-90 days');
```

```text
ultimos_90_dias
---------------
166
```

Ciento sesenta y seis pedidos en los últimos 90 días de la base. Puse la fecha a mano porque el último pedido de esta tienda es del 24 de junio de 2026 y quiero que a ti te salga el mismo número que a mí. En tu trabajo, en cambio, vas a querer "hoy", y ahí es donde los cuatro se ponen creativos.

La fecha de hoy

| PostgreSQL | `CURRENT_DATE` |
|---|---|
| MySQL | `CURDATE() -- también CURRENT_DATE` |
| SQL Server | `CAST(GETDATE() AS DATE)` |
| SQLite | `DATE('now') -- también CURRENT_DATE` |

CURRENT_DATE funciona en tres de los cuatro; el que se queda fuera es SQL Server, que tiene CURRENT_TIMESTAMP pero no CURRENT_DATE. Y en SQLite el DATE('now') te da la hora UTC, o sea cinco horas adelantada de Lima: si te importa el borde del día, es DATE('now', 'localtime').

Restarle 90 días a una fecha

| PostgreSQL | `fecha - INTERVAL '90 days'` |
|---|---|
| MySQL | `DATE_SUB(fecha, INTERVAL 90 DAY)` |
| SQL Server | `DATEADD(day, -90, fecha)` |
| SQLite | `DATE(fecha, '-90 days')` |

Cuatro sintaxis distintas para la misma resta, y ninguna se parece a otra. Esta es la fila que yo tengo pegada al monitor.

## Ponerle formato al final

```
SELECT id, fecha, STRFTIME('%d/%m/%Y', fecha) AS a_la_peruana
FROM pedidos
ORDER BY id
LIMIT 3;
```

```text
id  fecha       a_la_peruana
--  ----------  ------------
1   2025-05-30  30/05/2025
2   2025-06-03  03/06/2025
3   2025-09-23  23/09/2025
```

Mostrar una fecha como 24/06/2026

| PostgreSQL | `TO_CHAR(fecha, 'DD/MM/YYYY')` |
|---|---|
| MySQL | `DATE_FORMAT(fecha, '%d/%m/%Y')` |
| SQL Server | `FORMAT(fecha, 'dd/MM/yyyy') -- también CONVERT(varchar, fecha, 103)` |
| SQLite | `STRFTIME('%d/%m/%Y', fecha)` |

Cuatro nombres distintos y encima dos idiomas de máscara: MySQL y SQLite usan %d/%m/%Y, PostgreSQL usa DD/MM/YYYY y SQL Server usa dd/MM/yyyy con la M en mayúscula para el mes, porque la m minúscula ahí son los minutos.

Y una cosa de orden que vale para los cuatro: **el formato se pone en el SELECT, nunca en el WHERE ni en el ORDER BY**. Si ordenas por `'24/06/2026'` estás ordenando por día, y diciembre de 2025 te va a quedar antes que enero del mismo año.

El día de la semana también sale de ahí, y sirve para preguntas de negocio de verdad.

```
SELECT COUNT(*) AS pedidos_en_domingo
FROM pedidos
WHERE STRFTIME('%w', fecha) = '0';
```

```text
pedidos_en_domingo
------------------
129
```

En SQLite el domingo es el 0. En MySQL `DAYOFWEEK()` también arranca el domingo pero en 1, y en SQL Server `DATEPART(weekday, ...)` depende de una configuración de la sesión. Si vas a usar el día de la semana, compruébalo con una fecha que sepas antes de creerle.

## Cuánto tiempo pasó

```
SELECT nombre,
       fecha_alta,
       CAST(JULIANDAY('2026-06-24') - JULIANDAY(fecha_alta) AS INTEGER) AS dias_con_nosotros
FROM clientes
ORDER BY dias_con_nosotros DESC
LIMIT 3;
```

```text
nombre                          fecha_alta  dias_con_nosotros
------------------------------  ----------  -----------------
Bodega La Esquina 044           2025-01-04  536
Almacenes Vega 014              2025-01-05  535
  RESTAURANTE MIRAFLORES 070    2025-01-05  535
```

`JULIANDAY` convierte una fecha en un número de días corridos, así que restar dos julianday da días. Y mira el tercero de la lista, que sigue teniendo su nombre sucio: la limpieza que hicimos arriba no cambió la base, solo la consulta 🧽

Días entre dos fechas

| PostgreSQL | `fecha_fin - fecha_ini -- con columnas DATE devuelve el número de días` |
|---|---|
| MySQL | `DATEDIFF(fecha_fin, fecha_ini)` |
| SQL Server | `DATEDIFF(day, fecha_ini, fecha_fin)` |
| SQLite | `JULIANDAY(fecha_fin) - JULIANDAY(fecha_ini)` |

Fíjate bien en las dos del medio: se llaman igual, DATEDIFF, y reciben los argumentos al revés. La de MySQL va (fin, inicio) y la de SQL Server va (unidad, inicio, fin). Copiar una consulta de un motor al otro te cambia el signo del resultado sin un solo error.

## Ejercicios

Los siete corren sobre `tienda.db`. Intenta antes de abrir 💛

**1. La ficha del cliente**

Arma una sola columna que diga `NOMBRE LIMPIO (segmento, ciudad)` para los clientes 1, 12 y 44. El 12 es uno de los sucios.

```
SELECT UPPER(TRIM(nombre)) || ' (' || segmento || ', ' || ciudad || ')' AS ficha
FROM clientes
WHERE id IN (1, 12, 44)
ORDER BY id;
```

```text
ficha
-----------------------------------------
MINIMARKET EL SOL 001 (Horeca, Piura)
MARKET CENTRAL 012 (Horeca, Arequipa)
BODEGA LA ESQUINA 044 (Minimarket, Piura)
```

El `TRIM` va por dentro del `UPPER`, aunque en este caso da igual el orden. Lo que no da igual es que esté.

**2. Cuánta suciedad hay**

Cuenta cuántos clientes tienen espacios en los bordes. Sin subconsultas todavía, que eso es el capítulo 8.

```
SELECT COUNT(*) AS con_espacios
FROM clientes
WHERE nombre <> TRIM(nombre);
```

```text
con_espacios
------------
18
```

```
SELECT COUNT(*) AS en_mayuscula
FROM clientes
WHERE nombre = UPPER(nombre);
```

```text
en_mayuscula
------------
18
```

Dieciocho y dieciocho, y no es casualidad: son los mismos dieciocho registros, que entraron por la misma carga mal hecha. Un 15% de la tabla de clientes 🫠

**3. El código de tres dígitos, como número**

Saca los tres dígitos del final del nombre y conviértelos en entero, para los clientes 1, 26 y 120. El 26 es sucio, así que ahí está la gracia.

```
SELECT id, CAST(SUBSTR(TRIM(nombre), -3) AS INTEGER) AS codigo
FROM clientes
WHERE id IN (1, 26, 120)
ORDER BY id;
```

```text
id   codigo
---  ------
1    1
26   26
120  120
```

El `TRIM` tiene que ir *dentro* del `SUBSTR`. Si lo pones fuera, cortas primero y limpias después, o sea que te llevas `'6 '` y el `CAST` te devuelve 6 en vez de 26. Sin error.

Y de paso: el `CAST` se come los ceros de la izquierda, por eso el 001 sale como 1. Si el código es un identificador y no una cantidad, déjalo como texto.

**4. Todas las bodegas**

Cuenta los clientes cuyo nombre empieza por "Bodega", de forma que dé el mismo número en los cuatro motores.

```
SELECT COUNT(*) AS bodegas
FROM clientes
WHERE UPPER(TRIM(nombre)) LIKE 'BODEGA%';
```

```text
bodegas
-------
17
```

Diecisiete. El `UPPER` lo hace igual en los cuatro y el `TRIM` rescata a las que tenían espacios delante, que con `LIKE 'BODEGA%'` se habrían quedado fuera porque el `%` está al final y no al principio.

**5. El primer trimestre de 2026**

Cuántos pedidos y cuántos soles entre el 1 de enero y el 31 de marzo de 2026.

```
SELECT COUNT(*) AS primer_trimestre_2026, ROUND(SUM(monto), 2) AS soles
FROM pedidos
WHERE fecha BETWEEN '2026-01-01' AND '2026-03-31';
```

```text
primer_trimestre_2026  soles
---------------------  --------
139                    81510.72
```

Funciona porque la columna es texto en formato ISO y no lleva hora. El día que esa columna tenga hora, el 31 de marzo a las 3 de la tarde se queda fuera y el `BETWEEN` te miente. Por eso en producción se escribe `>= '2026-01-01' AND fecha < '2026-04-01'`, que es correcto lleve hora o no.

**6. Fin de semana por WhatsApp**

Cuántos pedidos de WhatsApp cayeron en sábado o domingo.

```
SELECT COUNT(*) AS whatsapp_fin_de_semana
FROM pedidos
WHERE canal = 'WhatsApp' AND STRFTIME('%w', fecha) IN ('0', '6');
```

```text
whatsapp_fin_de_semana
----------------------
70
```

Setenta de los 231 pedidos de WhatsApp, o sea un 30%, que es casi exactamente lo que pesan dos días de siete. Un dato que suena a hallazgo y no lo es: antes de contárselo a nadie, compara siempre contra lo que saldría por puro azar 📊

**7. Escríbelo para los cuatro**

Sin ejecutar: los pedidos de los últimos 90 días contados desde hoy, en los cuatro motores.

```
-- PostgreSQL
SELECT COUNT(*) FROM pedidos WHERE fecha >= CURRENT_DATE - INTERVAL '90 days';

-- MySQL
SELECT COUNT(*) FROM pedidos WHERE fecha >= DATE_SUB(CURDATE(), INTERVAL 90 DAY);

-- SQL Server
SELECT COUNT(*) FROM pedidos WHERE fecha >= DATEADD(day, -90, CAST(GETDATE() AS DATE));

-- SQLite
SELECT COUNT(*) FROM pedidos WHERE fecha >= DATE('now', '-90 days');
```

Cuatro formas de escribir exactamente lo mismo. No hay salida publicada porque el resultado cambia cada día que la corras, y en este libro no se publica una salida que no se pueda comprobar.

Sobre nuestra base te va a dar un número distinto al mío según el día que la corras, porque "hoy" se mueve y los pedidos no. Y en algún momento va a dar cero, porque el último pedido de la tienda es del 24 de junio de 2026. Los datos de práctica también envejecen 🐣

## Cuando septiembre es mayor que octubre

Ya viste que aquí las fechas son texto. Esto es lo que pasa cuando se te olvida por un segundo, y pasa en una sola línea 🫠

La trampa

Filtras por fecha. Alguien del equipo te pasa el rango escrito a mano y lo pegas tal cual en la consulta.

```
SELECT '2025-9-1' > '2025-10-01';
-- 1
```

**Qué está mal**

Septiembre es mayor que octubre 🫠 Y tiene todo el sentido del mundo, porque eso no son fechas: son textos. Y comparando letra a letra, el `9` va después del `1`.

En SQLite las fechas se guardan como texto, así que el orden solo funciona si están escritas **siempre con el mismo formato y con el cero delante**: `2025-09-01`. Eso es lo que hace que ISO 8601 sea el formato correcto y no una manía: es el único que se ordena bien siendo texto.

Un rango escrito sin el cero no falla, filtra mal. Te devuelve filas de más o de menos y el reporte sale con un número que nadie puede cuadrar.

Antes de fiarte de un filtro de fechas, míralas: `SELECT MIN(fecha), MAX(fecha) FROM pedidos`. Si el máximo no es la fecha más reciente que esperabas, ahí tienes el formato.

Comprueba que lo tienes

En SQLite guardaste las fechas como texto y filtras con WHERE fecha >= '2026-1-5'. No sale ninguna venta de enero.

- Sin el cero delante, el texto no ordena como ordena el calendario
- SQLite no sabe comparar fechas
- Hay que convertirlas con CAST antes de comparar
- Falta escribirlo como DATE '2026-1-5'

## Lo que te llevas

- 🧼 Antes de agrupar por un texto: `UPPER(TRIM(columna))`. Doce marcas se convirtieron en veintitrés por saltarse esto.
- 🔗 Pegar texto es `||` en PostgreSQL y SQLite, `CONCAT()` en MySQL y `+` en SQL Server. `CONCAT()` es la que funciona en los cuatro.
- 🔍 `LIKE` distingue mayúsculas en PostgreSQL y no en los otros tres. `UPPER(columna) LIKE '%TEXTO%'` da el mismo número siempre.
- 📅 SQLite no tiene tipo fecha: son textos en formato AAAA-MM-DD, y ese formato es obligatorio o las comparaciones mienten sin avisar.
- 🚫 `YEAR()` no existe en SQLite ni en PostgreSQL. En SQLite todo sale de `STRFTIME`, y hay que envolverlo en `CAST` para tener un número.
- ⚠️ `DATEDIFF` se llama igual en MySQL y en SQL Server y recibe los argumentos al revés.
- 🎀 El formato bonito va en el `SELECT`, nunca en el `WHERE` ni en el `ORDER BY`.

Y si de todo el capítulo te llevas una sola frase, que sea esta:

Para la base, `Market Central` y `MARKET CENTRAL` son dos clientes distintos. Y no te avisa.

Esa limpieza de texto también se hace del otro lado, cuando los datos ya salieron de la base, y ahí los métodos `.str` hacen lo mismo que el `TRIM` y el `UPPER` de acá: [libro de Python](https://missyera.com/guias/python-desde-cero/) 🧼

En el capítulo 6 llega `GROUP BY`, que es donde el SQL deja de listar filas y empieza a responder preguntas. Y donde PostgreSQL te va a exigir una cosa que MySQL te deja pasar, con consecuencias.

Que tengas lindo día! 🌸

## Seguir leyendo

- Anterior: [Tipos de dato y nulos](https://missyera.com/guias/sql-desde-cero/tipos-y-nulos.md)
- Siguiente: [GROUP BY, donde SQL empieza a responder](https://missyera.com/guias/sql-desde-cero/group-by.md)

## Contacto

- Diagnóstico de 30 minutos con Miss Yera: https://calendly.com/soymissyera/reunion-con-yera
- Correo: hola@missyera.com
- Esta página en HTML: https://missyera.com/guias/sql-desde-cero/texto-y-fechas/
- Índice de todas las páginas en texto: https://missyera.com/llms-index.txt
