---
title: "Pydantic v2 — Field(alias='_id') Serializes as _id, Breaking Frontend .id Access"
aliases: [pydantic-v2-alias, pydantic-alias-id, pydantic-mongodb-id, fastapi-mongodb-id]
tags: [pydantic, fastapi, mongodb, python, frontend, debugging, gotcha]
sources:
  - "daily/2026-04-27.md"
created: 2026-04-27
updated: 2026-04-27
---

# Pydantic v2 — Field(alias='_id') Serializes as _id, Breaking Frontend .id Access

When a Pydantic v2 response model uses `Field(alias="_id")` to map MongoDB's `_id` field, the JSON response contains the key `_id` — not `id`. Frontend JavaScript code that reads `.id` from the response gets `undefined`. Downstream effects are silent and varied: dropdown selects that use `value={item.id}` silently use the display text as the value, causing API calls with URL paths like `/clients/3M/teams` (using the name) instead of `/clients/64a3b.../teams` (using the ObjectId).

## Key Points

- **`Field(alias="_id")` in Pydantic v2** causes the response JSON key to be `_id`, not `id` — even if the Python attribute is named `id`
- **Frontend receives `{_id: "...", name: "..."}` not `{id: "...", name: "..."}`** — accessing `.id` returns `undefined`
- **Silent failure in dropdowns**: a `<Select value={item.id}>` where `item.id === undefined` causes the browser to use the label text as the option value — the wrong value is sent to the API
- **The fix**: remove the alias from response models; use an explicit `_from_doc()` helper that maps `_id → id` and drops the MongoDB key
- Pydantic v1 handled this differently — `response_model_by_alias=True` was a common workaround; v2 changes the alias serialization behavior

## Details

### The Problem

```python
# ❌ BROKEN Pydantic v2 response model
class ClientResponse(BaseModel):
    id: str = Field(alias="_id")     # causes JSON to contain "_id" not "id"
    name: str
    domain: str
```

```json
// JSON response from FastAPI
{"_id": "64a3b...", "name": "3M", "domain": "3m.com"}
```

```typescript
// Frontend
const { data: clients } = await fetch("/clients")
clients.map(c => c.id)    // → [undefined, undefined, undefined]
                           // c._id has the value, but JS reads c.id
```

When a React `<Select>` uses `value={client.id}` and `client.id` is `undefined`, React renders the option but the value attribute is the display text of the selected option. The form submits the client's name ("3M") as the ID. The API endpoint `/clients/3M/teams` returns 404.

### The Fix: Explicit _from_doc() Helper

Remove all aliases from response models. Use a classmethod to construct the model from a MongoDB document:

```python
# ✅ CORRECT — no alias, explicit mapping
class ClientResponse(BaseModel):
    id: str                          # JSON key is "id"
    name: str
    domain: str

    @classmethod
    def _from_doc(cls, doc: dict) -> "ClientResponse":
        return cls(
            id=str(doc["_id"]),      # explicit rename from _id to id
            name=doc["name"],
            domain=doc.get("domain", ""),
        )
```

```python
# In the route
@router.get("/clients")
async def list_clients(db=Depends(get_db)):
    docs = await db.clients.find().to_list(None)
    return [ClientResponse._from_doc(doc) for doc in docs]
```

### Frontend Defensive Guard

While fixing the backend is the correct solution, add a guard in hooks to catch future regressions:

```typescript
// React hook with guard
const { data } = await fetch("/clients")
if (data[0]?.id === undefined) {
    console.error("API response missing 'id' field — backend may be returning '_id'")
}
```

Also guard against `undefined` being used as a Select value:

```typescript
// Only fetch teams if we have a valid client ID
const { data: teams } = useQuery(
    ["teams", clientId],
    () => fetchTeams(clientId),
    { enabled: clientId !== undefined && clientId !== "" }
)
```

### Pydantic v2 Behavior vs v1

In Pydantic v1, `Field(alias="_id")` worked differently with `response_model_by_alias=True` in FastAPI — it would serialize using the alias (`_id`) in responses. This was actually the intended behavior for MongoDB models, but it produced the same frontend-breaking JSON key.

In Pydantic v2, the model's serialization always uses the alias if set, regardless of FastAPI's `by_alias` setting. The correct approach in v2 is to avoid aliases on response models entirely and use `_from_doc()` helpers.

### Which Models Are Affected

This issue affects any Pydantic response model that maps MongoDB's `_id`:

```python
# All of these produce "_id" in JSON response (v2 behavior)
id: str = Field(alias="_id")
id: PyObjectId = Field(alias="_id")
id: Annotated[str, Field(alias="_id")]
```

The pattern is ubiquitous in FastAPI+MongoDB tutorials because it matches Pydantic v1 idioms that no longer work correctly in v2.

### Real Incident (2026-04-27)

video-accessibility admin panel: `ClientResponse`, `TeamResponse`, and `ProjectResponse` all used `Field(alias="_id")`. The client/team/project cascading dropdown selects silently used display names as values. Creating a job for "3M" → Team dropdown → API called `/clients/3M/teams` → 404. Fixed in commit during the 2026-04-27 session by removing aliases and adding `_from_doc()` classmethod to all three response models.

## Related Concepts

- [[wiki/concepts/fastapi-mongodb-role-migration]] — FastAPI + MongoDB backend patterns; `_from_doc` helper fits into this same pattern
- [[wiki/tech-patterns/fastapi-python-docker]] — FastAPI tech stack used in Oliver Agency projects
- [[wiki/concepts/export-endpoint-filter-pattern]] — another case of frontend state being silently wrong due to ID mismatch

## Sources

- [[daily/2026-04-27.md]] — video-accessibility admin dropdown 404s; root cause was Pydantic v2 `Field(alias="_id")` serializing as `_id` in JSON; `client.id === undefined` on frontend; fixed by removing aliases and adding `_from_doc()` helpers; same pattern applied to Team and Project models
