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

Table of contents
- Why Choose SQLModel Over Plain SQLAlchemy?
- Part 1: Your First Steps — Setup and Engine
- The Database Engine
- Defining a Table with SQLModel
- Part 2: CRUD Operations and the Importance of the Session
- What is a Session and Why is it Essential?
- Create: Inserting Data
- Read: Querying Data
- Update: Modifying Records
- Delete: Removing Records
- Part 3: Modeling Data Relationships
- One-to-Many Relationships
- Many-to-Many Relationships
- Part 4: Putting It All Together
- Final Words

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.
FEATURES | SQLALCHEMY | SQLMODEL |
Type Validation | ❌ Manual | ✅ Built-in via Pydantic |
Model Definition | Flexible but verbose | Clean and concise |
Learning Curve | Steeper | Easier for new developers |
Integration with FastAPI | Good | Native 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 aSQLModel
that maps to a databasetable
.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 adefault
value, marking it as aprimary_key
, or adding anindex
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.
- 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
?
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.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.
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:
A foreign key column in the “many” table (
Course
) that points to the primary key of the "one" table (Department
).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.
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.