74 lines
2.6 KiB
Markdown
74 lines
2.6 KiB
Markdown
---
|
|
title: "MongoDB Enum Deserialization to String"
|
|
tags: [mongodb, pydantic, python, enum, fastapi, bug-pattern]
|
|
sources: [99 Daily/2026-04-29.md]
|
|
created: 2026-04-30
|
|
updated: 2026-04-30
|
|
---
|
|
|
|
# MongoDB Enum Deserialization to String
|
|
|
|
MongoDB stores Python `Enum` fields as their raw string (or int) values. When a document is read back and deserialized into a Pydantic model, the field may arrive as a plain `str` instead of the `Enum` member — depending on the Pydantic version, ODM, and field configuration.
|
|
|
|
## The Bug
|
|
|
|
```python
|
|
class UserRole(str, Enum):
|
|
ADMIN = "admin"
|
|
VIEWER = "viewer"
|
|
|
|
class User(BaseModel):
|
|
role: UserRole
|
|
```
|
|
|
|
After reading from MongoDB, `user.role` may be `"admin"` (a `str`), not `UserRole.ADMIN`. Calling `.value` on it then crashes:
|
|
|
|
```python
|
|
user.role.value # AttributeError: 'str' object has no attribute 'value'
|
|
```
|
|
|
|
This 500 is often **silent in production** — FastAPI catches it generically and logs it as an unhandled exception, not obviously linked to the Enum field.
|
|
|
|
## Root Cause
|
|
|
|
The crash occurred in `audit_logger.py:82` calling `user.role.value` under the assumption that `role` was a Python `Enum`. MongoDB had stored it as `"admin"` and the ODM passed the raw string through without coercion.
|
|
|
|
## Guard Pattern (Short-term)
|
|
|
|
```python
|
|
role_value = user.role.value if hasattr(user.role, "value") else user.role
|
|
```
|
|
|
|
Works at any call site but is defensive noise. Prefer the boundary fix below.
|
|
|
|
## Boundary Fix (Long-term)
|
|
|
|
Coerce at deserialization time using a Pydantic validator so all downstream code can safely assume the field is the right type:
|
|
|
|
```python
|
|
from pydantic import field_validator
|
|
|
|
class User(BaseModel):
|
|
role: UserRole
|
|
|
|
@field_validator("role", mode="before")
|
|
@classmethod
|
|
def coerce_role(cls, v):
|
|
if isinstance(v, UserRole):
|
|
return v
|
|
return UserRole(v) # raises ValueError if value is unknown
|
|
```
|
|
|
|
With Pydantic v2 and `model_config = ConfigDict(use_enum_values=True)` the Enum is stored as its value — reading it back requires the validator to re-wrap it.
|
|
|
|
## When It Happens
|
|
|
|
- Motor / PyMongo (no ODM) — always returns raw BSON types; no Enum coercion
|
|
- Beanie ODM — coerces correctly in most cases; can break with `use_enum_values=True`
|
|
- Pydantic v1 `orm_mode` — coerces from raw value automatically
|
|
- Pydantic v2 — requires explicit `mode="before"` validator or `ConfigDict(use_enum_values=False)`
|
|
|
|
## Related
|
|
|
|
- [[wiki/concepts/pydantic-default-factory-type-alias|pydantic-default-factory-type-alias]] — another silent Pydantic/MongoDB mismatch
|
|
- [[wiki/tech-patterns/fastapi-patterns|fastapi-patterns]] — FastAPI error handling
|