Skip to content

Part 3: Data Models and Serializers

In the previous parts, we created a project and application, and implemented a Hello World API. Now we will design data models using Tortoise ORM and create serializers that provide automatic CRUD operations.

We will design the data structure for a student course enrollment system: students, courses, and the many-to-many relationships between them.

Data Model Design

Business Requirements

Our enrollment system needs to support:

  • Course Management: Create and manage course information
  • Student Management: Manage student records
  • Enrollment Relationships: Students can enroll in multiple courses; courses can have multiple students
  • Timestamps: Track creation and update times

Defining Models

Edit enroll/models.py:

# enroll/models.py

from tortoise import fields
from tortoise.models import Model


class Student(Model):
    class Meta:
        table = "students"

    id = fields.IntField(pk=True)
    name = fields.CharField(max_length=100)
    email = fields.CharField(max_length=255, unique=True)
    age = fields.IntField()
    student_id = fields.CharField(max_length=20, unique=True)

    created_at = fields.DatetimeField(auto_now_add=True)
    updated_at = fields.DatetimeField(auto_now=True)

    courses = fields.ManyToManyField(
        "models.Course",
        related_name="students",
        through="student_course",
    )

    def __str__(self):
        return f"{self.student_id} - {self.name}"


class Course(Model):
    class Meta:
        table = "courses"

    id = fields.IntField(pk=True)
    name = fields.CharField(max_length=200)
    code = fields.CharField(max_length=20, unique=True)
    description = fields.TextField()
    credits = fields.IntField(default=3)
    max_students = fields.IntField(default=50)
    is_active = fields.BooleanField(default=True)

    created_at = fields.DatetimeField(auto_now_add=True)
    updated_at = fields.DatetimeField(auto_now=True)

    students: fields.ManyToManyRelation["Student"]

    def __str__(self):
        return f"{self.code} - {self.name}"

Unfazed automatically discovers models.py in installed apps and registers them with Tortoise ORM. See Tortoise ORM for the full reference.

Key Field Types

Field Type Example Description
IntField(pk=True) id Primary key, auto-increment
CharField(max_length=100) name String field with max length
CharField(unique=True) email, student_id Unique constraint field
TextField() description Long text field
BooleanField(default=True) is_active Boolean with default value
DatetimeField(auto_now_add=True) created_at Automatically set on creation
DatetimeField(auto_now=True) updated_at Automatically set on update
ManyToManyField() courses Many-to-many relationship

Relationship Design

  • Student.courses and Course.students form a many-to-many relationship
  • The junction table student_course is created automatically by Tortoise ORM
  • Both sides support bidirectional queries (e.g. student.courses.all(), course.students.all())

Database Configuration

Configure the Database Connection

Edit entry/settings/__init__.py to add the DATABASE configuration:

# entry/settings/__init__.py

import os
from pathlib import Path

PROJECT_DIR = Path(__file__).resolve().parent.parent

UNFAZED_SETTINGS = {
    "DEBUG": True,
    "PROJECT_NAME": "Tutorial Project",
    "ROOT_URLCONF": "entry.routes",
    "INSTALLED_APPS": [
        "enroll",
    ],
    "MIDDLEWARE": [
        "unfazed.middleware.internal.common.CommonMiddleware",
    ],
    "DATABASE": {
        "CONNECTIONS": {
            "default": {
                "ENGINE": "tortoise.backends.sqlite",
                "CREDENTIALS": {
                    "FILE_PATH": os.path.join(PROJECT_DIR, "db.sqlite3"),
                },
            }
        },
    },
}

See Tortoise ORM — Database Configuration for all supported engines and credentials.

Initialize and Migrate the Database

Unfazed uses Aerich for database migrations. Run these commands from the backend directory:

# 1. Initialize the migration directory (first time only)
unfazed-cli init-db

# 2. Generate a migration from model changes
unfazed-cli migrate --name initial

# 3. Apply the migration
unfazed-cli upgrade

See Tortoise ORM — Migration Commands for the full command reference.

Serializer Design

Serializers bridge the gap between database models and your API layer. They auto-generate Pydantic fields from Tortoise models and provide built-in async CRUD operations. See Serializer for the full reference.

Creating Serializers

Edit enroll/serializers.py:

# enroll/serializers.py

from unfazed.serializer import Serializer

from . import models as m


class StudentSerializer(Serializer):
    class Meta:
        model = m.Student
        include = [
            "id", "name", "email", "age", "student_id",
            "created_at", "updated_at",
        ]


class CourseSerializer(Serializer):
    class Meta:
        model = m.Course
        include = [
            "id", "name", "code", "description", "credits",
            "max_students", "is_active", "created_at", "updated_at",
        ]


class StudentWithCoursesSerializer(Serializer):
    class Meta:
        model = m.Student
        include = [
            "id", "name", "email", "age", "student_id",
            "created_at", "updated_at", "courses",
        ]
        enable_relations = True


class CourseWithStudentsSerializer(Serializer):
    class Meta:
        model = m.Course
        include = [
            "id", "name", "code", "description", "credits",
            "max_students", "is_active", "created_at", "updated_at", "students",
        ]
        enable_relations = True

Meta Options

Option Type Default Description
model Type[Model] required The Tortoise ORM model to serialize.
include List[str] [] Fields to include. If set, others are excluded.
exclude List[str] [] Fields to exclude. Cannot be used with include.
enable_relations bool False Generate fields for FK, M2M, and O2O relations.

Using CRUD Operations

All CRUD methods are class methods that accept a Pydantic BaseModel as the context:

from pydantic import BaseModel
from enroll.serializers import StudentSerializer, CourseSerializer


# --- Create ---

class CreateStudent(BaseModel):
    name: str
    email: str
    age: int
    student_id: str

student = await StudentSerializer.create_from_ctx(
    CreateStudent(name="Alice", email="alice@example.com", age=20, student_id="2024001")
)
# student is a StudentSerializer instance


# --- Retrieve ---

class StudentId(BaseModel):
    id: int

student = await StudentSerializer.retrieve_from_ctx(StudentId(id=1))


# --- Update ---

class UpdateStudent(BaseModel):
    id: int
    age: int

updated = await StudentSerializer.update_from_ctx(
    UpdateStudent(id=1, age=21)
)


# --- Delete ---

await StudentSerializer.destroy_from_ctx(StudentId(id=1))


# --- List with pagination ---

result = await StudentSerializer.list_from_ctx(
    cond={},
    page=1,
    size=10,
)
# result.count — total matching records
# result.data  — list of StudentSerializer instances

Relationship Data

To include related objects when retrieving, use a serializer with enable_relations = True:

class StudentId(BaseModel):
    id: int

student_with_courses = await StudentWithCoursesSerializer.retrieve_from_ctx(
    StudentId(id=1)
)
# student_with_courses.courses contains the enrolled course data

Testing the Models

You can verify the models interactively using the Unfazed shell:

unfazed-cli shell
from pydantic import BaseModel
from enroll.models import Student, Course
from enroll.serializers import StudentSerializer, CourseSerializer


class CreateCourse(BaseModel):
    name: str
    code: str
    description: str
    credits: int
    max_students: int

class CreateStudent(BaseModel):
    name: str
    email: str
    age: int
    student_id: str


# Create a course
course = await CourseSerializer.create_from_ctx(
    CreateCourse(
        name="Python Programming",
        code="CS101",
        description="Learn the basics of Python",
        credits=3,
        max_students=30,
    )
)

# Create a student
student = await StudentSerializer.create_from_ctx(
    CreateStudent(
        name="Alice",
        email="alice@example.com",
        age=20,
        student_id="2024001",
    )
)

# Enroll the student in the course (via model instances)
student_instance = await Student.get(id=student.id)
course_instance = await Course.get(id=course.id)
await student_instance.courses.add(course_instance)

# Verify the relationship
enrolled_courses = await student_instance.courses.all()
course_students = await course_instance.students.all()

print(f"Student {student.name} enrolled in {len(enrolled_courses)} courses")
print(f"Course {course.name} has {len(course_students)} students")

Next Steps

You have designed data models and serializers with built-in CRUD operations. In the next part, we will:

  • Design API endpoints with typed parameter annotations
  • Define request/response schemas for OpenAPI documentation
  • Learn Unfazed's parameter system (Path, Query, Json, etc.)

Continue to Part 4: API Interface Design and Schema Definition.