Salida tipada y Parquet
Se usa cuando el archivo va rumbo al análisis — pandas, DuckDB, Spark, un data
warehouse — y usted necesita que cargue tipos de columna reales y un NULL real, y
no nada más texto que el lector tenga que adivinar. Póngale nombre a sus columnas
<data>, déle a la salida un nombre .parquet
y TDC escribe un archivo binario y tipado — sin bibliotecas externas, sin banderas extra.
Toda la salida vista hasta ahora en este sitio ha sido texto: CSV, JSON, SQL. Eso es perfecto para una persona y para cualquier cosa que lea caracteres. Para el análisis de datos tiene dos problemas que Parquet resuelve.
- Sin tipos. En un CSV todo es un string. Un científico de datos carga el archivo y
tiene que volver a adivinar qué columna es número, cuál es fecha y cuál es texto plano
— y adivina mal:
007se vuelve7, y un número de documento queda corrompido en silencio. - Sin NULL. Un espacio vacío entre dos comas: ¿es «texto vacío» o «no había valor»?
missingemite un string vacío, y la distinción se pierde.
Parquet es el formato binario columnar en el que se estandarizan las herramientas
analíticas. Cada columna tiene un tipo real y un NULL real, y el archivo se abre
en una línea — pd.read_parquet("data.parquet") — sin nada que reparar después.
Las salidas de ejemplo de abajo son ilustrativas: los valores exactos pueden cambiar
según la versión del core y la semilla. Lo que importa es la forma: la línea de esquema
por columna, y dónde aparece un null de verdad.
- Aun row group: una rebanada de filas que se arma, se escribe y se libera
- Bla primera columna de esa rebanada, guardada por separado
- Cla segunda columna
- Dla tercera — un lector que necesita una sola columna toca únicamente sus trozos
Cómo activarlo
Son dos cosas: marcar cuáles etiquetas
<data> son columnas, y nombrar el archivo
.parquet.
Una columna es un <data> con un atributo name. Sin name, la etiqueta se queda
como texto de formato ordinario y nunca llega al archivo. El tipo se fija con type.
<block>
<line>
<data name="id" type="int64">${{Id}}</data>
<data name="reading" type="int64">${{Reading}}</data>
<data name="is_outlier" type="bool">${{IsOutlier}}</data>
<data name="city" type="string">${{City}}</data>
<data name="amount" type="int64|null">${{Amount}}</data>
</line>
</block>
El formato se elige por la extensión del archivo — no hay bandera nueva:
tdcv2 data.tdc -o data.parquet # binario, tipado
tdcv2 data.tdc -o data.csv # texto, exactamente como antes
Esto es lo que ve quien abre el archivo: un esquema (un tipo por columna) y luego las filas:
id INT64 REQUIRED
reading INT64 REQUIRED
is_outlier BOOLEAN REQUIRED
city BYTE_ARRAY REQUIRED {"type":"STRING"}
amount INT64 OPTIONAL
{"id":1,"reading":45, "is_outlier":false,"city":"Chicago","amount":2143}
{"id":2,"reading":54, "is_outlier":false,"city":"Chicago","amount":2328}
{"id":3,"reading":42, "is_outlier":false,"city":"Austin", "amount":5275}
{"id":4,"reading":42, "is_outlier":false,"city":"Denver", "amount":null}
{"id":5,"reading":540,"is_outlier":true, "city":"Denver", "amount":5787}
{"id":6,"reading":53, "is_outlier":false,"city":"Austin", "amount":3308}Vale la pena detenerse en dos detalles. El amount de la fila 4 es un null de
verdad (la columna es OPTIONAL), no un string vacío —
missing por fin llegando como debe ser. Y is_outlier
es un BOOLEAN real: una columna marcadora de
anomaly_flag, es decir, un dataset etiquetado listo para
poner a prueba un detector de anomalías.
Los tipos que puede escribir
type= | Qué es | Cómo se lee desde texto |
|---|---|---|
bool | true / false | true/false, 1/0 |
int32 | entero de 32 bits | -42 |
int64 | entero de 64 bits | 9007199254740993 — exacto |
double | flotante de 8 bytes | 3.14, 1e3 |
string | texto UTF-8 | tal cual |
date | fecha de calendario | 2020-05-14 |
timestamp | instante en el tiempo | ISO-8601 |
decimal(p,s) | decimal exacto (dinero) | 123.45 — sin redondeo. Precisión 1–18, escala 0–precisión: los valores viajan en un INT64, así que 18 dígitos es el techo (TDC194) |
uuid | UUID como 16 bytes | forma canónica |
json | JSON | tal cual |
float | flotante de 4 bytes | 3.14 — la mitad de espacio que double |
float16 | flotante de 2 bytes | 3.14 — ~3 dígitos significativos |
enum | texto enumerado | RED — un string, pero etiquetado |
uint8/16/32/64 | entero sin signo | 255 — rechaza un negativo |
Agregue \|null después del tipo para que la columna acepte nulos:
type="int64\|null". Sin eso, un valor vacío es un error — una puerta de calidad
gratis: si una columna no debería quedar vacía, TDC se lo dice.
decimal nunca redondea en silenciodecimal(18,2) con el valor 123.456 es un error, no un centavo perdido.
float y float16 deliberadamente renuncian a precisión — de eso se trata, ocupan
menos espacio. 0.1 se vuelve 0.100000001490116 como float y 0.0999755859375 como
float16. El valor guardado es exactamente el que verá el lector (TDC redondea de
entrada, así que las estadísticas nunca describen números que el archivo no contiene).
Salirse del rango (1e40 para float, 100000 para float16) es un error, no un
infinito silencioso.
uint64 guarda números hasta 18 446 744 073 709 551 615 — más grandes que int64. Un
valor negativo en una columna así es un error.
Puede omitir type= — TDC lo infiere
El motor sabe qué generador produjo cada columna, así que en la mayoría de los casos
type= sale sobrando. Aquí hay una configuración sin un solo type=:
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Price"><gen type="number" value="1..999" decimals="2"/></sequence>
<sequence name="Qty"><gen type="number" value="1..99" missing="0.4"/></sequence>
<sequence name="Born"><gen type="date" range="1990-01-01..2000-12-31" format="YYYY-MM-DD"/></sequence>
<sequence name="Key"><gen type="template" value="common.id.uuid"/></sequence>
<sequence name="R"><gen type="number" value="10..20" anomaly="0.4" anomaly_flag="Flag"/></sequence>
...
<data name="id">${{Id}}</data>
<data name="price">${{Price}}</data>
<data name="qty">${{Qty}}</data>
<data name="born">${{Born}}</data>
<data name="key">${{Key}}</data>
<data name="flag">${{Flag}}</data>
Lo que TDC dedujo por su cuenta:
id INT64 REQUIRED
price DOUBLE REQUIRED
qty INT64 OPTIONAL
born INT32 REQUIRED {"type":"DATE"}
key FIXED_LEN_BYTE_ARRAY REQUIRED {"type":"UUID"}
flag BOOLEAN REQUIRED
{"id":1,"price":230,"qty":63, "born":"1996-05-25","key":"e96b21bc-...","flag":true}
{"id":2,"price":589,"qty":null,"born":"2000-05-01","key":"85caccad-...","flag":false}Las reglas son simples: un number sin decimals → entero;
con decimals → flotante; un contador increment →
entero; common.id.uuid → UUID; una columna marcadora de
anomaly_flag → booleano, y también una columna
<mix flag=>. Y muy convenientemente,
missing hace la columna nullable por sí solo (qty
quedó como OPTIONAL).
Las columnas calculadas también reciben tipo, por el mismo razonamiento: lo que pueden producir se sabe antes de que exista una sola fila.
| generador | tipo de columna |
|---|---|
timeseries, pattern | entero, o decimal con decimals |
running | el tipo de la columna que nombra su of= |
stat | count → entero; mean, median, stddev → decimal; sum, min, max → el tipo del origen |
formula | entero o decimal cuando se da decimals=, texto en caso contrario |
file con read="quantile" | decimal, o entero con decimals="0" |
increment / decrement | entero, o decimal cuando value= o step= es fraccionario |
<mix> | el tipo compartido cuando cada rama es un solo <gen> que deriva a él; string ante cualquier desacuerdo, o cuando una rama tiene texto literal. Una columna <mix flag=> es booleana |
La fila de la fórmula se sale a propósito. expr="A + 1" es un número entero,
expr="A / 2" ya no lo es, y expr="A > 5 ? over : under" es una PALABRA — así que
decimals= es la única señal honesta de lo que contiene la columna. Escriba decimals="0"
cuando la respuesta sea entera y quiera una columna de enteros.
El orden es: type= explícito → inferido a partir del generador → texto. TDC
nunca adivina un tipo a partir de los valores mismos — eso es exactamente lo que
corrompe los CSV (007 → 7). Cuando TDC no está seguro, la columna se queda como
string: un string no rompe nada.
Una lectura por cuantiles es la única fila de esa tabla donde el
generador file recibe un tipo, y por la razón de siempre: read="quantile" exige que el
archivo sea numérico o la ejecución se rechaza, así que la columna es un número por
construcción — un hecho sobre el generador, no una conjetura a partir de los valores. Una
lectura normal de archivo sigue siendo texto, porque un archivo es un saco de lo que
contenga.
Qué número lo decide la configuración sola, porque esta capa nunca abre el archivo.
decimals="0" es la única declaración que promete valores enteros; sin ella la precisión
viene de la fuente y puede ser decimal, así que la respuesta es un decimal. Esa es la
dirección segura: un decimal admite cualquier valor que esa columna pueda producir, y 31
escrito como 31.0 no pierde nada, mientras que el texto pierde el tipo entero:
<sequence name="Amount"><gen type="file" src="amounts.txt" read="quantile"/></sequence>
<sequence name="Age"><gen type="file" src="ages.txt" read="quantile" decimals="0"/></sequence>
amount sale como DOUBLE y age como INT64. Declare type= a mano si quiere algo más
estrecho.
Dos casos donde la inferencia se omite a propósito
- Un
datesinformat="YYYY-MM-DD". Por omisión una fecha se imprime como05/25/1996, que no es ISO — declararla comodatesería deshonesto. - Una
masko uncaseen el generador. Estos reescriben el texto, así que un número deja de ser un número.
En ambos casos ponga type= a mano si sabe lo que está haciendo.
Cuando un valor no cabe en su tipo
TDC nunca escribe un archivo corrupto: se detiene y dice exactamente dónde:
tdc: column "n", row 1: "abc" is not an integer (int64)
Un error de dedo en el nombre del tipo mismo lo atrapa el validador antes de que
la generación siquiera empiece (código TDC194).
Volúmenes grandes — row groups
El archivo se escribe en row groups (50 000 filas cada uno): un grupo se arma, se escribe y se libera, así que la memoria no crece con el número de filas. 120 000 filas son tres grupos, y un lector puede saltarse grupos enteros sin parsear el archivo de punta a punta. Es el mismo comportamiento de streaming que mantiene planas en memoria las salidas grandes.
Esos mismos grupos le dan paralelismo. Los bytes de un grupo no dependen de dónde queda ubicado en el archivo — los encabezados de página registran los tamaños y todos los desplazamientos se juntan en el footer — así que los hilos arman grupos de forma independiente y, al final, un coordinador los acomoda uno tras otro y escribe un solo footer con los desplazamientos corregidos.
El trabajo se reparte por grupo, no por fila: si partiera un grupo a la mitad obtendría grupos que una corrida de un solo hilo nunca produce. Por eso la salida es idéntica byte por byte con cualquier número de hilos. Sobre un millón de filas:
--jobs 1 6.51 s --jobs 4 2.51 s --jobs 8 2.18 s <- los archivos de las tres corridas son idénticos
Una condición: hace falta un archivo real (-o) — es la extensión
.parquet de esa ruta la que selecciona el escritor, y sin -o la corrida imprime texto.
Escribir en paralelo también necesita el archivo: el coordinador tiene que saber dónde
aterrizó cada grupo. Fije el número de hilos con --jobs si
quiere; los bytes son los mismos de cualquier forma.
Listas
Una columna puede guardar una lista de valores en vez de uno solo:
type="[]int64", o simplemente repeat en el generador —
y entonces el tipo se infiere. Las listas vacías y un null dentro de una lista se
registran con honestidad.
<data name="scores" type="[]int64">${{Scores}}</data>
scores INT64 OPTIONAL (repeated)
{"scores":[45,52,61]}
{"scores":[]}
{"scores":[70,null,55]}Consultas rápidas — estadísticas por columna
Para cada columna TDC registra el mínimo, el máximo y el conteo de NULL. Eso le
permite a un lector saltarse bloques enteros: para una consulta como amount > 500,
mira el máximo de un bloque y, si es menor, ni siquiera parsea el bloque.
La comparación sigue las reglas del formato, no las de JavaScript: los strings se
comparan por sus bytes UTF-8. En ASCII una mayúscula va antes que una minúscula, así
que "Apple" < "apple" < "zebra", y cualquier texto no ASCII va después de todo lo
ASCII — un orden estable y portable en el que todos los lectores de Parquet coinciden.
El archivo además declara ese orden en su pie (column_orders). Sin esa declaración el
formato exige que un lector ignore los límites, por correctos que sean: los lectores
basados en Java descartan directamente el min/max de las cadenas. TDC escribía límites
correctos que nadie tenía permiso de usar; ahora escribe la declaración junto a ellos.
Los valores repetidos se guardan una sola vez
Cuando una columna tiene pocos valores distintos — ciudades, estatus, categorías — TDC los guarda con un diccionario: la lista de valores una vez, y en cada fila un número chico que apunta a ella. La decisión es automática, tomada a partir de los datos. Sobre 50 000 filas:
| columna | valores distintos | diccionario | tamaño |
|---|---|---|---|
city | 5 | sí | 18 KB |
status | 3 | sí | 12 KB |
uuid | 50 000 | no — no vale la pena | 781 KB |
A una columna de valores únicos un diccionario solo le haría daño, así que TDC no aplica
ninguno ahí. La regla es simple: use diccionario cuando la cantidad de valores distintos
sea la mitad del número de filas o menos — salvo un bool, que nunca recibe uno. Un
booleano ya cuesta un bit, así que un diccionario solo podría añadir una página. Dos
valores distintos sobre 2000 filas están muy por debajo de la mitad y aun así quedan en
PLAIN a secas, mientras que un int64 y un string al lado reciben RLE_DICTIONARY.
Compresión
Las páginas se comprimen con snappy — el estándar de Parquet que todo lector entiende. Sobre un conjunto real de 50 000 filas y 14 columnas:
no compression, no dictionary: 5.70 MB now: 1.99 MB <- casi la tercera parte del tamaño
La compresión se elige por columna, y solo cuando conviene. Snappy tiene bytes de
sobrecarga, y en una página muy chica cuestan más de lo que ahorran — TDC deja esa
columna sin comprimir. Una columna de ciudad con 5 valores sobre 50 000 filas toma ese
camino: 18 839 bytes de entrada, 18 839 de salida, códec UNCOMPRESSED. Una columna uuid
sobre las mismas filas va al revés y snappy ahorra 209 KB de 2 MB.
La decisión se toma sobre la página, no sobre el fragmento, así que un fragmento que se comprime a casi nada puede terminar un par de bytes más grande de lo que entró — un caso medido es 99 bytes de entrada y 101 de salida. Dos bytes en un archivo que de todos modos es un tercio de su tamaño crudo: conviene saberlo, no conviene evitarlo.
Está implementada en el propio código de TDC, sin bibliotecas de terceros — y no solo por el bien de las dependencias: dos implementaciones de snappy pueden emitir bytes distintos (igualmente válidos) para los mismos datos, y un codificador compartido es lo que permite que las cinco implementaciones produzcan archivos idénticos byte por byte en la misma versión.
Leerlo de vuelta en pandas
La recompensa está del lado del lector. Una línea, y el dataframe ya trae los dtypes
correctos y NaN de verdad donde el archivo tenía null — nada que limpiar:
import pandas as pd
df = pd.read_parquet("data.parquet")
print(df.dtypes)
id int64 reading int64 is_outlier bool city object amount float64 dtype: object
id y reading son enteros, is_outlier es un booleano genuino, city es texto, y
amount — la columna nullable — regresa como float64 para poder guardar NaN en las
filas faltantes (pídale a pandas su backend nullable con
pd.read_parquet(..., dtype_backend="numpy_nullable") para que se quede como un Int64
nullable). El dataframe en sí:
id reading is_outlier city amount 0 1 45 False Chicago 2143.0 1 2 54 False Chicago 2328.0 2 3 42 False Austin 5275.0 3 4 42 False Denver NaN 4 5 540 True Denver 5787.0
El amount de la fila 4 es NaN, no un string vacío — el null sobrevivió el viaje de
ida y vuelta. Para la API completa de la biblioteca en cada lenguaje, vea
Bindings de lenguajes.
Mapas
Una columna puede guardar un mapa: type="{}int64" es un mapa de texto a int64.
La celda lleva pares clave:valor, separados igual que los elementos de una lista:
<data name="gauges" type="{}int64|null">cpu:${{Cpu}},mem:${{Mem}}</data>
host STRING REQUIRED
gauges MAP<STRING, INT64> REQUIRED
{"host":"db-1", "gauges":{"cpu":74,"mem":null}}
{"host":"db-1", "gauges":{"cpu":65,"mem":43}}
{"host":"web-1", "gauges":{"cpu":15,"mem":15}}
{"host":"web-1", "gauges":{"cpu":10,"mem":12}}
{"host":"web-2", "gauges":{"cpu":6,"mem":null}}Quien lo lee recibe un mapa de verdad, no una cadena que tenga que desarmar:
gauges['cpu'] es una consulta, y el missing= del generador que hay detrás de un valor
llega adentro como un null genuino.
La clave siempre es texto. Parquet no admite una clave nula, la celda llega como texto
de todos modos, y un segundo parámetro de tipo duplicaría la sintaxis para comprar una
conversión que nadie ha pedido. Así que {}T se lee «un mapa de texto a T» y no queda
nada más que decidir. El |null se refiere al VALOR, igual que se refiere al elemento de
una lista.
Cómo se escribe la celda
- La clave es todo lo que va antes del primer
:; el valor es el resto. Por eso un valor puede llevar dos puntos (start:12:30:00es un solo par) y una clave no. - Una celda vacía es un mapa vacío, la misma regla que sigue una lista.
- Tres cosas se rechazan en vez de adivinarse, porque cada una le dejaría un mapa al
que le falta una entrada en silencio: un trozo sin
:, una clave vacía y una clave que se repite dentro de una misma fila (los lectores no se ponen de acuerdo sobre cuál de las dos gana, y algunos descartan el mapa entero de esa fila).
Todavía no soportado
- Compresión zstd / brotli — snappy ya está; estas todavía no.
- Los diccionarios para números de punto flotante funcionan, pero la ganancia suele ser menor: las repeticiones entre flotantes son raras.
- Listas de listas —
[]int64y{}int64funcionan, pero[][]int64no: haría falta un segundo separador, y ninguna forma de hacerlo se ha ganado todavía su lugar. - Tipos geométricos — se van agregando de uno en uno; cada uno es apenas una etiqueta sobre los mismos bytes.
float, float16 y enum aparecían aquí como no implementados; ya funcionan y producen
los tipos lógicos correctos (FLOAT, FLOAT16, ENUM).
Vea también
- Formatos de salida (CSV, JSON, SQL…) — el lado de texto del mismo bloque de salida, y dónde muerde la sintaxis de cada formato.
- Salida y formato —
<block>,<line>y<data>a detalle. - Salidas grandes y streaming — row groups,
--jobsy memoria plana a cualquier tamaño. - CLI —
-o,--jobs,--engine. - Máscaras y mayúsculas —
mask/case, que apagan la inferencia de tipos. - Bindings de lenguajes — leer y escribir desde las cinco: TypeScript, Python, Java, C# y Rust.