Profiles tabular data and measures its quality. Discovers
structural patterns, implicit types, mixed date formats and reusable
findings about potential data issues; defines quality frameworks with
user-declared dimensions, reusable metrics with explicit granularity and
typed aggregation; keeps versioned histories and detects structural drift;
writes self-contained HyperText Markup Language reports; and produces
editable cleaning plans that apply only the actions the user selects, with
an audit log. The general model follows Batini and Scannapieco (2016)

Data profiling and quality measurement with evidence. Every result states its scope and what it could not measure.
lupa examines a table or a database before you trust it. It finds what
summary() misses:
"S/D" (sin dato, “no data”) or -99;It says which rows each problem is in and which checks it could not run. Then it helps turn what was confirmed into measurements, evaluate them, clean a copy with an editable plan and compare deliveries. It is for anyone who analyzes, produces or publishes tabular data and needs to know how far it can be trusted.
What sets it apart:
Install the released version from CRAN:
install.packages("lupa")
or the development version from GitHub:
# install.packages("pak")
pak::pak("sebollin/lupa")
It depends only on cli and
data.table. Suggested packages
enable specific capabilities:
DBI with a driver
(RSQLite,
duckdb,
RPostgres or
odbc) to profile inside a database;stringdist for approximate
text comparison;sf for geometries.The API, the help pages, the guides and the printed output are in Spanish, and
the public names are part of the contract: the outputs below appear exactly as
lupa writes them.
datos_operativos ships with the package: 13 rows and
10 columns with planted defects. Profile it in one call
and start with the findings of severity error:
library(lupa)
perfil <- perfilar(datos_operativos)
h <- hallazgos(perfil)
errores_primero <- order(h$severidad, decreasing = TRUE)
print(head(h[errores_primero, c("columna", "tipo_hallazgo", "severidad")], 8),
row.names = FALSE)
#> columna tipo_hallazgo severidad
#> codigo_usuario faltantes_disfrazados error
#> fecha_evento faltantes_disfrazados error
#> fecha_evento formatos_fecha_mixtos error
#> canal faltantes_disfrazados error
#> <NA> filas_duplicadas error
#> codigo_usuario alta_cardinalidad sospechoso
#> fecha_evento alta_cardinalidad sospechoso
#> fecha_evento tipo_declarado_distinto sospechoso
A columna of <NA> marks a finding about the whole table, such as the
duplicate rows.
What could not be measured does not disappear. In this table
9 diagnostics were left unevaluated, each one counted in every
column where it could not run: the outlier search, for instance, needs more
values than the table has. cobertura(perfil) says which ones, in which column,
why and how to resolve them:
as.data.frame(table(diagnostico = cobertura(perfil)$diagnostico), responseName = "columnas")
#> diagnostico columnas
#> 1 outliers 4
#> 2 patron_raro 1
#> 3 proximidad_vocabulario 3
#> 4 resumen_cuantitativo 1
perfilar() profiles a data frame in memory. perfilar_dbi() profiles a table
inside the database: the aggregates run as SQL on the engine, and only the
rows that fit within the caps come into R. The same surface is measured against
6 real engines:
| Engine | Version tested | R driver |
|---|---|---|
| DuckDB | 1.5 | duckdb |
| MariaDB | 11 | RMariaDB |
| MySQL | 8 | RMySQL |
| PostgreSQL | 16 | RPostgres |
| SQL Server | 2022 | odbc |
| SQLite | — | RSQLite |
PostgreSQL and MariaDB are measured on every push
(engines CI),
and SQLite and DuckDB by the test suite on every CI platform; MySQL and SQL
Server, against local containers before each release. Oracle is expected to
work but has not been measured, and BigQuery is not documented.
requisitos_motor() says what each engine needs. The versions, the drivers and
the date of each run are in
Motores y escala
(Engines and scale).
summary() does not showsummary() describes columns one at a time. lupa compares representations
and cross-checks columns and rows. It returns its findings as a table: type,
severity, evidence, counts, a suggestion and traceability down to the rows.
A finding type that does not appear is a diagnostic that ran and found nothing;
what did not run is in cobertura().
Among the outputs of that run are:
| What it reveals | tipo_hallazgo |
Evidence (excerpt) |
|---|---|---|
| Missing values hidden behind codes | faltantes_disfrazados |
<blanco> (1); S/D (1) |
| Dates in mixed formats | formatos_fecha_mixtos |
%Y-%m-%d (4); %d/%m/%Y (4); ... |
| Extra whitespace | espacios_sobrantes |
1 valor; ejemplos: "web " |
| Inconsistent capitalization | mayusculas_inconsistentes |
"web"; "Web" |
| Declared and inferred types disagree | tipo_declarado_distinto |
Declarado: texto; inferido: fecha (0.846 compatible); ... |
| A constant column | constante |
Valor: principal; frecuencia: 13 |
| Exact duplicate rows | filas_duplicadas |
2 filas en grupos duplicados (1 excedente) |
| Repeated columns | columnas_duplicadas |
id_registro = id_copia; comparadas sobre 13 filas |
The full vocabulary, with what each finding type means, is in the article Alcance y cobertura (Scope and coverage).
severidad (severity) is an ordered factor: ok < sospechoso < error.
ok records an informative condition, not an approval: a column that looks
like an identifier is ok.sospechoso (suspicious) asks for a look in context: alta_cardinalidad
(high cardinality) in codigo_usuario suggests checking whether the column is
free text, an identifier or a poorly normalized category.error means that a check crossed its declared criterion. It is the only
level worth using as an automatic gate, and only after accepting that criterion.With clave (key), each finding carries the values that identify its rows, so
the case can be looked up in the source system without opening the table:
con_clave <- perfilar(datos_operativos, clave = "id_evento")
#> Warning: La clave declarada no es única: entre las 13 filas con la clave completa, 1 repite su
#> valor (2 filas participan en colisiones). La clave cumple la ausencia de nulos: no tiene
#> valores ausentes.
h <- hallazgos(con_clave)
fila <- which(h$tipo_hallazgo == "faltantes_disfrazados" &
h$columna == "fecha_evento")
claves <- h$trazabilidad[[fila]]$claves
claves
#> id_evento
#> 1 EVT011
datos_operativos[datos_operativos$id_evento %in% claves$id_evento,
c("id_evento", "fecha_evento")]
#> id_evento fecha_evento
#> 13 EVT011 NULL
The warning, which says that the declared key is not unique, is part of the
answer: datos_operativos has a duplicated row, so no key can be unique, and
the profile records this as a clave_no_unica finding of severity error.
What could not be measured is recorded, never counted as zero. That is why an
automated check that looks for error must also read cobertura(): zero
errors with unevaluated diagnostics is not a clean profile.
Counts (rows, missing values, distinct values, exact duplicates and summaries)
use every row; patterns, types and date formats are inferred from a systematic
sample of up to 100,000 rows (muestra). The guide
Leer un perfil (Reading a profile)
explains each column of the findings, keys, disagreements between columns and
the glossary.
To summarize and plot columns there are skimr
and DataExplorer; to
diagnose a table and write up its report, dlookr
and dataReporter; to evaluate
rules that someone has already written, validate,
pointblank, assertr
and dataquieR. lupa works on the stretch before the
rule and on the one after it: it finds what no rule names yet, proposes what to
measure without built-in thresholds and, once a rule is confirmed, measures it
with its scope, cleans a copy and compares deliveries. It is meant to be used
alongside those packages, not instead of them.
| Task | Main functions | Guide |
|---|---|---|
| Look at a table for the first time | perfilar(), hallazgos(), columnas(), cobertura() |
Empezar en diez minutos, Leer un perfil |
| Explore columns and share the result | analizar(), distribucion_valores(), detectar_asociaciones(), analizar_tiempo(), reportar(), guardar_analisis() |
Explorar columnas con analizar() |
| Find structure and legitimately empty cells | detectar_claves(), sugerir_clave(), detectar_relaciones(), detectar_dependencias(), detectar_discordancias(), perfilar_por() |
Estructura no declarada, El vacío por diseño |
| Clean without touching the original | planificar_limpieza(), guiar_limpieza(), aplicar(); the reparar_codificacion strategy for mis-encoded text |
Limpiar con un plan |
| Define, measure and evaluate quality | proponer_modelo(), marco_calidad(), metrica(), modelo(), medir(), agregar(), evaluar(), tablero_calidad() |
Del hallazgo a la métrica, Marcos y modelos de calidad |
| Compare deliveries and track quality over time | comparar_perfiles(), comparar_equivalencia(), historico_calidad(), detectar_deriva_calidad() |
Comparar entregas y seguir la calidad |
| Profile a database or a collection of tables | perfilar_dbi(), plan_perfilado_dbi(), requisitos_motor(), coleccion(), perfilar_coleccion() |
Perfilar una base, Colecciones |
| Find approximate duplicates | detectar_duplicados_aproximados(), estimar_costo() |
Escala y duplicados aproximados |
The reference documents every function.
perfilar_dbi() returns two blocks: resumen_tabla, with the aggregates that
the engine computes in SQL, and perfil_muestra, the row-level profile of what
is read into R. By default the aggregates use the whole table and the row-level
profile tries to bring in all the rows that fit within the caps: it is not a
cheap sample.
con <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
DBI::dbWriteTable(con, "eventos", datos_operativos)
en_la_base <- perfilar_dbi(con, "eventos")
names(en_la_base)
#> [1] "resumen_tabla" "perfil_muestra"
| Option | SQL aggregates over | Brings rows into R? |
|---|---|---|
Default: universo = "tabla_completa", muestra = Inf |
the whole table | Yes: all that fit within the cell and byte caps |
muestra = n |
the whole table | Yes, up to n: it bounds the read, not the aggregates |
universo = "muestra_motor", muestra_motor = n |
a sample taken by the engine | Yes, the rows in that sample |
bloque_muestra = "solo_agregados" |
whatever universo says |
No: aggregates only, no row-level findings |
Without orden_muestra, a muestra = n read gets the first rows the engine
returns, in an order SQL does not guarantee; with orden_muestra the read can be
reproduced. What was read into R is described in perfil_muestra$meta$origen_dbi$muestreo;
resumen_tabla$meta$muestreo exists only with universo = "muestra_motor". There
is no snapshot: resumen_tabla$meta$snapshot is FALSE, because each aggregate
is measured in its own query and the table can change from one query to the next.
The tested engines are DuckDB, MariaDB, MySQL, PostgreSQL, SQL Server and SQLite. The step-by-step walkthrough is in Perfilar una base (Profiling a database).
max_celdas_muestra) and 512 MiB (max_bytes_muestra),
and the cut is recorded in cobertura() together with the cap that triggered it (Motores y escala).perfilar_dbi(), politica_costo = "todas" (the
default) computes the mode and the median in every column; "por_cardinalidad" skips
the mode where the proportion of distinct values reaches umbral_cardinalidad (0.5),
and plan_perfilado_dbi() shows the queries before running them (Motores y escala).detectar_duplicados_aproximados()
runs an exact comparison of up to 50,000,000 pairs (max_pares). With the
default strategy ("auto"), no sample and that cap, more than 10,000 rows
take it to MinHash/LSH, which can miss pairs and says so (candidates,
bands and the collision guarantee); with a lower max_pares or a sample it
stays with the exact or sampled comparison, and estrategia = "lsh" forces it.
estimar_costo() anticipates the switch (Escala y duplicados aproximados).perfilar() may take; the estimate relies on an
assumption built into the code (10,000 cells per second), not on a measurement
of the machine; the dated measurements are in Evidencia y rendimiento.perfilar() and analizar() take minutes, not
seconds: profile a sample or use perfilar_dbi() instead. The measured figures,
with their dates, are in Evidencia y rendimiento (Evidence and performance).analizar() gathers the profile, the distributions, the associations and the
temporal analysis into one object, together with a proposed quality model, its
dashboard and a cleaning plan; reportar() writes it all to a self-contained
HTML file for sharing.
analisis <- analizar(datos_operativos)
reportar(analisis, archivo = "informe-lupa.html")
The proposal is only a suggestion: domains and patterns learned from the same
delivery are proposed as inactive and are not measured until someone confirms them.
guardar_analisis() and leer_analisis() save and restore the whole analysis. The guide
is Explorar columnas con analizar() (Exploring columns with analizar()).
lupa does not turn the name of a quality framework into a measurement. A
framework is a taxonomy: a factor is measured when the data provide evidence for
it or when the requirement and the metric that measures it are declared. This is
the coverage of the included frameworks:
| Framework | Factors | Reachable by declaring | Out of scope | Measured by perfilar() alone |
|---|---|---|---|---|
| AGESIC | 17 | 12 | 5 | 2 |
| CEPAL | 19 | 6 | 13 | 0 |
| ISO/IEC 25012 | 15 | 15 | 0 | 0 |
The 13 CEPAL principles that are out of scope concern the
statistical system and its institutional process, not the values in a
table. ISO/IEC 25012 is fully covered once its requirements and metrics are
declared. cobertura_analisis() reports which factors were measured; the
absence of findings is not a claim that the data are good.
Nor does it certify a dataset or invent a global score:
indice_calidad() computes an index only from the weights the project declares.
And it does not modify the input: no analysis function alters the table it
receives (not its values, types, names or attributes), not even a
data.table. Cleaning returns a copy and leaves the original intact.
The guides are meant to be read in this order, and all 12 are on the website.
Guides 1 to 6 also ship with the package (vignette("lupa") opens the first
one); 7 to 12 are on the website only.
clave, read the coverage, save a report and clean a column in a copy.analizar() (Exploring columns with analizar()): what analizar() builds in a single call; how to read patterns, types, dates, distributions, associations and time; and how to save and report the result.perfilar_dbi(), what each option brings into R and where the sampling metadata is kept.The detailed reference lives in four articles on the website, in Spanish.
Whatever changes the cost, the memory use or the meaning of a result is also in
the help page of each function, which ships with the package (?perfilar, ?perfilar_dbi,
?plan_perfilado_dbi, ?detectar_duplicados_aproximados).
| Topic | Where |
|---|---|
| Universes and sampling in SQL, metadata, tested engines with version and date, costs, batches, memory budgets, consistency across queries, dialects and threads | Motores y escala |
What uses every row and what is sampled, no_aplica and no_disponible, units and traceability, sequential numberings, sentinels, the full finding vocabulary and what gets masked |
Alcance y cobertura |
| Evidence benchmarks, performance figures and figures from production data, each with its date and its reproducer | Evidencia y rendimiento |
| Printing in any locale and repairing mis-encoded text | Codificación y locales |
| Every signature, default value and return structure | Reference |
Each piece of evidence states how it is reproduced: whether it regenerates offline, needs a database engine or depends on a downloaded resource, with its date and checksum (Evidencia y rendimiento).
citation("lupa")
The conceptual references are Batini and Scannapieco (2016), the Data Quality Management Framework for Digital Government v1.6 by AGESIC, the CEA/CEPAL quality assurance framework (2022) and ISO/IEC 25012:2008.
The public API of lupa may change before 1.0; breaking changes are
announced in NEWS, with a deprecation warning
first whenever possible. The stable contracts are the declared units, the
scope, the protection of personal data and the audit log; implementation
details and timings may change as long as those contracts still hold.
Bugs, proposals and documentation fixes go to the
issue tracker, and
CONTRIBUTING.md explains how to contribute. lupa is released
under the GPL-3; inst/COPYRIGHTS
details the Apache-2.0 material translated from ftfy
and the MIT data taken from naniar.