Imagine you work at a library and all the books are stored in a giant filing cabinet with a very specific, rigid system — every book has a drawer, a folder, and a card. Now imagine you hate dealing with that system and just want to say 'give me all sci-fi books by Asimov.' An ORM is like a super-smart librarian who stands between you and that filing cabinet — you speak plain English (or Python, or Java), and they handle all the drawer-opening and card-filing for you. You never have to touch the cabinet directly.
Every app that does something useful stores data — user accounts, orders, messages, preferences. That data lives in a database, and databases speak their own language called SQL. For decades, developers had to write SQL by hand, carefully crafting queries like SELECT * FROM users WHERE id = 42 just to fetch a single record. It worked, but it was tedious, error-prone, and meant your application code was tangled up with raw database instructions.
An ORM — Object-Relational Mapper — solves this by acting as a translation layer. Your code works with normal objects (like a User object or a Product object), and the ORM silently translates those interactions into the SQL your database understands. You stop thinking in tables and rows, and start thinking in objects and properties — the same way you think about everything else in your code.
By the end of this article you'll know exactly what an ORM is, why it was invented, how it translates your code into database queries under the hood, and when using one is the right call. You'll also see real, runnable code showing the before-and-after — raw SQL versus ORM — so the difference is undeniable.
The Problem ORMs Were Built to Solve: Raw SQL in Application Code
Before ORMs existed, developers mixed two very different worlds in the same file. On one side: application logic written in Python, Java, or Ruby — working with objects, classes, and methods. On the other side: SQL strings embedded in that same code — brittle text that the database had to parse and execute.
This created real problems. First, your database structure was scattered everywhere. If you renamed a column in your database, you had to hunt through thousands of lines of code to find every SQL string that referenced it. Miss one and your app breaks in production.
Second, SQL strings are just strings. Your editor can't autocomplete them, the compiler can't check them, and bugs hide inside them until runtime. A typo in a column name doesn't fail at compile time — it fails when a real user hits that code path.
Third, different databases use slightly different SQL dialects. Code written for MySQL doesn't always run on PostgreSQL. You were locked in.
ORMs fix all three problems at once. Your database schema is defined once, in code. Column names become object properties your editor can autocomplete and your compiler can check. And switching databases often requires changing just one configuration line.
raw_sql_example.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import sqlite3
# Connect directly to the database
connection = sqlite3.connect('bookstore.db')
cursor = connection.cursor()
# Step 1: Create the table with raw SQL — notice this is just a plain string.# If you mistype 'VARCHAR', nothing warns you until you run this.
cursor.execute('''
CREATETABLEIFNOTEXISTSbooks (
id INTEGERPRIMARYKEYAUTOINCREMENT,
title VARCHAR(200),
author VARCHAR(100),
price REAL
)
''')
# Step 2: Insert a book — the values are positional. Easy to mix up the order.
cursor.execute(
'INSERT INTO books (title, author, price) VALUES (?, ?, ?)',
('Foundation', 'Isaac Asimov', 12.99)
)
connection.commit()
# Step 3: Fetch all books — the result is a list of plain tuples, not objects.# cursor.fetchall() returns [(1, 'Foundation', 'Isaac Asimov', 12.99)]# You must remember that index 1 is title, index 2 is author, etc.
rows = cursor.execute('SELECT * FROM books').fetchall()
for row in rows:
# Accessing data by index position — fragile and hard to readprint(f"Title: {row[1]}, Author: {row[2]}, Price: ${row[3]:.2f}")
connection.close()
Output
Title: Foundation, Author: Isaac Asimov, Price: $12.99
Watch Out:
See how the result comes back as row[1], row[2], row[3]? If someone adds a new column to that table, every index shifts and your code silently reads the wrong data. This is a real class of bug that raw SQL creates — and it's exactly what ORMs prevent.
How an ORM Actually Works: Classes Become Tables, Objects Become Rows
Here's the core idea of an ORM, stripped to its essence: every table in your database maps to a class in your code, and every row in that table maps to an instance of that class.
Think about a 'books' table with columns: id, title, author, price. An ORM lets you define a Book class where those columns are just properties. The ORM reads that class definition and knows everything it needs to create the table, insert records, run queries, and give you results back as real Book objects — not raw tuples.
When you call something like Book.query.filter_by(author='Asimov').all(), the ORM translates that into SELECT * FROM books WHERE author = 'Asimov' and fires it at the database. The raw SQL is still happening — you're just not writing it.
This translation layer is called the mapping, which is where the name Object-Relational Mapper comes from. 'Object' refers to your Python/Java/Ruby objects. 'Relational' refers to your relational database (the kind organised into tables and rows). 'Mapper' is the thing connecting both worlds.
Popular ORMs include SQLAlchemy (Python), Hibernate (Java), ActiveRecord (Ruby on Rails), Eloquent (PHP/Laravel), and Prisma (JavaScript/TypeScript). Each one speaks to the same concept — your objects, their tables, zero raw SQL required.
orm_example_sqlalchemy.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# Using SQLAlchemy — one of the most popular Python ORMs.# Install it first: pip install sqlalchemyfrom sqlalchemy import create_engine, Column, Integer, String, Floatfrom sqlalchemy.orm importDeclarativeBase, Session# --- STEP 1: Define the database connection ---# 'sqlite:///bookstore.db' means: use SQLite, stored in a file called bookstore.db# The ORM handles the actual connection — you just describe WHERE the database is.
engine = create_engine('sqlite:///bookstore.db', echo=False)
# --- STEP 2: Define your Base class ---# All ORM model classes will inherit from this. It's the foundation the ORM builds on.classBase(DeclarativeBase):
pass# --- STEP 3: Define the Book model (this REPLACES the CREATE TABLE SQL) ---# This class definition IS your table definition. The ORM reads it and knows# the table name, the column names, and the data types.classBook(Base):
__tablename__ = 'books' # The name of the database table this maps to
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200), nullable=False)
author = Column(String(100), nullable=False)
price = Column(Float, nullable=False)
# This makes printing a Book object readable — very handy when debuggingdef__repr__(self):
return f"Book(title='{self.title}', author='{self.author}', price=${self.price:.2f})"# --- STEP 4: Create the table in the database ---# The ORM inspects all classes that inherit from Base and issues CREATE TABLE# statements automatically. You never write CREATE TABLE again.Base.metadata.create_all(engine)
# --- STEP 5: Insert a record using a Python object, not a SQL string ---# A 'Session' is your workspace — think of it as a shopping basket.# Changes go in the basket (session) and are committed to the database together.withSession(engine) as session:
new_book = Book(
title='Foundation',
author='Isaac Asimov',
price=12.99
)
session.add(new_book) # Add the object to the basket
session.commit() # Flush the basket — this runs INSERT INTO books (...) VALUES (...)# --- STEP 6: Query using Python, get back Python objects ---# This line becomes: SELECT * FROM books WHERE author = 'Isaac Asimov'
asimov_books = session.query(Book).filter_by(author='Isaac Asimov').all()
for book in asimov_books:
# 'book' is a real Book object — access properties by name, not indexprint(f"Found: {book.title} by {book.author} — ${book.price:.2f}")
# --- STEP 7: Update a record — just change the property and commit ---# No UPDATE SQL needed. The ORM detects the change and writes the SQL for you.
first_book = asimov_books[0]
first_book.price = 9.99# Change the price on the Python object
session.commit() # ORM runs: UPDATE books SET price = 9.99 WHERE id = 1print(f"Updated price: ${first_book.price:.2f}")
# --- STEP 8: Delete a record ---
session.delete(first_book) # Mark for deletion
session.commit() # ORM runs: DELETE FROM books WHERE id = 1print("Book deleted.")
Output
Found: Foundation by Isaac Asimov — $12.99
Updated price: $9.99
Book deleted.
Key Insight:
The ORM didn't stop SQL from happening — it just wrote it for you. If you set echo=True in create_engine(), SQLAlchemy prints every SQL statement it generates. This is fantastic for learning: you can see the exact SQL your Python code produces, which demystifies the ORM completely and helps you debug performance issues later.
ORM vs Raw SQL: When to Use Each One
An ORM isn't always the right tool, and blindly reaching for one on every project is a mistake. Understanding when to use an ORM — and when to drop down to raw SQL — is what separates a thoughtful developer from someone who just follows trends.
ORMs shine when your application logic is the hard part. If you're building a web app with user accounts, product listings, or blog posts, an ORM lets you move fast. Your models are readable, your queries are type-safe, and your codebase stays consistent. Teams especially benefit — a shared ORM model is self-documenting in a way that scattered SQL strings never are.
Raw SQL wins when performance is critical and the query is complex. Suppose you need a report that joins seven tables, uses window functions, and aggregates millions of rows. ORMs can generate inefficient SQL for complex cases, and fighting the ORM to produce the query you want is slower than just writing it yourself. Most ORMs let you drop to raw SQL for specific queries, so you don't have to pick just one approach for a whole project.
A practical rule of thumb: start with an ORM for 90% of your queries. When profiling reveals a bottleneck in a specific query, replace that one query with hand-crafted SQL. You get the productivity of an ORM everywhere else and the precision of raw SQL exactly where you need it.
orm_vs_raw_sql_comparison.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from sqlalchemy import create_engine, text, Column, Integer, String, Floatfrom sqlalchemy.orm importDeclarativeBase, Session
engine = create_engine('sqlite:///bookstore.db', echo=False)
classBase(DeclarativeBase):
passclassBook(Base):
__tablename__ = 'books'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200))
author = Column(String(100))
price = Column(Float)
Base.metadata.create_all(engine)
# Seed some data so our queries have something to work withwithSession(engine) as session:
session.add_all([
Book(title='Foundation', author='Isaac Asimov', price=12.99),
Book(title='Dune', author='Frank Herbert', price=14.99),
Book(title='Neuromancer', author='William Gibson', price=10.49),
Book(title='The Left Hand of Darkness', author='Ursula K. Le Guin', price=11.99),
])
session.commit()
withSession(engine) as session:
# ---- APPROACH 1: ORM Query ----# Clean, readable, safe from SQL injection, works across database brandsprint("=== ORM Query: Books under $13 ===")
affordable_books = (
session.query(Book)
.filter(Book.price < 13.00) # The ORM adds a WHERE clause
.order_by(Book.price) # The ORM adds ORDER BY
.all()
)
for book in affordable_books:
print(f" {book.title} — ${book.price:.2f}")
# ---- APPROACH 2: Raw SQL via the ORM connection ----# Useful for complex queries the ORM struggles to express cleanly.# text() tells SQLAlchemy this is a literal SQL string — handle with care.print("\n=== Raw SQL: Average price per author initial ===")
raw_query = text("""
SELECTSUBSTR(author, 1, 1) AS first_initial,
ROUND(AVG(price), 2) AS average_price,
COUNT(*) AS book_count
FROM books
GROUPBY first_initial
ORDERBY average_price DESC""")
results = session.execute(raw_query).fetchall()
for row in results:
# Raw SQL results come back as named tuples — row.first_initial works hereprint(f" Initial '{row.first_initial}': avg ${row.average_price} ({row.book_count} books)")
Output
=== ORM Query: Books under $13 ===
Neuromancer — $10.49
The Left Hand of Darkness — $11.99
Foundation — $12.99
=== Raw SQL: Average price per author initial ===
Initial 'F': avg $14.99 (1 books)
Initial 'I': avg $12.99 (1 books)
Initial 'U': avg $11.99 (1 books)
Initial 'W': avg $10.49 (1 books)
Pro Tip:
ORMs and raw SQL aren't enemies — they're teammates. The best real-world codebases use an ORM for everyday CRUD operations and drop to raw SQL for reporting queries or performance-critical paths. Knowing when to switch is a mark of experience.
Feature / Aspect
Using an ORM
Raw SQL
Code readability
High — reads like plain English (Book.filter_by(...))
Lower — SQL strings embedded in application code
SQL injection safety
Built-in — parameters are escaped automatically
Manual — you must sanitise every input yourself
Speed of development
Fast — CRUD operations in 1-2 lines
Slower — every query written and tested by hand
Query performance
Good for simple queries; can be inefficient for complex joins
Optimal — you control every byte of the query
Database portability
High — switch databases by changing one config line
Low — SQL dialects differ between MySQL, PostgreSQL, SQLite
Learning curve
Moderate — must learn the ORM API on top of Python/Java
Lower to start, but SQL expertise required for complex work
Debugging difficulty
Medium — generated SQL can be hard to trace
Low — you wrote exactly what runs
Best used for
Web apps, APIs, CRUD-heavy applications
Analytics, reporting, data pipelines, high-performance queries
Key takeaways
1
An ORM maps database tables to classes and database rows to objects
so you work with familiar code constructs instead of raw SQL strings scattered through your application.
2
The ORM doesn't eliminate SQL
it generates it for you. Enabling query logging (echo=True in SQLAlchemy) reveals the exact SQL your code produces, which is essential for debugging and performance tuning.
3
The N+1 query problem is the most common ORM performance trap
loading a collection then querying each item individually. Always use eager loading (joinedload / prefetch_related) when you know you'll need related data.
4
ORMs and raw SQL aren't mutually exclusive
the best approach is ORM for everyday CRUD, raw SQL for complex analytics or performance-critical queries. Most ORMs let you mix both in the same project.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
What does ORM stand for and what does it do?
ORM stands for Object-Relational Mapper. It's a tool that sits between your application code and your database, automatically translating operations on your code objects (like creating or updating a User object) into the SQL queries your database needs to execute. You write Python, Java, or Ruby — the ORM writes SQL.
Was this helpful?
02
Do I still need to learn SQL if I use an ORM?
Yes — and this is important. An ORM generates SQL for you, but you need to understand SQL to verify that the generated queries are correct and efficient. Developers who skip SQL and rely entirely on an ORM often ship slow applications because they can't spot a bad query when they see one. Learn SQL first, then reach for an ORM to save time.
Was this helpful?
03
Is using an ORM slower than writing raw SQL?
For simple queries, the difference is negligible. For complex queries involving multiple joins, aggregations, or large datasets, a hand-crafted SQL query will almost always outperform ORM-generated SQL because you have full control over every detail. The smart approach is to use an ORM by default and drop to raw SQL only for the specific queries where profiling shows a bottleneck.