User Profile — One-to-One Relationship

A one-to-one relationship pairs one user with one profile, one order with one shipping address, one company with one billing account. In SQLAlchemy, one-to-one is a special case of one-to-many where the “many” side is constrained to at most one row — enforced by a UNIQUE constraint on the foreign key column and by using Mapped["Profile | None"] (not Mapped[list["Profile"]]). The UserProfile pattern is common: users have a profile with bio, avatar, and social links stored separately to keep the core users table lean and allow profile fields to be null without cluttering the main table.

UserProfile Model — One-to-One

# app/models/user_profile.py
from sqlalchemy import String, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from datetime import datetime
from app.database import Base

class UserProfile(Base):
    __tablename__ = "user_profiles"

    id:          Mapped[int]          = mapped_column(primary_key=True)
    # UNIQUE on user_id enforces the one-to-one constraint
    user_id:     Mapped[int]          = mapped_column(
                     ForeignKey("users.id", ondelete="CASCADE"),
                     unique=True,        # ← the key to one-to-one
                     index=True,
                 )
    bio:         Mapped[str | None]   = mapped_column(Text,        nullable=True)
    avatar_url:  Mapped[str | None]   = mapped_column(String(500), nullable=True)
    website_url: Mapped[str | None]   = mapped_column(String(500), nullable=True)
    twitter:     Mapped[str | None]   = mapped_column(String(100), nullable=True)
    github:      Mapped[str | None]   = mapped_column(String(100), nullable=True)
    updated_at:  Mapped[datetime]     = mapped_column(
                     server_default=func.now(), onupdate=func.now())

    # ── Relationship ──────────────────────────────────────────────────────────
    user: Mapped["User"] = relationship(back_populates="profile")


# In User model:
# profile: Mapped["UserProfile | None"] = relationship(
#     back_populates="user",
#     uselist=False,         # ← critical: makes it return one object, not a list
#     cascade="all, delete-orphan",
# )
Note: uselist=False on the User.profile relationship is what makes it one-to-one from the Python perspective — without it, user.profile would return a list (one-to-many behaviour). The UNIQUE constraint on user_profiles.user_id enforces one-to-one at the database level. Both are needed: the UNIQUE prevents duplicate profile rows; uselist=False makes the ORM return a single object instead of a list. If you forget uselist=False, your code will receive [profile] instead of profile.
Tip: Decide whether a profile is mandatory (created automatically at registration) or optional (created lazily when the user first visits the profile page). Mandatory creation is simpler to query — you can always do user.profile.bio without a null check. Lazy creation reduces the number of rows created for users who never complete their profile. For the blog application, create the profile automatically during user registration to ensure every user has one.
Warning: When loading a User and needing their profile, always use joinedload(User.profile) in the query rather than lazy loading. Accessing user.profile after the session is closed (or in an async context) will trigger a lazy load that either fails with DetachedInstanceError or makes an unexpected extra database query. For the user-detail endpoint, explicitly eager-load the profile as part of the response query.

Profile Schemas and Router

from pydantic import BaseModel, Field, AnyHttpUrl
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload

class ProfileUpdate(BaseModel):
    bio:         str | None = Field(None, max_length=500)
    avatar_url:  str | None = None
    website_url: str | None = None
    twitter:     str | None = Field(None, max_length=50, pattern=r"^@?[\w]+$")
    github:      str | None = Field(None, max_length=50, pattern=r"^[\w-]+$")

class ProfileResponse(BaseModel):
    model_config = {"from_attributes": True}
    bio:         str | None
    avatar_url:  str | None
    website_url: str | None
    twitter:     str | None
    github:      str | None

class UserWithProfile(BaseModel):
    model_config = {"from_attributes": True}
    id:      int
    name:    str
    email:   str
    profile: ProfileResponse | None = None

router = APIRouter(prefix="/users", tags=["Users"])

@router.get("/{user_id}", response_model=UserWithProfile)
def get_user(user_id: int, db: Session = Depends(get_db)):
    user = db.scalars(
        select(User)
        .options(joinedload(User.profile))   # eager-load profile
        .where(User.id == user_id)
    ).first()
    if not user:
        raise HTTPException(404, "User not found")
    return user

@router.put("/me/profile", response_model=ProfileResponse)
def update_my_profile(
    update_in:    ProfileUpdate,
    db:           Session = Depends(get_db),
    current_user: User    = Depends(get_current_user),
):
    # Get or create profile
    profile = db.scalars(
        select(UserProfile).where(UserProfile.user_id == current_user.id)
    ).first()

    if not profile:
        profile = UserProfile(user_id=current_user.id)
        db.add(profile)

    for field, value in update_in.model_dump(exclude_none=True).items():
        setattr(profile, field, value)

    db.flush()
    db.refresh(profile)
    return profile

Auto-Create Profile at Registration

@router.post("/register", response_model=UserWithProfile, status_code=201)
def register(user_in: UserCreate, db: Session = Depends(get_db)):
    # Create user
    user = User(
        email         = user_in.email,
        name          = user_in.name,
        password_hash = hash_password(user_in.password),
    )
    db.add(user)
    db.flush()   # get user.id

    # Automatically create empty profile
    profile = UserProfile(user_id=user.id)
    db.add(profile)
    db.flush()
    db.refresh(user)
    return user

Common Mistakes

Mistake 1 — Forgetting uselist=False (profile returned as list)

❌ Wrong — profile is a list:

profile: Mapped["UserProfile"] = relationship(back_populates="user")
# user.profile → [UserProfile(...)] not UserProfile(...)!

✅ Correct:

profile: Mapped["UserProfile | None"] = relationship(
    back_populates="user", uselist=False   # ✓ returns one object
)

Mistake 2 — Missing UNIQUE constraint (two profiles for one user)

❌ Wrong — no uniqueness enforced at DB level:

user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))   # no unique!

✅ Correct:

user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)   # ✓

Mistake 3 — Lazy loading profile outside session

❌ Wrong — profile accessed after session closed:

user = db.get(User, user_id)
return UserWithProfile(id=user.id, profile=user.profile)   # profile lazy load fails!

✅ Correct — eager load with joinedload before accessing.

Quick Reference

Element Code
One-to-one FK user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
One side of relation profile: Mapped["UserProfile | None"] = relationship(uselist=False)
Load with profile select(User).options(joinedload(User.profile))
Get or create Query first, create if None, flush
Auto-create at register flush User to get ID, then add UserProfile

🧠 Test Yourself

You query a user without joinedload and then try to access user.profile.bio in a FastAPI route handler after the with Session(engine) as session: block exits. What error occurs?