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_largetask 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.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀