Ruby on Rails Interview Questions · 2026

Ruby on Rails Interview Questions (2026): With Answers

A backend candidate at a Series B logistics company got asked to explain why calling current_user.orders.count in a Rails view, once per row in a loop of 200 orders, brought a staging server to its knees. He knew ActiveRecord. He'd never had to reason about what "N+1" meant when the N was 200 and the query was hiding inside a helper method three files away from the loop that triggered it. Rails sits at 6.2 percent framework adoption among professional developers per the 2025 Stack Overflow survey, a smaller share than a decade ago but a stubbornly loyal one (Stack Overflow, 2025). Most Rails interviews in 2026 aren't testing whether you can scaffold a CRUD app. They're testing whether you understand what the framework is doing underneath the convenience methods, because that's exactly where production incidents happen.

Here's a pattern worth naming: a lot of Rails prep material still leans hard on syntax trivia, symbol versus string hash keys, the exact list of ActiveSupport core extensions, because it's easy to write a quiz question about. The questions that actually separate a mid-level candidate from a senior one are about behavior under load and behavior over time: how callbacks and transactions interact, when eager loading actually helps versus when it just moves the N+1 problem into a bigger single query, why a migration that looked safe in staging locked a production table for four minutes. You can write Rails for years leaning on scaffolding and never once have to explain any of that. You can't survive an incident review without it.

This page covers Ruby on Rails interview questions across eight areas: MVC and the request lifecycle, ActiveRecord and migrations, associations, callbacks and validations, routing and controllers, views and security, performance (the N+1 problem, caching, background jobs), and testing plus a few of the harder edge cases that come up in senior loops. Code examples are real Rails, current through the 8.x guides, not simplified pseudocode.

50Questions
6.2%Framework Adoption 2025
MVC + ActiveRecord ORMCore Pattern
Rails 8Current Major Version

MVC, the request lifecycle, and what makes Rails "Rails"

Almost every loop opens here, even for a candidate with a decade of experience. It's a warm-up, but a vague answer sets a bad tone for the thirty minutes after it.

Easy questions

15

MVC separates what data looks like and how it's stored (the model), what the user sees (the view), and the logic that decides which model to load and which view to render (the controller). The real benefit isn't the folders, it's that a change to how you display something shouldn't force a change to how you query it, and a change to your database schema shouldn't force you to rewrite your templates.

In Rails specifically, ActiveRecord is the model layer, ActionView renders templates, and ActionController sits between them handling params, sessions, and picking a response format. The convention is strong enough that a Rails developer can open an unfamiliar Rails app and generally know where to look for a given piece of behavior, which is a bigger productivity win in practice than any single convenience method.

Convention over configuration means Rails assumes a sensible default and only asks you to configure something when you deviate from it. A model class named Order is assumed to map to a table named orders, a controller named OrdersController is assumed to live at app/controllers/orders_controller.rb, and a foreign key on a belongs_to :user association is assumed to be a column named user_id.

ruby
class Order < ApplicationRecord
 belongs_to :user
end
# Rails infers: table "orders", foreign key "user_id", no config needed

The tradeoff is real too: when you break convention, a table with a non-standard name, a foreign key with a custom name, you have to explicitly configure it, and forgetting to do so produces confusing errors for anyone who doesn't already know the convention.

render tells the current request to respond with a view or data directly, no new HTTP request happens, and any instance variables set earlier in the action are still available to the template. redirect_to sends the browser an HTTP 302 (or 301) telling it to make a brand-new request to a different URL, which means the current action's instance variables are gone and the target action starts from scratch.

ruby
def create
 @order = Order.new(order_params)
 if @order.save
  redirect_to @order # new request, browser URL changes to /orders/5
 else
  render :new # same request, form re-displays with @order's validation errors
 end
end

The classic bug: rendering the new form after a failed save works because @order still holds the validation errors. Redirecting instead would lose those errors entirely, since the next request starts clean.

rails console opens an interactive REPL with your app fully loaded, models, routes, everything, so you can poke around, run a query, and see the result immediately. It's built for exploration, checking what a scope actually returns, testing a method by hand before writing it into code.

ruby
$ rails console
irb> Order.where(status: "pending").count
=> 42

$ rails runner "puts Order.where(status: 'pending').count"

rails runner executes a single Ruby statement or script non-interactively with the same app environment loaded, then exits, which is what you actually want for a cron job or a one-off script run from a deploy pipeline, since nothing is waiting on human input.

Gemfile lists the gems your app depends on, often with a loose version constraint, gem "rails", "~> 7.1". Gemfile.lock records the exact resolved version of every gem, including transitive dependencies, that bundle install actually installed the first time it ran.

ruby
# Gemfile
gem "rails", "~> 7.1"

# Gemfile.lock (excerpt)
rails (7.1.3)

Committing it means every developer, every CI run, and production all install the identical set of gem versions, instead of each environment independently resolving the loose constraints and potentially landing on a slightly different patch version with slightly different behavior. Gitignoring it is a common beginner mistake that eventually produces a "works on my machine" bug nobody can reproduce.

Scaffolding generates a full, working CRUD slice in one command, a model, a migration, a controller with all seven RESTful actions, views for each, and a basic test file, wired together and immediately runnable. It's genuinely useful for prototyping an idea or learning the framework's conventions quickly.

ruby
$ rails generate scaffold Order customer:references status:string
# creates: model, migration, controller, 7 views, routes entry, test stub

Real apps rarely ship the generated views and controller unmodified, because scaffolding produces generic, unstyled markup and controller logic with no authorization checks, no pagination, no real business rules. Its real value in an interview context is showing you understand what Rails considers the default shape of a resource, not that you'd deploy the raw output.

ActiveRecord is Rails' object-relational mapper: it maps database tables to Ruby classes and rows to Ruby objects, so you write Order.where(status: "pending") instead of hand-writing SQL strings. It also implements the ActiveRecord pattern itself, meaning the objects know how to save, validate, and destroy themselves.

The real win isn't just avoiding SQL syntax, it's that the same query builder works across different database adapters (PostgreSQL, MySQL, SQLite) without you rewriting anything, and it composes: you can chain where, order, and limit and Rails builds one final SQL statement lazily, only hitting the database once you actually enumerate the results.

The bang versions raise ActiveRecord::RecordInvalid if validation fails; the non-bang versions just return false and let you check that yourself. Both save the record's current in-memory attributes, but update and update! also assign new attributes before saving, doing both steps in one call.

ruby
order.status = "shipped"
order.save     # returns false if invalid, doesn't raise
order.save!     # raises ActiveRecord::RecordInvalid if invalid

order.update(status: "shipped")  # assign + save, returns false on failure
order.update!(status: "shipped") # assign + save, raises on failure

A common bug is calling the non-bang version and never checking the boolean it returns, silently swallowing a validation failure. The bang versions are usually the safer default inside a background job or a rake task, where an uncaught failure should be loud rather than silently ignored.

find looks up by primary key and raises ActiveRecord::RecordNotFound if nothing matches, which is exactly what you want in a Rails controller action, since Rails automatically turns that exception into a 404 response. find_by takes arbitrary conditions and returns nil instead of raising, so you have to explicitly handle the missing case yourself.

ruby
Order.find(5)          # raises RecordNotFound if id 5 doesn't exist
Order.find_by(status: "shipped") # returns nil, no exception, if none match
Order.where(status: "shipped")  # always returns a relation, possibly empty

where always returns an ActiveRecord::Relation, even with zero matches, and it's lazy, the query doesn't actually run until you call something that forces evaluation like .to_a, .each, or .count.

The foreign key always lives on the "belongs_to" side. If an Order belongs_to :customer, the orders table has a customer_id column, not the other way around. has_many :orders on Customer just tells Rails to look up orders where customer_id matches this customer's id.

ruby
class Customer < ApplicationRecord
 has_many :orders
end

class Order < ApplicationRecord
 belongs_to :customer
 # orders table has a customer_id column
end

Getting this backwards, putting belongs_to on the model without the foreign key column, is one of the most common mistakes junior Rails developers make, and it fails with a clear enough error message that it's rarely a source of real bugs once you've hit it once (Rails Guides, Active Record Associations).

validates is the declarative shorthand for built-in validators, presence, uniqueness, length, format, and covers the overwhelming majority of real validation needs in one line. A custom validate method is for logic that doesn't fit a single built-in validator, usually because it depends on more than one attribute at once.

ruby
class Order < ApplicationRecord
 validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }

 validate :ship_date_after_order_date

 private

 def ship_date_after_order_date
  return if ship_date.blank? || order_date.blank?
  errors.add(:ship_date, "can't be before the order date") if ship_date < order_date
 end
end

resources :photos generates all seven conventional RESTful routes in one line: index, show, new, create, edit, update, and destroy, each mapped to a specific HTTP verb and path combination against PhotosController (Rails Guides, Rails Routing from the Outside In).

ruby
resources :photos
# GET  /photos     => photos#index
# GET  /photos/new   => photos#new
# POST  /photos     => photos#create
# GET  /photos/:id   => photos#show
# GET  /photos/:id/edit => photos#edit
# PATCH /photos/:id   => photos#update
# DELETE /photos/:id   => photos#destroy

You can narrow this down with only: [:index, :show] or except: [:destroy] when you don't want all seven, which also keeps rails routes output smaller and the app's public surface intentionally limited.

before_action runs before the controller action executes and can halt the request entirely, most commonly used for authentication checks, redirecting away before the action's own code ever runs. after_action runs after the action completes but before the response is sent, used less often, mostly for logging or modifying the response.

ruby
class OrdersController < ApplicationController
 before_action :authenticate_user!
 before_action :set_order, only: [:show, :update]

 around_action :measure_request_time

 private

 def measure_request_time
  start = Time.current
  yield
  Rails.logger.info("Request took #{Time.current - start}s")
 end
end

around_action wraps the entire action, requiring an explicit yield to actually run it, which makes it the right tool for anything that needs to measure or wrap the whole action, timing, or opening and closing a resource around it.

An inline call to an external service, sending an email through SMTP, hitting a payment gateway, ties up a web worker's response time to that external service's latency, and any timeout or failure on their end becomes a failed page load for your user. Moving it into a background job, ActiveJob backed by Sidekiq or GoodJob, lets the controller respond immediately and the job retry independently if the external call fails.

ruby
class OrdersController < ApplicationController
 def create
  @order = Order.create!(order_params)
  OrderConfirmationJob.perform_later(@order.id)
  redirect_to @order
 end
end

The tradeoff worth naming out loud: the job isn't guaranteed to have run by the time the user sees the next page, so anything the user immediately expects to see, a confirmation number generated synchronously, still needs to happen before the response, only the slow, non-blocking side effects move to the background.

A model test exercises an ActiveRecord model in isolation, validations, scopes, instance methods, without going through a controller or HTTP request at all. A controller test (or the newer, more common request spec style) sends a simulated HTTP request into the app and checks the response, status code, redirect, rendered template, without a real browser.

ruby
# test/models/order_test.rb
test "invalid without an email" do
 order = Order.new(email: nil)
 assert_not order.valid?
end

# test/system/orders_test.rb
test "user can create an order" do
 visit new_order_path
 fill_in "Email", with: "test@example.com"
 click_on "Create Order"
 assert_text "Order was successfully created"
end

A system test runs an actual browser, headless Chrome by default with Capybara, driving real clicks and form fills, which catches JavaScript-dependent bugs the other two levels can't see, but runs an order of magnitude slower, which is why most of a healthy test suite should be model and request tests, with system tests reserved for the handful of critical user flows.

Medium questions

24

The request enters through Rack, the interface every Ruby web server and framework builds on, and passes through the middleware stack, things like session handling, cookie parsing, and Rails' own exception-catching middleware, before it reaches the router. The router matches the HTTP verb and path against the routes defined in config/routes.rb and dispatches to a controller action.

The controller action runs, typically loading data through ActiveRecord, and then either renders a view (HTML, JSON, whatever format was requested) or issues a redirect. The response works its way back out through the same middleware stack in reverse before the server sends bytes to the browser. Knowing this stack matters most when something's slow or broken and you need to know whether the problem is in your controller, a gem's middleware, or the database.

The asset pipeline compiles, concatenates, and fingerprints CSS, JavaScript, and images so browsers get a small number of cache-busted files instead of hundreds of individual requests. Sprockets was the default for years and still handles a lot of legacy apps' CSS and image pipeline.

Rails 7 introduced Import Maps as the default JavaScript approach, letting you reference ES modules directly from a CDN or vendored file without a Node-based build step at all. Newer apps can also opt into esbuild, Webpack, or Vite through jsbundling-rails if they need a real bundler for a heavier frontend, but the framework no longer assumes you need Node just to serve JavaScript, which is a real shift from the Webpacker-by-default era.

Rack is the interface every Ruby web server and framework agrees on: an object that responds to call(env) and returns a status, headers, and body. Rails itself is built as a stack of Rack middleware, each one wrapping the next, handling things like session management, cookie parsing, and exception rendering before your controller ever runs.

ruby
class RequestTimingMiddleware
 def initialize(app)
  @app = app
 end

 def call(env)
  start = Time.now
  status, headers, body = @app.call(env)
  headers["X-Request-Time"] = (Time.now - start).to_s
  [status, headers, body]
 end
end

# config/application.rb
config.middleware.use RequestTimingMiddleware

Custom middleware makes sense for cross-cutting concerns that apply to every request regardless of which controller handles it, request timing, rate limiting, or rejecting requests from a blocked IP list before they reach routing at all.

A gem is a packaged library, code you require and call. A Rails engine is a gem that is itself a miniature Rails application: it can bring its own models, views, controllers, routes, and even migrations, and it mounts into a host app's routing namespace. Devise and Spree are common real-world engines.

ruby
# host app's config/routes.rb
mount MyEngine::Engine => "/my_engine"

The distinction matters in interviews because engines share the host app's initialization process and can conflict with it, two engines defining routes at the same path, or an engine's migration colliding with the host's schema, in ways a plain gem never would.

A migration describes a schema change as a Ruby method, usually change, that Rails can run forward to apply the migration or in reverse to roll it back. Simple operations like add_column or create_table are automatically reversible because their inverse is obvious, Rails just drops the column or table.

ruby
class RemoveStatusFromOrders < ActiveRecord::Migration[7.1]
 def change
  remove_column :orders, :status, :string
  # column type is required here because Rails can't infer it
  # to reverse a remove_column, it needs to know what to add back
 end
end

Operations that lose information, dropping a column without specifying its type, or an arbitrary execute call with raw SQL, aren't reversible on their own. For those you write explicit up and down methods instead of change, so rollback has an unambiguous path.

schema.rb is a snapshot of the database structure at its current state, generated after migrations run, and loading it (rails db:schema:load) recreates that end state in one shot, much faster than replaying every migration ever written in a five-year-old app. It's what CI and new developer setups use by default.

The catch: schema.rb can't represent database-specific features like custom PostgreSQL types, triggers, or stored procedures, since it's meant to be portable across adapters. Apps that rely on Postgres-specific features switch to structure.sql, a raw SQL dump that captures everything but loses that cross-database portability.

Strong parameters require you to explicitly permit which attributes from params are allowed to be mass-assigned into a model, protecting against a mass assignment vulnerability where an attacker adds an unexpected field, admin=true, to a form submission and has it silently written to the database.

ruby
def order_params
 params.require(:order).permit(:item_id, :quantity)
 # :admin, if submitted, is silently dropped, not assigned
end

Rails used attr_accessible for this in older versions, declared on the model itself. Strong parameters, introduced with Rails 4, moved that decision into the controller instead, since what's safe to mass-assign often depends on which action and which user role is doing the assigning, not just the model in the abstract.

default_scope applies a condition to every query on a model automatically, including ones you didn't expect, an internal Rails method, a background job, an association preload. That's exactly the problem: it's invisible at the call site, so a bug shows up as "why did Order.find(5) return nothing" with no clue in the code that a scope silently filtered it out.

ruby
class Order < ApplicationRecord
 default_scope { where(deleted: false) }
 # Order.find(5) can now raise RecordNotFound even though row 5 exists,
 # if it happens to be soft-deleted, with no hint at the call site why
end

class Order < ApplicationRecord
 scope :active, -> { where(deleted: false) }
 # explicit at every call site: Order.active.find(5)
end

A named, explicit scope you call intentionally at each query site is almost always the better tradeoff, since it costs a few extra characters per call but makes every query's actual behavior visible in the code that runs it, instead of hidden in a model definition someone has to already know about.

Both model many-to-many relationships, but has_and_belongs_to_many uses a plain join table with no model of its own, just two foreign key columns and nothing else. has_many :through uses a real model for the join, which means that join can have its own validations, callbacks, timestamps, and extra columns.

ruby
class Physician < ApplicationRecord
 has_many :appointments
 has_many :patients, through: :appointments
end

class Appointment < ApplicationRecord
 belongs_to :physician
 belongs_to :patient
 # extra columns here: appointment_date, notes, etc.
end

In practice almost every real-world many-to-many relationship eventually needs an extra attribute on the join, a tag with a position, a membership with a role, so most experienced Rails developers default to has_many :through from the start and treat has_and_belongs_to_many as the rare exception (Rails Guides, Active Record Associations).

A polymorphic association lets one model belong to more than one other type of model through a single association, using two columns, a *_id and a *_type, instead of a separate foreign key per possible owner. A Comment that can belong to either a Post or a Photo is the textbook example.

ruby
class Comment < ApplicationRecord
 belongs_to :commentable, polymorphic: true
 # commentable_id and commentable_type columns
end

class Post < ApplicationRecord
 has_many :comments, as: :commentable
end

The cost: the database can't enforce a real foreign key constraint on a polymorphic column, since it might point at rows in different tables, so referential integrity becomes the application's job instead of the database's. It also makes eager loading slightly more complex, since Rails has to group and query each target type separately.

counter_cache stores a running count of an association's size directly on the parent as a column, so customer.orders_count reads one integer instead of running a COUNT(*) query every time. Rails keeps it updated automatically through callbacks whenever a child record is created or destroyed through the association.

ruby
class Order < ApplicationRecord
 belongs_to :customer, counter_cache: true
 # customers table needs an orders_count integer column
end

It drifts when records are deleted in ways that skip callbacks, a raw SQL DELETE, a dependent: :delete_all, or a direct database migration, since the counter update lives entirely in Ruby callback code, not a database trigger. The fix is a periodic reconciliation job calling Customer.reset_counters, and the interview-worthy point is recognizing that any bulk operation bypassing ActiveRecord callbacks is a place counter caches silently rot.

Creating a new record fires, in order: before_validation, after_validation, before_save, around_save, before_create, around_create, after_create, after_save, and finally after_commit (or after_rollback if the surrounding transaction failed) (Rails Guides, Active Record Callbacks).

ruby
class Order < ApplicationRecord
 before_validation :normalize_email
 after_create :send_confirmation_email
 after_commit :notify_analytics, on: :create
end

The distinction that matters most in practice: after_create runs inside the database transaction, so if a later step in the same transaction fails and rolls back, whatever that callback did (an external API call, an email send) already happened and can't be undone. after_commit only fires once the transaction has actually committed to the database, which is the right place for anything with an external side effect that shouldn't fire on a rolled-back save.

A concern is a module built with ActiveSupport::Concern that lets you extract shared model or controller behavior, associations, validations, scopes, callbacks, into its own file, while still having that behavior register correctly on whichever class includes it. Without ActiveSupport::Concern, defining has_many or a class-level method inside a plain module and including it requires manually wiring up self.included and ClassMethods boilerplate every time.

ruby
module Archivable
 extend ActiveSupport::Concern

 included do
  scope :archived, -> { where(archived: true) }
 end

 def archive!
  update!(archived: true)
 end
end

class Order < ApplicationRecord
 include Archivable
end

The honest downside, and a fair thing to raise if asked about concerns critically: concerns can turn into a dumping ground for loosely related behavior across many models, which makes it harder to trace where a given method actually lives. Plenty of senior Rails engineers now prefer composition, small service objects, over concerns for anything beyond genuinely shared, cohesive behavior.

after_initialize fires every time an ActiveRecord object is instantiated, whether that's Order.new in memory or Order.find(1) loading an existing row from the database, since both paths go through initialize internally. A plain Ruby initialize override would only run for the .new path unless you were careful to call super and handle both cases yourself.

ruby
class Order < ApplicationRecord
 after_initialize do
  self.status ||= "pending"
 end
end

Order.new.status    # "pending"
Order.find(1).status  # whatever was already saved, untouched

It's used sparingly in practice, mostly for setting an in-memory default on new records, because it runs on every single record loaded from a query, including large batch loads, and any nontrivial logic inside it becomes a real performance cost across thousands of rows.

The plural form assumes a collection the user manages multiple instances of, and includes an index action and an :id in member routes. The singular resource :profile is for something the current context only ever has one of, a user's profile, an account's billing settings, so there's no index action and no :id in the path, since there's nothing to disambiguate (Rails Guides, Rails Routing from the Outside In).

ruby
resource :profile
# GET  /profile/new
# POST  /profile
# GET  /profile
# GET  /profile/edit
# PATCH /profile
# DELETE /profile
# no index, no :id anywhere in these paths

Deeply nested resources, magazines/:magazine_id/ads/:ad_id/comments/:comment_id, produce URLs that are hard to read, hard to construct correctly by hand, and often unnecessary, since once you have a specific ad's id you don't need the magazine id at all to look it up. The Rails Guides explicitly recommend nesting resources no more than one level deep for this reason (Rails Guides, Rails Routing from the Outside In).

ruby
resources :magazines do
 resources :ads, shallow: true
end
# collection actions (index, new, create) stay nested: /magazines/:magazine_id/ads
# member actions (show, edit, update, destroy) don't need the parent: /ads/:id

shallow: true gets you the readability of a flat member URL while keeping the nested URL for the actions that genuinely need the parent's id to know which collection to create into.

cookies is the lower-level interface, whatever you write there gets sent to the browser and stored client-side, readable and, unless you use the encrypted or signed variant, tamperable by the user. session is built on top of cookies but, with Rails' default cookie store, is automatically signed (and typically encrypted) so the browser can't read or modify its contents even though the data physically lives in a cookie.

ruby
cookies[:theme] = "dark"         # plain, readable, editable by the user
cookies.signed[:user_id] = current_user.id # tamper-proof, still client-side
session[:user_id] = current_user.id   # Rails' session store, signed by default

The practical rule: use session for anything security-sensitive, like the logged-in user's id, and plain cookies only for genuinely non-sensitive preferences like a UI theme toggle, since a plain cookie is exactly as trustworthy as anything else the client sends you.

Cross-site request forgery protection stops a malicious site from tricking a logged-in user's browser into submitting a form to your app using the user's own cookies, since the browser sends cookies automatically to any site's request. Rails embeds a per-session CSRF token in every form and verifies it matches on any state-changing request, rejecting the request if the token is missing or wrong.

ruby
class ApplicationController < ActionController::Base
 protect_from_forgery with: :exception
end

An API-only app typically authenticates with a bearer token in a header instead of a session cookie, and since the attacker's forged request can't read or set that header from a different origin the way it can piggyback on cookies, the CSRF attack vector doesn't apply the same way. That's why rails new --api apps skip CSRF protection by default, not because the risk doesn't exist for cookie-based sessions, but because token-based auth closes that particular door differently.

The N+1 problem happens when you load a collection of records and then, for each one, trigger a separate query to load an association. Fetching 10 books and then calling book.author.name inside a loop runs 1 query for the books plus 10 more, one per book, to load each author, 11 total instead of 2 (Rails Guides, Active Record Query Interface).

ruby
Book.limit(10).each { |book| puts book.author.name }
# 1 query for books + 10 queries for authors = 11 total

Book.includes(:author).limit(10).each { |book| puts book.author.name }
# 1 query for books + 1 query for all authors (IN clause) = 2 total

includes is the go-to fix, letting Rails decide whether to run two separate queries or one LEFT OUTER JOIN depending on whether you're also filtering on the association. eager_load forces the single-join approach explicitly, and preload forces the two-separate-queries approach explicitly, useful when you know exactly which behavior you want and don't want Rails guessing.

perform_later serializes the job's arguments (which have to be simple, JSON-serializable types or ActiveRecord objects Rails knows how to serialize as a global ID) and enqueues them onto whichever backend adapter is configured, Sidekiq, Solid Queue, GoodJob, rather than running the job's code immediately in the current process.

ruby
OrderConfirmationJob.perform_later(order.id)
# order.id, not order itself, is a good practice: keeps the payload small
# and re-fetches fresh data when the job actually runs, later

If the queue backend is unreachable when perform_later is called, the behavior depends entirely on the adapter, some raise immediately and the enqueue call itself fails inside your controller action, which is why production apps often wrap critical enqueues in a retry or fall back to inline execution rather than assuming enqueuing always succeeds silently.

Turbo intercepts link clicks and form submissions and replaces just the changed part of the page over an existing connection instead of doing a full page reload, and Turbo Streams let a server response update a specific DOM element (append a new comment, replace a form) using plain HTML fragments sent over the wire, no client-side JavaScript framework or JSON API required.

ruby
# controller action responding to a Turbo Stream request
def create
 @comment = @post.comments.create!(comment_params)
 respond_to do |format|
  format.turbo_stream
  format.html { redirect_to @post }
 end
end

The tradeoff: it works well for the kind of interactivity a server round-trip can express, but it's a different mental model from React or Vue, and a team that needs genuinely rich, offline-capable, heavily stateful client interactions will still hit Turbo's limits and reach for a real frontend framework for that specific piece.

Rendering a collection with render partial: "order", collection: @orders lets Rails automatically use a cache-friendly, batched rendering path, and if every item in the collection responds to a fragment cache key, Rails can fetch all of them from the cache in a single round trip instead of one cache lookup per item.

ruby
<%= render partial: "order", collection: @orders %>
# vs
<% @orders.each { |order| render partial: "order", locals: { order: order } } %>

Functionally they produce the same HTML, but the collection form is measurably faster once fragment caching is involved and any partial that's used both individually and in a collection benefits from Rails' internal partial rendering optimizations that the manual loop bypasses.

A fixture is a static YAML file defining test data loaded into the database once before the whole test run, referenced by a short symbolic name. They're fast, since the data's loaded in bulk once, but they're also global and implicit, every test file can see every fixture, which makes it easy for one test's change to a fixture to break an unrelated test somewhere else in the suite.

ruby
# FactoryBot, built per-test instead of shared globally
order = create(:order, status: "pending", customer: create(:customer))

FactoryBot builds objects explicitly inside each test, which is slower per-object but keeps each test's data local and readable in the test itself rather than requiring you to go look up a fixture file to understand what data a test is actually running against. Most teams building anything beyond a small app end up on FactoryBot for that readability, even at some cost in raw test suite speed.

Rails' ActiveJob test helpers let you assert a job was enqueued with specific arguments without the job's perform method actually executing, which keeps the test fast and isolated from whatever side effect the job itself performs.

ruby
test "creating an order enqueues a confirmation email" do
 assert_enqueued_with(job: OrderConfirmationJob, args: [@order.id]) do
  post orders_path, params: { order: valid_order_params }
 end
end

Use perform_enqueued_jobs instead when you specifically want the job's actual logic to run inline during the test, useful for an integration-style test that checks the end result of the job's work, an email actually landing in the test mailer's delivery list, rather than just checking that the job was scheduled.

Hard questions

11

On some database versions, adding a column with a default value requires rewriting every existing row to backfill that default, which takes an exclusive lock on the table for the entire operation. On a table with tens of millions of rows, that can mean minutes of downtime for every read and write against it.

ruby
class AddStatusToOrders < ActiveRecord::Migration[7.1]
 def change
  add_column :orders, :status, :string
  # step 1: add nullable, no default, fast metadata-only change
  change_column_default :orders, :status, "pending"
  # step 2: set default for future rows only
  # step 3, separately: backfill existing rows in batches, then add NOT NULL
 end
end

PostgreSQL 11 and later actually made this specific case (constant default) fast, since it can store the default as metadata rather than rewriting rows immediately. But candidates who know to split the migration into safe steps regardless are showing they've been burned by a lock before, not just read about it.

dependent: :destroy loads every associated record into memory and calls destroy on each one individually, which runs each record's callbacks and validations, and is correspondingly slow for a large association. dependent: :delete_all issues a single SQL DELETE statement matching the foreign key, skipping ActiveRecord entirely, no callbacks, no instantiation.

ruby
class Customer < ApplicationRecord
 has_many :orders, dependent: :destroy
 # deleting a customer with 10,000 orders instantiates and destroys all 10,000
end

Pick :destroy when the associated model has callbacks that must run, freeing up inventory, sending a notification. Pick :delete_all when you know there's no meaningful cleanup logic and you just need the rows gone fast. Using :destroy by habit on a large, callback-free association is a common, avoidable performance bug.

Rails tries to automatically detect when two associations are the inverse of each other, a Post has_many :comments and a Comment belongs_to :post, so that setting one side in memory is reflected on the other without a fresh database query. When it can detect the inverse automatically, an in-memory object stays consistent: post.comments.first.post returns the exact same object as post, not a re-queried copy.

ruby
class Post < ApplicationRecord
 has_many :comments, inverse_of: :post
end

class Comment < ApplicationRecord
 belongs_to :post, inverse_of: :comments
end

Automatic detection breaks down once you add a custom :foreign_key, a :through association, or a scope, at which point Rails can't reliably guess the inverse and you have to declare :inverse_of yourself, or accept that changes on one side won't show up on the other until you reload.

validates uniqueness: true runs a SELECT query to check whether the value already exists before the record is saved. Between that check and the actual INSERT, another request can slip in and insert the same value, since the check-then-insert isn't atomic at the database level. Two requests can both pass the uniqueness check for the same email a few milliseconds apart and both succeed.

ruby
class User < ApplicationRecord
 validates :email, uniqueness: true
end
# race condition still possible under concurrent requests

# migration:
add_index :users, :email, unique: true
# this is what actually guarantees uniqueness

The Rails validation is a good user-facing check, a friendly error message instead of a raw database exception, but the only real guarantee is a unique index at the database level. A senior answer mentions both: the validation for UX, the index for correctness, and rescuing ActiveRecord::RecordNotUnique as the actual safety net.

includes is smart by default, but that smartness has an edge case: the moment you add a where condition that references a column on the included association, Rails switches to a join automatically to make that filtering possible, which can produce duplicate rows if the association has many matching records.

ruby
Book.includes(:reviews).where(reviews: { rating: 5 })
# includes silently becomes a JOIN here because of the where on reviews
# eager_load does the same thing explicitly and predictably

Book.preload(:reviews)
# always 2 queries, never a join, can't filter on reviews at all

Pick eager_load explicitly when you know you need to filter on the association and want the join behavior to be obvious in the code rather than an implicit side effect of adding a where clause later. Pick preload when you specifically want to avoid ever generating a join, usually for a has_many association where a join would multiply rows and mess up something like .count.

Rails.cache.fetch is a general-purpose key-value cache you call explicitly anywhere in your code, controller, model, background job, to store the result of something expensive keyed by whatever you choose. Fragment caching is specifically for view output: you wrap a chunk of a template in a cache block, and Rails generates the cache key automatically from the object and its updated_at timestamp.

ruby
<% cache @order do %>
 <%= render @order %>
<% end %>

"Russian doll" refers to nesting cached fragments inside other cached fragments, an order's page caching the whole order, which itself contains cached fragments for each line item. The key insight that makes it work without stale data: each nested fragment's cache key incorporates its own updated_at, so touching one line item busts only that line item's cache and its parents' keys, without needing to manually invalidate anything.

Mocking Order.where(...) or stubbing a model's return value lets a test pass based on what you assume the database would return, not what it actually returns given real constraints, indexes, uniqueness rules, and default scopes. A test that stubs Order.find to return a hardcoded object will pass even if the actual query has a typo in a column name or an association that was never set up correctly in the schema.

The safer default in Rails is to let model and request tests hit a real (test) database, since ActiveRecord's behavior is genuinely tied to schema details that a mock can't reproduce faithfully, uniqueness constraints, database-level defaults, cascading deletes. Mocking still has a place for genuinely external dependencies, a third-party payment API, a network call you don't want to make in every test run, just not for the ORM layer talking to your own schema.

Modern Rails runs multiple requests concurrently on separate threads within the same process (Puma's default threaded mode), which means any state shared across those threads, a class variable, a module-level instance variable used as a cache, can be read or written by two requests at the same instant and produce corrupted or unexpected results.

ruby
class ReportGenerator
 @@cache = {}
 # shared across every thread in the process, unsafe under Puma's threading
 def self.cached_report(id)
  @@cache[id] ||= expensive_calculation(id)
 end
end

The most common accidental break is exactly this pattern, a class-level memoization hash meant to save a repeated calculation, that quietly becomes a shared mutable data structure two threads can write to simultaneously. The fix is usually Rails.cache (which is designed to be thread-safe and shared across processes) instead of a bare class variable, or a per-request memoization on the instance rather than the class.

Once a team gets burned by an N+1 bug, a common overcorrection is sprinkling includes on every association a controller might conceivably need, whether or not the view actually accesses it. Each additional includes is still a real query (or a bigger join), and preloading an association that's never actually read in that particular view is pure wasted database work, sometimes worse than the N+1 it was meant to prevent if the association has a lot of rows.

ruby
Order.includes(:customer, :line_items, :shipments, :refunds, :audit_logs)
# fine if the view touches all five
# wasteful if the view only ever reads order.customer.name

The right instinct is to preload exactly what the specific view or serializer actually accesses, verified with something like the Bullet gem, which flags both genuine N+1s and unused eager loads, rather than treating includes as a universally safe default to reach for defensively.

Optimistic locking adds a lock_version column and assumes conflicts are rare: it checks that the version hasn't changed since the record was loaded, and raises ActiveRecord::StaleObjectError if someone else saved in between, letting the application decide how to handle the conflict, usually reloading and asking the user to redo their change.

ruby
class Order < ApplicationRecord
 # requires a lock_version column
end

order = Order.find(1)
# another process updates and saves order 1 here
order.update!(status: "shipped")
# raises ActiveRecord::StaleObjectError

Pessimistic locking, Order.lock.find(1) or order.with_lock, takes an actual database-level row lock for the duration of the transaction, blocking any other transaction from touching that row until the first one finishes. Reach for pessimistic locking specifically for things like decrementing inventory or a wallet balance, where a lost update is a real financial bug and you'd rather have one request wait briefly than risk two requests both reading the same stale count.

The most likely cause is a schema change that requires an exclusive lock proportional to table size, adding an index without algorithm: :concurrently, adding a column with a non-constant default on an older database version, or a foreign key addition that validates every existing row. Staging tables are usually a fraction of production's row count, so the same migration that takes milliseconds there can lock production for minutes.

ruby
class AddIndexToOrdersStatus < ActiveRecord::Migration[7.1]
 disable_ddl_transaction!
 def change
  add_index :orders, :status, algorithm: :concurrently
 end
end

To investigate after the fact, check the database's own lock and slow-query logs for the exact statement and its duration, compare production and staging row counts for the affected table, and check whether the migration used any pattern known to require a full table rewrite. Going forward, the fix is testing migrations against a production-sized dataset, or a production database snapshot, before they ship, not just against a staging environment with a fraction of the data.

How to prepare for a Ruby on Rails interview

Skip re-reading another list of ActiveRecord method names. Build one small thing that forces you to actually hit these edge cases: a model with a polymorphic association and a counter cache, a controller action that enqueues a background job and a test asserting it was enqueued, a migration you deliberately write unsafely first and then rewrite to be lock-safe. Run rails routes against your own app and explain out loud why each line looks the way it does instead of just trusting the generator.

Across backend and full-stack mock interviews run through LastRoundAI, the N+1 question and its "which fix do you actually pick" follow-up trips up more mid-level candidates than any callback-ordering question does, even though callback order gets more prep-guide attention. Most candidates can define N+1 in the abstract. Fewer can explain, unprompted, why they'd pick preload over eager_load for a specific association shape. That gap is exactly where a senior answer separates from a memorized one.

Explaining a callback chain on paper is different from defending it out loud once an interviewer changes one detail and asks what breaks. LastRoundAI's mock interview mode runs live coding rounds with real-time follow-up questions in your browser, and the free plan includes 15 credits a month that reset monthly rather than piling up unused. Starter is $19/mo if a handful of sessions isn't enough runway.

Once your answers hold up under a follow-up, the slower part of the job hunt is usually just getting in front of enough backend and full-stack roles that actually test Rails instead of treating it as a legacy checkbox. Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

How this list was built

Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.

What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.

If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

Frequently asked questions

Do I need hands-on Ruby on Rails experience to pass?

It shows quickly either way. Textbook answers hold up until the interviewer asks what you did when it broke, and that is usually the question that separates candidates. A small real project you can discuss honestly beats a longer list of familiarity claims.

Is Ruby on Rails still worth learning in 2026?

For interview purposes the question is really whether the teams you are targeting use it, which is worth checking against their actual job postings rather than general popularity rankings. Where it is in use it tends to be deeply embedded and slow to replace.

Should I memorise Ruby on Rails syntax for the interview?

Rarely worth it. Most interviewers care that you know what to reach for and why, and will not fail you for forgetting an exact flag. Being confidently wrong about behaviour costs far more than admitting you would check the documentation.

What is the most common mistake in Ruby on Rails interviews?

Answering the question that was asked and stopping there. The strongest candidates add the trade-off or the failure mode without being prompted, which is what signals real use rather than revision.

Leave a Reply

Your email address will not be published. Required fields are marked *