Complete reference for the dataprof Python package (v0.12.0).
For upgrade-sensitive changes to sampling, execution controls, quality scores, parser behavior, semantic hints, and exception types, read the 0.12.0 release notes and migration guide.
The Python API is built for quick inspection and follow-up analysis: point it at a file, DataFrame, Arrow batch, ad-hoc notebook data, or database query and get back a report you can slice, export, and wire into notebooks or checks.
uv pip install dataprof
# or
pip install dataprofSupports standard, GIL-enabled CPython 3.10–3.14. PyPy, preview Python and
free-threaded builds are outside the supported wheel set; see the
interpreter policy.
The package ships pre-built wheels for Linux, macOS, and Windows, and declares no Python dependencies. The base API needs nothing else: local file profiling, DataFrame and Arrow inputs, ad-hoc dict/bytes inputs, and report exports. Install the pandas extra only for pandas-typed exports (to_dataframe(), describe() as a DataFrame).
The wheel also carries the async API: dataprof.asyncio, HTTP URL profiling,
and remote Parquet all work on a bare pip install dataprof.
Database profiling is the one documented feature the wheel does not contain. Connectors are a compile-time feature rather than a Python dependency, so no extra can install them; they need a build from source, either with pip from the published sdist or from a checkout:
pip install dataprof --no-binary dataprof --config-settings="build-args=--features python,python-async,async-streaming,parquet-async,database,sqlite"The feature list has to be complete, not just the extra parts. See Database Profiling for why, and for the checkout equivalent.
Inspect the current installation without importing optional packages or trying a network/database operation:
import dataprof as dp
features = dp.capabilities()
print(features)
if features.database and "sqlite" in features.database_connectors:
# Database helpers are available in this build.
...
if features.pandas_interop and features.pandas_installed:
# Both compiled interoperability and the optional Python package are present.
...import dataprof as dp
# Profile a file
report = dp.profile("data.csv")
print(f"{report.rows} rows, {report.columns} columns")
print(f"Quality score: {report.quality_score}")
# Access columns directly
col = report["age"]
print(f"mean={col.mean}, nulls={col.null_percentage}%")
# Profile a pandas DataFrame
import pandas as pd
df = pd.read_csv("data.csv")
report = dp.profile(df)
# Profile ad-hoc notebook data
report = dp.profile({"age": [31, 42, 29], "city": ["Rome", "Milan", "Rome"]})
report = dp.profile([{"age": 31, "city": "Rome"}, {"age": 42, "city": "Milan"}])
report = dp.profile(b"age,city\n31,Rome\n", format="csv")
# Profile a PyArrow table
import pyarrow.parquet as pq
table = pq.read_table("data.parquet")
report = dp.profile(table)dp.profile(
source, # str, Path, DataFrame, Arrow, dict, rows, or bytes
*,
engine="auto", # "auto", "incremental", "columnar"
chunk_size=None, # int -- bytes per streaming chunk
memory_limit_mb=None, # int -- memory cap
format=None, # str -- file format override
max_rows=None, # int -- row cap; Parquet samples across the file
name=None, # str -- label for DataFrame/Arrow sources
csv_delimiter=None, # str -- override auto-detection (e.g. ";")
csv_flexible=None, # bool -- allow variable column counts
sampling=None, # SamplingStrategy
stop_condition=None, # StopCondition
on_progress=None, # Callable[[ProgressEvent], None]
progress_interval_ms=None, # int -- ms between progress events
metrics=None, # list[str] -- "schema", "statistics", "patterns", "quality"
quality_dimensions=None, # list[str] -- subset of dimensions to compute
columns=None, # list[str] -- profile only these columns
locale=None, # str -- "CA"|"DE"|"FR"|"GB"|"IT"|"US"
positive_columns=None, # list[str] -- columns expected to be non-negative
identifier_columns=None, # list[str] -- semantic IDs, not measures
temporal_columns=None, # list[str] -- columns assessed for timeliness
) -> ProfileReportFor file profiling, engine accepts the case-insensitive names auto,
incremental (compatibility alias: streaming), and columnar (compatibility
alias: arrow). This applies to dp.profile(..., engine=...),
dp.profile_file(..., engine=...), and dp.Profiler().engine(...).profile(...).
CSV reports use the selected canonical name, incremental or columnar, in
report.engine and report.to_dict()["execution"]["engine"]; aliases are never
emitted, and auto reports the engine it selected.
Source types:
| Type | Description |
|---|---|
str or Path |
File path (CSV, JSON, JSONL, Parquet) |
pandas DataFrame |
In-memory DataFrame |
polars DataFrame |
In-memory Polars DataFrame |
PyArrow Table or RecordBatch |
Zero-copy via PyCapsule interface |
Arrow RecordBatchReader / __arrow_c_stream__ producer |
One-shot, incremental batch profiling; includes DuckDB relations |
dict[str, list] |
Columns of cells; profiled natively, no dependencies |
list[dict] |
Row-oriented notebook data; rows may omit keys, which read as nulls |
bytes or io.BytesIO |
In-memory file contents; requires format="csv", "json", "jsonl", or "parquet" |
Dict, row-dict, and byte inputs are profiled by the Rust core directly, so they
need no third-party package. That includes format="parquet": byte buffers go
through the same compiled Arrow reader as Parquet files, so the two report the
same types, column order, and statistics, and capabilities().local_parquet
predicts both.
A cell is missing when it is None, NaN, or a null-like token -- the same rule
the CSV, JSON, Parquet and Arrow paths use. The tokens are empty or whitespace,
"null" and "nan" in any case, and exactly "NA", "N/A", "n/a", "#N/A",
"\N" and "None"; metric_semantics["null_tokens"] records this vocabulary.
Note that a dict is not round-tripped through pandas, so an integer column
containing a null stays integer rather than being widened to float.
Dicts, row lists, pandas, polars and Arrow inputs apply StopCondition.max_rows
and row-only |/& compositions. They accept StopCondition.never() and
SamplingStrategy.none() as no-ops. Other stop conditions and sampling strategies
raise ValueError because those routes cannot apply them. max_rows and
stop_condition remain mutually exclusive.
StopCondition.confidence_threshold(f) means the fraction of the estimated
record count read. CSV files and materialized async CSV buffers use an exact
logical-record count from a bounded-memory scan before profiling. This preflight
also supplies progress percentages; scans without fraction stops or progress
skip it. The preflight reads the whole CSV before a profiling row or byte cap
takes effect; bytes_consumed describes the profiling pass, excluding this
preflight. A true async stream with a size hint estimates its total from complete
records and bytes
already read, excluding CSV header bytes; changing row widths can bias that
estimate. For a true stream without a size hint the condition is inert. Schema
stability starts after the first chunk establishes a baseline, and each leaf in an |/& tree
retains its own threshold.
Column order follows the source on every input and transport: the CSV header, the Parquet/Arrow schema, the dict or DataFrame key order, and for JSON/JSONL the field order of the first record, with fields that only appear in later records appended where they were first seen. Converting a dataset between formats therefore does not reshuffle the report.
Column projection narrows a profile to the columns you name. columns
takes a list of source column names and is accepted by dp.profile(),
dp.profile_file(), dp.Profiler().columns([...]), and every
dataprof.asyncio entry point — profile_file(), profile_bytes() and
profile_url() forward it through ProfilerConfig, which also carries it into
analyze_database_async():
report = dp.profile("orders.csv", columns=["amount_eur", "city"])
list(report) # ['city', 'amount_eur']
report.rows # every row is still readProjection selects columns, never rows: report.rows is unchanged, and each
retained column reports exactly the values it reports in an unprojected profile
of the same source — same types, statistics, patterns, and lengths. It applies
identically on every input and transport (CSV, JSON, JSONL, Parquet, bytes,
dicts, row dicts, DataFrames, and Arrow) and on both engines.
The projected report keeps source order, not the order you listed the names in, so a projection is a filter on the report rather than a reordering of it.
Names are validated rather than silently ignored. A name absent from the source
raises ValueError listing the unmatched names, and so does a repeated name.
columns=[] is a projection onto no columns, which is distinct from
columns=None (no projection): it reports columns == 0 while rows still
counts the rows that were read.
Quality is assessed over the projected columns only, so quality_score
generally differs from the score for the whole dataset. Two dimensions are
withheld rather than narrowed: completeness and uniqueness both report
row-level measurements (complete_records_ratio, total_cells,
duplicate_rows, rows_checked) that mean something different once a row has
been projected, and the report schema cannot mark only those fields as
projected. Rather than publish plausible numbers under full-row names, a
projected report leaves both dimensions None and out of
assessed_dimensions():
dp.profile("orders.csv").quality.completeness
# {'complete_records_ratio': 73.8, 'total_cells': 5000, ...}
dp.profile("orders.csv", columns=["amount_eur", "city"]).quality.completeness
# NoneThe overall score is therefore an average over fewer dimensions, and is often
higher than the unprojected score even for the same columns — dropping
completeness drops the dimension that penalizes nulls. Compare projected
scores against other projected scores, not against whole-dataset ones.
If a projection leaves no assessable dimension — quality_dimensions selecting
only completeness and/or uniqueness — the report carries no quality object
at all: report.quality is None and so is quality_score, which is the
"not analyzed" answer rather than a zero.
Semantic hints are resolved against the projection too — a positive_columns
hint naming a column that projection excluded raises, since that column is not
in the report it would describe.
Synchronous byte inputs use the in-memory columnar path. They support
max_rows, metric/quality selection, semantic hints, CSV delimiters, and JSONL
error policy, but reject streaming-only controls (chunk_size,
memory_limit_mb, stop_condition, progress callbacks, and flexible CSV
recovery) instead of silently ignoring them. For those controls, use
dataprof.asyncio.profile_bytes(). JSON and JSONL byte buffers follow RFC 8259:
the non-standard NaN and Infinity constants are malformed input.
JSON record policy. Only JSON objects are profileable records — they are the
only JSON value with named fields to become columns. A record that is valid JSON
but not an object (a scalar, an array, null) is never silently dropped: with
jsonl_on_error="skip" (the default) it is counted in error_count and the
records after it still profile, and with jsonl_on_error="strict" the first one
raises ValueError naming its position and JSON kind. Input whose every record
is non-object fails, exactly as all-malformed input does. File, byte, and async
byte transports apply this identically.
Records with no fields. A JSON object with no fields ({}) is a record: it
was read and analysed, and nothing was found in it. It profiles as a row against
zero columns, so [{}, {}] reports rows == 2, columns == 0 on every transport
— file, bytes, async bytes, URL, and a Python list of dicts. That keeps three
shapes distinct:
| Shape | Meaning | Example |
|---|---|---|
rows > 0, columns == 0 |
rows were read; none of them had fields | [{}, {}] |
rows == 0, columns == 0 |
the input held no records | [] |
rows == 0, columns > 0 |
a known schema with no rows under it | a CSV header line |
A zero-column report is a normal report: it serialises, round-trips, and
renders. len(report) counts columns, so it is 0 while report.rows is not.
A record with no fields is well-formed, so it is clean under both error
policies and never counted in error_count.
Engine options:
| Engine | When to use |
|---|---|
"auto" |
Let dataprof choose based on file size and format (recommended) |
"incremental" |
True streaming with bounded memory -- large files, streams |
"columnar" |
Arrow-based batch processing -- Parquet, in-memory data |
Returned by profile() and all analysis functions.
Properties:
| Property | Type | Description |
|---|---|---|
source |
str |
Source identifier (file path, table name, etc.) |
source_type |
str |
"file", "bytes", "query", "dataframe", "stream" |
engine |
str | None |
Engine or parser that produced the report |
rows |
int |
Number of rows processed |
columns |
int |
Number of columns detected |
column_profiles |
dict[str, ColumnProfile] |
Per-column statistics (by name) |
quality_score |
float | None |
Overall quality score (0--100) |
quality |
DataQualityMetrics | None |
Detailed quality breakdown |
quality_status |
str |
Why quality is or is not there (see below) |
quality_error |
str | None |
The error a failed quality computation reported |
quality_sampled_dimensions |
list[str] | None |
Metric components computed from a retained sample rather than every scanned row; None when there is no assessment or a loaded document does not record it |
quality_score_bounds |
dict | None |
Where the scores over every scanned row lie when some were computed over a retained sample: confidence_level, overall_score and dimension_scores, each interval {"lower", "upper"} or None when unbounded. None when every score is exact (see quality gates) |
metric_semantics |
dict[str, str] | None |
How the measurements were defined, e.g. {"text_length_unit": "unicode_scalar", "null_tokens": "common_markers", "pattern_match_count": "validated", "shape_moments": "adjusted_sample"}; None for a report written before 0.12, whose definitions are unknown. A 0.12 report records only text_length_unit: it counted only empty, null and nan as missing, counted every regex match as a pattern match, and used another skewness and kurtosis estimator |
execution_time_ms |
int |
Total processing time |
throughput |
float | None |
Rows per second |
memory_peak_mb |
float | None |
Peak memory usage |
truncation_reason |
str | None |
Why processing stopped early |
source_exhausted |
bool |
Whether the entire source was read |
ragged_row_count |
int |
Rows whose field count differed from the header (0 = no ragged rows). Reported for file, async and columnar CSV inputs |
unterminated_quote |
bool | None |
True when a CSV source ended inside a quoted field: a quote was never closed, so the last record holds every row after it. None for non-CSV input and for a scan stopped before the end of the source. csv_flexible=False refuses such a source, and CSV bytes passed to profile() always do |
recovery_events |
list[dict[str, str]] | None |
Ordered failed attempts and retries; [] means no recovery, None means history was not recorded |
sampling_applied |
bool |
Whether sampling was used |
sampling_ratio |
float | None |
Fraction of data sampled |
sampled_row_ranges |
list[list[int]] | None |
Exact zero-based, half-open source row intervals when recorded; [] means zero selected rows |
For Parquet files, byte buffers and HTTP URLs, max_rows=N selects up to 32
contiguous ranges spread across the file, with exactly N rows when the file
is larger than the cap. The first and last rows are included for N ≥ 2;
N = 1 selects the middle row and N = 0 selects none. Selection is deterministic
and independent of row-group sizes, batch sizes and transport. The exact ranges
appear in sampled_row_ranges and to_dict()["execution"]["sampled_row_ranges"].
sampling_applied is true, sampling_ratio is selected rows / file rows, and
source_exhausted is false with the usual max_rows(N) truncation reason.
A cap at or above the file row count reads the whole file without sampling.
Statistics and row-level quality metrics describe the selected population.
Duplicate detection includes matches across separate ranges. This deterministic
sample does not establish full-source uniqueness or provide an unbiased
estimate of full-source ratios. Before 0.12, local and byte-buffer Parquet caps
selected a prefix, while HTTP reads ignored the cap. Older reports have no
recorded ranges and load with sampled_row_ranges=None. Re-baseline capped
Parquet comparisons when upgrading.
What happened to the quality computation:
quality is None answers two different questions, so every report carries
quality_status. It is computed exactly when quality holds an assessment;
the other five states are the reasons it does not:
quality_status |
meaning |
|---|---|
computed |
quality was computed; quality holds the assessment |
not_requested |
the quality pack was deselected (metrics=["schema"]) |
no_data |
requested, but no quality sample was supplied to the assembler |
withheld_by_projection |
requested, but every requested dimension measures whole rows and columns= selected a subset |
failed |
requested and attempted; the computation failed, and quality_error says how |
unrecorded |
loaded from a report saved before 0.12 that carried no assessment |
An analyzed empty source reports computed with an empty assessment and
quality_score=None, whether or not it declares columns. This is distinct from
an unavailable quality sample (no_data).
A gate deciding on a report should branch on this before reading
quality_score: a failed computation and a run that never asked for quality
both leave the score None, and they are not the same verdict.
report = dataprof.profile("data.csv")
if report.quality_status != "computed":
raise SystemExit(f"no quality to gate on: {report.quality_status}")The same object is in to_dict()["quality_status"], as {"state": ...} plus
an "error" key on failed only.
Dict-like column access:
col = report["column_name"] # -> ColumnProfile
"column_name" in report # -> True/False
for name in report: print(name) # iterate column names
len(report) # number of columnsExport methods:
report.to_dict() # historical flat summary (rounded values)
report.to_json(indent=2) # complete canonical Rust report as JSON
report.to_dataframe() # pandas DataFrame -- all stats (requires pandas)
report.to_polars() # polars DataFrame -- all stats (requires polars)
report.to_arrow() # PyArrow Table -- all stats (requires pyarrow)
report.describe() # transposed summary like pandas describe()
report.quality_summary() # single-row dict for quality tracking
report.to_html() # embeddable HTML (same as the notebook display)
report.to_markdown() # GitHub-flavored markdown table
report.compare(other) # dict of quality/schema/null deltas vs another report
report.save("report.json") # save to JSON
report.save("report.csv") # save column profiles to CSV
report.save("report.parquet") # save column profiles to Parquet (requires pyarrow)
# Round-trip a saved report without re-profiling (read-only view)
reloaded = dp.ProfileReport.load("report.json") # from a saved .json file
reloaded = dp.ProfileReport.from_json(report.to_json()) # from a JSON string
reloaded = dp.ProfileReport.from_dict(report.to_dict()) # from a dictAll floating-point values in exported data are rounded. The precision is chosen by what the number is, not by which object it lives on:
| Kind | Precision | Examples |
|---|---|---|
0..100 percentage |
2dp | null_percentage, coefficient_of_variation, every float in the quality dimension dicts |
| statistic | 4dp | mean, std_dev, variance, skewness, kurtosis, avg_length |
| data value | 4dp | min, max, median, mode |
0..1 ratio |
4dp | uniqueness_ratio, true_ratio |
A 0..1 ratio takes 4dp so it carries the same resolution as the equivalent
percentage at 2dp. Data values take 4dp because a profiler must not report a
min the column never contained. Quartiles are the deliberate exception: they
stay at 2dp, being distribution landmarks rather than exact values.
Ties round the stored float, away from zero. This is the value actually
held, not the shortest decimal string that prints for it -- 23 / 4000 * 100
prints as 0.575 but is stored just below it, and so rounds to 0.57.
The Rust and Python layers implement the same convention and are held to shared
fixtures (tests/fixtures/rounding_parity.json and
report_rounding_parity.json), so the same data profiled through either gives
the same numbers. Raw property access on ColumnProfile returns unrounded Rust
values; use the export methods for rounded output. Cross-engine numeric
equality is defined on those serialized metrics, with no extra tolerance.
Native attributes may differ in their final digits with accumulation order;
loading a report retains the precision that was saved. Compare the same data,
schema semantics, analysis options and analyzed population, and exclude source
and execution provenance such as timing and memory from equality checks. See
the numeric contract for scope,
sampling and absence semantics.
Round-trip fidelity: a report reloaded with from_dict, from_json, or
load reports the same values as the report it was saved from, at the precision
above. Live and reloaded reports use the same read-only column, pattern, and
quality accessors. They read native values or the saved document respectively;
reloading does not recompute metrics or invent values for missing fields.
Both expose dataprof.ColumnProfile and dataprof.DataQualityMetrics objects,
so type checks and accessor behavior no longer depend on how a report was loaded.
Per-column profiling statistics.
| Field | Type | Description |
|---|---|---|
name |
str |
Column name |
data_type |
str |
Inferred type: "string", "identifier", "integer", "float", "date", "boolean", "nested". A nested column (struct, list or map) reports its counts only: unique_count, type_homogeneity, locale_number_count, stats and patterns are None |
total_count |
int |
Total number of values |
null_count |
int |
Number of null/missing values |
unique_count |
int | None |
Distinct value count |
invalid_count |
int | None |
Non-null values that did not parse as a finite number (parse failures and non-finite tokens like inf/NaN) and are excluded from the statistics. None = check did not run (non-numeric column, or statistics skipped); 0 = every non-null value parsed |
type_homogeneity |
dict[str, int] | None |
Non-null values counted by lexical class: {"numeric", "date", "boolean", "text"}. Tells a string column of ordinary text from one that defeated type inference, which data_type cannot. All four keys are always present; all-zero = classified with nothing to classify (all-null or no rows); None = the classification did not run. Counted over the values the profiler retained, so sum them against total_count - null_count to tell an exact count from one bounded by the 10k reservoir sample |
locale_number_count |
int | None |
Of the text values in type_homogeneity, how many are numbers written with a decimal comma or digit-group separators (10,50, 1.234,56, 1,234.56, 1'234.56, 1 234,56). dataprof does not parse them as numbers, so they are in no numeric statistic: a column of them is typed string, and a numeric column counts them in invalid_count. None = the count did not run (a nested column, or a report saved before 0.12); 0 = none found |
null_percentage |
float |
Null ratio (0.0--100.0) |
uniqueness_ratio |
float |
Unique values / total values |
min |
float | None |
Minimum (numeric columns) |
max |
float | None |
Maximum (numeric columns) |
mean |
float | None |
Mean (numeric columns) |
std_dev |
float | None |
Standard deviation |
variance |
float | None |
Variance |
median |
float | None |
Median |
mode |
float | None |
Mode |
skewness |
float | None |
Skewness |
kurtosis |
float | None |
Kurtosis |
coefficient_of_variation |
float | None |
CV |
quartiles |
dict | None |
{"q1", "q2", "q3", "iqr"}; iqr is None when q3 - q1 overflows f64 |
is_approximate |
bool | None |
Whether stats were estimated from a sample |
min_length |
int | None |
Shortest value, in Unicode scalar values (0.11: UTF-8 bytes) |
max_length |
int | None |
Longest value, in Unicode scalar values (0.11: UTF-8 bytes) |
avg_length |
float | None |
Mean length, in Unicode scalar values (0.11: UTF-8 bytes) |
true_count |
int | None |
Number of True values (boolean columns) |
false_count |
int | None |
Number of False values (boolean columns) |
true_ratio |
float | None |
Ratio of True values (0.0--1.0) |
patterns |
list[Pattern] | None |
List of detected value patterns |
Changed in 0.12 (#667). Numeric columns with no finite parsed values and
boolean columns with no parsed booleans have no statistics block. Their statistic
accessors return None, including boolean counts and true_ratio. The declared
column type, row/null counts, and numeric invalid_count remain available. A
measured numeric zero or an all-false column still reports 0.0. CSV columns
containing only nulls infer as text because CSV carries no declared type.
This uses the existing absent-statistics representation (ColumnStats::None in
Rust); numeric fields do not become optional within NumericStats, and the report
schema version is unchanged. Saved reports remain readable as written: loading an
older report does not replace its recorded zeros. Re-profile the source to obtain
the corrected absence semantics.
Changed in 0.12. Text lengths count Unicode scalar values, not UTF-8 bytes
and not grapheme clusters. "東京" has a length of 2 and "🙂" a length of 1.
In 0.11 and earlier the same values reported 6 and 4, because every
accumulator used Rust's byte-valued str::len() under a field named length
(#627). ASCII text is unaffected, and reports written by different releases are
not comparable for non-ASCII text.
A combining sequence counts each scalar, so the decomposed spelling of e-acute
(e followed by U+0301) has a length of 2 while the precomposed spelling
(U+00E9) has a length of 1. The two render identically and profile differently.
Segmenting grapheme clusters instead would need a Unicode segmentation table and
a policy for which version of it; encoded size is a property of the encoding
rather than of the value, and is reported at the source level instead.
Represents a pattern found in a column. Matches pass both the regular expression and the semantic validator, when the detector has one. The counts and percentages describe the analyzed non-null values, which may be a sample of the column.
| Field | Type | Description |
|---|---|---|
name |
str |
Name of the pattern (e.g. "Email", "URL") |
regex |
str |
Regular expression used for matching |
match_count |
int |
Number of analyzed non-null values passing validation |
match_percentage |
float |
Percentage of analyzed non-null values passing validation (0.0--100.0) |
Reusable configuration object:
config = dp.ProfilerConfig(
engine="incremental",
chunk_size=65536, # bytes per chunk
memory_limit_mb=512,
max_rows=100000,
csv_delimiter=";",
quality_dimensions=["completeness", "uniqueness"],
positive_columns=["pressure"],
identifier_columns=["order_id", "customer_id"],
)Controls how data is sampled during profiling:
from dataprof import SamplingStrategy
SamplingStrategy.none() # process everything
SamplingStrategy.random(size=10000) # uniform sample of 10000 rows
SamplingStrategy.reservoir(size=10000) # same guarantee, Algorithm R
SamplingStrategy.systematic(interval=10) # every Nth rowSampling applies to CSV sources on the auto and incremental engines and to
every dataprof.asyncio entry point. The columnar engine and the JSON/Parquet
readers cannot sample row by row and raise ValueError rather than silently
returning a full profile.
random and reservoir give the same guarantee — a uniform sample of exactly
size rows — and both hold size rows in memory, because which rows belong in
the sample is not settled until the source ends. The other strategies decide
each row as it arrives and add no memory. Sampling bounds the cost of
analysis, not of reading: use a StopCondition to stop early.
Composable early-termination conditions. Combine with | (any) or & (all):
from dataprof import StopCondition
# Stop after 10k rows or 50 MB
stop = StopCondition.max_rows(10000) | StopCondition.max_bytes(50_000_000)
# Stop when schema stabilizes AND confidence exceeds 95%
stop = StopCondition.schema_stable(500) & StopCondition.confidence_threshold(0.95)
# Built-in presets
StopCondition.schema_inference() # fast schema-only mode
StopCondition.quality_sample() # enough rows for quality assessment
StopCondition.never() # process everything (default)
report = dp.profile("huge.csv", stop_condition=stop)Track progress with a callback:
def on_progress(event):
if event.percentage is not None:
print(f"{event.percentage:.1f}% ({event.rows_processed} rows)")
report = dp.profile("data.csv", on_progress=on_progress)Every file route reports at least a started and a finished event. A CSV on
the default or incremental engine also reports schema_detected and
chunk_processed events while it reads; the columnar engine, JSON and Parquet
read without reporting, so they send the two bracketing events only.
Event fields: kind, rows_processed, bytes_consumed, elapsed_ms, processing_speed, percentage, column_names, total_rows, total_bytes, truncated, message, estimated_total_rows, estimated_total_bytes.
Quality metrics informed by ISO 8000 and ISO/IEC 25012, accessible via
report.quality. The aggregate score is dataprof's formula, not an ISO-defined
or certified score:
q = report.quality
# Overall score. None when no dimension was assessable — a header-only file, or
# one whose every dimension had a zero denominator. `assessed_dimensions()` is
# empty for exactly those reports, and `report.quality_score` agrees.
print(q.overall_quality_score())
print(q.assessed_dimensions())
print(q.score_weights) # relative weights used by dataprof's aggregate formula
# Nested dimension evidence (None when the dimension was not assessed)
print(q.completeness) # {"missing_values_ratio": ..., "complete_records_ratio": ..., "null_columns": [...]}
print(q.consistency) # {"data_type_consistency": ..., "format_violations": ..., "encoding_issues": ...}
print(q.uniqueness) # {"duplicate_rows": ..., "key_uniqueness": ..., "high_cardinality_warning": ...}
print(q.accuracy) # {"outlier_ratio": ..., "range_violations": ..., "negative_values_in_positive": ...}
print(q.timeliness) # {"future_dates_count": ..., "stale_data_ratio": ..., "temporal_violations": ...}
print(q.validity) # {"valid_values_ratio": ..., "invalid_values": ..., "values_checked": ...}
print(q.precision) # {"decimal_places_consistency": ..., "inconsistent_precision_values": ..., "numeric_values_checked": ...}A dimension's evidence is present only when it assessed something. None covers
both "not computed" and "computed with nothing to divide by": no cells, no
non-null values, no numeric values, no dates, no confidently detected pattern.
The dimensions holding evidence are exactly the ones whose dimension_scores()
entry is a number.
assessed_dimensions() is that set narrowed once more, to the dimensions
carrying weight in the overall score. The two coincide under the default
weights; a dimension given a weight of 0.0 still holds evidence and a score
while sitting outside assessed_dimensions(), because it contributes nothing to
the aggregate. Branch on dimension_scores() to ask "was this measured", and on
assessed_dimensions() to ask "is this behind the overall score".
That distinction is the point rather than a technicality. validity reporting
valid_values_ratio: 100.0 from values_checked: 0 reads as a clean bill of
health for something nobody looked at, and any file without a pattern-bearing
column used to report exactly that. Serialized reports follow the same rule: an
unassessed dimension has no key, so a stored report cannot be read as a perfect
score either.
Flat DataQualityMetrics accessors, deprecated in 0.9, are removed in 0.12.
The old names raise AttributeError naming the replacement key. Use nested
dimensions so skipped dimensions are explicit (see the
migration table):
if q.completeness is not None:
q.completeness["missing_values_ratio"]negative_values_in_positive is driven by explicit positive_columns; dataprof
does not infer positive-only domains from column names. identifier_columns
marks numeric-looking IDs as semantic strings so numeric stats and outlier
metrics do not treat them as measures.
Timeliness scoring assesses confidently inferred date columns by default.
temporal_columns adds columns that inference cannot identify confidently, such
as mixed-format date strings.
Hints are validated, never silently dropped. A hint that names a missing column
raises ValueError listing the unmatched names and the available columns; a
positive_columns hint on a column with no numeric values, or a
temporal_columns hint on a column with no dates, is likewise rejected.
report.semantic_hint_bindings records how each hint bound — column, kind,
checked_values, matched_values, and exact (whether the counts covered
every row or a sample):
report = dp.profile("readings.csv", positive_columns=["pressure"])
report.semantic_hint_bindings
# [{"column": "pressure", "kind": "positive",
# "checked_values": 1000, "matched_values": 1000, "exact": True}]Selective dimensions -- compute only what you need:
report = dp.profile("data.csv", quality_dimensions=["completeness", "uniqueness"])
# report.quality.consistency will be None
# report.quality.completeness will have valuesAll three return an enriched table of column profiles with rounded values:
# pandas DataFrame
df = report.to_dataframe()
# polars DataFrame (no pandas dependency needed)
pl_df = report.to_polars()
# PyArrow Table (no pandas dependency needed)
table = report.to_arrow()Columns included: name, data_type, total_count, null_count, null_percentage,
unique_count, uniqueness_ratio, locale_number_count, dominant_type,
dominant_type_share,
min, max, mean, std_dev, variance,
median, mode, skewness, kurtosis, coefficient_of_variation, q1, q2,
q3, iqr, is_approximate, min_length, max_length, avg_length,
top_pattern, top_pattern_pct.
Transposed summary similar to pandas.DataFrame.describe():
desc = report.describe()
# col_a col_b col_c
# count 1000 1000 1000
# null% 0.0 2.1 0.0
# unique 45 800 3
# mean 34.5 None None
# std 12.1 None None
# min 1.0 None None
# 25% 25.0 None None
# 50% 33.0 None None
# 75% 44.0 None None
# max 99.0 None NoneReturns a pandas DataFrame if available, otherwise a dict-of-dicts.
Single-row dict for easy aggregation across multiple reports:
qs = report.quality_summary()
# {"source": "data.csv", "rows": 1000, "quality_score": 92.3,
# "completeness": 98.0, "consistency": 95.0, ...}
# Track quality over time
import pandas as pd
rows = [dp.profile(f).quality_summary() for f in files]
history = pd.DataFrame(rows)Render the report for sharing outside a notebook:
html = report.to_html() # same rich table Jupyter shows, as a string
open("report.html", "w").write(html)
md = report.to_markdown() # GitHub-flavored markdown table
# Paste straight into a PR comment, issue, or Slack messageRebuild a report from previously exported data without re-profiling. The
reconstructed report is a read-only view backed by the exported values, but
all export methods (to_json, to_markdown, to_dataframe, describe,
quality_summary, mapping access, …) work as usual.
load(path) is the path-based entry point — the natural counterpart to
save(). Only .json files carry a full report; .csv / .parquet store
column profiles only and cannot round-trip:
report.save("report.json")
# ...later...
reloaded = dp.ProfileReport.load("report.json")
reloaded.quality_score # == the original report's quality_score
reloaded["email"].null_percentagefrom_json(text) and from_dict(data) take an in-memory JSON string or dict
instead of a file path:
reloaded = dp.ProfileReport.from_json(report.to_json())
reloaded = dp.ProfileReport.from_dict(report.to_dict())Saved reports are durable artifacts — CI baselines, drift references, agent inputs — so the document carries its own schema version, independent of the package version:
report.to_dict()["schema_version"] # == dp.REPORT_SCHEMA_VERSIONThe compatibility policy when loading:
| Document | Behavior |
|---|---|
No schema_version field |
Legacy pre-0.10 report; loads through a compatibility path |
schema_version ≤ dp.REPORT_SCHEMA_VERSION |
Loads normally; unknown additive fields from newer writers are ignored |
schema_version > dp.REPORT_SCHEMA_VERSION |
Raises ValueError immediately — an incompatible report is never partially decoded |
The version only increments when the document format itself changes
incompatibly, not on every dataprof release. The same field with the same
semantics appears in reports serialized from Rust (serde), where readers
enforce the identical policy.
The committed JSON Schema 2020-12 contract
can validate to_dict(), to_json(), and JSON save() output in CI or another
consumer. Version 1 includes both the high-level Python export shape and the
complete Rust serialization shape; see the
schema notes for compatibility and regeneration rules.
In 0.12, to_json() and JSON save() write the complete Rust document, with
data_source, column_profiles, id, timestamp, and quality.metrics /
quality.confidence. to_dict() keeps the historical summary keys source,
source_type, columns, and flattened quality scores. It is a convenience
projection, so json.loads(report.to_json()) is the way to obtain the complete
document as a dictionary. All three loaders accept both layouts. An old flat
document stays flat on resave because its missing provenance cannot be inferred.
When quality metrics are present, the summary's quality block always carries a
low_sample_warning boolean (true when the profiled sample was below the
recommended minimum of 10 rows, false otherwise). It round-trips through
to_dict()/from_dict(); treat quality_score and the per-dimension ratios
as directional rather than reliable whenever it is true.
A report holds every metric and says nothing about which ones matter.
findings() turns the metrics already computed into a short, deterministic
list, so the call site does not have to invent thresholds:
report = dp.profile("orders.csv")
result = report.findings()
for finding in result:
print(finding.severity, finding.code, finding.column, finding.evidence)
# warning null_heavy amount {'null_percentage': 25.0, 'threshold': 20.0}
# info constant_column channel {'non_null_count': 4, 'unique_count': 1}
# info sensitive_pattern email {'category': 'contact', 'match_percentage': 100.0, 'pattern': 'Email'}Each finding has a stable code, a severity ("warning" or "info"), the
column it concerns (None for the whole report), the evidence that caused
it, and a fixed summary sentence. Evidence is metric values, thresholds, and
names; no finding carries a raw cell value.
| Code | Severity | Reported when |
|---|---|---|
all_null |
warning | Every value of a column is null |
null_heavy |
warning | A column's null percentage is at least null_heavy_percentage (default 20) |
mixed_types |
warning | Values outside a column's dominant lexical type are at least mixed_types_percentage (default 5) of those classified. dominant_type in the evidence says which way the mix leans. Identifier columns are exempt |
locale_numbers |
warning | At least half of a column's text values are numbers written with a decimal comma or digit grouping (locale_number_count), which no numeric statistic includes. Identifier columns are exempt |
duplicate_rows |
warning | The source holds exact duplicate rows |
future_dates |
warning | Date values lie after the time the report was produced |
temporal_order_violations |
warning | Start dates fall after their paired end dates |
ragged_rows |
warning | Rows had a different field count from the header and were recovered |
unterminated_quote |
warning | A CSV source ended inside a quoted field, so its last record may hold several source rows |
records_skipped |
warning | Errors were counted while reading, e.g. JSONL lines skipped under jsonl_on_error="skip" |
constant_column |
info | Every non-null value of a column is the same one, seen more than once |
sensitive_pattern |
info | A column confidently matches a contact or financial pattern, a US SSN, or an Italian codice fiscale |
partial_scan |
info | The scan stopped early or sampled rows (reason is "truncated" or "sampled") |
Findings sort by severity, then code, then report-level before column-level,
then column position. The default thresholds are the ones to_llm_context()
flags at. Findings compare at the report's 2dp precision and the flags do not,
so a share within rounding of a threshold (4.9992% against 5) can be a
finding without being a flag. Both thresholds take a percentage above 0 and at
most 100, and are applied at 2dp, so the evidence states exactly the threshold
that was compared. Anything else, including a value that rounds to 0, raises
ValueError:
report.findings(null_heavy_percentage=50, mixed_types_percentage=10)Absence is not a clean result. A rule whose input the report does not
carry produces no finding and is listed in result.not_evaluated, so an empty
result.findings means "looked, found nothing" only for the rules not listed
there. The result has no len(), and bool(result) raises TypeError, so
if not report.findings(): cannot silently read an unevaluated rule as clean.
dp.profile("orders.csv", metrics=["schema"]).findings().not_evaluated
# ({'code': 'duplicate_rows', 'reason': 'quality_unavailable', 'quality_status': 'not_requested'},
# ...
# {'code': 'sensitive_pattern', 'reason': 'not_computed', 'columns': ['order_id', 'email', ...]})reason |
Meaning |
|---|---|
quality_unavailable |
The report carries no quality assessment; quality_status says why |
not_assessed |
Quality was computed, but the dimension the rule reads had nothing to assess |
estimated |
The duplicate count is an estimate, which witnesses nothing |
sampled |
The count is zero, but it came from the retained quality sample, so it rules nothing out for the rows the sample left behind. This is the ordinary state for timeliness on a source larger than the sample. A nonzero count is still reported |
unrecorded |
The document was written before dataprof recorded what the rule needs: ragged rows before 0.10, quality sample coverage and locale-formatted numbers before 0.12 |
not_computed |
The metric was not computed for the listed columns (pack not selected, or a nested column) |
no_values |
The listed columns had no values to look at |
Findings are derived, not stored. They are not part of the saved report,
and a report loaded with ProfileReport.load() or from_dict() yields the
same findings as the one that was saved. Findings describe the rows the report
read: a truncated or sampled scan adds partial_scan rather than withholding
the rest. For a pass/fail decision about the whole source, use check().
result.to_dict() / to_json() serialize the whole result, identical to what
the Rust FindingPolicy writes for the same report and thresholds.
State a policy as data and get a structured verdict back. Nothing is printed, the process is not exited, and the report is not modified, so this composes into Airflow, Dagster, a GitHub Action, or a shell script -- no CLI involved.
report = dp.profile("daily_drop.csv")
result = report.check(
min_quality_score=90,
max_null_percentage={"customer_id": 0, "*": 20},
max_duplicate_rows=0,
require_metrics=["quality"],
)
if not result.passed:
for check in result.violations:
print(check.code, check.column, check.message, check.observed)| Keyword | Type | Meaning |
|---|---|---|
min_rows |
int |
Floor for the number of rows the scan read |
min_quality_score |
float |
Floor for the overall score, 0--100 |
min_dimension_scores |
dict[str, float] |
Floor per ISO 25012 dimension, 0--100 |
max_null_percentage |
dict[str, float] | float |
Ceiling for a column's null percentage, 0--100. "*" sets the limit for every column without its own entry; a bare number means the same as {"*": number} |
max_duplicate_rows |
int |
Ceiling for the duplicate-row count |
require_metrics |
list[str] |
Metrics that must have been analyzed: "quality", or a dimension name |
scope |
str |
"full_source" (default) or "observed" |
Thresholds are percentages on the same 0--100 scale the report reports, not
0--1 ratios. A policy that cannot be evaluated as written -- a threshold
outside the range, an unknown dimension, no requirement at all -- raises
ValueError rather than failing the dataset: a misconfigured gate is not a
bad extract. A threshold must be a real number: text is refused even when it
spells one ("90"), and so is a number too large for a float. Numpy scalars,
Decimal and Fraction are accepted.
The gate is evaluated in Rust, the same code a Rust caller uses. It reads a
profiled report or one loaded from the JSON save() and to_json() write. A
report loaded from a flat to_dict() summary raises ValueError: the summary
does not record the provenance the gate decides on.
The verdict has three values, not two.
result.verdict |
Meaning |
|---|---|
"fail" |
At least one requirement was conclusively violated |
"inconclusive" |
Nothing was violated, and something could not be checked |
"pass" |
Every requirement was evaluated and met |
result.passed is True only for "pass", so an unanswerable gate never reads
as a green one. "fail" wins over "inconclusive": a witnessed violation is a
decision.
A requirement goes unevaluated -- and lands in result.unevaluated -- when:
- the report carries no quality assessment (
reason: quality_unavailable, carrying the report's ownquality_status, so "you did not ask for this" is distinguishable from "this broke"); - the dimension or column had nothing to measure (
reason: not_assessed); - the report holds no profile for a named column (
reason: column_not_profiled) -- it was projected away or it is not in the source, and a report does not record which, so it is not decided either way; - the evidence does not reach as far as the requirement does (
reason: evidence_incomplete).
require_metrics is how a caller turns the first of those into a failure,
which is usually what a pipeline wants when quality is supposed to be
configured on.
Scope and evidence. scope="full_source" states the requirements about the
entire source. A ratio or an average computed over a truncated scan, a sampled
scan, or a bounded quality sample bounds nothing about the rows that were not
read, so such a requirement is left unevaluated rather than passed. The
exception is a witnessed count: rows already seen as duplicates do not stop
being duplicates when more rows are read, so an exact duplicate count above the
allowance fails conclusively even on a partial scan -- while a count at or
below it is still not a pass, and an estimated count settles neither direction.
scope="observed" states the requirements about whatever the metrics actually
measured, which is always evaluable and says nothing about the rest of the
source.
Each check records its own evidence, which can be weaker than the scan's:
report.quality_sampled_dimensions names the components computed from a
retained sample, and a fully read file can still have them. Provenance is
resolved per component, so a completeness score from exact column counters is
decided on its value, and the overall score only counts as sampled when a
sampled component actually reaches it (a dimension the score weights exclude
cannot move the aggregate).
Sampled scores on a fully read source (0.12+). Most quality dimensions are
computed over a uniform sample of up to 10,000 values per column, so on any
larger source the scores are estimates. The report bounds them:
report.quality_score_bounds gives an interval per score that holds, all at
once, with probability confidence_level (0.999). The sample fixes each check
(a column's type, dominant form, detected pattern, decimal scale and outlier
fences); the interval covers what those same checks give over every value the
scan read. When the scan read the whole source and the only gap is the
quality sample, a min_quality_score or min_dimension_scores requirement is
decided on that interval: it passes when the whole interval meets the minimum,
fails when none of it does, and stays evidence_incomplete only when the
minimum falls inside it. The check records the interval it used as bounds
next to evidence, which still says quality_sampled. On a clean column of
10,000 sampled values the interval is about 0.15 points wide; at 5% failures,
about 2 points.
Past a million distinct values the key count and the duplicate-row count are
estimates rather than samples. They are bounded with certainty instead of at
0.999: when an exact count is dropped, the distinct values it held are a floor
the true count cannot go under. Uniqueness then gets an interval from those
floors, wide on a source far past a million rows (the floor is a million of
its rows), and the overall score is bounded again. A max_duplicate_rows
requirement on an estimated count stays unevaluated, since the estimate can
neither witness duplicates nor rule them out.
min_rows reads the report's execution.rows_processed. Rows already read do
not go away when more are read, so a scan that read at least min_rows rows
passes even when it stopped early or sampled. One that read fewer fails when it
covered the whole source, or under scope="observed"; under "full_source" a
scan that stopped early leaves it evidence_incomplete, since the rows it did
not read may make up the difference.
A duplicate-row scan over a sample, on engines without a row tracker, has no
bound, and a requirement that reads it stays unevaluated. Start/end date ordering is
compared only between columns whose samples hold the same rows, so a pair
where either date column has nulls is not compared. When no pair is compared,
the temporal_order_violations finding reads not_assessed.
scope="observed" is unchanged and decides on the sampled score itself.
A report loaded from a document written before dataprof recorded this says
nothing about how its numbers were obtained. Unknown coverage is a third answer
rather than "nothing was sampled", so quality_sampled_dimensions is None
and a full-source check reports coverage_unrecorded. Both language layers
answer the same way for such a report.
result.to_dict() / to_json() serialize the whole result, identical to
what the Rust QualityPolicy writes for the same report and policy:
{
"verdict": "fail",
"scope": "full_source",
"evidence": {"coverage": "complete"},
"checks": [
{
"code": "max_null_percentage",
"column": "customer_id",
"expected": {"comparison": "at_most", "value": 0.0},
"observed": 16.67,
"scope": "full_source",
"evidence": {"coverage": "complete"},
"status": "failed",
"message": "this column's null percentage is above the allowance",
}
],
}A score decided on its interval also carries bounds:
{
"code": "min_quality_score",
"expected": {"comparison": "at_least", "value": 90.0},
"observed": 97.41,
"scope": "full_source",
"evidence": {"coverage": "incomplete", "reason": "quality_sampled"},
"bounds": {"lower": 96.87, "upper": 97.93, "confidence_level": 0.999},
"status": "passed",
"message": "the overall quality score was computed over a sample, and its whole-source interval meets the required minimum",
}message carries no numbers on purpose: observed and expected hold those,
so nothing depends on how a float was formatted into a sentence.
See python/examples/etl_quality_gate.py
for a runnable gate over four drops.
Evaluate the same policy from a shell or CI job:
python -m dataprof.check daily_drop.csv --min-quality 90 --max-null 'customer_id=0' --json
python -m dataprof.check daily_drop.csv --policy quality-policy.json --json > verdict.jsonTo keep the full profile behind the verdict, add --report PATH (0.13+):
python -m dataprof.check daily_drop.csv --min-quality 90 --report profile.json --json > verdict.jsonThe path must end in .json; reload it with dp.ProfileReport.load(). It is the
report the verdict was computed from, written on pass, fail and
inconclusive and not when profiling fails. An existing file is overwritten; a
write failure exits 3 and names the path. --json stdout is unchanged.
The report path must not refer to the source or policy file, including through
symbolic links, hard links or case-insensitive path aliases.
The policy file is a UTF-8 JSON object containing the check() keywords above;
a leading byte-order mark (BOM) is accepted.
Thresholds are JSON numbers; a quoted one such as "90" is a policy error
(exit 3). Commit it alongside the pipeline so threshold changes can be reviewed:
{
"min_rows": 1,
"min_quality_score": 90,
"max_null_percentage": {"customer_id": 0, "*": 20},
"max_duplicate_rows": 0,
"require_metrics": ["quality"],
"scope": "full_source"
}Flags replace the corresponding policy-file key in full. For example,
--max-null '*=10' replaces the entire max_null_percentage mapping, including
any column-specific entries. Repeat --max-null COLUMN=PERCENT,
--min-dimension NAME=PERCENT, or --require-metric NAME to supply multiple
entries; the last occurrence of a repeated mapping key wins.
--min-rows, --min-quality, --max-duplicate-rows, and --scope cover the
other policy keywords. Empty policies, unknown keywords, and duplicate JSON keys (including
inside dimension or column mappings) are policy errors (exit 3).
--engine, --format, --max-rows, and repeatable --metric PACK pass through
to profile_file(). As with the library, capped or sampled evidence may leave a
full-source requirement inconclusive. --scope observed explicitly limits the
claim to the analyzed population. A threshold on an unavailable metric stays
unevaluated; an explicit --require-metric requirement fails if it is absent.
| Exit code | Meaning |
|---|---|
0 |
Every requirement passed; also used by --help and --version |
1 |
The gate found a proven violation, even if other checks are unevaluated |
2 |
The gate is inconclusive: a threshold could not be evaluated |
3 |
An argument, policy, or source could not be read or used, or the report could not be written |
Human summaries go to stderr. With --json, stdout contains only the existing
QualityGateResult.to_json() document, including inconclusive results. Errors
(exit 3) write an error object instead of a result document:
{"error": {"kind": "argument" | "policy" | "input" | "output", "message": "...", "path": "..."}},
where path is present when the error names a file. A policy error without
path comes from a command-line flag (for example --min-quality 150), not
from the policy file. Without --json, errors
go to stderr and stdout stays empty. --help prints usage to stdout and lists
every flag and code. --version prints dataprof <version> to stdout and exits 0.
Baseline-relative gates await support in ProfileReport.check().
--baseline PATH currently exits 3 with an explicit unsupported-operation
message, whether or not the path exists. It never ignores the requested
comparison or substitutes an absolute check.
For a project with daily_drop.csv and quality-policy.json checked in, this
GitHub Actions job runs the gate and retains its result even when it fails:
name: Data quality
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- run: python -m pip install dataprof==0.12.0
- name: Check incoming data
run: python -m dataprof.check daily_drop.csv --policy quality-policy.json --json > verdict.json
- uses: actions/upload-artifact@v7
if: always()
with:
name: quality-verdict
path: verdict.jsonThis example targets the 0.12 release. Before it is published, build this
checkout with uv sync and uv run maturin develop, then invoke the module with
uv run --no-sync python -m dataprof.check ....
Detect quality drift or schema changes between two profiles (e.g. the same dataset before and after a pipeline run):
before = dp.profile("data_v1.csv")
after = dp.profile("data_v2.csv")
delta = before.compare(after)
# {
# "quality_score": {"a": 92.3, "b": 88.1, "abs": -4.2, "rel_pct": -4.55},
# "dimensions": {"completeness": {...}, "consistency": {...}, ...},
# "columns": {"email": {"null_pct_a": 1.0, "null_pct_b": 6.5, "null_pct_delta": 5.5}, ...},
# "schema": {"added": ["phone"], "removed": [], "common": ["id", "email", ...]},
# "metric_semantics": {"a": {...}, "b": {...}, "comparable": True},
# }metric_semantics.comparable is True when both reports record every
measurement definition this release knows, with the same values, and None
when either side does not, which includes every report written before 0.12. Text lengths counted UTF-8 bytes through 0.11,
so across that boundary a changed max_length on non-ASCII text can be the unit
rather than the data.
The
compare()result shape is provisional and will align with the Rust-sideQualityDeltatype once it lands.
report.save("report.json") # full report as JSON
report.save("profiles.csv") # column profiles as CSV (no extra deps)
report.save("profiles.parquet") # column profiles as Parquet (requires pyarrow)
report.save("report.html") # HTML fragment (same as to_html())
report.save("report.md") # markdown table (same as to_markdown())Fast operations that don't require a full profile:
result = dp.infer_schema("data.csv")
print(f"{result.num_columns} columns, {result.rows_sampled} rows sampled")
for col in result.columns:
print(f" {col['name']}: {col['data_type']}")Every format runs the same inference profile() does. On a Parquet file that
means the metadata answers for every column it types, and text columns are
sampled: only their values say whether they hold dates, integers or booleans,
which is what a writer that did not type its input leaves behind.
rows_sampled reports what that cost -- 0 for a fully typed file.
The sample is bounded in every format, so the type matches a full profile()
whenever schema_stable is true -- nothing was left unread that could move it,
either because the scan reached the end of the file or because the Parquet
metadata typed every column and no scan was needed. Once inference stops at the
cap, a later row can still move it.
structure = dp.analyze_structure("data.parquet")
for column in structure.columns:
print(f"{column.name}: {column.data_type} nulls={column.null_count} ({column.provenance})")Every counter it reports describes the whole file or is absent -- it never
returns a partial count. For CSV and JSON that means checking truncated; for
Parquet the counts come from the file footer, so they cover every row even when
the type sample stopped at max_rows. Distinct counts are absent for Parquet,
which has no whole-file source for one.
result = dp.quick_row_count("data.parquet")
print(f"{result.count} rows ({'exact' if result.exact else 'estimated'})")
print(f"Method: {result.method}, took {result.count_time_ms}ms")CSV counts scan every logical record, including quoted multiline fields, and
return exact=True, method full_scan, and no relative-error estimate. This
costs a complete read even for large CSV files. Parquet uses footer metadata;
large JSONL files retain their sampled density estimate.
The dataprof.asyncio module provides async variants for use in web frameworks, stream processors, and other async contexts. These helpers ship in the published wheels.
from dataprof.asyncio import profile_file, profile_bytes, profile_url
# Async file profiling
report = await profile_file("data.csv", max_rows=10000)
# Profile raw bytes (e.g. from an HTTP request body)
report = await profile_bytes(csv_bytes, format="csv")
# Profile a remote file over HTTP
report = await profile_url("https://example.com/data.parquet")Remote Parquet is read with several ranged requests, all tied to the ETag
the server sent first. If the object is replaced while it is being read,
profile_url raises OSError naming the URL instead of profiling a mix of the
two versions. A server that sends no ETag leaves nothing to check against.
Async byte streams use the incremental engine; engine="columnar" is rejected.
Local async Parquet profiling honors max_rows and row-limit stop conditions,
and rejects stop conditions or sampling strategies the Parquet reader cannot
apply.
Additional async utilities:
from dataprof.asyncio import infer_schema_stream, quick_row_count_stream
schema = await infer_schema_stream(csv_bytes, format="csv")
count = await quick_row_count_stream(csv_bytes, format="csv")Async database functions for PostgreSQL, MySQL, and SQLite are not in the
published wheels, and there is no extra that installs them. On a wheel install
they exist as stubs that raise ImportError when called, and
dp.capabilities().database is False. They need a source build with
python-async, database, and the connector features you want.
No checkout required:
pip install dataprof --no-binary dataprof --config-settings="build-args=--features python,python-async,async-streaming,parquet-async,database,sqlite"uv run maturin develop --features "python,python-async,async-streaming,parquet-async,database,sqlite"--features replaces the [tool.maturin] features list in
pyproject.toml; it does not add to it. This is the part that bites, because
the result looks like it worked:
# Wrong: builds database support and silently drops async, URL profiling,
# and remote Parquet — surface that a plain `pip install dataprof` includes.
--features "python,python-async,database,sqlite"That command yields capabilities().database == True alongside
async_streaming == False, url_profiling == False, and
remote_parquet == False. Nothing errors; the package is simply smaller than
the published wheel. The lists above are the shipped set plus the database
features, which is why they are long.
database on its own is also not enough: the connectors are reported only when
python-async is compiled in as well.
A source build needs a Rust toolchain (1.96 or later; CI compiles the extension
crate on exactly 1.96, so that floor is tested rather than assumed).
postgres and mysql are pure Rust; sqlite compiles libsqlite3-sys, which
is C.
Check what you actually got before relying on it:
import dataprof as dp
caps = dp.capabilities()
assert caps.database and "sqlite" in caps.database_connectors
assert caps.async_streaming # still present, because the list above kept itThen the following APIs become available:
import asyncio
import dataprof as dp
async def main():
# Test connection
ok = await dp.test_connection_async("postgres://user:pass@localhost/mydb")
# Profile a query
report = await dp.analyze_database_async(
"postgres://user:pass@localhost/mydb",
"SELECT * FROM users",
batch_size=10000,
)
print(f"{report.rows} rows, quality: {report.quality_score}")
# Get table schema
columns = await dp.get_table_schema_async(
"postgres://user:pass@localhost/mydb", "users"
)
# Count rows
count = await dp.count_table_rows_async(
"postgres://user:pass@localhost/mydb", "users"
)
asyncio.run(main())batch_size must be greater than zero. Query result column names must be
unique; duplicate aliases are rejected before values can be merged.
profile() also accepts one-shot producers implementing the
Arrow C Stream capsule protocol:
import pyarrow as pa
import dataprof
batches = [pa.record_batch({"id": [1, 2]}), pa.record_batch({"id": [3, 4]})]
reader = pa.RecordBatchReader.from_batches(batches[0].schema, iter(batches))
report = dataprof.profile(reader, max_rows=3)
assert report.rows == 3
assert report.source_type == "stream"
# With DuckDB installed, pass a relation directly:
# report = dataprof.profile(duckdb.sql("select * from my_table"), max_rows=1000)dataprof consumes and releases each batch before fetching another; it does not
call to_arrow_table() or collect the stream. Producer batch buffers and the
bounded profiling accumulators determine memory use. A producer may itself
materialize data when exporting; dataprof cannot control that allocation.
PyArrow and DuckDB remain optional producer dependencies.
max_rows stops at the requested row boundary, including zero, without asking
for a subsequent batch. Reaching the cap sets truncation_reason even if that
row happened to be the stream's last: end-of-stream was not checked. An uncapped
or shorter stream is complete only after its producer signals the end. The
report uses engine columnar, source type stream, and source system
arrow_c_stream; the name identifies one profiling operation (batch_id="0").
The stream is one-shot: use a fresh reader for another profile.
Metric packs, quality dimensions, locale, semantic hints, and column selection
work as on Arrow tables. Unsupported file/transport controls raise ValueError
before capsule export. Duplicate names are rejected before any batch is read,
even with column selection. Selected nested types (structs, lists, maps, unions)
raise TypeError; select flat columns before exporting, or with columns=.
An empty stream with a schema retains its columns and unassessed quality.
Batch/schema failures abort the call; no partial report is returned. The C
interface carries producer error text, which is retained as __cause__, rather
than the original Python exception object. Existing Table, C Array, pandas, and
polars adapters retain their behavior.
The RecordBatch class supports zero-copy exchange via the Arrow PyCapsule interface:
import dataprof as dp
import pyarrow as pa
# Profile a PyArrow table directly
report = dp.profile(table)
# RecordBatch properties
batch.num_rows
batch.num_columns
batch.column_names
# Convert to other formats
df = batch.to_pandas()
pl_df = batch.to_polars()
# PyCapsule protocol for zero-copy exchange
schema_capsule = batch.__arrow_c_schema__()
array_capsule = batch.__arrow_c_array__()