12.1. What Is an ORM?
Table of Contents
Why ORMs Exist
When you build backend applications, you almost always need to store data in a database, often a relational database such as PostgreSQL.
Relational databases understand SQL, not Python or JavaScript. As a backend developer you have two main ways to talk to the database:
- Raw SQL
Write SQL strings by hand and send them to the database driver. - ORM
Use an Object Relational Mapper, which lets you work with objects in your programming language and lets the library translate those operations into SQL for you.
"ORM" stands for Object Relational Mapper. The word “mapper” is important. It maps between:
- Objects in your application (for example Python classes and instances)
- Rows in database tables
So instead of writing:
INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'alice@example.com');you might write in Python:
user = User(id=1, name="Alice", email="alice@example.com")
session.add(user)
session.commit()The ORM turns this object operation into the SQL command that the database understands.
Definition:
An ORM (Object Relational Mapper) is a library that maps database tables and rows to programming language classes and objects, so you can work with the database using objects and methods instead of writing raw SQL directly.
Objects vs Tables
To understand an ORM, compare how data looks in code and in a relational database.
The Database View: Tables and Rows
In a relational database, data is organized in tables. Each table has:
- Columns with specific data types
- Rows that represent individual records
Example users table:
| id | name | is_active | |
|---|---|---|---|
| 1 | Alice | alice@example.com | true |
| 2 | Bob | bob@example.com | false |
To work with this data in SQL, you might write:
SELECT id, name, email, is_active
FROM users
WHERE is_active = true;The Code View: Classes and Objects
In code, we usually think in terms of classes and objects:
class User:
def __init__(self, id, name, email, is_active=True):
self.id = id
self.name = name
self.email = email
self.is_active = is_active
# One instance (one row)
alice = User(id=1, name="Alice", email="alice@example.com", is_active=True)In object oriented code:
- A class is like a blueprint for a table
- An object (instance) is like a row in the table
- Attributes on the object are like columns
How an ORM Maps Them
An ORM connects these two worlds.
You tell the ORM how your class corresponds to a table:
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
email = Column(String, unique=True)
is_active = Column(Boolean, default=True)Here the ORM learns:
Userclass ↔"users"tableUser.idattribute ↔"id"columnUser.nameattribute ↔"name"column- and so on
From now on when you create a User instance, the ORM knows how to insert or query the matching row in the database.
How ORMs Work Conceptually
Although ORMs can look like magic, the basic ideas underneath are simple.
1. Mapping Classes to Tables
First, you define models (classes) that describe your data. The ORM records:
- Which class maps to which table
- Which class attribute maps to which column
- Which fields are primary keys, foreign keys, unique, and so on
Internally the ORM stores some metadata like:
| Concept | Example from code | Database side |
|---|---|---|
| Model / Entity | User class | users table |
| Field / Attribute | User.email | email column |
| Identity | User.id | primary key id |
| Relationship | User.posts | foreign key in posts |
You only define this once, then you reuse these models across your project.
2. Translating Operations Into SQL
When you perform operations on ORM objects, the ORM creates appropriate SQL statements.
Example: creating and saving a user:
new_user = User(name="Alice", email="alice@example.com")
session.add(new_user)
session.commit()Conceptually the ORM:
- Sees a new
Userobject - Builds an
INSERTSQL statement - Sends it to the database
- Updates the object with database generated values (for example an auto-incremented
id)
Similarly, querying:
active_users = session.query(User).filter(User.is_active == True).all()This becomes something like:
SELECT id, name, email, is_active
FROM users
WHERE is_active = true;
The ORM reads the result rows and creates User objects for you.
Key Idea:
You work with objects and methods, but under the hood the ORM generates and executes SQL queries and then turns result rows back into objects.
3. Tracking Changes
Most ORMs have a unit of work or session concept. The session:
- Tracks which objects were loaded
- Knows which ones were changed, added, or deleted
- Generates the corresponding SQL when you save or commit
Example:
# 1. Load a user
user = session.query(User).filter(User.id == 1).first()
# 2. Modify the object
user.name = "Alice Updated"
# 3. Commit the transaction
session.commit()
You never wrote an UPDATE query. The ORM:
- Detects that
user.namechanged - Generates
UPDATE users SET name='Alice Updated' WHERE id=1; - Executes it when you call
commit()
4. Handling Relationships
Relational databases connect tables using foreign keys. ORMs map these to object relationships.
For example you might have:
- A
userstable - A
poststable with auser_idforeign key
In the ORM, you might write:
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True)
title = Column(String)
content = Column(String)
user_id = Column(Integer, ForeignKey("users.id"))
user = relationship("User", back_populates="posts")
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
posts = relationship("Post", back_populates="user")Now you can do:
user = session.query(User).filter(User.id == 1).first()
# Get all this user's posts, as a list of Post objects
user_posts = user.posts
# Create a new post for this user
new_post = Post(title="Hello", content="World", user=user)
session.add(new_post)
session.commit()The ORM handles foreign keys and joins behind the scenes.
Benefits of Using an ORM
ORMs exist because they make most backend work easier and safer.
1. Less Boilerplate, More Productivity
Without an ORM, you repeat a lot of similar code:
# Raw SQL example (conceptual)
cursor.execute(
"INSERT INTO users (name, email, is_active) VALUES (%s, %s, %s) RETURNING id;",
("Alice", "alice@example.com", True)
)
row = cursor.fetchone()
user_id = row[0]With an ORM:
user = User(name="Alice", email="alice@example.com", is_active=True)
session.add(user)
session.commit()
# user.id is now filled inYou write less code and focus on the logic of your application, not SQL string management.
2. Safer by Default
Good ORMs use parameterized queries internally. This helps protect you from many SQL injection problems that occur when you accidentally build SQL by string concatenation.
Raw and unsafe:
# Very unsafe example, do NOT do this
cursor.execute(f"SELECT * FROM users WHERE email = '{user_input}'")ORM style:
user = session.query(User).filter(User.email == user_input).first()The ORM will send this to the database using parameters, not string concatenation.
3. Database Portability
If you write raw SQL tuned for a specific database, it may not work on a different database engine, or you might need to adjust many queries.
With an ORM:
- Most of your code does not change if you switch from SQLite to PostgreSQL or MySQL
- You usually change only configuration and maybe some specific types
This can be very helpful early in a project when you are not yet sure which database you will use in production.
4. Clearer, More Expressive Code
Queries in ORM code can be easier to read for people who are comfortable with the programming language but less experienced with SQL.
Compare:
SELECT *
FROM users
WHERE is_active = true
AND email LIKE '%@example.com'
ORDER BY id DESC
LIMIT 10;versus an ORM style:
users = (
session.query(User)
.filter(User.is_active == True)
.filter(User.email.ilike("%@example.com"))
.order_by(User.id.desc())
.limit(10)
.all()
)Both are fine, but in many backend codebases, having everything in one language (Python) can make it easier to refactor and reuse logic.
5. Built-in Features
Most ORMs provide many useful features out of the box:
- Automatic schema generation or migrations helpers
- Validation hooks
- Easy pagination
- Convenient methods for filtering, sorting, joining
- Tools for transactions and connection pooling (these topics are covered in other chapters)
You get a lot of common database tasks for "free."
Trade‑offs and Limitations
ORMs are powerful but not perfect. It is important to understand their limitations so you can decide when to use them and when to use raw SQL.
1. Performance Overhead
For simple queries, the performance difference between ORM and raw SQL is usually small. But in some cases:
- ORM generated queries can be more complex than needed
- It can be easy to accidentally load too much data
Classic example, the N + 1 query problem:
# Example of a common performance issue with ORMs
users = session.query(User).all()
for user in users:
print(user.name, len(user.posts))If the ORM is not instructed to load posts efficiently, it might run:
- 1 query to get all users
- 1 query per user to get posts
So if you have 100 users, you suddenly run 101 queries.
There are ORM techniques to avoid this, such as eager loading, but you must be aware of them.
2. Complex Queries Can Become Awkward
Some very advanced SQL features or complex reporting queries can be:
- Hard to express with ORM syntax
- Less readable in ORM form than in plain SQL
- Not supported by the ORM
In those cases, many teams will mix approaches:
- Use ORM for most day to day queries
- Use raw SQL for specific complex or performance sensitive operations
3. Learning Curve
To use an ORM effectively, you need to understand:
- Basic relational database concepts
- The ORM’s own query API
- Often some underlying SQL anyway
Using an ORM does not mean you can ignore SQL completely. It is a tool that sits on top of SQL, not a replacement for understanding data and queries.
Important:
ORMs are powerful abstractions, but you still need to understand relational databases and SQL basics to use them correctly and avoid performance or correctness problems.
4. Hidden Behavior
Because ORMs do a lot automatically, it is easy to forget exactly what SQL is being executed.
Examples of hidden behavior:
- When exactly is a query run, at the moment you write
.filter(...)or only when you call.all()? - When do changes get flushed to the database?
- Are relationships loaded lazily or eagerly?
If you do not know these answers, you can introduce subtle bugs or performance issues. A good practice is to log or inspect generated SQL during development.
Where ORMs Fit in a Backend Application
You will learn about application architecture and layers elsewhere in this course. Here is where ORMs typically belong.
Layers Involved
A simplified backend architecture often has:
| Layer | Responsibility |
|---|---|
| API / Controller | Handle HTTP requests and responses |
| Service Layer | Business logic, use cases |
| Data Access | Interact with the database |
ORMs mostly live in the data access layer.
Example Flow Without Too Much Detail
Imagine a request to POST /users to create a new user.
High level sequence:
- FastAPI endpoint receives the HTTP request.
- The endpoint calls a service function like
create_user(...). - The service constructs a
UserORM object. - The ORM session adds and commits the new object.
- The service returns a data object or model to the API layer.
- The API layer returns a JSON response.
Only step 3 and 4 care about the ORM. The rest of the application does not need to know that the database is a relational database or that SQL is being used.
Often developers combine ORMs with patterns like the Repository Pattern to keep ORM details isolated. That pattern is covered in a later chapter.
Examples of Common ORM Operations
Here are a few concrete examples to show how an ORM typically feels, without going deep into any specific ORM library.
Creating a Record
SQL way:
INSERT INTO users (name, email, is_active)
VALUES ('Alice', 'alice@example.com', true)
RETURNING id;ORM style:
user = User(name="Alice", email="alice@example.com", is_active=True)
session.add(user)
session.commit()
# Now user.id is available
print(user.id)Reading Records
SQL way:
SELECT id, name, email, is_active
FROM users
WHERE is_active = true
ORDER BY id
LIMIT 5;ORM style:
active_users = (
session.query(User)
.filter(User.is_active == True)
.order_by(User.id)
.limit(5)
.all()
)
for user in active_users:
print(user.id, user.name)Updating Records
SQL way:
UPDATE users
SET is_active = false
WHERE id = 1;ORM style:
user = session.query(User).filter(User.id == 1).first()
user.is_active = False
session.commit()Deleting Records
SQL way:
DELETE FROM users
WHERE id = 1;ORM style:
user = session.query(User).filter(User.id == 1).first()
session.delete(user)
session.commit()These examples show the typical pattern:
- Fetch objects
- Modify or delete them
- Commit the session
The ORM manages the SQL details.
When to Use an ORM and When Not To
Although many modern backend applications use an ORM, it is not mandatory.
Good Situations for an ORM
An ORM is usually a good choice when:
- You are building a typical web application or REST API
- Your queries are mostly CRUD (Create, Read, Update, Delete)
- You like object oriented code
- You want to move quickly and reduce boilerplate
- You want built-in safety and structure
For the projects in this course, you will see how an ORM supports building real backend applications faster and more reliably.
Situations Where Raw SQL Might Be Better
Sometimes you might prefer raw SQL or a more lightweight approach:
- Complex reporting or analytics queries
- Heavy use of database specific features
- Very high performance workloads where you want full control over queries
- Small scripts that run a few specific queries
Many teams use both:
- ORM for 80–90 percent of the application
- Raw SQL for special cases
You do not have to choose one forever. You can mix them in the same project where it makes sense.
Summary
- An ORM maps objects in your code to rows in database tables.
- It lets you work with your database using classes and methods instead of writing raw SQL everywhere.
- The ORM:
- Maps classes to tables and attributes to columns
- Translates object operations into SQL
- Tracks changes and handles inserts, updates, and deletes
- Helps manage relationships between tables
- Benefits include:
- Less boilerplate and more productivity
- Safer database access with parameterized queries
- Easier database portability
- More expressive, object oriented code
- Trade offs:
- Possible performance overhead and hidden queries
- Complex queries can be awkward
- You still need to understand SQL and relational databases
- ORMs typically live in the data access part of your backend architecture and integrate with the rest of your application logic.
In the following chapters you will see how to use a specific ORM, how to define models, work with database sessions, and perform all the common database operations in real backend applications.
Views: 6
KAHIBARO