Summary
fd.clean accepts DataFrames whose column labels are not strings (for example integer labels from pd.read_csv(header=None) or df.T), but several other entry points assume str labels. Some raise KeyError/TypeError; others silently return wrong results because one code path keys by str(col) and another by the raw label. #209 fixed this class for StreamingCleaner/clean_timeseries (PR #221); the paths below are separate.
Affected functions
Environment
freshdata 2.0.0; found at 55a8044 and re-checked on main c87efbd (2026-09-15). Reproduces on Python 3.9.6 / pandas 1.5.3 / numpy 1.26.4 and Python 3.12.14 / pandas 2.3.3 / numpy 2.5.3. The FreshCore item was run against a stand-in freshdata_freshcore module (no Rust toolchain available); see its details.
Suggested fix
- Normalise label handling in one shared helper (key every per-column mapping by the original label and only stringify for display), and use it in each function listed.
- Add one parametrised test that runs each API on a frame with integer labels and with mixed
1/"1" labels.
Details
1. fd.clean(semantic_mode=...) raises KeyError on non-string column labels
fd.clean works with integer column labels, but enabling the semantic layer crashes. build_semantic_context stores name = str(col), proposals carry that string, and run_semantic indexes the frame with it (out['2']). This is the same pattern as #209, which covers only StreamingCleaner and clean_timeseries; this is a separate code path.
Reproduction
import sys, warnings; warnings.simplefilter("ignore")
import pandas as pd
import freshdata as fd
df = pd.DataFrame({1: ["yes", "no", "yes", "no"], 2: ["twenty", "5", "6", "7"]})
fd.clean(df, column_names=False, verbose=False) # control: core clean works
try:
fd.clean(df, semantic_mode="auto", column_names=False, verbose=False); err = None
except Exception as e:
err = f"{type(e).__name__}: {e}"
print("EXPECTED: semantic clean succeeds on integer labels (core clean does)")
print("ACTUAL:", err or "ok")
Expected
The semantic clean completes, as the core clean does.
Actual
EXPECTED: semantic clean succeeds on integer labels (core clean does)
ACTUAL: KeyError: '2'
Where
src/freshdata/semantic/context.py:84: name = str(col)
src/freshdata/semantic/apply.py:280: out[col] = _apply_column(out[col], mapping)
2. FreshCore engine silently drops a column when two labels stringify alike (1 and '1')
The FreshCore adapter sends column names as str(label) and rebuilds the output with a dict keyed by name. When two distinct labels have the same str(), such as the int 1 and the string "1", the second column overwrites the first. The report still says cols_after == 2, and columns_dropped is empty. The duplicate-label guard doesn't catch this because it checks raw labels, which differ here.
Reproduction
Needs the built freshdata_freshcore extension.
import pandas as pd, freshdata as fd
df = pd.DataFrame({1: [10.0, 20.0], "1": ["a", "b"]})
kw = dict(strategy="conservative", fix_dtypes=False, return_report=True, verbose=False)
print(list(fd.clean(df, engine="pandas", **kw)[0].columns))
out, rep = fd.clean(df, engine="freshcore", **kw)
print(list(out.columns), out.shape, rep.cols_after, rep.columns_dropped)
Expected
Two columns come back ([1, '1']), as with the pandas engine, or the run falls back.
Actual
['1'] (2, 1) 2 []: the numeric column is gone without any record.
Where
src/freshdata/execution/backends/_freshcore.py:158: self._column_payload(str(name), ...).
_freshcore.py:127: frame.columns.duplicated() checks raw labels only.
_freshcore.py:204-213: data[name] = values, where a later column with the same name overwrites the earlier one.
3. explain_clean reports 0 changed cells and blank dtypes for non-string column labels
ExplainReport keys before_stats/after_stats by str(col) but keys cell_changes by the raw label. For integer column labels:
to_frame() looks up cell_changes with the string key, so every column shows changed_cells = 0.
- The HTML view looks up the stats with the raw key, so both dtype cells are blank.
With MultiIndex columns, the tuple keys in cell_changes also make to_html() raise TypeError (from json.dumps), and _repr_html_() returns None, so the notebook shows nothing.
Reproduction
import pandas as pd, freshdata as fd
df = pd.DataFrame({0: [" a", "b ", "c", "d"], 1: [1.0, 2.0, 3.0, 4.0]})
rep = fd.explain_clean(df, verbose=False)
print(rep.cell_changes) # {0: 2, 1: 0}
print(rep.to_frame()) # changed_cells 0 for column 0
mi = pd.DataFrame([[1, " a", None], [2, "b", 3.0]],
columns=pd.MultiIndex.from_tuples([("a", "x"), ("a", "y"), ("b", "z")]))
r2 = fd.explain_clean(mi, verbose=False)
print(r2._repr_html_()) # None
r2.to_html() # TypeError
Expected
Column 0 shows changed_cells = 2 with dtype object in both to_frame() and HTML, and the MultiIndex report renders.
Actual
cell_changes: {0: 2, 1: 0} | before_stats keys: ['0', '1']
column before_dtype after_dtype changed_cells
0 object object 0
1 float64 float64 0
HTML row for column 0: blank dtypes
_repr_html_() -> None | to_html(): TypeError: keys must be str, int, float, bool or None, not tuple
Where
src/freshdata/explain.py:36: stats[str(col)] = entry
src/freshdata/explain.py:80: changes[col] = changed (raw label)
src/freshdata/explain.py:183: "changed_cells": self.cell_changes.get(col, 0) with a string col
src/freshdata/render/renderers.py (render_explain): rep.before_stats.get(col, {}) with a raw col, then H.json_download("explain.json", rep.to_dict(), ...)
4. enforce_contract and diff_schema mishandle non-string column labels
Frames with integer column labels, for example from pd.read_csv(..., header=None), cannot be checked with contracts:
- Declaring the column as
0 reports it as missing.
- Declaring it as
"0" raises KeyError.
diff_schema raises KeyError as well.
Column profiles are keyed by str(col), but the frame is later indexed with that string.
Reproduction
import pandas as pd
import freshdata as fd
df = pd.DataFrame({0: [1, 2, 3], 1: ["a", "b", "c"]})
r = fd.enforce_contract(df, fd.DataContract("c", (fd.ColumnContract(0, dtype="int", min_value=0),)))
print([f.message for f in r.findings if f.status != "passed"])
fd.enforce_contract(df, fd.DataContract("c", (fd.ColumnContract("0", dtype="int", min_value=0),)))
fd.diff_schema(df, contract=fd.DataContract("c", (fd.ColumnContract("x"),)))
Expected
Column 0 is found and checked with no failures, and diff_schema returns a report saying x is missing.
Actual
{'0': ['required column 0 is missing'], "'0'": "KeyError: '0'", 'diff_schema': "KeyError: '0'"}
Where
Profiles are keyed by the string label, and the frame is then indexed with it:
src/freshdata/enterprise/contracts.py:1674 builds str(col): _profile_column(frame[col], ...).
contracts.py:1323 does passes &= _contract_values(findings, cc, frame[col]), which raises KeyError: '0'.
contracts.py:1902 does new_family = _normalize_dtype(str(df[new].dtype)), which raises KeyError: '0' in diff_schema.
5. apply_privacy_policy raises KeyError on non-string column labels
classify_columns keys its results by str(col), and apply_privacy_policy then indexes the frame with that string. Any frame with integer (or other non-str) column labels that has a classified column crashes. Headerless CSVs and DataFrame(ndarray) are common sources of such labels. fd.clean accepts these frames.
Reproduction
import pandas as pd
from freshdata.enterprise import PrivacyPolicy, apply_privacy_policy, load_compliance_pack
pol = PrivacyPolicy(packs=(load_compliance_pack("hipaa"),), jurisdiction="US")
df = pd.DataFrame({0: ["a@b.com", "c@d.com"], 1: [1, 2]})
apply_privacy_policy(df, pol)
Expected
The policy is applied to column 0, giving ['<EMAIL>', '<EMAIL>'] as it does with str labels.
Actual
Where
src/freshdata/enterprise/privacy_policy.py:474 and :481: result[str(col)] = _ColumnClassification(...)
src/freshdata/enterprise/privacy_policy.py:628: series = frame[col], using the stringified name.
6. explain_clean and infer_roles crash with TypeError on mixed int/str column labels
fd.clean accepts a frame whose column labels mix integers and strings. fd.explain_clean and fd.infer_roles crash on the same frame, because both sort the per-column context dict by label.
Reproduction
import pandas as pd, freshdata as fd
df = pd.DataFrame({0: [1.0, None, 3.0, 4.0], "name": ["a", "b", None, "d"]})
fd.clean(df, verbose=False) # ok
fd.explain_clean(df, verbose=False) # TypeError
fd.infer_roles(df) # TypeError
Expected
Both functions work on the frame fd.clean accepts.
Actual
ACTUAL: {'explain_clean': "TypeError: '<' not supported between instances of 'str' and 'int'", 'infer_roles': "TypeError: '<' not supported between instances of 'str' and 'int'"}
Where
src/freshdata/explain.py:100: for col, ctx in sorted(contexts.items()):
src/freshdata/api.py:1141: for col, ctx in sorted(contexts.items()):
Summary
fd.cleanaccepts DataFrames whose column labels are not strings (for example integer labels frompd.read_csv(header=None)ordf.T), but several other entry points assumestrlabels. Some raiseKeyError/TypeError; others silently return wrong results because one code path keys bystr(col)and another by the raw label. #209 fixed this class forStreamingCleaner/clean_timeseries(PR #221); the paths below are separate.Affected functions
src/freshdata/semantic/context.py:84)src/freshdata/execution/backends/_freshcore.py:158)src/freshdata/explain.py:36)src/freshdata/enterprise/contracts.py:1674)src/freshdata/enterprise/privacy_policy.py:474)src/freshdata/explain.py:100)Environment
freshdata 2.0.0; found at 55a8044 and re-checked on main c87efbd (2026-09-15). Reproduces on Python 3.9.6 / pandas 1.5.3 / numpy 1.26.4 and Python 3.12.14 / pandas 2.3.3 / numpy 2.5.3. The FreshCore item was run against a stand-in
freshdata_freshcoremodule (no Rust toolchain available); see its details.Suggested fix
1/"1"labels.Details
1. fd.clean(semantic_mode=...) raises KeyError on non-string column labels
fd.cleanworks with integer column labels, but enabling the semantic layer crashes.build_semantic_contextstoresname = str(col), proposals carry that string, andrun_semanticindexes the frame with it (out['2']). This is the same pattern as #209, which covers only StreamingCleaner and clean_timeseries; this is a separate code path.Reproduction
Expected
The semantic clean completes, as the core clean does.
Actual
Where
src/freshdata/semantic/context.py:84:name = str(col)src/freshdata/semantic/apply.py:280:out[col] = _apply_column(out[col], mapping)2. FreshCore engine silently drops a column when two labels stringify alike (1 and '1')
The FreshCore adapter sends column names as
str(label)and rebuilds the output with a dict keyed by name. When two distinct labels have the samestr(), such as the int1and the string"1", the second column overwrites the first. The report still sayscols_after == 2, andcolumns_droppedis empty. The duplicate-label guard doesn't catch this because it checks raw labels, which differ here.Reproduction
Needs the built
freshdata_freshcoreextension.Expected
Two columns come back (
[1, '1']), as with the pandas engine, or the run falls back.Actual
['1'] (2, 1) 2 []: the numeric column is gone without any record.Where
src/freshdata/execution/backends/_freshcore.py:158:self._column_payload(str(name), ...)._freshcore.py:127:frame.columns.duplicated()checks raw labels only._freshcore.py:204-213:data[name] = values, where a later column with the same name overwrites the earlier one.3. explain_clean reports 0 changed cells and blank dtypes for non-string column labels
ExplainReportkeysbefore_stats/after_statsbystr(col)but keyscell_changesby the raw label. For integer column labels:to_frame()looks upcell_changeswith the string key, so every column showschanged_cells = 0.With MultiIndex columns, the tuple keys in
cell_changesalso maketo_html()raiseTypeError(fromjson.dumps), and_repr_html_()returnsNone, so the notebook shows nothing.Reproduction
Expected
Column
0showschanged_cells = 2with dtypeobjectin bothto_frame()and HTML, and the MultiIndex report renders.Actual
Where
src/freshdata/explain.py:36:stats[str(col)] = entrysrc/freshdata/explain.py:80:changes[col] = changed(raw label)src/freshdata/explain.py:183:"changed_cells": self.cell_changes.get(col, 0)with a stringcolsrc/freshdata/render/renderers.py(render_explain):rep.before_stats.get(col, {})with a rawcol, thenH.json_download("explain.json", rep.to_dict(), ...)4. enforce_contract and diff_schema mishandle non-string column labels
Frames with integer column labels, for example from
pd.read_csv(..., header=None), cannot be checked with contracts:0reports it as missing."0"raisesKeyError.diff_schemaraisesKeyErroras well.Column profiles are keyed by
str(col), but the frame is later indexed with that string.Reproduction
Expected
Column
0is found and checked with no failures, anddiff_schemareturns a report sayingxis missing.Actual
Where
Profiles are keyed by the string label, and the frame is then indexed with it:
src/freshdata/enterprise/contracts.py:1674buildsstr(col): _profile_column(frame[col], ...).contracts.py:1323doespasses &= _contract_values(findings, cc, frame[col]), which raisesKeyError: '0'.contracts.py:1902doesnew_family = _normalize_dtype(str(df[new].dtype)), which raisesKeyError: '0'indiff_schema.5. apply_privacy_policy raises KeyError on non-string column labels
classify_columnskeys its results bystr(col), andapply_privacy_policythen indexes the frame with that string. Any frame with integer (or other non-str) column labels that has a classified column crashes. Headerless CSVs andDataFrame(ndarray)are common sources of such labels.fd.cleanaccepts these frames.Reproduction
Expected
The policy is applied to column
0, giving['<EMAIL>', '<EMAIL>']as it does with str labels.Actual
Where
src/freshdata/enterprise/privacy_policy.py:474and:481:result[str(col)] = _ColumnClassification(...)src/freshdata/enterprise/privacy_policy.py:628:series = frame[col], using the stringified name.6. explain_clean and infer_roles crash with TypeError on mixed int/str column labels
fd.cleanaccepts a frame whose column labels mix integers and strings.fd.explain_cleanandfd.infer_rolescrash on the same frame, because both sort the per-column context dict by label.Reproduction
Expected
Both functions work on the frame
fd.cleanaccepts.Actual
Where
src/freshdata/explain.py:100:for col, ctx in sorted(contexts.items()):src/freshdata/api.py:1141:for col, ctx in sorted(contexts.items()):