Question: Can anyone explain how Pydantic manages attribute names with an underscore? In Pydantic models, there is a weird behavior related to attribute naming when using the underscore. That behavior does not occur in python classes. The test results show some allegedly "unexpected" errors. The following code is catching some errors for simple class and instance operations: class PydanticClass(BaseModel): a: int = "AAA" _z: int = "_Z_Z_Z" @classmethod def get_a(cls): return cls.a @classmethod def get_z(cls): return cls._z class NonPydanticClass: a: int = "AAA" _z: int = "_Z_Z_Z" @classmethod def get_a(cls): return cls.a @classmethod def get_z(cls): return cls._z print ("PYDANTIC MODEL CLASS TEST:") pydantic_instance = PydanticClass() try: msg1, msg2 = "SUCCESS", f"{pydantic_instance.get_a()}" except Exception as e: msg1, msg2 = "ERROR", f"{e}" print('{0:<7} :: {1:<27} :: {2:<65} :: {3}'.format(msg1, "1 pydantic_instance.get_a()", "Accessing non-underscored attributes names in a class method", msg2)) try: msg1, msg2 = "SUCCESS", f"{pydantic_instance.get_z()}" except Exception as e: msg1, msg2 = "ERROR", f"{e}" print('{0:<7} :: {1:<27} :: {2:<65} :: {3}'.format(msg1, "2 pydantic_instance.get_z()", "Accessing underscored attributes names in a class method", msg2)) try: msg1, msg2 = "SUCCESS", f"{PydanticClass.a}" except Exception as e: msg1, msg2 = "ERROR", f"{e}" print('{0:<7} :: {1:<27} :: {2:<65} :: {3}'.format(msg1, "3 PydanticClass.a ","Accessing non-underscored attribute names as a 'static attribute'", msg2)) try: msg1, msg2 = "SUCCESS", f"{PydanticClass._z}" except Exception as e: msg1, msg2 = "ERROR", f"{e}" print('{0:<7} :: {1:<27} :: {2:<65} :: {3}'.format(msg1, "4 PydanticClass._z","Accessing underscored attributes names as a 'static attribute'", msg2)) print ("\nNON PYDANTIC CLASS TEST:") nom_pydantic_instance = NonPydanticClass() try: msg1, msg2 = "SUCCESS", f"{nom_pydantic_instance.get_a()}" except Exception as e: msg1, msg2 = "ERROR", f"{e}" print('{0:<7} :: {1:<27} :: {2:<65} :: {3}'.format(msg1, "1 class_instance.get_a()", "Accessing non-underscored attributes names in a class method", msg2)) try: msg1, msg2 = "SUCCESS", f"{nom_pydantic_instance.get_z()}" except Exception as e: msg1, msg2 = "ERROR", f"{e}" print('{0:<7} :: {1:<27} :: {2:<65} :: {3}'.format(msg1, "2 class_instance.get_z()", "Accessing underscored attributes names in a class method", msg2)) try: msg1, msg2 = "SUCCESS", f"{NonPydanticClass.a}" except Exception as e: msg1, msg2 = "ERROR", f"{e}" print('{0:<7} :: {1:<27} :: {2:<65} :: {3}'.format(msg1, "3 PydanticClass.a ","Accessing non-underscored attribute names as a 'static attribute'", msg2)) try: msg1, msg2 = "SUCCESS", f"{NonPydanticClass._z}" except Exception as e: msg1, msg2 = "ERROR", f"{e}" print('{0:<7} :: {1:<27} :: {2:<65} :: {3}'.format(msg1, "4 PydanticClass._z","Accessing underscored attributes names as a 'static attribute'", msg2))  Here are the results: PYDANTIC MODEL CLASS TEST: ERROR :: 1 pydantic_instance.get_a() :: Accessing non-underscored attributes names in a class method :: type object 'PydanticClass' has no attribute 'a' SUCCESS :: 2 pydantic_instance.get_z() :: Accessing underscored attributes names in a class method :: _Z_Z_Z ERROR :: 3 PydanticClass.a :: Accessing non-underscored attribute names as a 'static attribute' :: type object 'PydanticClass' has no attribute 'a' SUCCESS :: 4 PydanticClass._z :: Accessing underscored attributes names as a 'static attribute' :: _Z_Z_Z NON PYDANTIC CLASS TEST: SUCCESS :: 1 class_instance.get_a() :: Accessing non-underscored attributes names in a class method :: AAA SUCCESS :: 2 class_instance.get_z() :: Accessing underscored attributes names in a class method :: _Z_Z_Z SUCCESS :: 3 PydanticClass.a :: Accessing non-underscored attribute names as a 'static attribute' :: AAA SUCCESS :: 4 PydanticClass._z :: Accessing underscored attributes names as a 'static attribute' :: _Z_Z_Z 

Answer: This different treatment of underscored variables is actually explicitly documented in the section "Automatically excluded attributes", although it is not worded clearly enough and could be better explained in my humble opinion.  UPDATE (2023-08-16): In fairness, the Pydantic v2 docs do a better job by explicitly stating that they are not treated as fields. See below for more on what that means.  One crucial thing to understand about why Pydantic models treat their namespace differently than "regular" Python classes is that by default Pydantic constructs a field for every name declared in its namespace. A Pydantic field is a special construct that behaves differently than regular class/instance attributes would by design. A field can only be written to and read from on instances of a model. Even though you can "assign a value" in the class namespace definition, under the hood that is treated as defining a default value for that field and the value is not retained as a class attribute (unlike with regular classes). This is done by the meta class (source) of BaseModel, whenever you subclass it: from pprint import pprint from pydantic import BaseModel class Model(BaseModel): a: int = "foo" _b: int = "spam" pprint(dict(Model.__dict__)) print(f"{hasattr(Model, 'a')=}") print(f"{hasattr(Model, '_b')=}")  Output (reduced) for Pydantic v1: {# ... '__annotations__': {'_b': <class 'int'>, 'a': <class 'int'>}, # ... '__fields__': {'a': ModelField(name='a', type=int, required=False, default='foo')}, # ... '_b': 'spam'} hasattr(Model, 'a')=False hasattr(Model, '_b')=True  As you can see by checking the class' __dict__, once the subclass is fully constructed, there is no attribute a left on Model, which is why you get an error, when you try to do get it via Model.a (or inside a classmethod via cls.a, which is equivalent). Instead it has a field a that can be accessed from instances of Model and that field will receive the value "foo", unless another value is passed to the constructor. Underscored names inside a model's definition are an exception to this behavior. Let me quote:  Class variables which begin with an underscore [...] will be automatically excluded from the model.  What "excluded from the model" means is that underscored names are actually treated like "normal" class attributes by Pydantic v1 models. Meaning if you assign a value to them in the definition, you can later access that from the class directly. But they do not get all the special bells and whistles that fields get. They are just plain old attributes. And while those attributes can be read from both the class and an instance of that class, they can not be re-assigned on an instance (neither from the outside nor in an instance method) in Pydantic v1: # Pydantic v1 from pydantic import BaseModel class Model(BaseModel): _b: str = "spam" obj = Model() print(obj._b) # spam try: obj._b = "eggs" except Exception as e: print(repr(e)) # ValueError('"Model" object has no field "_b"')  The reason for that is that Pydantic v1 models also have a custom __setattr__ implementation, which by default raises a ValueError, if you try to assign to a non-field (source). If you need underscored (protected) attributes for instances of your model, in Pydantic v1 you need to define them accordingly, using either PrivateAttr or the Config setting to make underscored attributes private by default: # Pydantic v1 from pydantic import BaseModel, PrivateAttr class Model(BaseModel): _b: str = PrivateAttr("spam") obj = Model() print(obj._b) # spam obj._b = "eggs" print(obj._b) # eggs  This behavior may seem weird, when you first notice it, if you assume fields behave like regular class/instance attributes, but you get used to it, once you understand that this different behavior is necessary to enable all the magic that Pydantic models provide.  UPDATE: With Pydantic v2 this is no longer necessary because all single-underscored attributes are automatically converted to "private attributes" and can be set as you would expect with normal classes: # Pydantic v2 from pydantic import BaseModel class Model(BaseModel): _b: str = "spam" obj = Model() print(obj._b) # spam obj._b = "eggs" print(obj._b) # eggs  This is arguably a less confusing, more consistent approach now. But the distinction between regular fields and private attributes is still present and important in v2.