model_config vs class Config in Pydantic v2

Key takeaways:

  • class Config is deprecated but still functional in Pydantic 2.13.4 — it warns, it does not fail.
  • Renamed keys are the trap: orm_mode warns, is stored unchanged, and is then never applied.
  • Unknown keys are accepted with no warning at all, so a typo is invisible.
  • class Config and model_config on the same model raise PydanticUserError at import time.
  • BaseSettings needs SettingsConfigDict, not ConfigDict.

This is one step in the Pydantic V2 Migration Guide. Unlike the decorator migrations — @validator and @root_validator — this one rarely announces itself with an exception. It fails quietly.

The Problem This Solves

You upgrade to Pydantic v2, the app imports, the test suite mostly passes, and then a single endpoint starts returning a 500 because a model that used to accept a SQLAlchemy row no longer does. Nothing was deleted. The class Config block is still there, orm_mode = True is still in it, and Pydantic even printed a warning naming the exact replacement.

The reason this is so easy to miss is that the migration looks mechanical. Rename the block, rename a few keys. But the failure mode is not "your config was rejected" — it is "your config was accepted and ignored", which no amount of reading the diff will reveal.

Why It Happens

When ModelMetaclass builds a model class, it looks for an inner attribute named Config, pulls the public names off it, and merges them into the model_config dict. That is the whole compatibility shim: a copy.

Along the way it runs the copied keys past a lookup table of names that changed between v1 and v2 and emits a UserWarning for each hit. What it does not do is rewrite them. orm_mode goes into model_config as orm_mode.

That matters because of how config is consumed on the other side. Building the core schema is a series of explicit lookups — the generator asks model_config for from_attributes, for extra, for frozen, by exact key. There is no fall-back to old names and no validation pass that rejects keys nobody asked for. A key that is never looked up is simply inert. orm_mode: True sits in the config dict looking authoritative and doing nothing.

How a v1 class Config becomes model_config and where a renamed key is lostAn inner class Config is copied key by key into model_config. Renamed keys trigger a warning but keep their old name, and the core schema builder only reads known key names, so the renamed key is never applied.class Configorm_mode=Truemetaclass copynames onlyrename checkwarns onlymodel_configorm_mode=Truecoreschemaasks forfrom_attributesfinds nothingThe key survives the copy under its old name, so the lookup misses.
Nothing rewrites the key. The warning and the lookup are separate mechanisms, which is why a warned-about config still silently does nothing.

Prerequisites

  • Pydantic 2.13.4 — every transcript below was produced on that version.
  • pydantic-settings if you configure BaseSettings.

What v2 Actually Does — Executed

Rather than describe the behaviour, here it is measured. Each case defines a model at runtime with warnings recorded, then reports the resulting model_config:

"""What Pydantic v2 actually does with a v1-style `class Config`: warnings, renames, and errors."""
import warnings
from typing import Any

from pydantic import BaseModel, ConfigDict, ValidationError


def build(source: str) -> dict[str, Any]:
    """Define a model from source with warnings recorded, so the transcript shows the real ones."""
    namespace: dict[str, Any] = {"BaseModel": BaseModel, "ConfigDict": ConfigDict}
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        try:
            exec(source, namespace)
        except Exception as exc:
            return {
                "outcome": "raised at class definition",
                "exception_type": type(exc).__name__,
                "message": str(exc).split("\n")[0],
                "warnings": [str(w.message).split("\n")[0] for w in caught],
            }
    model = namespace["M"]
    return {
        "outcome": "class defined",
        "warnings": [f"{w.category.__name__}: {str(w.message)}" for w in caught],
        "model_config": {k: repr(v) for k, v in dict(model.model_config).items()},
    }


RENAMED = '''
class M(BaseModel):
    id: int
    class Config:
        orm_mode = True
'''

UNKNOWN = '''
class M(BaseModel):
    id: int
    class Config:
        allow_mutation = False
        not_a_real_key = 123
'''

BOTH = '''
class M(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    class Config:
        frozen = True
'''

Real output from _verify/output/up-pyd-class-config.txt:

$ GET /config/renamed-key
200 OK
{
  "outcome": "class defined",
  "warnings": [
    "PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/",
    "UserWarning: Valid config keys have changed in V2:\n* 'orm_mode' has been renamed to 'from_attributes'",
    "UserWarning: Valid config keys have changed in V2:\n* 'orm_mode' has been renamed to 'from_attributes'"
  ],
  "model_config": {
    "orm_mode": "True"
  }
}

Read the last three lines carefully. Pydantic named the replacement key, and then stored orm_mode. from_attributes is not in the config dict at all.

The unknown-key case is worse, because there is no warning to grep for:

$ GET /config/unknown-key
200 OK
{
  "outcome": "class defined",
  "warnings": [
    "PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/",
    "UserWarning: Valid config keys have changed in V2:\n* 'allow_mutation' has been removed",
    "UserWarning: Valid config keys have changed in V2:\n* 'allow_mutation' has been removed"
  ],
  "model_config": {
    "allow_mutation": "False",
    "not_a_real_key": "123"
  }
}

allow_mutation was on the removal list, so it warned. not_a_real_key was not, so it did not. Both are sitting in model_config doing nothing. If you mistype populate_by_name as populate_by_names, that is the behaviour you get: silence.

Mixing the two styles is the one case that fails loudly:

$ GET /config/both-styles
200 OK
{
  "outcome": "raised at class definition",
  "exception_type": "PydanticUserError",
  "message": "\"Config\" and \"model_config\" cannot be used together",
  "warnings": []
}

Proving the Setting Is Inert

The config dict is circumstantial evidence. The direct test is whether attribute-based validation actually works. Same Row object, two models — one configured the v1 way, one the v2 way:

class Row:
    """A stand-in for an ORM row: attributes, not a dict."""

    id = 7
    display_name = "ada"
$ GET /from-attributes/v1-config
200 OK
{
  "exception_type": "ValidationError",
  "from_attributes_in_effect": false,
  "errors": [
    {
      "type": "model_type",
      "msg": "Input should be a valid dictionary or instance of M"
    }
  ]
}

$ GET /from-attributes/v2-config
200 OK
{
  "validated_from_object": {
    "id": 7
  }
}

from_attributes_in_effect: false is the whole page in one line. The v1 config warned, was accepted, and left the feature switched off — so model_validate rejects the row with model_type. That is the 500 you eventually see in production, several layers away from the config block that caused it.

The Key Map

The v2 form for the common v1 keys:

v1 class Configv2 model_configNote
orm_mode = Truefrom_attributes=TrueRenamed. Warns, does not migrate.
allow_population_by_field_namepopulate_by_name=TrueRenamed.
anystr_strip_whitespacestr_strip_whitespace=TrueRenamed.
min_anystr_lengthstr_min_lengthRenamed.
allow_mutation = Falsefrozen=TrueRemoved, not renamed.
json_encodersfield/model serializersDeprecated; behaviour differs.
schema_extrajson_schema_extraRenamed.
underscore_attrs_are_private(dropped)Private attributes are inferred.

The correct v2 form, and what it produces:

class M(BaseModel):
    model_config = ConfigDict(from_attributes=True, populate_by_name=True, extra="forbid")
    id: int
$ GET /config/v2-form
200 OK
{
  "outcome": "class defined",
  "warnings": [],
  "model_config": {
    "from_attributes": "True",
    "populate_by_name": "True",
    "extra": "'forbid'",
    "validate_by_alias": "True",
    "validate_by_name": "True"
  }
}

Note that populate_by_name=True brought validate_by_alias and validate_by_name with it. Pydantic 2.13 splits the old single switch into two finer-grained ones and sets both for you, which is worth knowing if you assert on model_config contents in tests.

json_encoders deserves its own treatment, since it is a behaviour change rather than a rename — see replacing json_encoders with field_serializer.

Settings models take the settings-aware subclass:

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    # SettingsConfigDict for BaseSettings; ConfigDict for plain models.
    model_config = SettingsConfigDict(env_prefix="APP_", extra="forbid")
    database_url: str

A plain ConfigDict on a BaseSettings subclass is not an error — it just has nowhere to put env_prefix. The layered approach is covered in managing environment variables with Pydantic Settings.

Verification

Because the failure is silent, assert on the config rather than trusting the absence of warnings. Two checks catch essentially everything:

import warnings

import pytest


def test_no_model_uses_class_config():
    # The deprecation warning is the signal; turn it into a failure.
    with warnings.catch_warnings():
        warnings.simplefilter("error", DeprecationWarning)
        import app.models  # noqa: F401 — re-import under strict warnings


@pytest.mark.parametrize("model", [User, Order, Invoice])
def test_orm_models_really_read_attributes(model):
    assert model.model_config.get("from_attributes") is True

The second one is the important half. It asserts the setting is in effect, which is exactly what the warning does not tell you. A cheap CI grep for orm_mode, allow_population_by_field_name, schema_extra and json_encoders across the codebase is worth adding alongside it.

Trade-offs and When Not To

Leaving class Config in place is defensible in the short term. It works, it warns, and on a large codebase mid-migration a mechanical sweep of every model is a big diff to review at once. If you take that route, run your test suite with -W error::DeprecationWarning so the warnings are visible rather than scrolled past, and treat the rename list as the real work — the block style is cosmetic, the key names are not.

What is not defensible is a half-migrated inheritance chain. The PydanticUserError protects you from mixing the two styles on one class, but it cannot help across a hierarchy: a base model on model_config and a subclass on class Config is legal, and the merge order is now something you have to reason about. Standardise per inheritance chain at minimum.

Finally, extra is worth an explicit decision rather than a copy. v1 defaulted to ignoring unknown input fields, and a lot of code inherited that default without choosing it. If your API should reject unexpected keys, set extra="forbid" deliberately — it changes the error surface of every endpoint that uses the model, so it belongs in the migration plan described in migrating without breaking APIs, not in a drive-by commit.

FAQ

Does class Config still work in Pydantic v2? Yes, but it is deprecated and scheduled for removal in V3. On Pydantic 2.13.4 it emits PydanticDeprecatedSince20 and its keys are copied into model_config. Keys whose names did not change keep working; keys that were renamed do not.

Why does orm_mode still fail even though Pydantic warned me about it? Because the warning is only a warning. Pydantic detects the old name, tells you it is now from_attributes, and then stores the key unchanged as orm_mode. The core schema builder only reads from_attributes, so attribute validation stays off and model_validate rejects ORM rows.

Are unknown config keys rejected in Pydantic v2? No. A key Pydantic has never heard of, such as not_a_real_key, is stored in model_config with no warning at all. Only keys on the v1-to-v2 rename and removal list produce a warning, so typos pass silently.

Can I use class Config and model_config on the same model? No. Pydantic raises PydanticUserError with the message that Config and model_config cannot be used together, and it raises at class definition time, so the failure surfaces on import rather than on first request.

What does BaseSettings use instead of ConfigDict?SettingsConfigDict, from the separate pydantic-settings package. It is a superset of ConfigDict that adds settings-only keys such as env_prefix, env_file, and env_nested_delimiter, so a plain ConfigDict would drop those.