82 lines
3.1 KiB
Markdown
82 lines
3.1 KiB
Markdown
---
|
|
title: "FastAPI ORM @property for JSON-Column Fields — No Migration Needed"
|
|
aliases: [orm-property-json-column, sqlalchemy-json-property, fastapi-no-migration-field]
|
|
tags: [fastapi, sqlalchemy, python, orm, json, migration, pattern]
|
|
sources:
|
|
- "daily/2026-05-07.md"
|
|
created: 2026-05-07
|
|
updated: 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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.
|
|
|
|
## 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 | — | ✓ |
|
|
|
|
## Related Concepts
|
|
|
|
- [[wiki/concepts/fastapi-response-model-silent-field-strip]] — FastAPI schema omission silently strips fields; adding a `@property` only helps if the field is also in the Pydantic model
|
|
- [[wiki/tech-patterns/fastapi-python-docker]] — FastAPI deployment context
|
|
|
|
## 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
|