Migrate PostgresDsn.build from pydentic v1 to pydantic v2

Question:

I have simple Config class from FastAPI tutorial. But it seems like it uses old pydantic version. I run my code with pydantic v2 version and get a several errors. I fix almost all of them, but the last one I cannot fix yet. This is part of code which does not work:

from pydantic import AnyHttpUrl, HttpUrl, PostgresDsn, field_validator
from pydantic_settings import BaseSettings
from pydantic_core.core_schema import FieldValidationInfo

load_dotenv()


class Settings(BaseSettings):
    ...
    POSTGRES_SERVER: str = 'localhost:5432'
    POSTGRES_USER: str = os.getenv('POSTGRES_USER')
    POSTGRES_PASSWORD: str = os.getenv('POSTGRES_PASSWORD')
    POSTGRES_DB: str = os.getenv('POSTGRES_DB')
    SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None

    @field_validator("SQLALCHEMY_DATABASE_URI", mode='before')
    @classmethod
    def assemble_db_connection(cls, v: Optional[str], info: FieldValidationInfo) -> Any:
        if isinstance(v, str):
            return v
        postgres_dsn = PostgresDsn.build(
            scheme="postgresql",
            username=info.data.get("POSTGRES_USER"),
            password=info.data.get("POSTGRES_PASSWORD"),
            host=info.data.get("POSTGRES_SERVER"),
            path=f"{info.data.get('POSTGRES_DB') or ''}",
        )
        return str(postgres_dsn)

That is the error which I get:

sqlalchemy.exc.ArgumentError: Expected string or URL object, got MultiHostUrl('postgresql://user:password@localhost:5432/database')

I check a lot of places, but cannot find how I can fix that, it looks like build method pass data to the sqlalchemy create_engine method as a MultiHostUrl instance instead of string. How should I properly migrate this code to use pydantic v2?

UPDATE

I have fixed that issue by changing typing for SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None to SQLALCHEMY_DATABASE_URI: Optional[str] = None.
Because pydantic makes auto conversion of result for some reason. But I am not sure if that approach is the right one, maybe there are better way to do that?

Asked By: Vladyslav

||

Answers:

Pydantic V2 BaseSettings will automatically load values from the environment, based on prefix:

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix='POSTGRES_', case_sensitive=False)

    server: str = Field(default='localhost:5432')
    user: str = 'xxx'
    password: str = 'xxx'
    db: str = 'xxx'
    user: str = 'xxx'

    sqlalchemy_database_uri: Optional[PostgresDsn]


    @field_validator("SQLALCHEMY_DATABASE_URI", mode='after')
    def assemble_db_connection(cls, v: Optional[Union[PostgresDsn, str]], info: FieldValidationInfo) -> PostgresDsn:
        if isinstance(v, (str, PostgresDsn)):
            return v
        else:
            return PostgresDsn(
                scheme="postgresql",
                username=cls.user,
                password=cls.password,
                host=cls.server,
                path=cls.db,
            )

Also, your validator should operate after validation, since values need to be loaded first.

Answered By: Yaakov Bressler

I have met the same issue and fixed it with tipecasting on engine create:

from sqlalchemy.ext.asyncio import create_async_engine

create_async_engine(str(settings.POSTGRES_URI))
Answered By: Mastermind

You can use unicode_string() to stringify your URI as follow:

from sqlalchemy.ext.asyncio import create_async_engine

create_async_engine(settings.POSTGRES_URI.unicode_string())

Check documentation page here for additional explanation.

Answered By: farch

I had the same problem and eventually came up with the following migration:

Pydantic V1:
from pydantic import BaseSettings, PostgresDsn, validator


class Settings(BaseSettings):
    POSTGRES_SERVER: Optional[str]
    POSTGRES_USER: Optional[str]
    POSTGRES_PASSWORD: Optional[str]
    POSTGRES_DB: Optional[str]
    SQLALCHEMY_DATABASE_URI: Union[Optional[PostgresDsn], Optional[str]] = None

    @validator("SQLALCHEMY_DATABASE_URI", pre=True)
    def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any:
        if isinstance(v, str):
            print("Loading SQLALCHEMY_DATABASE_URI from docker.env file ...")
            return v
        print("Creating SQLALCHEMY_DATABASE_URI from .env file ...")
        return PostgresDsn.build(
            scheme="postgresql",
            user=values.get("POSTGRES_USER"),
            password=values.get("POSTGRES_PASSWORD"),
            host=values.get("POSTGRES_SERVER"),
            path=f"/{values.get('POSTGRES_DB') or ''}",
        )

    class Config:
        env_file = ".env"
        case_sensitive = True

Pydantic V2:
from pydantic import PostgresDsn, field_validator, ValidationInfo
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", case_sensitive=True)

    POSTGRES_SERVER: Optional[str] = None
    POSTGRES_USER: Optional[str] = None
    POSTGRES_PASSWORD: Optional[str] = None
    POSTGRES_DB: Optional[str] = None
    SQLALCHEMY_DATABASE_URI: Union[Optional[PostgresDsn], Optional[str]] = None

    @field_validator("SQLALCHEMY_DATABASE_URI", mode="before")
    @classmethod
    def assemble_db_connection(cls, v: Optional[str], values: ValidationInfo) -> Any:
        if isinstance(v, str):
            print("Loading SQLALCHEMY_DATABASE_URI from .docker.env file ...")
            return v
        print("Creating SQLALCHEMY_DATABASE_URI from .env file ...")
        return PostgresDsn.build(
            scheme="postgresql",
            username=values.data.get("POSTGRES_USER"),
            password=values.data.get("POSTGRES_PASSWORD"),
            host=values.data.get("POSTGRES_SERVER"),
            path=f"{values.data.get('POSTGRES_DB') or ''}",
        )          

Also, for calling the settings instance objects you need casting to string in V2:

settings.SQLALCHEMY_DATABASE_URI.unicode_string()
# or
f"{settings.SQLALCHEMY_DATABASE_URI}
Answered By: Benyamin Jafari

I ran into the same problem and this is how I fixed it.
No other changes were needed throughout the codebase.

Pydantic V2:

from typing import Optional
from pydantic import PostgresDsn, field_validator, ValidationInfo
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    POSTGRES_HOST: str
    POSTGRES_USER: str
    POSTGRES_PASSWORD: str
    POSTGRES_DB: str
    SQLALCHEMY_DATABASE_URI: Optional[str] = None

    @field_validator("SQLALCHEMY_DATABASE_URI", mode="before")
    @classmethod
    def assemble_db_uri(cls, field_value, info: ValidationInfo) -> str:
        if isinstance(field_value, str):
            return field_value
        return PostgresDsn.build(
            scheme="postgresql+psycopg2",
            username=info.data.get("POSTGRES_USER"),
            password=info.data.get("POSTGRES_PASSWORD"),
            host=info.data.get("POSTGRES_HOST"),
            path=info.data.get("POSTGRES_DB") or "",
        ).unicode_string()

Pydantic V1:

from typing import Any, Dict, Optional
from pydantic import BaseSettings, PostgresDsn, validator


class Settings(BaseSettings):
    POSTGRES_HOST: str
    POSTGRES_USER: str
    POSTGRES_PASSWORD: str
    POSTGRES_DB: str
    SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None

    @validator("SQLALCHEMY_DATABASE_URI", pre=True)
    def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any:
        if isinstance(v, str):
            return v
        return PostgresDsn.build(
            scheme="postgresql+psycopg2",
            user=values.get("POSTGRES_USER"),
            password=values.get("POSTGRES_PASSWORD"),
            host=values.get("POSTGRES_HOST"),
            path=f"/{values.get('POSTGRES_DB') or ''}",
        )

Here’s the diff:

> diff -u pydantic_v1.py pydantic_v2.py

--- pydantic_v1.py
+++ pydantic_v2.py
@@ -1,5 +1,6 @@
-from typing import Any, Dict, Optional
-from pydantic import BaseSettings, PostgresDsn, validator
+from typing import Optional
+from pydantic import PostgresDsn, field_validator, ValidationInfo
+from pydantic_settings import BaseSettings
 
 
 class Settings(BaseSettings):
@@ -7,16 +8,17 @@
     POSTGRES_USER: str
     POSTGRES_PASSWORD: str
     POSTGRES_DB: str
-    SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None
+    SQLALCHEMY_DATABASE_URI: Optional[str] = None
 
-    @validator("SQLALCHEMY_DATABASE_URI", pre=True)
-    def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any:
-        if isinstance(v, str):
-            return v
+    @field_validator("SQLALCHEMY_DATABASE_URI", mode="before")
+    @classmethod
+    def assemble_db_uri(cls, field_value, info: ValidationInfo) -> str:
+        if isinstance(field_value, str):
+            return field_value
         return PostgresDsn.build(
             scheme="postgresql+psycopg2",
-            user=values.get("POSTGRES_USER"),
-            password=values.get("POSTGRES_PASSWORD"),
-            host=values.get("POSTGRES_HOST"),
-            path=f"/{values.get('POSTGRES_DB') or ''}",
-        )
+            username=info.data.get("POSTGRES_USER"),
+            password=info.data.get("POSTGRES_PASSWORD"),
+            host=info.data.get("POSTGRES_HOST"),
+            path=info.data.get("POSTGRES_DB") or "",
+        ).unicode_string()
Answered By: Miguel Sousa