Módulo 5: Hidden Partitioning And Partition Evolution
Transforms de partición: `IdentityTransform`, `BucketTransform`, `DayTransform`
Descripción
Las lecciones 2 y 3 hablaron de "partición" como si solo hubiera una forma de hacerla: agrupar filas por el valor exacto de una columna, como store_id. Esta lección te da el vocabulario preciso para las tres formas que vas a usar en el resto de este módulo — cada una resuelve un problema distinto, y elegir la equivocada tiene un costo real, medible, que esta lección también te muestra. IdentityTransform preserva el valor tal cual; BucketTransform lo distribuye en cubetas mediante un hash; DayTransform lo trunca a su fecha de calendario.
Conexión con el módulo. Esta lección es la última parada conceptual antes de la ejecución real. La lección 5 va a usar IdentityTransform sobre store_id en el PartitionSpec inicial de kiosko.fact_orders_at_scale; la lección 6 va a agregar DayTransform sobre order_ts en la evolución de ese spec. BucketTransform no se usa en la tabla principal de esta guía —store_id con solo tres valores no lo necesita—, pero se explica aquí, con evidencia ejecutada, porque es la herramienta correcta para el caso que sí tiene alta cardinalidad en Kiosko: franchise_id, con 250,000 valores distintos.
Una analogía: tres formas de organizar el mismo archivero
Retomando el archivero de la lección 2: hay más de una forma razonable de decidir qué va en cada gaveta. Si organizas por tienda —tres tiendas, tres gavetas—, cada gaveta tiene un tamaño manejable y la etiqueta es directa: "esto es exactamente lo de S01". Esa es IdentityTransform: la etiqueta de la gaveta es el valor mismo.
Ahora imagina que, en vez de tres tiendas, tuvieras 250,000 franquicias, y decidieras aplicar la misma lógica —una gaveta por franquicia—. Terminarías con 250,000 gavetas, la mayoría casi vacías, un archivero completamente inmanejable. La solución razonable es agrupar varias franquicias en un número fijo de gavetas más grandes —digamos, 16—, usando una regla determinista para decidir en cuál va cada una (la misma franquicia siempre cae en la misma gaveta, pero varias franquicias comparten gaveta). Esa es BucketTransform: no te da una gaveta por valor, te da un número fijo de gavetas, y una función de hash decide cuál le toca a cada valor.
Y si organizaras por fecha de la operación, no querrías una gaveta por segundo exacto —seguirías con el mismo problema de 250,000 gavetas casi vacías—; querrías una gaveta por día. Esa es DayTransform: trunca un timestamp completo a su fecha de calendario, agrupando naturalmente todo lo que ocurrió el mismo día.
Ejemplo trabajado: los tres transforms, ejecutados directamente
PyIceberg permite invocar un transform directamente sobre un valor, sin necesidad de una tabla — es la forma más directa de ver, sin ambigüedad, qué produce cada uno:
# l4_transforms_demo.py
from datetime import datetime
from pyiceberg.transforms import IdentityTransform, BucketTransform, DayTransform
from pyiceberg.types import IntegerType, StringType, TimestampType
print("=== IdentityTransform: el valor de particion ES el valor de la columna ===")
identity = IdentityTransform()
identity_fn = identity.transform(StringType())
for store_id in ["S01", "S02", "S03"]:
print(f" IdentityTransform()({store_id!r}) -> {identity_fn(store_id)!r}")
print("\n=== BucketTransform(4): hash deterministico en N cubetas ===")
bucket = BucketTransform(4)
bucket_fn = bucket.transform(IntegerType())
for franchise_id in [0, 1, 2, 3, 4, 100, 249_999]:
print(f" BucketTransform(4)(franchise_id={franchise_id}) -> bucket {bucket_fn(franchise_id)}")
print("\n=== DayTransform: trunca un timestamp a su fecha de calendario ===")
day = DayTransform()
day_fn = day.transform(TimestampType())
for iso in ["2026-08-03T08:14:00", "2026-08-03T23:59:00", "2026-08-09T10:05:00"]:
ts = datetime.fromisoformat(iso)
epoch_days = day_fn(ts)
print(f" DayTransform()({iso}) -> {epoch_days} dias desde epoch (1970-01-01)")
Qué esperar (verificado corriendo el script real):
=== IdentityTransform: el valor de particion ES el valor de la columna ===
IdentityTransform()('S01') -> 'S01'
IdentityTransform()('S02') -> 'S02'
IdentityTransform()('S03') -> 'S03'
=== BucketTransform(4): hash deterministico en N cubetas ===
BucketTransform(4)(franchise_id=0) -> bucket 0
BucketTransform(4)(franchise_id=1) -> bucket 0
BucketTransform(4)(franchise_id=2) -> bucket 0
BucketTransform(4)(franchise_id=3) -> bucket 3
BucketTransform(4)(franchise_id=4) -> bucket 2
BucketTransform(4)(franchise_id=100) -> bucket 0
BucketTransform(4)(franchise_id=249999) -> bucket 0
=== DayTransform: trunca un timestamp a su fecha de calendario ===
DayTransform()(2026-08-03T08:14:00) -> 20668 dias desde epoch (1970-01-01)
DayTransform()(2026-08-03T23:59:00) -> 20668 dias desde epoch (1970-01-01)
DayTransform()(2026-08-09T10:05:00) -> 20674 dias desde epoch (1970-01-01)
Tres observaciones directas de esta salida. Primero, IdentityTransform es literalmente la función identidad: el valor de entrada y el de salida son el mismo objeto, sin ninguna transformación real —el nombre no es una coincidencia—. Segundo, BucketTransform(4) produce enteros entre 0 y 3 —cuatro cubetas, como pediste—, y la asignación es determinista pero no intuitiva a simple vista: franchise_id=0, 1, 2 y 100 caen todos en la cubeta 0, mientras que 3 cae en la 3 y 4 en la 2 — no hay ningún patrón visible a ojo, porque el hash está diseñado, a propósito, para distribuir de forma pareja sin importar el orden de los valores de entrada. Tercero, DayTransform colapsa 08:14:00 y 23:59:00 del mismo día (2026-08-03) al mismo valor —20668—, mientras que una fecha distinta (2026-08-09) produce un valor distinto (20674); el número en sí es la cuenta de días desde el 1970-01-01 (la época Unix), la representación interna que Iceberg usa — cuando consultes una tabla particionada por DayTransform con table.inspect.partitions() (lección 7), vas a ver ese mismo valor ya convertido a una fecha legible (datetime.date(2026, 8, 3)), no el entero crudo.
Cuándo usar cada uno
| Transform | Úsalo cuando... | El riesgo de NO usarlo |
|---|---|---|
IdentityTransform | La columna tiene pocos valores distintos, y sueles filtrar por su valor exacto (store_id, con 3 valores) | Ninguno especial — es la opción por defecto para columnas de baja cardinalidad |
BucketTransform(N) | La columna tiene alta cardinalidad (franchise_id, con 250,000 valores), y quieres un número fijo y manejable de archivos | Usar IdentityTransform sobre una columna de alta cardinalidad produce partición explosiva: un archivo casi vacío por cada valor distinto — el error común de esta lección |
DayTransform | Filtras seguido por rangos de fecha (order_ts >= '2026-08-05'), o necesitas expirar datos viejos por antigüedad | Sin truncar la fecha, cada timestamp exacto sería su propia partición — el mismo problema de explosión, aplicado al tiempo |
Diagrama: de la columna al PartitionSpec
flowchart LR
subgraph valores["Valores de negocio"]
S["store_id: S01, S02, S03\n(3 valores)"]
F["franchise_id: 0..249,999\n(250,000 valores)"]
T["order_ts: timestamps exactos\n(potencialmente infinitos valores)"]
end
S -->|"IdentityTransform"| PS["PartitionSpec\nkiosko.fact_orders_at_scale\n(leccion 5)"]
T -->|"DayTransform -> order_day"| PS
F -.->|"BucketTransform(N)\n(no usado en la tabla\nprincipal de esta guia,\nver Ejercicio 2)"| BX["N cubetas manejables"]
Profundización: por qué los field_id de partición empiezan en 1000
Fíjate en un detalle que vas a ver de nuevo en la lección 5: cuando construyas un PartitionField a mano, su field_id va a empezar en 1000, no en 1. Esto no es arbitrario — es la misma convención documentada en los ejemplos oficiales de PyIceberg, y existe por la misma razón que ya viste en el módulo 4 con los field_id del esquema: cada identificador numérico interno de Iceberg necesita ser único y estable para siempre, incluso después de que una columna se borre o un campo de partición se elimine. Empezar los field_id de partición en 1000 deja margen —los números del 1 al 999— para columnas del esquema de negocio (como las que ya viste en los módulos 1 a 4, con field_id de 1 a 7), sin que ambos espacios de numeración choquen nunca entre sí.
También vale la pena notar la diferencia entre cómo vas a crear un spec por primera vez (lección 5) y cómo vas a evolucionarlo después (lección 6). Para crear el spec inicial de una tabla que todavía no existe, no hay ningún esquema vivo al que preguntarle "¿cuál es el field_id de store_id?" — por eso tienes que construir el PartitionField a mano, con el source_id numérico exacto de esa columna en el Schema. Una vez que la tabla ya existe, table.update_spec().add_field("order_ts", DayTransform(), "order_day") te deja referirte a la columna por nombre — porque ahora sí hay un esquema vivo, con un catálogo real, que puede resolver ese nombre por ti.
Errores comunes
Usar IdentityTransform sobre una columna de alta cardinalidad, "porque es la opción más simple". Qué pasa: alguien, sin pensarlo dos veces, particiona kiosko.fact_orders_at_scale por franchise_id con IdentityTransform, en vez de store_id. Por qué pasa: IdentityTransform es, conceptualmente, el transform más fácil de entender —"la partición es el valor"—, así que es la opción por defecto si no se conoce la cardinalidad de antemano. Cómo detectarlo: si tu tabla termina con más de unos pocos miles de archivos de datos después de una carga, y cada uno pesa unos pocos kilobytes, sospecha de partición explosiva — revisa cuántos valores distintos tiene la columna que usaste para particionar. Cómo corregirlo: para franchise_id, con 250,000 valores posibles, la elección correcta es BucketTransform(N) con un N razonable (decenas, no cientos de miles) — agrupa muchas franquicias en cada cubeta, en vez de una gaveta casi vacía por franquicia. El Ejercicio 2 de esta lección te hace calcular ese N con evidencia.
Esperar que el mismo valor de entrada siempre caiga en el mismo número de cubeta, sin importar cuántas cubetas totales elijas. Qué pasa: alguien corre BucketTransform(4)(franchise_id=100) y obtiene 0, después corre BucketTransform(16)(franchise_id=100) y se sorprende de obtener un número distinto. Por qué pasa: es fácil asumir que el hash de un valor es una propiedad fija del valor mismo, independiente de cuántas cubetas existan. Cómo detectarlo: si tu código asume que el número de bucket de un valor no cambia al cambiar N, revisa el ejemplo trabajado de esta lección otra vez — el resultado depende tanto del valor de entrada como del número total de cubetas (N), porque el hash se reduce módulo N. Cómo corregirlo: trata el número de cubetas de un BucketTransform como una decisión que, una vez tomada y con datos ya escritos, no deberías cambiar a la ligera — cambiarlo es, formalmente, una evolución de partición (lección 6), y los archivos viejos van a quedar organizados según el N viejo mientras los nuevos usan el N nuevo, exactamente igual que vas a ver con DayTransform en la lección 7.
Ejercicios
Ejercicio 1 — Reproduce los tres transforms tú mismo, con valores distintos. Corre el ejemplo trabajado de esta lección, pero con store_id reemplazado por nombres de producto (P001 a P004), franchise_id con al menos diez valores nuevos de tu elección, y tres fechas de tu elección para DayTransform. Confirma que el patrón se sostiene: IdentityTransform no cambia nada, BucketTransform distribuye sin patrón visible, DayTransform colapsa horas del mismo día.
Ver solución
No hay una única salida "correcta" —depende de los valores que elijas—, pero el patrón estructural debe sostenerse siempre: cada valor de IdentityTransform debe ser idéntico a su entrada; los valores de BucketTransform deben caer siempre entre 0 y N-1 (con N el número de cubetas que uses), sin ningún orden visible relacionado con el valor de entrada; y cualquier par de timestamps del mismo día calendario deben producir el mismo entero bajo DayTransform, mientras que un timestamp de un día distinto debe producir un entero distinto.
Ejercicio 2 — Calcula un N razonable de BucketTransform para franchise_id. Con 250,000 franquicias, y sabiendo que un archivo Parquet demasiado chico (unos pocos KB) desperdicia overhead de metadata, y un archivo demasiado grande dificulta la poda, ¿qué rango de N te parece razonable para BucketTransform(N) sobre franchise_id? Justifica con un cálculo simple de cuántas franquicias caerían, en promedio, en cada cubeta.
Ver solución
Con 250,000 franquicias distribuidas de forma pareja entre N cubetas, cada cubeta recibe, en promedio, 250,000 / N franquicias. Con N=16 (un valor común en ejemplos de la documentación oficial), eso son aproximadamente 15,625 franquicias por cubeta — cada una con 40 filas, así que cada cubeta terminaría con alrededor de 625,000 filas, un tamaño de archivo razonable para Parquet. Con N demasiado chico (por ejemplo, 4), cada cubeta cargaría proporcionalmente más filas —archivos más grandes, menos paralelismo posible al leer—; con N demasiado grande (por ejemplo, 100,000), volverías a acercarte al problema de partición explosiva que este transform existe para evitar. No hay un único N "correcto" — es una decisión de ingeniería que depende del volumen real y de los patrones de consulta esperados, no una fórmula fija.
Ejercicio 3 — Predicción: ¿por qué DayTransform trunca a día, y no ofrece, por ejemplo, SecondTransform? Sin buscarlo todavía, predice: ¿por qué crees que Iceberg documenta transforms de tiempo hasta la hora (HourTransform) pero no más finos que eso? Piensa en el mismo problema de cardinalidad de este módulo.
Ver solución
Un transform más fino que la hora —por segundo, o por milisegundo— reproduciría exactamente el mismo problema de partición explosiva que ya viste con IdentityTransform sobre franchise_id: con timestamps que en la práctica casi nunca se repiten al segundo exacto, cada partición terminaría con una o muy pocas filas, multiplicando el número de archivos sin ningún beneficio real de poda. Los transforms de tiempo que Iceberg sí ofrece —YearTransform, MonthTransform, DayTransform, HourTransform— están pensados, cada uno, para el nivel de granularidad en el que las consultas de negocio típicamente filtran ("dame los datos de este mes", "dame los datos de hoy"), no para la resolución técnica máxima que el tipo timestamp permite almacenar.
Resumen y siguiente paso
En esta lección conociste, con evidencia ejecutada, los tres transforms de partición que vas a usar en el resto de este módulo: IdentityTransform (el valor tal cual, para columnas de baja cardinalidad como store_id), BucketTransform (hash determinista en N cubetas, para columnas de alta cardinalidad como franchise_id), y DayTransform (fecha de calendario, para columnas de tiempo como order_ts). Viste, también, por qué el field_id de un campo de partición empieza en 1000, y la diferencia entre construir un spec a mano (tabla nueva) y evolucionarlo por nombre (tabla existente).
Antes de avanzar deberías poder: explicar con tus propias palabras cuándo usar cada uno de los tres transforms; y anticipar por qué IdentityTransform sobre franchise_id sería un error, aunque técnicamente funcione sin lanzar ningún error.
La lección 5 usa el primero de los tres, IdentityTransform, para crear el PartitionSpec real de kiosko.fact_orders_at_scale — la ejecución completa, a escala completa, del contraste que este módulo prometió desde la lección 1.
Recursos
- Apache Iceberg — documentación oficial, "Partitioning", sección "Partition Transforms" (la lista completa de transforms disponibles, incluidos los que esta lección no usa:
year,month,hour,truncate). iceberg.apache.org/docs/latest/partitioning. En inglés. - PyIceberg — referencia de API,
IdentityTransform,BucketTransform,DayTransformy el resto del módulopyiceberg.transforms. py.iceberg.apache.org/api. En inglés. - Apache Iceberg — especificación de la tabla ("Table Spec"), sección "Partitioning" (la convención de
field_idempezando en1000para campos de partición). iceberg.apache.org/spec. En inglés. - DISEÑO de esta guía — la sección del módulo 5, con los tres transforms nombrados explícitamente como parte del alcance.
src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.