obsidian/wiki/concepts/fastapi-orm-property-json-column.md
2026-05-14 21:09:52 +01:00

4.3 KiB

title aliases tags sources created updated
FastAPI ORM @property for JSON-Column Fields — No Migration Needed
orm-property-json-column
sqlalchemy-json-property
fastapi-no-migration-field
fastapi
sqlalchemy
python
orm
json
migration
pattern
daily/2026-05-07.md
2026-05-07 2026-05-07

FastAPI ORM @property for JSON-Column Fields — No Migration Needed

When an ORM model already stores data in a JSON column (e.g. fields_json), new read-only display fields can be exposed via Python @property on the model class — zero Alembic migration, zero schema change, forward-compatible.

Key Points

  • If the data already exists inside a JSON column, a @property surfaces it as a first-class attribute on the model
  • No ALTER TABLE, no Alembic revision, no downtime — the column is already there
  • Works seamlessly with FastAPI's response_model: add the field to the Pydantic schema and it resolves via the property
  • Use this pattern for read-only derived or display fields; for fields that need filtering/ordering in SQL, a real column is still required
  • The property must be declared with a @property decorator; SQLAlchemy does not auto-expose JSON sub-keys

Pattern

from sqlalchemy import Column, String, JSON
from sqlalchemy.orm import DeclarativeBase

class Base(DeclarativeBase):
    pass

class Task(Base):
    __tablename__ = "tasks"

    id = Column(String, primary_key=True)
    title = Column(String)
    fields_json = Column(JSON, default=dict)   # existing JSON column

    # ← New display fields via @property — no migration needed
    @property
    def tags(self) -> list[str]:
        return (self.fields_json or {}).get("tags", [])

    @property
    def priority(self) -> str | None:
        return (self.fields_json or {}).get("priority")

Pydantic Schema

from pydantic import BaseModel, ConfigDict

class TaskResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)  # enables ORM mode

    id: str
    title: str
    tags: list[str] = []       # ← resolved from ORM @property
    priority: str | None = None

[!info] from_attributes=True is required Pydantic v2 from_attributes=True (previously orm_mode = True in v1) allows the schema to read Python object attributes, including @property, not just dict keys.

Pydantic v2 Gotcha — @property Is NOT Serialized

In Pydantic v2, a plain @property on a Pydantic model class is not included in .model_dump() or JSON serialization. The property must be declared as @computed_field to appear in the API response.

# WRONG in Pydantic v2 — property not serialized
class TaskResponse(BaseModel):
    fields_json: dict = {}

    @property
    def tags(self) -> list[str]:
        return self.fields_json.get("tags", [])
    # tags will NOT appear in .model_dump() or JSON response

# CORRECT — use @computed_field
from pydantic import computed_field

class TaskResponse(BaseModel):
    fields_json: dict = {}

    @computed_field
    @property
    def tags(self) -> list[str]:
        return self.fields_json.get("tags", [])
    # tags WILL appear in .model_dump() and JSON response

Note: @computed_field is a Pydantic v2 feature — not available in Pydantic v1.

For SQLAlchemy ORM models (not Pydantic models), @property works fine because Pydantic reads attributes via from_attributes=True. The @computed_field workaround is only needed when the property is on the Pydantic schema class itself.

When to Use

Scenario Use @property Use real column
Display-only field, data already in JSON
Field needs SQL WHERE/ORDER BY
No Alembic migration budget
Aggregation or JOIN on this field

Sources

  • daily/2026-05-07.md — Session 12:09: Vue 3 + FastAPI + Planka + Azure DevOps integration; tags and ado_work_item_id surfaced via @property from fields_json without adding DB columns