whyempty
Find the filter that emptied your data.
whyempty records what happens at each stage of a Python filter pipeline: input count, removals, survivors, retention, elapsed time, and the first rejection of each input occurrence. It works with ordinary iterables and has no required runtime dependencies. Python 3.10 or newer is required.
Installation
Install version 0.1.0 from PyPI:
python -m pip install whyempty==0.1.0
To install from source and run the example:
git clone https://github.com/giovannyTorres/whyempty.git
cd whyempty
python -m pip install -e .
python examples/basic_usage.py
Quick start
from whyempty import trace
records = [
{"id": 1, "active": True, "country": "MX", "score": 40},
{"id": 2, "active": False, "country": "MX", "score": 90},
{"id": 3, "active": True, "country": "US", "score": 95},
]
result = (
trace(records)
.keep("Active", lambda row: row["active"])
.keep("Mexico", lambda row: row["country"] == "MX")
.keep("Score >= 80", lambda row: row["score"] >= 80)
)
print(result.explain())
assert result.items == ()
assert result.count == 0
assert result.is_empty
Example output; timings depend on the machine:
whyempty | Filter diagnostic
Initial records: 3
Filter Before Removed Remaining Retained Time (ms)
----------------------------------------------------------------------
1. Active 3 1 2 66.7% 0.006
2. Mexico 2 1 1 50.0% 0.004
3. Score >= 80 1 1 0 0.0% 0.002
Result: EMPTY
All records were eliminated.
Final elimination: 3. Score >= 80
Significant reductions (at least 50% removed): 2. Mexico
The third filter caused final elimination. Earlier filters can contribute large reductions; the diagnostic calls a reduction significant when it removes at least 50% of a nonempty stage's input. This is a descriptive threshold, not a statistical significance test. Any subsequent filters receive zero records and are marked skipped. A collection that starts empty is reported separately and has no final-elimination filter.
API
| API | Behavior |
|---|---|
trace(iterable) |
Eagerly consume an iterable once into a Trace[T]. |
.keep(name, predicate) |
Keep truthy matches, record a stage, and return the same trace. |
.items |
Tuple of surviving references in original order; reading it makes no copy. |
.initial_count / .count / .is_empty |
Original count, survivor count, and empty status. |
.steps |
Tuple snapshot of immutable Step statistics in execution order. |
.first_empty_step |
The first Step that eliminated all remaining records, or None. |
.rejected() |
Tuple of first-rejection metadata, in original input order. |
.explain() |
Deterministic string for the recorded state; never prints or reruns predicates. |
.to_dict() |
JSON-compatible counts and stage statistics, without records or predicates. |
keep() mutates its trace: assigning its return value does not create a branch.
Previously retrieved .items, .steps, and .rejected() tuples are snapshots.
Items themselves are shared objects; no deep copy is performed. The library
does not mutate the input collection or records. User predicates can do so.
Each input occurrence reaching a stage is evaluated once, including truth
conversion. Repeated references are distinct occurrences. Iterators and
generators are consumed at trace() construction. No predicates are retained or
reevaluated by queries. With no filters, the result contains all input records.
A Step exposes index (one-based), name, before, removed, remaining,
retention_pct, elapsed_seconds, emptied, skipped, and
significant_reduction. For zero-input stages, retention is None in Python
and JSON null, displayed as N/A. Timing uses perf_counter and includes
partitioning and provenance recording; formatting and Step creation are
excluded. Duplicate filter names are allowed; stage indices disambiguate them.
Names must be nonblank, printable strings.
Inspect rejected occurrences
for rejection in result.rejected():
print(rejection.original_index, rejection.step_index, rejection.filter_name)
# 0 3 Score >= 80
# 1 1 Active
# 2 2 Mexico
Rejection contains a zero-based original_index, a one-based step_index,
and filter_name. It never contains the record. To inspect a rejected value,
retain your input and look it up explicitly:
rejection = result.rejected()[0]
rejected_record = records[rejection.original_index]
Positions work with dictionaries, lists, repeated values, and unhashable objects; no unique ID or object hashing is required. Provenance retains no rejected-record references. An object can remain referenced by another surviving occurrence, by the caller, or by an exception traceback.
Predicate failures
A predicate exception, including an error during truth conversion, aborts the
stage and raises FilterError with its original cause preserved. The failed
stage commits no survivor changes, rejections, or statistics. User side effects
cannot be rolled back; retrying a failed stage executes its predicates again.
from whyempty import FilterError, trace
pending = trace([{"score": 90}, {}])
try:
pending.keep("Score >= 80", lambda row: row["score"] >= 80)
except FilterError as error:
assert error.step_index == 1
assert error.original_index == 1
assert error.filter_name == "Score >= 80"
assert isinstance(error.__cause__, KeyError)
assert pending.count == 2
assert pending.steps == ()
Missing keys are errors unless the predicate explicitly supplies a default, for
example lambda row: row.get("score", 0) >= 80. Exceptions are never silently
treated as rejections. KeyboardInterrupt, SystemExit, and other
BaseException subclasses propagate unchanged. Input iteration failures also
propagate directly because construction has not completed.
Serialization and privacy
import json
summary = result.to_dict()
assert summary["first_empty_step"] == 3
payload = json.dumps(summary, allow_nan=False)
rejection_metadata = [entry.to_dict() for entry in result.rejected()]
The summary contains initial_count, count, is_empty, first_empty_step
(index or None), and a list of steps. It omits per-occurrence rejections to
keep the default summary proportional to the number of stages. Step and
Rejection also provide .to_dict(). No method automatically serializes records
or calls their repr().
Filter labels appear in diagnostics and summaries, so choose labels suitable
for your logs. FilterError messages omit record values and cause text; Python
tracebacks retain and may display the original exception and its data.
Performance
The reproducible runner compares three equivalent sequential list comprehensions
using the same predicates, whyempty filtering, and whyempty filtering followed
by explain(). Each operation starts from a prebuilt list(range(size)) and
keeps even values, then values below 80% of the size, then multiples of five.
The baseline replaces intermediate lists as each stage completes.
Time is measured without tracemalloc; incremental Python allocation peaks are
measured separately while retaining one result. Input construction is excluded;
trace materialization, rejection metadata, and stage statistics are included.
Allocation peaks are not process RSS and are not retained-memory sizes.
Run from an installed checkout:
python benchmarks/benchmark.py --repeat 15 --number 50 \
--json-output benchmarks/results.json
Measured on 2026-10-21: CPython 3.12.14, Linux x86_64. Medians of 15 rounds × 50 operations, with rotating scenario order:
| Records | Python ms | whyempty ms | + explain ms | Trace time / Python | + explain time / Python | Python peak KiB | Trace peak KiB |
|---|---|---|---|---|---|---|---|
| 1,000 | 0.125 | 0.224 | 0.266 | 1.78× (+78%) | 2.12× (+112%) | 7.34 | 59.95 |
| 10,000 | 1.228 | 2.400 | 2.440 | 1.95× (+95%) | 1.99× (+99%) | 73.22 | 662.48 |
| 100,000 | 12.410 | 26.100 | 26.341 | 2.10× (+110%) | 2.12× (+112%) | 776.84 | 6763.54 |
Tracing used 8.16–9.05× the baseline peak allocations in this workload. The trace-plus-explanation peak matched trace-only: the largest temporary partition was allocated before formatting. At 100,000 records, tracing adds about 13.7 ms over the baseline; rendering the three-stage diagnostic adds about 0.24 ms to the measured median. No core microoptimizations were made: the measured costs follow from the positional provenance and atomic stage buffers required by the API.
The benchmark JSON contains every sample, environment details, parameters, and ratios. See benchmark methodology. These are synthetic measurements on one host, not universal performance guarantees. Small differences between trace-only and trace-plus-explanation can reflect measurement noise.
whyempty adds a Python loop, truth conversion, positional bookkeeping, temporary partition buffers for stage atomicity, and immutable statistics. This trades time and memory for observability. It stores no historical record snapshots.
For N input occurrences and K filters, construction is O(N), filtering is
O(sum of records reaching each stage) plus predicate work, and persistent storage
is O(N + K). Reading .items is O(1). explain() and to_dict() are O(K),
excluding output size; rejected() scans N positions and allocates one metadata
object per rejection. See design and invariants.
Limitations
- Eager, finite inputs only; this is not a streaming pipeline.
- The trace is mutable and not thread safe; reentrant
keep()is rejected. - User object mutations and predicate side effects are outside rollback guarantees.
- Rejection metadata identifies the first failed filter actually executed. Later filters are not evaluated on rejected records; this is not counterfactual analysis.
- Standard
len()determines text-column widths; emoji and wide Unicode characters can affect visual alignment. - No automatic capture of comprehensions, Pandas/Polars integration, CLI, remote services, or distributed execution is included in 0.1.0.
Development and release
python -m pip install -e '.[dev]'
python -m pytest --cov=whyempty --cov-report=term-missing
python -m ruff check .
python -m ruff format --check .
python -m mypy
python -m build
python -m twine check dist/*
CI tests CPython 3.10–3.15, enforces at least 95% branch-inclusive coverage, and checks typing, formatting, distribution contents, and wheel installation. Publication remains manual. See CONTRIBUTING.md for release instructions and TestPyPI/PyPI commands. MIT licensed.
Related work
Coffea cutflows already describe cumulative selection counts for array-based analyses. more-itertools provides iterable filtering utilities, and IceCream helps inspect expressions. whyempty focuses on explicit sequential predicates over general Python iterables, positional first-rejection metadata, and data-free summaries with no required dependencies. This is not a claim that filter diagnostics are new.
Metadata
Release files for whyempty 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| whyempty-0.1.0.tar.gz | 30.9 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| whyempty-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 43.1 kB
Release files / whyempty-0.1.0.tar.gz
| Download URL | whyempty-0.1.0.tar.gz |
|---|---|
| Size | 30.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3162b8a155307f1f164216a541a480a84b54a89144d4edfea5503d9104487624
|
|
BLAKE2b-256 checksum How to use checksums |
d7d1f8738ef15923cf292cbc78cbed5911bdb4b7ed9dcd7b6f13b57111f0a10e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.15
|
Release files / whyempty-0.1.0-py3-none-any.whl
| Download URL | whyempty-0.1.0-py3-none-any.whl |
|---|---|
| Size | 12.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
464267739ede972fad5208a5c2cba951d1b5c2c206923d5a07fac67f152c24f9
|
|
BLAKE2b-256 checksum How to use checksums |
e28278e5ca945a113b16fce4e49d28f9c2750dfd03feda068dd5f511c8d61b58
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.15
|
Security for whyempty 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.