Skip to content

Apply case-insensitive field matching to optional nested models (#903) - #905

Merged
hramezani merged 2 commits into
mainfrom
fix-903-optional-nested-case-insensitive
Jul 8, 2026
Merged

hramezani merged 2 commits into
mainfrom
fix-903-optional-nested-case-insensitive

Conversation

@hramezani

@hramezani hramezani commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #903

Problem

With case_sensitive=False and env_nested_delimiter, environment variables fail to populate a nested model field when that nested model is declared optional. The raw (lowercase) env-var key is preserved instead of being resolved to the actual field-name casing:

class NestedModel(BaseModel):
    Name: str

class ConfigModel(BaseModel):
    nested: Optional[NestedModel] = None

class Settings(BaseSettings):
    config: ConfigModel
    model_config = SettingsConfigDict(env_nested_delimiter="__", case_sensitive=False)

os.environ["config__nested__name"] = "value"
Settings()  # ValidationError: config.nested.Name Field required

Removing Optional makes it work, so the optional and non-optional paths diverged.

Cause

_replace_field_names_case_insensitively rewrites lowercased env keys back to the real field casing by recursing through nested models. The recursion gate used:

_lenient_issubclass(sub_model_field.annotation, BaseModel)

For a required field the annotation is NestedModel (→ True, recurses). For an optional field the annotation is Optional[NestedModel], a Union (→ False), so it never recursed and the lowercase key survived. The function already unwrapped Optional for the outer field but not for this inner recursion check.

Fix

Extract the existing Optional[T] unwrapping into a small _unwrap_optional_annotation helper and apply it both at the top of the function (dedup, same behavior) and at the recursion gate, so optional nested models are resolved the same way as required ones.

Tests

Added test_case_insensitive_deeply_nested_optional mirroring the issue's repro (a required outer model containing an optional inner model — the deeper case the existing test_case_insensitive_nested_optional didn't cover). Verified it fails without the fix and passes with it. Full suite passes with no regressions.

🤖 Generated with Claude Code

`_replace_field_names_case_insensitively` only recursed into a nested
model when `sub_model_field.annotation` was directly a `BaseModel`
subclass. For an `Optional[NestedModel]` field the annotation is a
`Union`, so the recursion was skipped and the raw (lowercase) env-var
key was preserved, causing validation to fail under `case_sensitive=False`.

Unwrap `Optional[T]` before the `BaseModel` check (via a new
`_unwrap_optional_annotation` helper, also reused for the existing
top-level unwrap) so optional nested models are resolved the same way as
required ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hramezani
hramezani enabled auto-merge (squash) July 8, 2026 15:51
@hramezani
hramezani merged commit 281a96f into main Jul 8, 2026
19 checks passed
@hramezani
hramezani deleted the fix-903-optional-nested-case-insensitive branch July 8, 2026 15:53
@hramezani hramezani mentioned this pull request Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optional nested BaseModel does not apply case-insensitive field matching for env_nested_delimiter

1 participant