Skip to content

Pydantic Serialization: model_dump, JSON and Aliases

So far, data has been coming into our models. That is validation: outside data becomes a model. This lesson is about the opposite direction, serialization: a model becomes a dictionary or JSON text, ready to send to a browser, save in a file or pass to another system.

DirectionMethodResult
model to dictmodel.model_dump()Python dictionary
model to JSONmodel.model_dump_json()JSON text (a str)
dict to modelModel.model_validate(data)model
JSON to modelModel.model_validate_json(text)model

This lesson follows 04_serialization.py. All the examples in the first four sections use this Employee model, which contains a nested Address:

from datetime import date
from decimal import Decimal
from pydantic import BaseModel

class Address(BaseModel):
    city: str
    pin_code: str


class Employee(BaseModel):
    id: int
    first_name: str
    last_name: str
    email: str
    salary: Decimal
    joined_on: date
    manager_id: int | None = None
    is_active: bool = True
    address: Address | None = None


employee = Employee(
    id=1, first_name="Asha", last_name="Rao", email="asha@example.com", salary="75000.50",
    joined_on="2024-04-01", address={"city": "Pune", "pin_code": "411001"},
)

1. model_dump(): model to dict

data = employee.model_dump()
print(data)
print("Type of salary in the dict:", type(data["salary"]).__name__)     # Decimal stays Decimal

# mode="json" turns every value into a JSON-friendly type (Decimal/date -> str)
print(employee.model_dump(mode="json"))
Output
{'id': 1, 'first_name': 'Asha', 'last_name': 'Rao', 'email': 'asha@example.com', 'salary': Decimal('75000.50'), 'joined_on': datetime.date(2024, 4, 1), 'manager_id': None, 'is_active': True, 'address': {'city': 'Pune', 'pin_code': '411001'}}
Type of salary in the dict: Decimal
{'id': 1, 'first_name': 'Asha', 'last_name': 'Rao', 'email': 'asha@example.com', 'salary': '75000.50', 'joined_on': '2024-04-01', 'manager_id': None, 'is_active': True, 'address': {'city': 'Pune', 'pin_code': '411001'}}
  • The nested Address model also became a dictionary. model_dump() converts everything, all the way down.
  • By default the values keep their Python types: Decimal stays Decimal, date stays date. That's what you want when you pass the data to other Python code, for example to create a database row.
  • With mode="json", values become JSON-friendly: Decimal and date become strings.

2. model_dump_json(): model to JSON text

print(employee.model_dump_json())
print(employee.model_dump_json(indent=2))
Output
{"id":1,"first_name":"Asha","last_name":"Rao","email":"asha@example.com","salary":"75000.50","joined_on":"2024-04-01","manager_id":null,"is_active":true,"address":{"city":"Pune","pin_code":"411001"}}
{
  "id": 1,
  "first_name": "Asha",
  "last_name": "Rao",
  "email": "asha@example.com",
  "salary": "75000.50",
  "joined_on": "2024-04-01",
  "manager_id": null,
  "is_active": true,
  "address": {
    "city": "Pune",
    "pin_code": "411001"
  }
}

One call gives you valid JSON: None becomes null, True becomes true, and dates are written in the standard YYYY-MM-DD format. You don't need json.dumps(), and you won't get the famous "Object of type Decimal is not JSON serializable" error. indent=2 makes it readable for humans.

3. Choosing which fields to output

You rarely want to send every field. model_dump() and model_dump_json() take options to choose:

print("include         :", employee.model_dump(include={"id", "first_name"}))
print("exclude         :", employee.model_dump(exclude={"salary", "address"}))
print("nested exclude  :", employee.model_dump(exclude={"address": {"pin_code"}}, include={"id", "address"}))
print("exclude_none    :", employee.model_dump(exclude_none=True, include={"id", "manager_id"}))
print("exclude_defaults:", employee.model_dump(exclude_defaults=True))
Output
include         : {'id': 1, 'first_name': 'Asha'}
exclude         : {'id': 1, 'first_name': 'Asha', 'last_name': 'Rao', 'email': 'asha@example.com', 'joined_on': datetime.date(2024, 4, 1), 'manager_id': None, 'is_active': True}
nested exclude  : {'id': 1, 'address': {'city': 'Pune'}}
exclude_none    : {'id': 1}
exclude_defaults: {'id': 1, 'first_name': 'Asha', 'last_name': 'Rao', 'email': 'asha@example.com', 'salary': Decimal('75000.50'), 'joined_on': datetime.date(2024, 4, 1), 'address': {'city': 'Pune', 'pin_code': '411001'}}
OptionKeeps / removes
include={...}Keeps only these fields
exclude={...}Removes these fields
exclude={"address": {"pin_code"}}Removes a field inside a nested model
exclude_none=TrueRemoves fields whose value is None (manager_id was removed)
exclude_defaults=TrueRemoves fields that still have their default value (manager_id, is_active)
exclude_unset=TrueKeeps only fields that were actually given when the model was created

exclude_unset: very important for updates

changes = Employee.model_validate({"id": 1, "first_name": "A", "last_name": "B", "email": "e",
                                   "salary": 1, "joined_on": "2024-01-01"})
print("exclude_unset   :", changes.model_dump(exclude_unset=True))
print("fields that were set:", changes.model_fields_set)
Output (the order of the set may differ)
exclude_unset   : {'id': 1, 'first_name': 'A', 'last_name': 'B', 'email': 'e', 'salary': Decimal('1'), 'joined_on': datetime.date(2024, 1, 1)}
fields that were set: {'joined_on', 'email', 'salary', 'id', 'first_name', 'last_name'}

manager_id, is_active and address were not in the input, so they are left out. Pydantic remembers which fields were set in model_fields_set.

Why does this matter? When a user edits only their phone number, you must update only the phone number, not overwrite every other column with defaults or None. exclude_unset=True gives you exactly the fields the user sent. We use this for real database updates in lesson 06.

4. Round trip: model to JSON and back

json_text = employee.model_dump_json()
again = Employee.model_validate_json(json_text)
print(again)
print("Same as the original?", again == employee)
Output
id=1 first_name='Asha' last_name='Rao' email='asha@example.com' salary=Decimal('75000.50') joined_on=datetime.date(2024, 4, 1) manager_id=None is_active=True address=Address(city='Pune', pin_code='411001')
Same as the original? True

model_validate_json() reads JSON text and validates it in one step. The text "75000.50" becomes a Decimal again, and "2024-04-01" becomes a date. Nothing is lost in the round trip.

5. Aliases: different names outside, Python names inside

Python uses snake_case names (order_id), but JavaScript front ends and many APIs use camelCase (orderId). An alias maps the outside name to your Python field name, so your Python code stays clean.

from pydantic import ConfigDict, Field

class Order(BaseModel):
    model_config = ConfigDict(validate_by_name=True)   # also accept the Python name

    order_id: int = Field(alias="orderId")             # input AND output name
    customer_name: str = Field(alias="customerName")
    total: float


order = Order.model_validate({"orderId": 501, "customerName": "Ravi", "total": 1250})
print(order)                                          # inside Python: order_id, customer_name
print("by_alias=False:", order.model_dump())
print("by_alias=True :", order.model_dump(by_alias=True))
print("Python names also accepted:", Order(order_id=502, customer_name="Neha", total=10))
Output
order_id=501 customer_name='Ravi' total=1250.0
by_alias=False: {'order_id': 501, 'customer_name': 'Ravi', 'total': 1250.0}
by_alias=True : {'orderId': 501, 'customerName': 'Ravi', 'total': 1250.0}
Python names also accepted: order_id=502 customer_name='Neha' total=10.0
  • The input used orderId, but in Python you write order.order_id.
  • Output uses Python names by default; by_alias=True writes the alias names.
  • Without validate_by_name=True, only the alias (orderId) would be accepted as input. With it, both names work.

Different names for input and output

from pydantic import AliasChoices

# validation_alias    = name used only when READING input
# serialization_alias = name used only when WRITING output
# AliasChoices        = accept any of several input names
class Product(BaseModel):
    product_id: int = Field(validation_alias=AliasChoices("id", "productId", "ProductID"))
    name: str = Field(serialization_alias="productName")


print(Product.model_validate({"ProductID": 9, "name": "Pen"}).model_dump(by_alias=True))
print(Product.model_validate({"id": 10, "name": "Book"}).model_dump(by_alias=True))
Output
{'product_id': 9, 'productName': 'Pen'}
{'product_id': 10, 'productName': 'Book'}

This is handy when you receive data from several old systems that each spell the id differently (id, productId, ProductID). One model accepts them all.

6. alias_generator: camelCase for every field automatically

Writing an alias for every field is boring. An alias generator creates them automatically:

from pydantic.alias_generators import to_camel

class Invoice(BaseModel):
    model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True, serialize_by_alias=True)

    invoice_no: str
    customer_name: str
    total_amount: float
    due_date: date


invoice = Invoice.model_validate({"invoiceNo": "INV-1", "customerName": "Asha",
                                  "totalAmount": 999.5, "dueDate": "2026-12-01"})
print(invoice.model_dump_json(indent=2))          # camelCase output (serialize_by_alias=True)
Output
{
  "invoiceNo": "INV-1",
  "customerName": "Asha",
  "totalAmount": 999.5,
  "dueDate": "2026-12-01"
}
  • alias_generator=to_camel turns every snake_case field name into camelCase.
  • serialize_by_alias=True uses the aliases in the output automatically, so you don't need by_alias=True on every call.
  • Other generators: to_pascal (InvoiceNo) and to_snake.

7. field_serializer, computed_field and hidden fields

Sometimes the output must look different from the stored value: money with a currency symbol, a date in "01-Apr-2024" format, a password that must never be sent. And sometimes you want to include values that are calculated from other fields.

from datetime import datetime
from pydantic import computed_field, field_serializer

class Staff(BaseModel):
    first_name: str
    last_name: str
    salary: Decimal
    joined_on: date
    password: str = Field(exclude=True)             # NEVER appears in the output
    created_at: datetime = Field(default_factory=datetime.now)

    @field_serializer("salary")
    def format_salary(self, value: Decimal) -> str:
        return f"Rs. {value:,.2f}"

    @field_serializer("joined_on")
    def format_date(self, value: date) -> str:
        return value.strftime("%d-%b-%Y")            # 01-Apr-2024

    @field_serializer("created_at", when_used="json")   # only for JSON output
    def short_time(self, value: datetime) -> str:
        return value.strftime("%Y-%m-%d %H:%M")

    # computed_field = a calculated value that is included in the output
    @computed_field
    @property
    def full_name(self) -> str:
        return f"{self.first_name} {self.last_name}"

    @computed_field
    @property
    def years_of_service(self) -> int:
        return (date.today() - self.joined_on).days // 365


staff = Staff(first_name="Kiran", last_name="Desai", salary="125000", joined_on="2019-04-01", password="secret")
print(staff.model_dump())
print(staff.model_dump_json(indent=2))
print("password is still on the object:", staff.password)
Output (created_at and years_of_service depend on today's date)
{'first_name': 'Kiran', 'last_name': 'Desai', 'salary': 'Rs. 125,000.00', 'joined_on': '01-Apr-2019', 'created_at': datetime.datetime(2026, 10, 27, 11, 16, 59, 338655), 'full_name': 'Kiran Desai', 'years_of_service': 7}
{
  "first_name": "Kiran",
  "last_name": "Desai",
  "salary": "Rs. 125,000.00",
  "joined_on": "01-Apr-2019",
  "created_at": "2026-10-27 11:16",
  "full_name": "Kiran Desai",
  "years_of_service": 7
}
password is still on the object: secret
  • Field(exclude=True): password is a normal field you can use in Python, but it is never included in model_dump() or JSON. A simple, safe way to stop secrets from leaking into API responses.
  • @field_serializer("salary"): changes only how the value is written. Inside Python, staff.salary is still a Decimal you can calculate with.
  • when_used="json": the custom format is used only for JSON. In the dict output, created_at stayed a real datetime; in the JSON it is the short text.
  • @computed_field on a @property: a value calculated from other fields and included in the output. full_name and years_of_service are not stored; they are calculated every time.

Exercises

From the end of 04_serialization.py:

  1. Make a model Book(book_id, title, price, published_on) that reads {"BookId": 1, "Title": "...", "Price": 299, "PublishedOn": "2020-05-01"} (hint: alias_generator=to_pascal from pydantic.alias_generators).
  2. Print it as JSON with snake_case names and then with PascalCase names.
  3. Add a computed_field price_with_gst (price * 1.18).
  4. Add a field internal_code that never appears in model_dump().
  5. Turn the JSON back into a Book with model_validate_json().

Summary

  • model_dump() gives a dict (Python types); mode="json" or model_dump_json() gives JSON-friendly output.
  • include, exclude, exclude_none, exclude_defaults and exclude_unset choose which fields are written.
  • exclude_unset=True returns only the fields that were sent, which is essential for updates.
  • model_validate_json() reads and validates JSON in one step.
  • Aliases map outside names (camelCase) to Python names; alias_generator=to_camel does it for every field.
  • @field_serializer changes how a value is written, @computed_field adds calculated values, and Field(exclude=True) hides a field.

Next: converting SQLAlchemy database objects into Pydantic models.