/ Tags: DEVELOPER TIPS / Categories: TIPS

Common Mistakes Rails Beginners Make (and How to Fix Them)

Every one of these ships to production regularly, and none of them are signs that someone is a bad developer. They’re mistakes Rails makes easy — the framework optimises for getting something working quickly, and a few of the shortcuts it offers are traps that only spring at scale. Recognising the pattern is most of the fix.

1. N+1 Queries in Views


The most common Rails performance bug by a wide margin.

Before:

<% @orders.each do |order| %>
  <%= order.customer.name %>
<% end %>

Fifty orders, fifty-one queries. It’s invisible locally with twenty rows and it’s an outage at two million.

After:

@orders = Order.includes(:customer).recent.limit(50)

The habit: any time you loop and call an association, load it up front. bullet in development will tell you, and strict loading will make it impossible to write.

Watch for the sneaky version too — order.line_items.count inside a loop issues a COUNT per iteration even with includes. Use counter_cache or size on a loaded association.

2. Fat Controllers


Before:

def create
  @order = Order.new(order_params)
  @order.total = @order.line_items.sum { |i| i.price * i.quantity }
  @order.total -= @order.total * 0.1 if current_user.premium?
  @order.status = current_user.verified? ? "confirmed" : "pending_review"

  if @order.save
    OrderMailer.confirmation(@order).deliver_later
    Analytics.track("order_created", user_id: current_user.id)
    current_user.increment!(:order_count)
    redirect_to @order
  else
    render :new
  end
end

Untestable without a request, unreusable from a rake task, and it’ll be sixty lines within a month.

After:

def create
  result = CreateOrder.new(user: current_user, params: order_params).call

  if result.success?
    redirect_to result.order, notice: "Order created"
  else
    @order = result.order
    render :new, status: :unprocessable_entity
  end
end

A controller should do four things: authenticate, authorise, call something, respond. Business logic goes in the model or a service object where it can be unit tested.

3. Skipping status: on Failed Renders


Subtle and increasingly consequential.

render :new                                  # returns 200 OK
render :new, status: :unprocessable_entity   # correct

With Turbo — the default in Rails 7+ — a form submission that responds 200 with a non-redirect body is silently ignored. The user submits an invalid form, nothing happens, no error appears. This is the single most common “my Rails 7 form doesn’t work” report and the fix is one keyword argument.

4. Validations Without Database Constraints


class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
end

uniqueness runs a SELECT and then an INSERT. Two simultaneous requests both pass the check and both insert. You now have duplicate emails and a validation that swears you don’t.

Fix:

add_index :users, :email, unique: true
change_column_null :users, :email, false

Validations are for user-friendly messages. Constraints are for correctness. You need both, and the database is the only component that can enforce uniqueness atomically.

5. default_scope for Anything but Tenancy


class Post < ApplicationRecord
  default_scope { where(published: true) }
end

Now Post.count lies, Post.find(id) raises for an unpublished post that exists, admin screens can’t see drafts, and unscoped in one place turns off every scope including any you added later.

Use a named scope:

scope :published, -> { where(published: true) }

Multi-tenancy is the one legitimate exception, because there you want forgetting it to be impossible.

6. Callback Chains


class Order < ApplicationRecord
  after_save :update_inventory
  after_save :notify_warehouse
  after_save :recalculate_account_balance
  after_save :sync_to_erp
end

Now a test that creates an order hits four subsystems, a rake task backfilling a column triggers warehouse notifications, and the ERP call inside the transaction holds a database connection open on a network round trip.

Callbacks are for maintaining the record’s own integrity — normalising an email, setting a slug. Anything reaching outside the model belongs in the code that had the intent:

class CreateOrder
  def call
    order = Order.create!(attributes)
    UpdateInventoryJob.perform_later(order.id)
    NotifyWarehouseJob.perform_later(order.id)
    order
  end
end

If you must use a callback for an external effect, use after_commit — never after_save — so it can’t fire for a transaction that rolls back.

7. Business Logic in Migrations


class BackfillOrderTotals < ActiveRecord::Migration[7.1]
  def up
    Order.find_each { |o| o.update!(total: o.calculate_total) }
  end
end

Two problems. Migrations run against future versions of your code — in eighteen months calculate_total won’t exist and a fresh db:migrate will fail. And on a large table this runs for hours inside your deploy.

Use a rake task or a job for data changes, keep migrations to schema. If you must backfill in a migration, use update_all with raw column references and batch it.

8. Committing Secrets


config/master.key in the repo, or an API key hardcoded “temporarily.”

git log --all --full-history -- config/master.key

If that returns anything, the key is in the history and adding it to .gitignore now changes nothing. Rotate the credential — history rewriting is cleanup, not remediation.

9. Ignoring Strong Parameters Warnings


def order_params
  params.require(:order).permit!    # permits everything
end

permit! accepts every key in the request, including admin, account_id, and anything else a model happens to have. That’s mass assignment, and it’s how privilege escalation bugs happen.

List the attributes. When nested attributes aren’t saving, the answer is nearly always a missing :id and :_destroy in the permit list, not permit!.

10. Testing Implementation Instead of Behaviour


it "calls the calculator" do
  expect_any_instance_of(TotalCalculator).to receive(:call)
  order.recalculate!
end

This passes if call returns nonsense. Rename the class and it fails while the behaviour is fine.

it "recalculates the total from line items" do
  order.line_items.create!(price_cents: 1000, quantity: 2)
  order.recalculate!
  expect(order.total_cents).to eq(2000)
end

Test what the user of the code can observe.

11. Not Reading the Log


log/development.log shows every query, its duration, and the partials rendered. Most beginners never open it, and it answers most performance questions without any tooling.

tail -f log/development.log | grep -E "Duration|SELECT"

Seeing fifty identical SELECT statements scroll past teaches N+1 faster than any article.

Pro-Tip: The habit that prevents more of these than any other is seeding your development database with production-shaped volume. Almost every mistake on this list is invisible at twenty rows: the N+1 returns in 8ms, the missing index is irrelevant, the unbatched backfill finishes instantly, and the race condition never happens because you’re the only request. Write a db:seed_large task that creates fifty thousand orders with realistic associations, and run against it. You will find the N+1s the first time you load the index page, and you’ll find them while they cost one line to fix rather than during an incident. This is a two-hour investment that changes what you notice for the rest of the project.

Conclusion


The theme running through all of these is that Rails makes the convenient thing easy and the correct thing only slightly harder — permit! versus a list, after_save versus after_commit, a validation versus a validation plus a constraint. None of them are subtle once you know them, and none of them announce themselves at development scale. Seed realistic data, read the log, keep controllers to four lines of orchestration, and back every uniqueness validation with a unique index. The rest is recognising these shapes when they appear in review.

FAQs


Q1: Is it always wrong to put logic in a callback?
No — callbacks maintaining the record’s own state are fine and idiomatic. The rule is that a callback shouldn’t reach outside the model or trigger side effects the caller didn’t ask for.

Q2: When should I extract a service object?
When an action touches more than one model, has more than one step, or needs to be called from more than one place. A single-model create doesn’t need one.

Q3: How do I find N+1 queries I already shipped?
bullet in development, prosopite in test, or strict loading to make them raise. In production, look for endpoints with high query counts in your APM.

Q4: Should I use find_each or find_in_batches?
find_each yields records one at a time and batches under the hood; find_in_batches yields arrays. Use either over .all.each, which loads the entire table into memory.

Q5: What’s the fastest way to level up past these?
Read the Rails guides end to end once — they’re genuinely good and most people skip them — and read the log while you click through your own app.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More