Skip to main content

Command Palette

Search for a command to run...

The Secret Translator Between Your Code and Database: Understanding ORM

Updated
8 min readView as Markdown
The Secret Translator Between Your Code and Database: Understanding ORM

If you are a web developer using any backend framework, I am sure you have used ORM (Object-Relational Mapping) knowingly or unknowingly. ORM stands for Object-Relational Mapping. In simple terms, it is a technique that connects object-oriented code with a relational database.

Instead of writing SQL queries every time you want to read or save data, an ORM allows you to work with familiar objects, classes, and methods.

Some popular ORMs include:

  • Hibernate for Java and Spring Boot applications

  • Eloquent for Laravel applications

  • TypeORM for Node.js applications

  • Sequelize for Node.js applications

  • Entity Framework for .NET applications

ORMs make development faster and keep our code clean. However, using an ORM without understanding what happens behind the scenes can create serious performance problems.

Have you ever heard someone say:

“This query is taking too long. Let us replace it with a native SQL query.”

Sometimes that is the right solution. But the real problem is often not the ORM itself. The problem is how we are using it.

For this example, I will use TypeORM with TypeScript.

Let us look at this TypeORM query.
Java developers, I promise TypeScript is not that bad! 😂☕

const users: User[] = await this.userRepository.find({
  where: {
    type: 'EXTERNAL',
  },
});

This code looks simple and clean. It asks TypeORM to find all users whose type is EXTERNAL.

Assume our User entity looks like this:

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  uid: string;

  @Column()
  name: string;

  @Column()
  email: string;

  @Column()
  type: string;

  @ManyToOne(() => Region, {
    eager: true,
  })
  region: Region;

  @OneToOne(() => AddressDetail, {
    eager: true,
  })
  @JoinColumn()
  addressDetails: AddressDetail;
}

Because the region and addressDetails relationships are marked as eager, TypeORM may generate a SQL query similar to this:

SELECT
    "User"."id" AS "User_id",
    "User"."uid" AS "User_uid",
    "User"."name" AS "User_name",
    "User"."email" AS "User_email",
    "User"."type" AS "User_type",

    "User_region"."id" AS "User_region_id",
    "User_region"."name" AS "User_region_name",
    "User_region"."code" AS "User_region_code",

    "User_addressDetails"."id" AS "User_addressDetails_id",
    "User_addressDetails"."street" AS "User_addressDetails_street",
    "User_addressDetails"."city" AS "User_addressDetails_city",
    "User_addressDetails"."phone" AS "User_addressDetails_phone"

FROM "users" "User"

LEFT JOIN "regions" "User_region"
    ON "User_region"."id" = "User"."region_id"

LEFT JOIN "address_details" "User_addressDetails"
    ON "User_addressDetails"."id" = "User"."address_details_id"

WHERE "User"."type" = 'EXTERNAL';

The TypeScript code is only a few lines, but the generated SQL query is much larger.

This does not automatically mean the query is bad. The joins may be necessary if we need the user's region and address. But if we only need the user's ID and name, then the ORM is fetching much more data than necessary.

A simpler SQL query would be:

SELECT id, uid, name
FROM users
WHERE type = 'EXTERNAL';

This is why understanding the SQL generated by your ORM is important.

ORM Is Not Magic

An ORM is like a translator between your application and your database.

You write this:

const user = new User();
user.name = 'John';
user.email = 'john@example.com';

await this.userRepository.save(user);

The ORM translates it into something similar to:

INSERT INTO users (name, email)
VALUES ('John', 'john@example.com');

When you update the user, the ORM creates an UPDATE statement. When you delete the user, it creates a DELETE statement.

The ORM saves us from writing repetitive SQL, but the database still receives and executes SQL queries. That is why every backend developer should understand at least the basics of SQL.

Common ORM Performance Problems

1. Fetching More Data Than You Need

Imagine that the users table contains 20 columns, but your page only needs the user's name and email.

This query may fetch every column:

const users = await this.userRepository.find();

You can select only what you need:

const users = await this.userRepository.find({
  select: {
    id: true,
    name: true,
    email: true,
  },
});

The generated SQL will be smaller:

SELECT id, name, email
FROM users;

This reduces the amount of data transferred from the database to your application.

2. Loading Unnecessary Relationships

Relationships are useful, but they can also make queries expensive.

For example:

@ManyToOne(() => Region, {
  eager: true,
})
region: Region;

The eager: true option tells TypeORM to load the region whenever it loads a user.

That may be helpful in some places, but not everywhere. If you fetch 10,000 users, you may also fetch their related region data—even when you do not need it.

In many cases, it is better to load the relationship only when required:

const users = await this.userRepository.find({
  relations: {
    region: true,
  },
});

Now you have more control over when the relationship is included.

3. The N+1 Query Problem

The N+1 problem is one of the most common ORM performance issues.

Imagine that you fetch 100 users:

SELECT * FROM users;

Then, for every user, your application sends another query to fetch the user's region:

SELECT * FROM regions WHERE id = 1;
SELECT * FROM regions WHERE id = 2;
SELECT * FROM regions WHERE id = 3;

Instead of running one or two queries, the application may run 101 queries: one query for the users and another 100 queries for their regions.

This can make an application very slow.

You can usually solve this by loading the required relationship with a join:

const users = await this.userRepository
  .createQueryBuilder('user')
  .leftJoinAndSelect('user.region', 'region')
  .where('user.type = :type', { type: 'EXTERNAL' })
  .getMany();

This retrieves the users and regions using a single query.

4. Returning Thousands of Records

This query returns every external user:

const users = await this.userRepository.find({
  where: {
    type: 'EXTERNAL',
  },
});

That might work when you have 50 users. But what happens when the table grows to one million users?

We should use pagination:

const users = await this.userRepository.find({
  where: {
    type: 'EXTERNAL',
  },
  take: 20,
  skip: 0,
});

This tells the database to return only 20 records.

For larger datasets, cursor-based pagination may perform better than using large skip values.

5. Missing Database Indexes

Sometimes developers blame the ORM when the real problem is the database structure.

Consider this query:

SELECT id, name, email
FROM users
WHERE type = 'EXTERNAL';

If the type column is frequently used for filtering, an index may improve performance:

CREATE INDEX idx_users_type
ON users(type);

However, indexes should also be used carefully because they take storage space and can make inserts and updates slightly slower.

How Can You See the Generated SQL?

TypeORM allows you to enable query logging.

In your database configuration, add:

{
  type: 'postgres',
  host: 'localhost',
  port: 5432,
  username: 'postgres',
  password: 'password',
  database: 'my_database',
  logging: ['query', 'error'],
}

During development, you will see the SQL queries generated by TypeORM in your console.

You can also inspect a QueryBuilder query before executing it:

const query = this.userRepository
  .createQueryBuilder('user')
  .leftJoin('user.region', 'region')
  .select([
    'user.id',
    'user.name',
    'region.name',
  ])
  .where('user.type = :type', {
    type: 'EXTERNAL',
  });

console.log(query.getSql());

const users = await query.getMany();

Looking at generated SQL helps you discover unnecessary joins, duplicated queries, missing filters, and other possible performance problems.

Be careful when enabling full query logging in production because logs can become very large and may expose sensitive information.

Should We Stop Using ORMs?

No.

ORMs are extremely useful. They help us:

  • Write less repetitive code

  • Map database rows to application objects

  • Manage relationships between tables

  • Handle database migrations

  • Protect against SQL injection when parameters are used correctly

  • Build applications faster

  • Keep database operations consistent

The goal is not to avoid ORM. The goal is to understand it.

Native SQL can be useful for complex reports, bulk operations, or queries that require database-specific features. But native SQL is not automatically faster. A poorly written native query can be slower than a well-written ORM query.

Before replacing an ORM query, first inspect the generated SQL and check the database execution plan.

In PostgreSQL, you can use:

EXPLAIN ANALYZE
SELECT id, uid, name
FROM users
WHERE type = 'EXTERNAL';

This shows how PostgreSQL executes the query and where it spends time.

A Good Rule to Follow

Use the ORM for normal database operations, but always know what it is doing behind the scenes.

Before blaming the ORM, ask yourself:

  • Which SQL query did the ORM generate?

  • Am I selecting columns that I do not need?

  • Am I loading unnecessary relationships?

  • Is the application running too many queries?

  • Do I need pagination?

  • Does the database have the correct indexes?

  • What does the query execution plan show?

These questions will help you find the real problem.

Final Thoughts

ORM makes working with databases easier, but easy does not always mean efficient.

The clean code we write in our application can sometimes generate a large and expensive SQL query. As developers, we should not treat the ORM as a black box.

Take time to understand how your ORM handles relationships, joins, transactions, pagination, and query generation. Enable query logging during development and inspect slow queries.

You do not need to become a database expert overnight. Start by looking at the SQL generated by the queries you write every day.

Once you understand both sides, the clean ORM code and the SQL running behind it, you will write applications that are not only easier to maintain but also faster and more reliable.

The ORM is your translator, but you should still understand the language it speaks.

4 views