Mastering SQLModel: The Ultimate Beginner’s Guide to SQL in Python

J.A. ShezanJ.A. Shezan
13 min read

Working with SQL databases in Python often presents a dilemma: do you write raw SQL queries and risk errors and security vulnerabilities, or do you tackle the steep learning curve of a complex Object-Relational Mapper (ORM)? SQLModel provides an elegant third option, designed to be intuitive, powerful, and a joy to use.

Created by Sebastián Ramírez (the developer behind FastAPI), SQLModel is a library that intelligently combines the best features of Pydantic and SQLAlchemy. It allows you to write modern, type-safe Python code that seamlessly interacts with your SQL database.

This article will guide you from the absolute basics to advanced concepts, empowering you to use SQLModel effectively in your next project.

Why Choose SQLModel Over Plain SQLAlchemy?

While SQLModel is built on top of SQLAlchemy, the most powerful SQL toolkit for Python, it offers several compelling advantages, especially for beginners and those working with modern Python frameworks:

  • Simplicity and Readability: With SQLModel, you define your data model once. This single class serves as your database table definition and your data validation schema. This reduces boilerplate code and makes your models clean and easy to understand.

  • Type Safety and Autocompletion: By leveraging standard Python type hints, SQLModel provides exceptional editor support. You get autocompletion, type checking, and linting, which helps you catch bugs before you even run your code.

  • Automatic Data Validation: Powered by Pydantic, SQLModel automatically validates your data. If you try to save a string into an integer column or provide an invalid email, SQLModel will raise a clear, informative error, ensuring your database remains consistent and reliable.

Perfect for FastAPI: If you’re building APIs with FastAPI, SQLModel is a game-changer. You can use the exact same model to define your database table and to handle data from API requests and responses, streamlining your entire workflow.

FEATURESSQLALCHEMYSQLMODEL
Type Validation❌ Manual✅ Built-in via Pydantic
Model DefinitionFlexible but verboseClean and concise
Learning CurveSteeperEasier for new developers
Integration with FastAPIGoodNative support
Auto-generated Schemas❌ Requires extra code✅ With Pydantic

Part 1: Your First Steps — Setup and Engine

Let’s start by building a simple university enrollment system. We’ll need tables for Departments, Courses, and Students.

First, you need to install the library. We will use SQLite for our database, which is included with Python, so no external database server is needed.

pip install sqlmodel

The Database Engine

The engine is the heart of your database connection. It’s the central point of contact that understands the specific “dialect” of SQL you’re using (e.g., SQLite, PostgreSQL, MySQL). You typically create just one engine for your entire application.

Here is the code to set up our engine and a function to create the tables.

from sqlmodel import SQLModel, create_engine

# The database URL defines the dialect (sqlite) and the filename (university.db).
DATABASE_URL = "sqlite:///university.db"

# The 'echo=True' argument is incredibly helpful for learning. It prints every
# SQL statement that SQLModel executes, showing you what's happening under the hood.
engine = create_engine(DATABASE_URL, echo=True)


def create_db_and_tables():
    """
    Initializes the database by telling SQLModel to create all tables.
    SQLModel finds all classes that inherit from SQLModel with `table=True`
    and generates the corresponding `CREATE TABLE` statements.
    """
    SQLModel.metadata.create_all(engine)

Defining a Table with SQLModel

In SQLModel, a database table is just a Python class. This class inherits from SQLModel and includes the table=True argument. The class attributes, defined with standard Python type hints, become the columns of the table.

Let’s define our first model, Department.

from typing import Optional
from sqlmodel import Field, SQLModel

class Department(SQLModel, table=True):
    """
    Represents the Department table.
    """
    # This defines the 'id' column as the primary key.
    # 'Optional[int]' and 'default=None' tells the database that this value
    # is generated by the database itself (i.e., auto-incrementing).
    id: Optional[int] = Field(default=None, primary_key=True)

    # The 'name' column will be indexed for faster searching.
    name: str = Field(index=True)

    building: str

Let’s dissect this model:

  • class Department(SQLModel, table=True):: This is the core declaration. It's a SQLModel that maps to a database table.

  • id: Optional[int] = Field(...): This defines our primary key, the unique identifier for each row.

  • Field(...): This special function lets you add more configuration to a column, like setting a default value, marking it as a primary_key, or adding an index to speed up queries on that column.

Primary Keys: Auto-Incrementing Integer vs. UUID

You have two common choices for primary keys, and SQLModel handles both beautifully.

  1. Auto-Incrementing Integer (as used in Department):
  • How it works: The database automatically assigns sequential numbers (1, 2, 3, ...) to new records.

  • Pros: Simple, human-readable, and slightly more performant for simple database structures.

  • Cons: IDs are predictable and not unique across different databases or systems.

2. UUID (Universally Unique Identifier):

  • How it works: A 128-bit value represented as a long string (e.g., 550e8400-e29b-41d4-a716-446655440000).

  • Pros: Guaranteed to be globally unique. You can generate IDs anywhere without worrying about collisions, which is essential for distributed systems.

  • Cons: Less readable for humans and can be slightly less performant as a primary key index compared to an integer.

Let’s create a Student model using a UUID for its primary key.

import uuid
from sqlmodel import Field, SQLModel

class Student(SQLModel, table=True):
    """
    Represents the Student table using a UUID as the primary key.
    """
    # 'default_factory' is a function that will be called to generate a default
    # value for new records. Here, we use `uuid.uuid4` to create a new random UUID.
    id: uuid.UUID = Field(
        default_factory=uuid.uuid4, 
        primary_key=True, 
        index=True, 
        nullable=False
    )
    name: str
    email: str = Field(unique=True, index=True) # Ensure emails are unique

Part 2: CRUD Operations and the Importance of the Session

CRUD stands for Create, Read, Update, and Delete. These are the four fundamental operations you’ll perform on your data. In SQLModel (and SQLAlchemy), all these actions are managed through a Session.

What is a Session and Why is it Essential?

A Session is best understood as a transactional workspace. It's a temporary staging area for all the changes you want to apply to the database.

Why use a Session?

  1. Transactions (Atomicity): The session groups your operations. When you call session.commit(), it wraps all the changes (adds, updates, deletes) into a single database transaction. This means either all of them succeed, or none of them do. If an error occurs midway, the entire transaction is rolled back, preventing your database from ending up in a partially updated, inconsistent state.

  2. Efficiency: It manages database connections for you. Instead of opening a new connection for every single query, it can reuse one for all the operations within its scope, which is much more efficient.

  3. Unit of Work: It provides a clear and safe context for your database logic. The standard practice is to wrap your operations in a with Session(engine) as session: block, which guarantees that the transaction is properly committed or rolled back, and the connection is closed.

Create: Inserting Data

To add a new record, you create an instance of your model and add it to the session.

from sqlmodel import Session

def create_department():
    """Creates a new department and saves it to the database."""
    # Create an instance of our model
    cs_department = Department(name="Computer Science", building="Tech Hall")

    # The 'with' block creates a new Session and handles closing it properly
    with Session(engine) as session:
        # We add our new object to the session
        session.add(cs_department)

        # We commit the changes to the database
        session.commit()

        # After the commit, the object is updated with the database-generated ID.
        # We can refresh it to ensure our Python object has the latest state.
        session.refresh(cs_department)

    print("Created Department:", cs_department)

Read: Querying Data

To retrieve data, you use the select() function to construct a SELECT query.

Why do we sometimes need col()?

When you write a simple filter like select(Student).where(Student.name == "Alice"), SQLModel cleverly overloads the == operator to build the correct WHERE clause. However, for more complex comparisons like "greater than or equal to" (>=) or "less than" (<), this simple trick doesn't work. The col() function (which comes from SQLAlchemy) is used to explicitly refer to the database column. This allows you to use standard Python comparison operators (>=, <, !=) to build more complex queries.

from sqlmodel import select

def read_data():
    """Reads departments and students from the database."""
    with Session(engine) as session:
        # Create a select statement to query all departments
        statement = select(Department)

        # Execute the statement and get all results as a list
        departments = session.exec(statement).all()
        print("All Departments:", departments)

        # To get a single object by its primary key, session.get() is the most direct way.
        # Let's find one by name first to get its ID.
        statement = select(Department).where(Department.name == "Computer Science")
        cs_department = session.exec(statement).first() # .first() gets one or None

        if cs_department:
            dept_from_get = session.get(Department, cs_department.id)
            print(f"\nFetched with session.get():", dept_from_get)

Update: Modifying Records

To update a record, you follow a simple “read-modify-commit” pattern. First, you fetch the object from the database, then you change its attributes in Python, and finally, you commit the session. SQLModel detects the changes and generates the correct UPDATE statement.

def update_department():
    """Updates an existing department's building."""
    with Session(engine) as session:
        # Find the department we want to update
        statement = select(Department).where(Department.name == "Computer Science")
        department_to_update = session.exec(statement).one() # .one() gets one, errors if not exactly one

        # Modify the object's attribute directly
        department_to_update.building = "Innovation Tower"

        # Add it back to the session to stage the change
        session.add(department_to_update)

        # Commit to save the update
        session.commit()

        # Refresh the object
        session.refresh(department_to_update)

    print("\nUpdated Department:", department_to_update)

Delete: Removing Records

Deleting is just as easy: fetch the object and tell the session to delete it.

def delete_department():
    """Creates and then deletes a department."""
    # First, let's create a department just for this example
    with Session(engine) as session:
        history_dept = Department(name="History", building="Archive Hall")
        session.add(history_dept)
        session.commit()
        session.refresh(history_dept)

    print(f"\nDepartment to be deleted: {history_dept.name}")

    # Now, open a new session to find and delete it
    with Session(engine) as session:
        statement = select(Department).where(Department.name == "History")
        department_to_delete = session.exec(statement).one()

        # Tell the session to delete the object
        session.delete(department_to_delete)

        # Commit the deletion
        session.commit()

    print(f"Department '{department_to_delete.name}' has been deleted.")

Part 3: Modeling Data Relationships

Real-world data is interconnected. A Department offers many Courses. A Student enrolls in many Courses. SQLModel lets you define these relationships directly in your models, making it easy to navigate your data.

One-to-Many Relationships

This is a very common relationship. One Department has many Courses, but each Course belongs to only one Department.

To model this, we need two things:

  1. A foreign key column in the “many” table (Course) that points to the primary key of the "one" table (Department).

  2. A Relationship attribute on both models so SQLModel knows they are linked. This allows for easy navigation, like getting a list of all courses in a department.

Let’s define a Course model and update Department.

from __future__ import annotations
from typing import List
from sqlmodel import Relationship

# We need to redefine the Department model to add the new relationship
class Department(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    building: str

    # This attribute defines the "one-to-many" link. It will hold a list of Course objects.
    courses: List["Course"] = Relationship(back_populates="department")

class Course(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    title: str = Field(index=True)

    # This is the foreign key that links a Course to a Department
    department_id: int = Field(foreign_key="department.id")

    # The Relationship attribute lets you access the related Department object from a Course.
    # 'back_populates' is crucial: it tells SQLModel that this relationship is the
    # other side of the 'courses' relationship in the Department model.
    department: Department = Relationship(back_populates="courses")

Many-to-Many Relationships

A Student can enroll in many Courses, and a Course can have many Students. This is a many-to-many relationship.

SQL databases handle this with a third table, known as a link table or an association table. Its job is to connect the primary keys of the two tables.

We will create a link model, Enrollment, to connect Student and Course. A powerful feature of this approach is that the link table itself can contain additional data, such as the grade a student received in a course.

# The link table for the many-to-many relationship.
# This is often called an "Association Object" when it contains extra data.
class Enrollment(SQLModel, table=True):
    student_id: uuid.UUID = Field(foreign_key="student.id", primary_key=True)
    course_id: int = Field(foreign_key="course.id", primary_key=True)
    grade: Optional[str] = Field(default=None) # Extra data on the relationship

# Update Student model
class Student(SQLModel, table=True):
    id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True, nullable=False)
    name: str
    email: str = Field(unique=True, index=True)

    # The relationship to Course, specified via the Enrollment link model
    courses: List["Course"] = Relationship(back_populates="students", link_model=Enrollment)

# Update Course model
class Course(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    title: str = Field(index=True)
    department_id: Optional[int] = Field(default=None, foreign_key="department.id")
    department: Optional[Department] = Relationship(back_populates="courses")

    # The relationship to Student, also using the Enrollment link model
    students: List["Student"] = Relationship(back_populates="courses", link_model=Enrollment)

Part 4: Putting It All Together

Now let’s write a main function to demonstrate our complete university system. This script will create the database, populate it with departments, courses, and students, establish the relationships between them, and then run queries to retrieve the connected data.

This example showcases:

  • Creating related objects.

  • Querying across a one-to-many relationship (department -> courses).

  • Querying across a many-to-many relationship (student -> courses and course -> students).

# --- All imports needed for the full script ---
from __future__ import annotations
import uuid
from typing import List, Optional
from sqlmodel import Field, SQLModel, Relationship, Session, create_engine, select

# --- All Model Definitions (re-defined here to be self-contained) ---

class Enrollment(SQLModel, table=True):
    student_id: uuid.UUID = Field(foreign_key="student.id", primary_key=True)
    course_id: int = Field(foreign_key="course.id", primary_key=True)
    grade: Optional[str] = Field(default=None)

class Department(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    building: str
    courses: List["Course"] = Relationship(back_populates="department")

class Student(SQLModel, table=True):
    id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True, nullable=False)
    name: str
    email: str = Field(unique=True, index=True)
    courses: List["Course"] = Relationship(back_populates="students", link_model=Enrollment)

class Course(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    title: str = Field(index=True)
    department_id: Optional[int] = Field(default=None, foreign_key="department.id")
    department: Optional[Department] = Relationship(back_populates="courses")
    students: List["Student"] = Relationship(back_populates="courses", link_model=Enrollment)

# --- Engine Setup ---
DATABASE_URL = "sqlite:///university.db"
engine = create_engine(DATABASE_URL, echo=False) # Set echo=True to see all SQL

def create_db_and_tables():
    SQLModel.metadata.create_all(engine)

# --- Main Demonstration Function ---
def main():
    print("Starting university system setup...")
    create_db_and_tables()

    with Session(engine) as session:
        # It's good practice to start with a clean slate for a demo script
        for table in reversed(SQLModel.metadata.sorted_tables):
            session.execute(table.delete())

        # --- Create Instances ---
        dept_cs = Department(name="Computer Science", building="Tech Hall")
        dept_phy = Department(name="Physics", building="Quantum Hall")

        student_alice = Student(name="Alice", email="alice@example.com")
        student_bob = Student(name="Bob", email="bob@example.com")

        # Create courses and associate them with a department (one-to-many)
        course_intro_py = Course(title="Introduction to Python", department=dept_cs)
        course_data_struct = Course(title="Data Structures", department=dept_cs)
        course_mechanics = Course(title="Classical Mechanics", department=dept_phy)

        # Enroll students in courses (many-to-many)
        # SQLModel is smart: we can just append to the list
        student_alice.courses.append(course_intro_py)
        student_alice.courses.append(course_data_struct)
        student_bob.courses.append(course_intro_py)
        student_bob.courses.append(course_mechanics)

        session.add(dept_cs)
        session.add(dept_phy)
        session.add(student_alice)
        session.add(student_bob)

        session.commit()

        print("\n✅ Data population complete.")

        # --- Query and Print Results ---
        print("\n--- University Data ---")

        # Get a department and list its courses (one-to-many)
        cs_dept_from_db = session.exec(select(Department).where(Department.name == "Computer Science")).one()
        print(f"\nDepartment: {cs_dept_from_db.name} ({cs_dept_from_db.building})")
        for course in cs_dept_from_db.courses:
            print(f"  - Course: {course.title}")

        # Get a student and list their courses (many-to-many)
        alice_from_db = session.exec(select(Student).where(Student.name == "Alice")).one()
        print(f"\nStudent: {alice_from_db.name}")
        for course in alice_from_db.courses:
            print(f"  - Enrolled in: {course.title}")

        # Get a course and list its students (many-to-many reverse)
        python_course_from_db = session.exec(select(Course).where(Course.title == "Introduction to Python")).one()
        print(f"\nCourse: {python_course_from_db.title}")
        for student in python_course_from_db.students:
            print(f"  - Student: {student.name}")


if __name__ == "__main__":
    main()

Final Words

SQLModel offers a refreshingly modern and Pythonic way to interact with SQL databases. It successfully abstracts away much of the complexity of traditional ORMs while providing powerful features like type safety, automatic data validation, and an intuitive API for handling data relationships.

By understanding the core concepts of the Engine, the Session, and how to define models with Fields and Relationships, you now have the skills to build robust, data-driven applications. The next time you start a project, you can confidently choose SQLModel for a more productive and enjoyable development experience.

0
Subscribe to my newsletter

Read articles from J.A. Shezan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

J.A. Shezan
J.A. Shezan

Shezan loves technology who is currently studying Computer Science and Engineering. He codes frontend & backend of a website. He also does penetration testing on web apps.