SQL & ORMs

How to Read
ORM-Generated SQL

ORMs are great until something goes wrong. This guide shows you how to extract, format, and understand what your ORM is actually sending to your database.

10 min read·Updated Feb 2026

ORMs (Object-Relational Mappers) like Django's ORM, ActiveRecord, SQLAlchemy, and Hibernate abstract away SQL — which is exactly their job. But that abstraction becomes a problem when you have a slow query, unexpected results, or a performance issue you can't explain without seeing what's actually being sent to the database.

This guide covers how to extract the raw SQL from each major ORM, how to make it readable, and how to spot the most common ORM performance anti-patterns.

Why ORM SQL Is Hard to Read

When you write User.objects.filter(active=True).select_related('profile'), the ORM generates something like this:

SELECT "users"."id","users"."email","users"."is_active","users"."created_at","users"."profile_id","profiles"."id","profiles"."user_id","profiles"."bio","profiles"."avatar_url","profiles"."updated_at" FROM "users" INNER JOIN "profiles" ON ("users"."profile_id" = "profiles"."id") WHERE "users"."is_active" = true

That's a single line, no indentation, quoted column names, and every column explicitly listed. Paste it into ResourceCentral's SQL Formatter and it becomes:

SELECT
  "users"."id",
  "users"."email",
  "users"."is_active",
  "users"."created_at",
  "users"."profile_id",
  "profiles"."id",
  "profiles"."user_id",
  "profiles"."bio",
  "profiles"."avatar_url",
  "profiles"."updated_at"
FROM
  "users"
  INNER JOIN "profiles" ON ("users"."profile_id" = "profiles"."id")
WHERE
  "users"."is_active" = true

Now it's readable. You can see the JOIN condition, spot missing indexes, and understand exactly what data is being fetched.

How to Extract the SQL from Each ORM

Django (Python)

Three ways, in order of usefulness:

# 1. .query on a queryset (development only)
qs = User.objects.filter(active=True).select_related('profile')
print(str(qs.query))

# 2. Connection queries (requires DEBUG=True)
from django.db import connection
print(connection.queries[-1]['sql'])  # last executed query

# 3. django-debug-toolbar (best for full visibility)
# Add to INSTALLED_APPS and middleware — shows all queries per request
ActiveRecord (Ruby on Rails)
# In Rails console
User.where(active: true).includes(:profile).to_sql
# => "SELECT \"users\".* FROM \"users\" WHERE \"users\".\"active\" = 1"

# Rails logs SQL automatically — check log/development.log
# Or use the bullet gem for N+1 detection

# For explain plans:
User.where(active: true).explain
SQLAlchemy (Python)
# Method 1: echo=True on engine (logs all SQL)
engine = create_engine("postgresql://...", echo=True)

# Method 2: compile the query directly
stmt = select(User).where(User.active == True)
print(stmt.compile(dialect=postgresql.dialect()))

# Method 3: str() for quick inspection
query = session.query(User).filter(User.active == True)
print(str(query))  # dialect-independent version
Hibernate / JPA (Java)
# In application.properties (Spring Boot)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql=TRACE  # shows bind params

# Or programmatically with P6Spy proxy driver for full logging
Prisma (Node.js / TypeScript)
// Enable query logging in PrismaClient
const prisma = new PrismaClient({
  log: ['query', 'info', 'warn', 'error'],
})

// Or capture query events
prisma.$on('query', (e) => {
  console.log('Query:', e.query)
  console.log('Params:', e.params)
  console.log('Duration:', e.duration + 'ms')
})

The N+1 Problem: What to Look For

The single most common ORM performance problem is the N+1 query. It happens when your code loads a list of records and then lazily fetches a related record for each one individually.

What it looks like in your logs:

-- Query 1: load 100 posts
SELECT * FROM posts LIMIT 100;

-- Then 100 more queries, one per post:
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
-- ... 97 more

That's 101 queries instead of 2. On a page that runs on every request, this is a silent performance killer that gets worse as your data grows.

How to spot it: Look for repeated identical queries with only the WHERE clause value changing. Also look for query count that scales linearly with your result set size.

How to fix it: Use eager loading — tell the ORM to fetch the related data in the initial query.

❌ N+1 — Bad
# Django
posts = Post.objects.all()
for post in posts:
    print(post.author.name)  # lazy load!

# Rails
posts = Post.all
posts.each { |p| puts p.user.name }  # N+1

# SQLAlchemy
posts = session.query(Post).all()
for post in posts:
    print(post.author.name)  # lazy!
✅ Eager load — Fixed
# Django
posts = Post.objects.select_related('author')

# Rails
posts = Post.includes(:user)

# SQLAlchemy
posts = session.query(Post)\
  .options(joinedload(Post.author))\
  .all()

# Prisma
posts = await prisma.post.findMany({
  include: { author: true }
})

Reading EXPLAIN Output

Once you have the formatted SQL, run EXPLAIN ANALYZE (PostgreSQL) or EXPLAIN (MySQL) to understand how the database executes it. The key things to look for:

Seq Scan on large tables
A sequential scan on a table with millions of rows means no index is being used. Look at the WHERE clause — the filtered column probably needs an index.
Nested Loop with large row estimates
Nested loops are efficient for small datasets but devastating for large ones. If rows estimates are high, a Hash Join or Merge Join would be better — hint the planner or add statistics.
Hash Join
Generally efficient. Means the planner chose to build a hash table of one side of the join. Good for large datasets where both sides need to be fully scanned.
Sort with no index
If ORDER BY causes a sort step and it's slow, add an index on the sort column. Especially important for pagination queries.
cost=0.00..XXXXX rows=1 width=N
The cost estimate is the planner's guess. If actual rows differ wildly from estimated rows, run ANALYZE on the table to update statistics.

Security Note: Don't Share Raw ORM Queries with Online Tools

ORM-generated SQL often contains your real table names, column names, and sometimes literal data values. This is sensitive — your schema structure tells an attacker exactly what to target in an injection attack.

When you need to format a query for readability: use ResourceCentral's SQL Formatter, which runs entirely in your browser. Your query never touches an external server. Server-side formatters log your queries — and your schema with them.

Format Your ORM Query — Free & Private

Paste the raw SQL. Get it beautifully formatted. Zero network requests — your schema stays on your machine.

Open SQL Formatter — Free →

FAQ

How do I see the SQL generated by my ORM? +

Each ORM differs: Django uses str(queryset.query) or connection.queries. SQLAlchemy uses echo=True or str(query). ActiveRecord uses .to_sql or Rails logs. Hibernate uses show_sql=true. See the code examples above for each.

What is an N+1 query and how do I fix it? +

An N+1 query happens when loading N records causes N additional queries for related data. Fix it by using eager loading: select_related/prefetch_related in Django, includes in Rails, joinedload in SQLAlchemy, or include in Prisma.

Does formatting SQL change how it executes? +

No. SQL formatters only change whitespace and capitalisation. The query plan, results, and performance are completely unaffected. It's purely cosmetic — for human readability.

Should I use raw SQL instead of an ORM? +

For most CRUD operations, ORMs are the right choice — they handle parameterisation (preventing injection), migrations, and type safety. For complex analytics queries or performance-critical paths, dropping to raw SQL or using the ORM's raw query interface gives you full control without sacrificing the rest of the ORM's benefits.

Related