Inscríbete para acceder a todas las lecciones de este curso.
Lección 2 de 16· Arranque: el encargo y tu primera consulta
Tienda Andina es una tienda en línea colombiana que vende tecnología, hogar, muebles y accesorios. Te acaban de dar acceso a su base de datos de ventas y una sola instrucción: "dinos cómo nos está yendo". No hay documentación, no hay diccionario de datos, no hay nadie a quien preguntarle qué significa cada columna.
Así empiezan casi todos los proyectos de datos reales. Y la forma correcta de empezar no es abrir un notebook y ponerse a teorizar: es hacerle a la base la pregunta más tonta posible y ver qué contesta.
Esa es exactamente la meta de esta lección: en los próximos minutos vas a ejecutar tu primera consulta contra la base real de Tienda Andina, vas a ver el esquema con el que trabajarás durante todo el proyecto, y vas a calcular el número que abre cualquier reporte de ventas: el ingreso total.
Antes de leer una línea más, ejecuta la celda de abajo. Es una consulta de verdad contra una base PostgreSQL que corre dentro de tu navegador — no hay nada que instalar.
-- ¿Cuántos pedidos hay registrados en Tienda Andina?
SELECT COUNT(*) AS total_pedidos
FROM orders;30 pedidos. Listo: ya hiciste tu primera pregunta al negocio y la base te respondió.
Parece trivial, pero acabas de aprender tres cosas que valen oro al llegar a una fuente desconocida:
orders existe y se llama así (no pedidos, no sales, no tbl_orders).Un dato de contexto: COUNT(*) cuenta filas, no pedidos únicos. Aquí coinciden porque orders tiene una fila por pedido, pero en un momento verás una tabla donde eso no se cumple, y confundir las dos cosas es el error #1 de los reportes de ventas mal hechos.
Un conteo te dice cuánto hay. Para saber qué hay, asómate con LIMIT: pides unas pocas filas completas y lees las columnas reales.
-- Un vistazo a los primeros pedidos, ordenados por fecha
SELECT *
FROM orders
ORDER BY order_date
LIMIT 8;Ocho filas y ya tienes material para pensar. Léelas con ojo crítico:
| Columna | Qué observas | Por qué importa |
|---|---|---|
order_id | Enteros consecutivos desde 1001 | Es la llave del pedido; la usarás en cada JOIN |
customer_id | Se repite (el cliente 1 aparece dos veces) | Un cliente puede tener varios pedidos → relación 1 a N |
order_date | Fechas de 2024 | Con esto podrás armar la evolución mensual |
status | entregado, y también cancelado | No todo pedido es ingreso. Ojo aquí. |
channel | web, app… y algún NULL | La fuente ya tiene huecos: dato sucio confirmado |
Ese status con valores distintos de entregado es la primera trampa del encargo, y volveremos a ella en un minuto.
orders no vive sola. La base de Tienda Andina tiene cuatro tablas conectadas entre sí:
Léelo así, de izquierda a derecha:
customers — quién compra (15 clientes, con ciudad y fecha de registro).orders — la cabecera del pedido: quién, cuándo, en qué estado, por qué canal. Una fila por pedido.order_items — el detalle: qué productos y cuántas unidades lleva cada pedido. Una fila por producto dentro de un pedido, así que un pedido con tres productos ocupa tres filas aquí.products — el catálogo: nombre, categoría y precio de lista.Esa distinción entre cabecera (orders) y detalle (order_items) es la columna vertebral de casi todo modelo de ventas del mundo real, y es la razón por la que COUNT(*) sobre order_items no te da el número de pedidos.
Fíjate en algo importante: el precio aparece dos veces. En products.unit_price está el precio actual del catálogo; en order_items.unit_price está el precio al que se vendió esa línea, ese día. Para calcular ingresos siempre se usa el de order_items: es el precio histórico, el que de verdad se cobró. Si mañana suben el precio del monitor, tu reporte del pasado no debe cambiar.
Con eso, el ingreso de una línea de pedido es:
donde discount es una fracción entre 0 y 0.10 (un 0.05 es un 5 % de descuento). Sumando esa expresión sobre todas las líneas obtienes el ingreso total. Vamos a hacerlo.
-- Ingreso total: unimos el detalle con la cabecera del pedido
SELECT
COUNT(DISTINCT o.order_id) AS pedidos,
COUNT(*) AS lineas,
ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount)), 2) AS ingreso_bruto
FROM order_items AS oi
JOIN orders AS o ON o.order_id = oi.order_id;$12.549.650 COP en 30 pedidos y 40 líneas de detalle. Ese es el número que un gerente esperaría ver arriba del reporte.
Y ahí está el detalle que separa un reporte creíble de uno que te devuelven: ese número está inflado. Acabas de sumar también los pedidos cancelados y devueltos, que nunca entraron a caja. Repite la consulta filtrando solo lo que sí se entregó:
-- Ingreso real: solo pedidos efectivamente entregados
SELECT
COUNT(DISTINCT o.order_id) AS pedidos_entregados,
ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount)), 2) AS ingreso_real
FROM order_items AS oi
JOIN orders AS o ON o.order_id = oi.order_id
WHERE o.status = 'entregado';**27 pedidos y 2.187.000: un 17,4 % de ingreso que no existe y que habrías reportado como real.
Ese contraste es, en miniatura, todo el proyecto:
| Ingreso bruto (sin filtrar) | Ingreso real (status = 'entregado') | |
|---|---|---|
| Pedidos | 30 | 27 |
| Ingreso | $12.549.650 | $10.362.650 |
| Diferencia | — | −$2.187.000 (−17,4 %) |
La consulta "inflada" era sintácticamente perfecta. Nadie te va a avisar de un error así: no falla, no lanza excepción, simplemente miente. Por eso el próximo hito del proyecto no es calcular más métricas, sino auditar la fuente — conocer los valores posibles de status, dónde hay nulos y qué rangos son razonables — antes de comprometerte con una cifra.
Las cuatro tablas que acabas de consultar (customers, products, orders, order_items, con exactamente estas filas) son el mismo dataset que usarás de aquí al final. Cada lección con SQL lo siembra de nuevo en tu navegador, así que siempre partes de datos idénticos y tus resultados son comparables entre lecciones. Cuando en las próximas lecciones leas "el mismo dataset", es esto: la base de Tienda Andina.
Más adelante, cuando pases a pandas, trabajarás sobre la exportación plana de un JOIN entre estas cuatro tablas: una fila por producto dentro de un pedido, con las columnas de cliente y producto ya pegadas al lado… y con los defectos típicos de una exportación real (fechas como texto, Entregado/ENTREGADO mezclados, categorías vacías). Limpiar eso es el Hito 3.
COUNT(*) y LIMIT, no con teoría.orders es la cabecera (una fila por pedido) y order_items el detalle (una fila por producto del pedido): por eso se cuenta con COUNT(DISTINCT o.order_id).unit_price de order_items (precio histórico), no con el del catálogo.quantity * unit_price * (1 - discount).status no es un detalle cosmético: aquí cambia el resultado en 17 %.Ya sabes cuántos pedidos hay y cuánto ingresan. Falta el tercer dato que siempre se pide al abrir una fuente nueva: ¿qué período cubre? Sin eso no puedes decir si el reporte es de un trimestre, de un año o de datos viejos.
Complétalo tú en la celda calificable de abajo. Es una sola consulta y solo necesitas orders.
Escribe una consulta sobre la tabla orders que devuelva una sola fila con el número total de pedidos y el rango de fechas cubierto por la base.
Usa exactamente estos tres alias, en este orden: total_pedidos, primer_pedido, ultimo_pedido.
Para que las fechas se comparen bien contra el resultado esperado, conviértelas a texto con ::text (por ejemplo MIN(order_date)::text).
-- Devuelve, en UNA sola fila, tres valores sobre la tabla orders:
-- total_pedidos -> cuántos pedidos hay
-- primer_pedido -> la fecha del pedido más antiguo, como texto
-- ultimo_pedido -> la fecha del pedido más reciente, como texto
-- Pista de formato: para que la fecha salga como '2024-01-08', usa ::text
SELECT
-- TODO: completa las tres columnas con sus alias exactos
FROM orders;Gratis