Skip to content

Solving N+1’s with Rails `exists?` queries

Learn how to solve your Rails applications N+1’s caused by `exists?` queries.

Solving N+1’s with Rails `exists?` queries

We recently had a performance issue in our Rails application where we had several N+1 .exists? queries on a single API endpoint. In the following query, we check to see if the "data_imports" feature is enabled for a user:

Ruby
user.beta_feature.where(name: "data_imports").enabled.exists?

Output:

SQL
BetaFeature Exists? (0.6ms) SELECT 1 AS one FROM `beta_feature` WHERE `beta_feature`.`name` = 'data_imports' AND `beta_feature`.`target_type` = 'User' AND `beta_feature`.`target_id` = 1 AND `beta_feature`.`enabled_at` IS NOT NULL LIMIT 1

This pattern initially worked well, but became an issue as we started to add more beta features. Each new beta feature added an additional query, and it started to impact the speed of the API endpoint.

Generally, in Rails applications, you can solve N+1's by using includes to preload the data. This, however, doesn't work with exists? queries. Rails will still execute the query.

Solving the N+1

Let's look at how we went about solving the N+1 problem for our Rails exists? query. On our user model, we originally had this method for checking beta features:

Ruby
def beta_feature_enabled?(name)
beta_features.where(name: name).enabled.exists?
end

This will always execute a query whether or not beta_features is already loaded.

One way to avoid the query is by preloading all the records and then checking them in memory rather than executing a query.

Ruby
# New method, allows us to preload beta_features
def beta_feature_enabled?(name)
if beta_features.loaded?
beta_features.any? { |f| f.name == name.to_s && f.enabled? }
else
beta_features.where(name: name.to_s).enabled.exists?
end
end

Now, in our controllers, if we preload beta_features using includes, it will already be loaded. Any calls to beta_feature_enabled? won't execute additional queries.

Ruby
# Executes 2 queries
@users = User.all.includes(:beta_features)

You can also use this technique for reducing queries when loading a single record.

Ruby
@user = User.find(params[:id])
@user.beta_features.load # preload all beta features for the user

Preloading with a scope

With the above solution, we're loading every beta_feature for every user. For our use case, this is what we want.

This could result in loading unnecessary records if your application is only checking a few though.

If that's you, here's a solution. You can set up a new association that only loads the records you need:

Ruby
has_many :beta_features, as: :target, dependent: :destroy_async
PRELOADED_FLAGS = %w[dark_mode insights data_imports]
has_many :preloaded_beta_features, -> { where(name: PRELOADED_FLAGS) }, as: :target, class_name: "BetaFeature"

You can now replace beta_features with preloaded_beta_features to load in only the records you need.

Want to supercharge your Rails database?