Manejo de datos

Stack de R para importar, transformar y explorar datos tabulares

r
data-handling
tidyverse
data.table
arrow
missing-data
Referencia comentada de los paquetes de R que estructuran un flujo serio de manejo de datos: lectura, manipulación, reshape, fechas, exploración, valores perdidos y datos out-of-memory.

Sobre manejo de datos en R

R ofrece dos ecosistemas de primer nivel para manipular tablas en memoria: el tidyverse (dplyr, tidyr, readr, lubridate) y data.table. No son escalones de una escalera, son dos diseños distintos con compromisos diferentes:

  • tidyverse: API verbosa y legible, copy-on-modify, integración natural con ggplot2 y tidy evaluation. Coste: rendimiento moderado y consumo de memoria por las copias.
  • data.table: sintaxis densa, semántica por referencia (:=), radix sort y grouping extraordinariamente rápidos. Coste: curva de aprendizaje más empinada y menos integración con el resto del tidyverse.

Una recomendación honesta: usa tidyverse por defecto para análisis exploratorios, pipelines de informe y trabajo colaborativo donde el código se lee más veces de las que se ejecuta. Cambia a data.table cuando el dataset pase de unos millones de filas, cuando agrupes y agregues en bucle, o cuando el coste de memoria de copiar columnas sea prohibitivo. Para datasets que ya no entran en RAM, salta a arrow (o duckdb) y opera sobre el dataset sin materializarlo entero.

Esta página cataloga los paquetes que sostienen ese flujo, ordenados por jerarquía conceptual: primero la entrada de datos (lectura), luego la manipulación columnar, después el reshape de formato, el manejo de fechas, la exploración / diagnóstico, los valores perdidos, y por último las alternativas para datos que exceden la memoria.

Convenciones que asumo en los ejemplos:

  • Pipe nativo |> (R ≥ 4.1) en lugar de %>%, salvo cuando el segundo aporta algo (placeholder .).
  • tibbles en lugar de data.frame base. La diferencia práctica son los prints truncados y la conservación estricta de tipos al subscribir columnas.
  • Locale C explícito en parsing de fechas y números cuando la portabilidad importa.

readr

readr es el lector de archivos delimitados (CSV, TSV, fixed-width) del tidyverse. Devuelve tibbles, detecta tipos por muestreo de las primeras filas y reporta problemas de parsing de forma legible. Es lo que reemplaza a read.csv() y read.table() de R base en un flujo serio.

Diseñado por Hadley Wickham y Jim Hester. Comparte filosofía con vroom (mismo autor), de hecho, desde la versión 2.0 readr usa vroom por debajo, con lo que su rendimiento se acerca al de data.table::fread() en la mayoría de casos.

Cuándo usarlo

CSVs y TSVs de tamaño moderado-alto (hasta unos pocos GB en disco) donde quieras un parsing limpio con tipos explícitos y mensajes de error claros. Es la elección razonable por defecto para entrada de datos en pipelines de tidyverse.

Cuándo NO usarlo

  • Velocidad bruta sobre archivos enormes (>10 GB): data.table::fread() sigue siendo notablemente más rápido para parsing secuencial y consume menos memoria pico.
  • Lectura lazy o sobre datasets distribuidos en muchos archivos: usa arrow::open_dataset() o duckdb directamente sobre los CSV/Parquet.
  • Excel: readxl (mismo ecosistema) o openxlsx para escritura.
  • JSON o XML estructurado: jsonlite / xml2.

Conceptos clave

  • read_csv() infiere tipos del guess_max (por defecto 1000 filas). Si el dataset cambia de tipo más abajo, fallarás silenciosamente, fija tipos con col_types.
  • cols() y los shortcuts cols(.default = col_character()) o el string compacto "icnDT" (i=integer, c=character, n=numeric, D=date, T=datetime) son la forma idiomática de fijar el esquema.
  • problems(x) devuelve un tibble con los parsing failures. Inspeccionarlo siempre tras una carga importante.
  • locale() controla decimal_mark, grouping_mark, tz, encoding y el formato de fechas. Diferencia crítica entre datos exportados desde Excel en es-ES (coma decimal) vs en-US (punto decimal).
  • write_csv() escribe en UTF-8 sin BOM. Para Excel en Windows que espera BOM, usa write_excel_csv().

Patrón mínimo

library(readr)

# Lectura con tipos explícitos y locale español
df <- read_csv(
  "ventas_2025.csv",
  col_types = cols(
    fecha     = col_date(format = "%Y-%m-%d"),
    cliente   = col_character(),
    importe   = col_double(),
    categoria = col_factor(levels = c("A", "B", "C"))
  ),
  locale = locale(decimal_mark = ",", grouping_mark = ".", encoding = "UTF-8")
)

# Inspeccionar problemas de parsing antes de seguir
problems(df)

Trampas habituales

  • Tipo inferido por muestreo. Si las primeras 1000 filas son integer y la fila 1001 contiene un decimal, readr lanza warning y mete NA. Sube guess_max = Inf o, mejor, fija col_types siempre que conozcas el esquema.
  • Encoding latino. Archivos exportados desde sistemas Windows antiguos vienen en Latin1 o Windows-1252. Sin locale(encoding = "Latin1"), los acentos quedan rotos.
  • Coma decimal vs separador de campo. Datasets es-ES exportados como CSV usan ; como separador y , como decimal. Usa read_csv2() (preset europeo) en vez de read_csv().
  • read.csv (base) vs read_csv. No los mezcles: el primero devuelve data.frame con stringsAsFactors = FALSE por defecto desde R 4.0, el segundo devuelve tibble. Las diferencias de tipo silenciosas son fuente recurrente de bugs.

Enlaces

Relacionados en esta página

  • data.table, fread() para archivos muy grandes.
  • arrow, Parquet, CSV y datasets multi-archivo out-of-memory.

dplyr

dplyr es el estándar de facto para manipulación de datos tabulares en R idiomático. Expone un conjunto reducido de verbos (filter, select, mutate, summarise, arrange, group_by, *_join) que cubren el 95 % de las transformaciones que se hacen sobre un data frame. Su valor real no es la velocidad, data.table es más rápido, sino la legibilidad y la consistencia del API.

Es el corazón del tidyverse. Todo lo demás (tidyr, ggplot2, broom, recipes) está diseñado para encajar con sus verbos y con el pipe.

Cuándo usarlo

  • Análisis exploratorio y pipelines de informe donde el código se lee más veces de las que se ejecuta.
  • Datasets en memoria de hasta unos pocos millones de filas. A partir de ahí, evalúa data.table o dtplyr (frontend dplyr sobre data.table).
  • Trabajo colaborativo: el código dplyr es legible incluso por personas que no son su autor.

Cuándo NO usarlo

  • Performance crítica con muchos grupos: data.table agrupa y agrega 5-50× más rápido. Para summarise por miles de claves sobre millones de filas, la diferencia es real.
  • Modificación in-place: dplyr siempre copia (copy-on-modify). Si la presión de memoria importa, data.table::set() y := modifican por referencia.
  • Datos out-of-memory: usa dbplyr (traduce a SQL contra una base) o arrow (mismo verbos, ejecución sobre Arrow).
  • Bucles de agregación en hot path: el sobrecoste de NSE y masking se nota. Baja a collapse (gran rendimiento manteniendo API tidy) o a vectorización pura.

Conceptos clave

  • Cinco verbos básicos: filter (filas), select (columnas), mutate (nuevas columnas), summarise (reducir), arrange (ordenar). group_by modula los anteriores.
  • group_by() + summarise() desagrupa un nivel por defecto desde dplyr 1.0. Antes mantenía todos los niveles, verificarlo con .groups = "drop" explícito es buena práctica.
  • mutate(across(...)) y summarise(across(...)) reemplazan a mutate_at/mutate_if. Aceptan tidyselect (where(is.numeric), starts_with("x_")).
  • *_join: inner_join, left_join, right_join, full_join, semi_join, anti_join. Desde dplyr 1.1, el argumento relationship valida la cardinalidad ("one-to-one", "many-to-one", etc.), úsalo, evita bugs por explosión de filas.
  • Tidy evaluation: variables de columna pasan sin comillas. Para programar funciones que reciben nombres de columna, embebe con { var } (curly-curly) o .data[[name]].

Patrón mínimo

library(dplyr)

resumen <- ventas |>
  filter(fecha >= "2025-01-01", !is.na(importe)) |>
  mutate(
    mes      = lubridate::floor_date(fecha, "month"),
    margen   = importe - coste,
    margen_pct = margen / importe
  ) |>
  group_by(mes, categoria) |>
  summarise(
    n            = n(),
    ingresos     = sum(importe),
    margen_medio = mean(margen_pct, na.rm = TRUE),
    .groups = "drop"
  ) |>
  arrange(mes, desc(ingresos))

Trampas habituales

  • summarise() desagrupa un nivel. Tras group_by(a, b) |> summarise(...), el resultado sigue agrupado por a. Esto rompe joins y filtros posteriores. Cierra con .groups = "drop" o ungroup() explícito.
  • filter con NA: filter(x == 1) excluye NA. Para mantener NA, usa filter(is.na(x) | x == 1) o filter(x %in% 1).
  • mutate vs mutate(across). mutate(c1 = fn(c1), c2 = fn(c2), ...) se hace pesado. Pasa a across().
  • NSE en funciones de usuario. Pasar col_name como string falla si lo metes directo en filter(df, col_name == ...). Usa filter(df, .data[[col_name]] == ...) o el operador { } para nombres no entrecomillados.
  • select después de un join silencia errores. Si una columna se renombra automáticamente con sufijo .x / .y por colisión, un select(col) puede capturar la equivocada. Usa relationship y suffix explícitos.

Enlaces

Relacionados en esta página

  • tidyr, reshape complementario a la manipulación de dplyr.
  • data.table, alternativa de alto rendimiento.
  • arrow, mismos verbos sobre datasets que no caben en RAM.

tidyr

tidyr se encarga del cambio de forma (reshape) de las tablas: pasar de ancho a largo, separar y combinar columnas, anidar y desanidar listas-columna. Es el complemento natural de dplyr, uno transforma valores, el otro reorganiza la estructura.

Su API gira en torno a la idea de datos tidy: una observación por fila, una variable por columna, un tipo de observación por tabla. Conseguir esa forma es la mitad del trabajo de cualquier análisis serio. El resto es manipular ya con dplyr.

Cuándo usarlo

  • Transformar tablas anchas (una columna por fecha / por condición) a formato largo para ggplot2 o modelos.
  • Volver al ancho para presentación de informes (pivot_wider).
  • Separar columnas combinadas (separate_wider_delim, separate_wider_regex, separate_wider_position), los antiguos separate() y extract() están superseded.
  • Anidar dataframes por grupo (nest) para mapear modelos con purrr::map.

Cuándo NO usarlo

  • Reshapes masivos sobre millones de filas: data.table::melt() y dcast() son significativamente más rápidos y consumen menos memoria pico.
  • Reshapes triviales que no requieren agregación: a veces un dplyr::mutate + select ya basta sin un pivot_* formal.

Conceptos clave

  • pivot_longer(cols, names_to, values_to) apila columnas en formato largo. names_pattern y names_sep extraen estructura del nombre.
  • pivot_wider(names_from, values_from, values_fill, values_fn) hace lo inverso. values_fn agrega cuando hay múltiples filas por combinación.
  • separate_wider_* reemplaza al viejo separate() con mejor reporte de errores y control sobre filas problemáticas (too_few, too_many).
  • nest() y unnest() mueven entre formato long y list-column, base del patrón “modelo por grupo” con purrr.
  • complete() y expand() añaden filas faltantes para combinaciones de variables, útil antes de plotear series temporales con huecos.

Patrón mínimo

library(tidyr)
library(dplyr)

# Tabla ancha → larga para ggplot
ventas_largo <- ventas_ancho |>
  pivot_longer(
    cols      = starts_with("mes_"),
    names_to  = "mes",
    names_prefix = "mes_",
    values_to = "importe"
  )

# Larga → ancha con agregación implícita
resumen <- ventas_largo |>
  pivot_wider(
    names_from  = categoria,
    values_from = importe,
    values_fn   = sum,
    values_fill = 0
  )

# Modelos por grupo via nest + map
modelos <- ventas_largo |>
  group_by(region) |>
  tidyr::nest() |>
  mutate(fit = purrr::map(data, ~ lm(importe ~ mes, data = .x)))

Trampas habituales

  • Pérdida de tipos en pivot_longer. Si las columnas que apilas tienen tipos distintos, el resultado se convierte a character. Solución: values_transform = list(.default = as.character) y reconvierte tras separar, o homogeneiza antes.
  • pivot_wider con duplicados. Si la combinación names_from + id_cols no es única, obtienes una list-column de avisos. Define values_fn (sum, mean, first) explícitamente.
  • separate() está superseded. Funciona pero los nuevos separate_wider_* dan mejor diagnóstico de filas malformadas. Migra el código nuevo.
  • names_repair controla qué hacer con nombres duplicados ("unique", "minimal", "check_unique"). El default es estricto. En pipelines automáticos puede romper sin razón clara.

Enlaces

Relacionados en esta página

  • dplyr, manipulación que opera sobre tablas ya en forma tidy.
  • data.table, melt/dcast para reshapes masivos.

lubridate

lubridate es la librería de fechas y tiempos del tidyverse. Cubre lo que R base hace torpemente: parsing de strings heterogéneos, aritmética con duraciones e intervalos, manejo de zonas horarias y extracción de componentes (year, month, wday).

Forma parte del core del tidyverse desde 2020 y se carga con library(tidyverse). Reemplaza, para casi todo uso práctico, a as.Date, as.POSIXct y strptime de R base.

Cuándo usarlo

  • Cualquier parsing de fechas a partir de strings con formatos variados. Las funciones ymd(), mdy(), dmy(), ymd_hms() resuelven la mayoría de los casos sin necesidad de pasar formato.
  • Aritmética con duraciones (days(7), months(1), weeks(2)) y con intervalos (%within%, int_overlaps).
  • Conversión y normalización de zonas horarias (with_tz, force_tz).

Cuándo NO usarlo

  • Operaciones puramente vectoriales sobre fechas ya bien tipadas donde R base es suficiente y no introduce dependencia (as.Date, difftime, format).
  • Performance crítica sobre vectores enormes: data.table usa internamente IDate y ITime con representación entera y operaciones mucho más rápidas. Para binning temporal masivo, considera saltar.
  • Manejo intensivo de time series indexadas: xts, zoo o tsibble están diseñados para eso. lubridate solo cubre el dominio escalar / vectorial.

Conceptos clave

  • Parsers por orden de componentes: ymd("2025-05-18"), mdy("05/18/2025"), dmy("18-05-2025"). Aceptan separadores variados y vectores de strings con formatos mezclados.
  • Componentes: year(x), month(x, label = TRUE), wday(x, label = TRUE, week_start = 1), hour(x), tz(x).
  • Tres tipos de aritmética temporal:
    • Periods (days, months, years), calendario humano: ymd("2024-01-31") + months(1) da NA (no existe 31 feb), no salta a marzo.
    • Durations (ddays, dweeks), segundos exactos: ignoran calendario.
    • Intervals (interval, %within%), span entre dos fechas, permite operaciones de pertenencia.
  • Truncado: floor_date(x, "month"), ceiling_date(x, "week"), round_date(). Imprescindible para agregar por unidad temporal en dplyr.

Patrón mínimo

library(lubridate)
library(dplyr)

eventos <- tibble::tibble(
  ts = c("2025-05-18 14:23:00", "18/05/2025 14:23", "May 18, 2025 14:23")
)

eventos |>
  mutate(
    ts_iso   = ymd_hms(ts) %||% dmy_hm(ts) %||% mdy_hm(ts),
    semana   = floor_date(ts_iso, "week", week_start = 1),
    dia      = wday(ts_iso, label = TRUE, week_start = 1),
    en_q2    = ts_iso %within% interval("2025-04-01", "2025-06-30")
  )

Trampas habituales

  • Zona horaria implícita. ymd_hms("2025-05-18 14:00") asume UTC. Para timestamps locales sin TZ explícito, fija tz = "Europe/Madrid", confundir esto desplaza datos por horas y aparece como bug fantasma.
  • force_tz vs with_tz. force_tz(x, "Europe/Madrid") cambia la etiqueta de TZ sin convertir. with_tz() convierte el instante a la nueva zona. Confundirlos es la causa #1 de errores de timestamps.
  • Periods vs Durations. today() + years(1) puede dar NA un 29 de febrero. today() + dyears(1) siempre suma 365.25 días, y ya no estás en el mismo día calendario. Elige según semántica.
  • Locale del sistema. month(x, label = TRUE) devuelve nombre en el locale del SO. Para consistencia entre máquinas, fija Sys.setlocale("LC_TIME", "C") o pasa locale = explícito en parse_date_time.
  • Excel serial dates. Cuando readxl lee un Excel con fechas mal formateadas, llegan como número de serie. Conviértelos con as.Date(x, origin = "1899-12-30") (no 1900-01-01, bug histórico de Excel).

Enlaces

Relacionados en esta página

  • dplyr, combinaciones típicas mutate(mes = floor_date(...)).
  • data.table, IDate/ITime para operaciones temporales rápidas a escala.

dlookr

dlookr se especializa en diagnóstico exploratorio y profiling automático de datasets: estadísticos descriptivos, detección de outliers, evaluación de normalidad y reportes HTML/PDF con un solo comando. Es la herramienta a la que recurres cuando heredas un dataset de origen incierto y necesitas una foto rápida antes de modelar.

Ocupa el mismo nicho que skimr, DataExplorer o summarytools, todos válidos, todos opcionales. La razón para usar dlookr por encima de las alternativas es su enfoque en diagnóstico de calidad (no solo descripción) y los reportes parametrizables.

Cuándo usarlo

  • Primer contacto con un dataset desconocido: diagnose(), diagnose_numeric(), diagnose_category().
  • Detección formal de outliers (diagnose_outlier, métodos IQR y boxplot).
  • Tests automáticos de normalidad (normality()) y de transformaciones (find_skewness).
  • Reportes ejecutivos en HTML/PDF: diagnose_report(), eda_report(), transformation_report().

Cuándo NO usarlo

  • Profiling minimalista en consola: skimr::skim() es más rápido y más limpio para ver de un vistazo.
  • EDA visual interactivo: DataExplorer::create_report() produce un HTML con más gráficos por defecto.
  • Workflows reproducibles donde tú controlas cada gráfico: prefiere ggplot2 directo. dlookr es una herramienta de pre-análisis, no de informe final.

Conceptos clave

  • diagnose(df) da una tabla con tipos, conteos de NA, conteos únicos. Punto de entrada habitual.
  • diagnose_outlier(df) aplica IQR (1.5 * IQR como umbral). Devuelve estadísticos con/sin outliers para evaluar impacto.
  • normality(df, ...) aplica Shapiro-Wilk a columnas numéricas. Útil pero recuerda: con n alto cualquier desviación es significativa, inspecciona también el histograma.
  • imputate_na() y imputate_outlier() ofrecen métodos simples (media, mediana, KNN, predictive). Para imputación seria, considera mice o missForest.
  • Reportes: eda_report(df) genera HTML con descriptivos completos en un solo comando.

Patrón mínimo

library(dlookr)
library(dplyr)

# Diagnóstico general
df |> diagnose()

# Outliers en columnas numéricas
df |> diagnose_outlier() |> arrange(desc(outliers_ratio))

# Normalidad
df |> normality()

# Reporte EDA completo a HTML
df |> eda_report(output_file = "eda_report.html", output_dir = "reports/")

Trampas habituales

  • Reportes pesados sobre datasets grandes. eda_report() puede tardar varios minutos y producir HTMLs de cientos de MB con columnas no relevantes. Filtra a las columnas de interés antes de llamar.
  • Tests de normalidad con n grande. Shapiro rechaza casi siempre con n > 5000. No conviertas el resultado en decisión automática. Complementa con inspección visual.
  • Conflictos de namespace con dplyr. dlookr::transform() choca con base::transform. Carga dlookr antes de dplyr o usa dlookr:: explícito.
  • Imputación silenciosa. imputate_na() opera sin warning sobre columnas enteras. Audita el método elegido, la media rara vez es la opción correcta para datos sesgados.

Enlaces

Relacionados en esta página

  • naniar, diagnóstico específico de valores perdidos.

naniar

naniar es la librería especializada en valores perdidos: detección, visualización y diagnóstico de patrones de NA. Va más allá del is.na() base ofreciendo summaries estructurados y un conjunto de geoms de ggplot2 para visualizar la missingness.

Diseñado por Nick Tierney. Su contribución principal no es la imputación (para eso ya están mice, missForest, Amelia) sino el diagnóstico previo: entender la estructura de los NA antes de decidir qué hacer con ellos.

Cuándo usarlo

  • Auditar la missingness al recibir un dataset: ¿faltan datos al azar (MCAR), por patrón (MAR), o de forma informativa (MNAR)?
  • Visualizar correlaciones de missingness entre variables (gg_miss_upset, gg_miss_var).
  • Codificar y manejar sentinel values (-99, 9999, "N/A") que codifican faltantes pero llegan como valores reales (replace_with_na_*).

Cuándo NO usarlo

  • Imputación propiamente dicha: usa mice (imputación múltiple), missForest (bosques aleatorios) o mice::norm/mice::pmm según supuestos.
  • Datasets donde la missingness es mínima y aleatoria: el coste de cargar y aprender la API no compensa.

Conceptos clave

  • miss_var_summary(df) y miss_case_summary(df) resumen NAs por columna y por fila respectivamente.
  • gg_miss_var(df), gg_miss_case(df), gg_miss_upset(df) visualizan patrones, el último es especialmente útil para ver co-ocurrencia de NAs.
  • replace_with_na_all(df, condition = ~ .x == -99) convierte sentinelas explícitos a NA. Usa también replace_with_na_at() y replace_with_na_if() con tidyselect.
  • bind_shadow(df) añade una columna _NA por cada variable indicando si era NA. Útil para modelar la missingness como predictor.
  • naniar distingue missingness de implicit missingness (filas que deberían existir y no existen), tidyr::complete resuelve esto último.

Patrón mínimo

library(naniar)
library(dplyr)

# Resumen rápido
df |> miss_var_summary()

# Visualización de patrones
gg_miss_var(df, show_pct = TRUE)
gg_miss_upset(df)

# Convertir -99 / "N/A" a NA real
df_clean <- df |>
  replace_with_na_all(condition = ~ .x %in% c(-99, "N/A", ""))

# Shadow matrix para modelar missingness como feature
df_shadow <- bind_shadow(df_clean)

Trampas habituales

  • NA no es siempre NA. Datasets reales codifican faltantes como -99, 9999, "", "NA" (string), "NULL". Sin pasar por replace_with_na_* primero, todo el diagnóstico subsiguiente está mal.
  • Confundir MCAR / MAR / MNAR. gg_miss_upset te muestra patrones, no te dice el mecanismo. La decisión de imputación depende del mecanismo, que es un supuesto que tú haces (e idealmente justificas).
  • Borrar filas con na.omit por inercia. Si la missingness no es MCAR, eliminar filas sesga el análisis. Inspecciona antes con naniar y considera imputación si el mecanismo lo justifica.

Enlaces

Relacionados en esta página

  • dlookr, diagnóstico general que incluye un módulo de NAs.

data.table

data.table es la alternativa de alto rendimiento al tidyverse para manipulación tabular en memoria. Implementa su propia clase (data.table, que hereda de data.frame) y una sintaxis densa de tres argumentos DT[i, j, by] que cubre filtrado, transformación y agrupación en una sola expresión.

Diseñado por Matt Dowle y Arun Srinivasan. Es notablemente el motor más rápido del mundo R para operaciones tabulares estándar, grouping, joins, ordering, y consume menos memoria que dplyr gracias a la modificación por referencia (:=, set).

Cuándo usarlo

  • Datasets de millones de filas donde la diferencia de tiempos importa (minutos vs horas).
  • Operaciones repetidas en bucle: agregaciones por miles de claves, joins masivos.
  • Pipelines con presión de memoria: data.table modifica in-place y evita las copias defensivas de dplyr.
  • Rolling joins, non-equi joins y update on join (DT[i, var := ..., on = ...]), patrones que en dplyr requieren acrobacias.

Cuándo NO usarlo

  • Análisis exploratorio colaborativo donde la legibilidad gana. La sintaxis DT[, .(x = mean(y)), by = z] es densa. Un colaborador no familiarizado tarda en leerla.
  • Datasets pequeños donde la diferencia de rendimiento es irrelevante. Aquí dplyr paga su coste de legibilidad sin penalización práctica.
  • Si quieres dplyr por estética pero con velocidad de data.table: usa dtplyr, traduce verbos dplyr a operaciones data.table en lazy evaluation.

Conceptos clave

  • DT[i, j, by]: i filtra filas, j opera sobre columnas, by agrupa. Las tres dimensiones en una expresión.
  • Semántica por referencia con :=: DT[, x := log(y)] modifica DT sin copiar. Devuelve DT invisiblemente. Esto es lo que rompe la mente de quien viene de tidyverse, no hay DT <- DT |> mutate(...).
  • fread() y fwrite(): lector/escritor extraordinariamente rápido. Detecta separadores, tipos y compresión (.gz, .bz2, .xz) automáticamente.
  • Keys e indexing: setkey(DT, col) ordena físicamente y permite binary search en lookups. setindex(DT, col) añade índice secundario sin reordenar.
  • .SD y .SDcols: .SD es el sub-data.table dentro del by. .SDcols selecciona qué columnas verá. DT[, lapply(.SD, mean), by = grp, .SDcols = is.numeric] reemplaza a summarise(across(where(is.numeric), mean)).
  • melt() y dcast(): equivalentes mucho más rápidos a pivot_longer/pivot_wider.

Patrón mínimo

library(data.table)

# Lectura rápida
DT <- fread("ventas_2025.csv")

# Filtro + agregación por grupo
resumen <- DT[
  fecha >= "2025-01-01" & !is.na(importe),
  .(
    n            = .N,
    ingresos     = sum(importe),
    margen_medio = mean((importe - coste) / importe, na.rm = TRUE)
  ),
  by = .(mes = lubridate::floor_date(fecha, "month"), categoria)
][order(mes, -ingresos)]

# Modificación in-place (sin copia)
DT[, margen := importe - coste]
DT[importe > 0, margen_pct := margen / importe]

# Update on join (no tiene equivalente directo en dplyr)
ref <- data.table(categoria = c("A","B"), iva = c(0.21, 0.10))
DT[ref, iva := i.iva, on = "categoria"]

Trampas habituales

  • := modifica por referencia. Si haces DT2 <- DT y luego DT2[, x := 1], DT también cambia (apuntan al mismo objeto). Usa copy(DT) cuando quieras una copia real. Esta es la fuente de bugs más frecuente para quien viene de tidyverse.
  • DT[, x] vs DT[, "x"] vs DT[, .(x)]. El primero (sin comillas) evalúa x como expresión (devuelve vector). El segundo (con string) extrae columna por nombre (devuelve vector). El tercero (.() que es alias de list()) devuelve data.table. Conocer la diferencia evita errores opacos.
  • Print de data.table en RMarkdown / Quarto. Por defecto imprime las primeras y últimas 5 filas. Para presentación, considera convertir a tibble o usar head()/tail() explícito.
  • .SD es un data.table, no un data.frame. Funciones que esperan data.frame puro pueden comportarse raro dentro de .SD.
  • Encoding en fread. Detecta UTF-8 / Latin1 automáticamente, pero con archivos exóticos pasa encoding = "Latin-1" o encoding = "UTF-8" explícito.

Enlaces

Relacionados en esta página

  • dplyr, el otro estándar. Complementario más que sustitutivo.
  • tidyr, melt/dcast cumplen el mismo rol que pivot_*.
  • arrow, siguiente paso cuando el dataset no cabe en RAM.

arrow

arrow es el binding de R para Apache Arrow: un formato columnar en memoria y un motor de ejecución out-of-core que permite trabajar con datasets que no caben en RAM. Lee y escribe Parquet, Feather y CSV en chunks, con soporte para datasets particionados en cientos o miles de archivos.

La pieza clave es que expone los mismos verbos de dplyr (filter, mutate, summarise, group_by, *_join) sobre objetos arrow_dataset o arrow_table. La ejecución se traduce a operaciones Arrow nativas y solo se materializa el resultado final con collect(), pagas memoria por lo que devuelves, no por el dataset completo.

Cuándo usarlo

  • Datasets que exceden la memoria (decenas o cientos de GB en disco). open_dataset() indexa los archivos sin cargarlos.
  • Datasets particionados por columna (hive-style: año=2025/mes=05/...). Arrow aplica predicate pushdown y solo lee las particiones necesarias.
  • Intercambio con Python (pandas, polars), DuckDB, Spark: Arrow es el formato lingua franca cross-language.
  • Almacenamiento Parquet: compresión por columna, tipos preservados, mucho más rápido y compacto que CSV.

Cuándo NO usarlo

  • Datasets pequeños en RAM donde dplyr o data.table son más rápidos por evitar la sobrecarga de la abstracción.
  • Manipulación de strings complejos que aún no soporta el ejecutor Arrow, algunas funciones de stringr no traducen y caen a collect() implícito, perdiendo la ventaja.
  • Consultas SQL complejas con joins múltiples: considera duckdb (mismo nicho out-of-memory, motor SQL más maduro, mejor optimizer. Lee Parquet directo y se conecta vía dbplyr).

Conceptos clave

  • arrow::open_dataset(path) crea una referencia lazy a uno o varios archivos. No carga datos.
  • dplyr lazy: encadenas verbos sin ejecutar. collect() materializa el resultado a tibble en memoria. compute() materializa a arrow_table (sigue en formato Arrow).
  • Particionado: write_dataset(df, "out/", partitioning = c("year", "month")) produce estructura Hive. Las consultas posteriores filtran sin leer particiones irrelevantes.
  • Tipos Arrow: más estrictos que R. int32int64, timestamp[ns]timestamp[us]. La conversión a R unifica a integer/double/POSIXct pero al volver puedes perder precisión.
  • Predicate pushdown: filtros se aplican antes de leer del disco cuando son sobre columnas particionadas. mutate y otras expresiones complejas no siempre se empujan. Mira show_exec_plan() para verificar.

Patrón mínimo

library(arrow)
library(dplyr)

# Abrir dataset particionado (no carga datos)
ds <- open_dataset("data/ventas/", format = "parquet")

# Pipeline lazy: solo se ejecuta en collect()
resumen <- ds |>
  filter(año == 2025, categoria %in% c("A", "B")) |>
  group_by(mes, categoria) |>
  summarise(
    n        = n(),
    ingresos = sum(importe, na.rm = TRUE),
    .groups  = "drop"
  ) |>
  collect()

# Convertir un CSV grande a Parquet particionado
read_csv_arrow("ventas_2025.csv") |>
  group_by(año = lubridate::year(fecha), mes = lubridate::month(fecha)) |>
  write_dataset("data/ventas/", format = "parquet")

Trampas habituales

  • No todas las funciones de R están vectorizadas en Arrow. Si tu mutate usa una función no soportada, Arrow materializa silenciosamente a R y pierdes la ventaja de out-of-memory. Verifica con show_exec_plan() y mira ?acero para la lista de funciones soportadas.
  • collect() carga todo a RAM. Es el comando que materializa. Si filtras a 200 GB, sigue sin caber. Diseña los filtros para que el collect final sea manejable.
  • Diferencias de tipo R ↔︎ Arrow. Strings grandes que en R serían character en Arrow son string (utf8). Fechas pueden viajar como int32 (días desde epoch). Conversiones implícitas en joins entre data.frame y arrow_table causan errores raros, homogeniza tipos antes.
  • Particionado granular. Si particionas por una variable de alta cardinalidad (p. ej. cliente_id), generas miles de ficheros pequeños y el rendimiento cae. Particiona por variables con cardinalidad baja-media (año, mes, región).
  • Versión de Arrow ≠ versión de R package. El binario Arrow C++ subyacente debe coincidir. En Linux a menudo hay que recompilar con LIBARROW_BINARY=true para que funcionen los Parquet con compresión zstd. Mira arrow_info() al instalar.

Enlaces

Relacionados en esta página

  • readr, lectura en memoria cuando el dataset cabe.
  • dplyr, los verbos son los mismos. Solo cambia el backend.
  • data.table, alternativa en memoria de alto rendimiento.