Advanced ActiveRecord Query Optimization in Rails: From Arel to Raw SQL
Posted at 29-August-2025 / Written by Rohit Bhatt

30-sec summary
ActiveRecord is one of Rails’ greatest strengths — but when your queries get complex or performance becomes critical, knowing how to go beyond the basics is essential. In this article, we’ll explore how to push ActiveRecord to its limits, when to leverage Arel for fine-grained query building, and when raw SQL is the best tool for the job.
Why Query Optimization Matters in Rails
Rails apps start simple, but as features grow, so does query complexity. Eager loading can only take you so far. A dashboard with aggregated metrics, a report with complex joins, or a performance-sensitive API endpoint — all can slow down drastically if you stick to naive ActiveRecord usage. Optimizing queries directly impacts response times, database load, and ultimately user experience.
The Three Layers of Querying in Rails
Rails developers work with three main query layers. Understanding when to use each is crucial.
- 1.ActiveRecord: High-level, object-oriented, easy to read and write. Perfect for 80% of use cases.
- 2.Arel: Rails’ relational algebra engine. Verbose, but powerful for conditional queries and dynamic query building.
- 3.Raw SQL: The escape hatch. Use for CTEs, window functions, and advanced PostgreSQL features that ActiveRecord/Arel don’t cover.
ActiveRecord: Start Simple
Let’s begin with a common reporting scenario: counting confirmed bookings per company. In ActiveRecord:
1Company.joins(:bookings)
2 .where(bookings: { status: ['confirmed', 'completed'] })
3 .group('companies.id')
4 .count
ActiveRecord Limitations
This is fine, but once you need conditional counts (like confirmed vs cancelled vs pending), ActiveRecord gets messy. Chaining scopes creates SQL duplication, and trying to express conditional aggregations is awkward.
Enter Arel: Rails’ Hidden Power
Arel gives you precise SQL control while keeping Ruby syntax. Let’s redo the same query with conditional counts:
1companies = Company.arel_table
2bookings = Booking.arel_table
3
4query = companies
5 .join(bookings).on(bookings[:company_id].eq(companies[:id]))
6 .project(
7 companies[:id],
8 bookings[:id].count.as('total_bookings'),
9 Arel.sql("COUNT(CASE WHEN bookings.status = 'confirmed' THEN 1 END)").as('confirmed_count'),
10 Arel.sql("COUNT(CASE WHEN bookings.status = 'cancelled' THEN 1 END)").as('cancelled_count')
11 )
12 .group(companies[:id])
13
14Company.find_by_sql(query.to_sql)
Dynamic Query Building with Arel
Arel shines when queries are built dynamically. Example: filtering based on optional params.
1def bookings_query(params)
2 bookings = Booking.arel_table
3 q = bookings.project(Arel.star)
4
5 q = q.where(bookings[:status].eq(params[:status])) if params[:status].present?
6 q = q.where(bookings[:appointment_date].gteq(params[:from])) if params[:from]
7 q = q.where(bookings[:appointment_date].lteq(params[:to])) if params[:to]
8
9 Booking.find_by_sql(q.to_sql)
10end
When Raw SQL is the Best Option
Arel has limits. For things like CTEs, window functions, JSONB operators, and lateral joins, you’ll often need raw SQL.
Example: Common Table Expressions (CTEs)
1Company.find_by_sql(<<~SQL)
2 WITH booking_counts AS (
3 SELECT company_id,
4 COUNT(*) AS total,
5 COUNT(*) FILTER (WHERE status = 'confirmed') AS confirmed,
6 COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled
7 FROM bookings
8 GROUP BY company_id
9 )
10 SELECT companies.id, companies.name, total, confirmed, cancelled
11 FROM companies
12 JOIN booking_counts ON booking_counts.company_id = companies.id
13SQL
Example: Window Functions
Suppose you want the latest booking per customer:
1Booking.find_by_sql(<<~SQL)
2 SELECT DISTINCT ON (customer_id) *
3 FROM bookings
4 ORDER BY customer_id, appointment_date DESC
5SQL
Example: Lateral Joins
Get each company’s most recent booking:
1Company.find_by_sql(<<~SQL)
2 SELECT companies.*, latest.*
3 FROM companies
4 JOIN LATERAL (
5 SELECT * FROM bookings
6 WHERE bookings.company_id = companies.id
7 ORDER BY appointment_date DESC
8 LIMIT 1
9 ) latest ON true
10SQL
Performance Tuning in Practice
- 1.Avoid N+1 Queries: Use `includes` or `preload`. Use Bullet gem in dev to catch N+1s early.
- 2.Batch Processing: Use `in_batches` or `find_in_batches` to avoid loading huge sets into memory.
- 3.Proper Indexing: Always align queries with DB indexes. For Postgres, consider partial indexes and GIN/GIN_trgm for text searches.
- 4.Use EXPLAIN ANALYZE: Check query plans regularly. ActiveRecord might look clean, but DB may choose a bad plan.
Practical Tips
- 1.Mix & Match: Don’t be dogmatic. Start with ActiveRecord, drop into Arel when needed, and use raw SQL for the final 10%.
- 2.Keep Queries in Models/Scopes: Encapsulate raw SQL in model scopes or service objects. Keeps controllers clean.
- 3.Test Your Queries: Use RSpec + database cleaner to ensure queries produce expected results.
Conclusion
Mastering Rails queries means mastering all three layers: ActiveRecord for productivity, Arel for flexibility, and raw SQL for power. The trick is knowing when to switch layers. With this toolbox, you’ll write code that’s not just elegant, but fast and production-ready.