The 60 Ruby on Rails questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Ruby on Rails is a full-stack web application framework written in Ruby, created by David Heinemeier Hansson and first released in 2004. It bundles everything a database-backed web app needs: an ORM (Active Record), a routing layer, controllers, a view system, mailers, background jobs, and asset handling. Two ideas shape it: convention over configuration, where sensible defaults replace boilerplate, and DRY (don't repeat yourself). Rails organizes code with the Model-View-Controller pattern, so a request flows from the router to a controller to a model and back out through a view. In interviews, Rails questions probe how the pieces connect: how Active Record maps objects to tables, how routes resolve to actions, what the callback chain does, and where N+1 queries hide. The official Getting Started with Rails guide from the Rails team is the canonical reference for the request lifecycle and the generators. This page collects the 60 questions that come up most, each with a direct answer and runnable code. Increasingly the first Rails round runs as a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Ruby in 100 Seconds
Video: Ruby in 100 Seconds (Fireship, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Ruby on Rails certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Ruby on Rails is a full-stack web framework written in Ruby. It gives you an ORM, routing, controllers, views, mailers, and background jobs out of the box, so a database-backed web app comes together with very little setup and a small team can ship features fast.
It solves the boilerplate problem. Two ideas drive it: convention over configuration, where sensible defaults replace config files, and DRY, where you say each thing once. That's why small teams ship features on Rails fast.
Key point: A one-line definition plus the two ideas (convention over configuration, DRY) beats a feature list. This is the warm-up question.
Watch a deeper explanation
Video: Ruby on Rails in 100 Seconds (Fireship, YouTube)
MVC splits an app into three parts. The Model holds data and business logic (usually an Active Record class mapped to a table). The View renders what the user sees. The Controller receives the request, talks to the model, and picks a view.
A request flows router to controller to model and back through a view. Keeping logic in models and presentation in views is the discipline interviewers check, because fat controllers are the classic Rails smell.
Key point: Say where a request enters and how it flows. Bonus points for naming 'fat model, skinny controller' as the guideline.
Watch a deeper explanation
Video: Ruby On Rails Crash Course (Traversy Media, YouTube)
Rails assumes sensible defaults so you write less setup code. The a model Article and Rails expects an articles table and an id primary key without you declaring any of it. Follow the naming rules and things just wire up.
The payoff is speed and consistency: any Rails developer can read any Rails app because the The technical sequence is the same. The cost is that fighting the conventions is painful, so you learn them rather than override them.
Key point: Give the model-to-table naming example. It's the clearest one-sentence proof you understand the idea.
Active Record is Rails' ORM (object-relational mapper). Each model class maps to a database table, each instance maps to a row, and attributes map to columns. You call Ruby methods and Active Record writes the SQL for you, so everyday reads and writes never need hand-written queries.
Inherit from ApplicationRecord and you get finders, validations, associations, and callbacks. User.find(1) becomes a SELECT, user.save becomes an INSERT or UPDATE. You rarely write raw SQL for everyday work.
class Article < ApplicationRecord
end
article = Article.new(title: "Hello")
article.save # INSERT INTO articles ...
Article.find(1) # SELECT * FROM articles WHERE id = 1 LIMIT 1
Article.where(published: true) # SELECT ... WHERE published = TRUEKey point: The mapping precisely: class to table, instance to row, attribute to column. That crispness indicates real understanding.
The request reaches Rack and the middleware stack, then the router matches the URL and HTTP verb to a controller action. The controller runs its before_action callbacks, executes the action, and usually loads or changes data through a model before it decides what to render back.
The action then renders a view (or JSON) into a response, which flows back out through the middleware stack to the browser. Knowing this path is what lets you place logic in the right layer.
A request through a Rails app
Placing logic in the right layer starts with knowing this order cold.
Key point: Interviewers love this because it reveals whether you see Rails as a pipeline or a bag of tricks. Trace it in order.
Routes live in config/routes.rb and map an HTTP verb plus a URL path to a controller action. The single line resources :articles generates the seven RESTful routes (index, show, new, create, edit, update, destroy), so you declare a resource once instead of writing each route by hand.
You can also declare individual routes with get, post, patch, and delete. rails routes prints the full table, which is the first thing to check when a URL 404s.
Rails.application.routes.draw do
root "articles#index"
resources :articles # 7 RESTful routes
get "about", to: "pages#about"
end
# GET /articles -> ArticlesController#index
# POST /articles -> ArticlesController#createHow a request is routed in Rails
Run 'rails routes' to print the full table; it's the first check when a URL returns 404.
Key point: Mention 'resources' and the seven routes. Then mention 'rails routes' as your debugging move. That pairing sounds like experience.
Watch a deeper explanation
Video: Ruby in 100 Seconds (Fireship, YouTube)
Generators create files from templates so you don't write boilerplate by hand. Running rails generate model Article creates the model class and a matching migration, while rails generate controller makes a controller along with its view files, so the skeleton of a feature appears in seconds.
rails generate scaffold Article title:string body:text produces the whole slice: model, migration, controller with CRUD actions, views, and a resources route. It's a fast way to see the full stack, though production code usually replaces the generated controller.
rails generate model Article title:string body:text
rails generate controller Pages about contact
rails generate scaffold Comment body:text article:referencesA migration is a Ruby class that describes a change to the database schema: create a table, add a column, add an index. Migrations are versioned and run in order, so every developer and every environment reaches the same schema.
You run them with rails db:migrate and undo the last with rails db:rollback. Because they're code, schema changes get reviewed in pull requests instead of run by hand on a database.
class AddPublishedToArticles < ActiveRecord::Migration[7.1]
def change
add_column :articles, :published, :boolean, default: false
add_index :articles, :published
end
endKey point: Say 'versioned and reversible'. The follow-up is often 'how do you undo one?', so have db:rollback ready.
Validations are rules declared in the model that run automatically before a record saves. If any rule fails, save returns false and the reasons show up in record.errors, so invalid data never reaches the database. They put data-integrity rules right next to the model they protect.
Common ones: presence, uniqueness, length, numericality, and format. valid? runs them without saving, which is handy in tests and controllers.
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
validates :age, numericality: { greater_than: 0 }
end
u = User.new
u.save # false
u.errors.full_messages # ["Email can't be blank", ...]Key point: Note that uniqueness at the model level still needs a database unique index to be race-safe. That detail separates juniors from mid-levels.
Associations declare how models relate to each other: belongs_to, has_many, has_one, and has_many :through. They generate helper methods so you can walk relationships in plain Ruby instead of writing joins by hand, and they tell Rails where the foreign key lives so lookups just work.
Declare has_many :comments on Article and you get article.comments, article.comments.create, and more. The foreign key lives on the belongs_to side by convention.
class Article < ApplicationRecord
has_many :comments, dependent: :destroy
end
class Comment < ApplicationRecord
belongs_to :article
end
article.comments.create(body: "Nice post")| Association | Meaning | Foreign key lives on |
|---|---|---|
| belongs_to | This model references one other | This model |
| has_many | One model owns many others | The other model |
| has_one | One model owns exactly one other | The other model |
| has_many :through | Many-to-many via a join model | The join model |
Strong parameters whitelist which request params a model will accept for mass assignment. You require the top-level key and permit only the named attributes, so a crafted form can't set fields you never intended. It's the standard defense against mass-assignment attacks in every Rails controller.
Without them, someone could post admin=true and flip a flag they shouldn't touch. The pattern lives in a private controller method, usually named after the resource.
def create
@article = Article.new(article_params)
@article.save
end
private
def article_params
params.require(:article).permit(:title, :body)
endKey point: Frame it as a security feature, not just plumbing. 'It blocks unexpected mass assignment' is the answer they want.
The Rails console (rails console) is an interactive Ruby session with your whole app loaded. You can query models, create records, call methods, and inspect data live against the real environment, which makes it the fastest way to explore behavior or reproduce a bug without touching the browser.
Use rails console --sandbox to roll back any changes on exit, so you can experiment against real data without leaving a mess.
rails console
> User.count
> u = User.first
> u.articles.pluck(:title)
> User.where(active: true).limit(5)The Gemfile lists the gems (libraries) your app depends on. Bundler reads it, resolves a compatible set of versions, installs them, and writes Gemfile.lock, which pins the exact versions so every machine and your production server all get the identical set of dependencies rather than whatever happens to be newest.
You run bundle install to install and bundle update to bump versions. Committing Gemfile.lock is what keeps a teammate's install identical to yours and to production.
# Gemfile
source "https://rubygems.org"
gem "rails", "~> 7.1"
gem "pg"
gem "puma"
group :development, :test do
gem "rspec-rails"
endERB (Embedded Ruby) is Rails' default template language. HTML files with a .html.erb extension can embed Ruby: the <%= %> tag outputs a value into the page, and the <% %> tag runs code without printing anything. The controller sets instance variables and the matching view renders them into HTML.
The controller sets instance variables (like @articles) and the matching view reads them. Layouts wrap views with shared markup, and partials (_form.html.erb) let you reuse chunks.
<h1>Articles</h1>
<ul>
<% @articles.each do |article| %>
<li><%= link_to article.title, article %></li>
<% end %>
</ul>Key point: Know the difference between <%= %> (prints) and <% %> (runs). Getting that wrong in live coding is an easy tell.
Rails ships with three environments: development, test, and production. Each one has its own config file and its own database, so your test runs never touch development data and production settings stay separate. The RAILS_ENV variable picks which environment loads when the app boots.
Development reloads code on each request and shows full error pages. Production caches classes, hides errors from users, and serves compiled assets. RAILS_ENV picks the environment.
| Environment | Code reloading | Errors shown |
|---|---|---|
| development | Reloads every request | Full stack traces |
| test | Loaded once per run | Assertions and failures |
| production | Cached, no reload | Generic error page |
The params object is a hash-like store holding every piece of input for a request: route segments like an :id, query-string values after the question mark, and posted form or JSON data, all merged into one place. It's how the outside request reaches your controller code.
You read params[:id] to find a record and pass a permitted subset to a model. It's the bridge between the outside request and your controller logic.
# GET /articles/42?sort=recent
def show
@article = Article.find(params[:id]) # 42
@sort = params[:sort] # "recent"
endThe flash is a small store for one-time messages that survive a single redirect, like showing 'Article created' after a successful save. It clears itself after the next request, so the message appears exactly once and doesn't stick around on later pages. It lives in the session under the hood.
flash[:notice] and flash[:alert] are the common keys. Use flash.now when you render instead of redirect, so the message doesn't leak into a later request.
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article, notice: "Article created"
else
flash.now[:alert] = "Fix the errors below"
render :new
end
endREST maps HTTP verbs to actions on resources. In Rails, resources :articles turns that idea into seven conventional routes, so a GET on the collection lists articles, a GET on one path shows it, and a POST creates one. The verb carries the intent, which keeps URLs predictable across every Rails app.
The verb carries the intent: GET reads, POST creates, PATCH or PUT updates, DELETE removes. Following REST keeps URLs predictable and lets Rails helpers generate paths for you.
| Verb + Path | Controller action | Purpose |
|---|---|---|
| GET /articles | index | List records |
| GET /articles/:id | show | Show one record |
| POST /articles | create | Create a record |
| PATCH /articles/:id | update | Update a record |
| DELETE /articles/:id | destroy | Delete a record |
Helpers are Ruby methods available inside views that keep templates clean and readable. Rails ships plenty of them, like link_to, form_with, and number_to_currency, and you can write your own in app/helpers. The point is to move presentation logic out of the ERB and into named, testable methods.
The point is to move presentation logic out of the ERB and into named, testable methods. If a view has a tangle of conditionals, that logic usually belongs in a helper.
# app/helpers/articles_helper.rb
module ArticlesHelper
def status_badge(article)
article.published? ? "Live" : "Draft"
end
end
# in the view: <%= status_badge(@article) %>The db/seeds.rb file holds plain Ruby that populates the database with starter or sample data. You run it with rails db:seed, and it's the standard place to create default records like an admin user, a category list, or demo content. Because it's Ruby with your models loaded, you can loop and guard against duplicates.
Because it's plain Ruby with your models loaded, you can loop, use Faker, or guard with find_or_create_by so re-running doesn't duplicate data.
# db/seeds.rb
Admin = User.find_or_create_by(email: "admin@example.com") do |u|
u.name = "Admin"
end
5.times { |i| Article.create(title: "Post #{i}") }For candidates with working experience: Active Record depth, request handling, and the questions that separate users from understanders.
N+1 happens when you load a list of N records, then trigger one extra query per record to read an association. Rendering 50 articles and calling article.author on each fires 1 query for the articles plus 50 for the authors.
The fix is eager loading with includes, which preloads the association in one or two queries instead of N. It's the single most common Active Record performance bug, and the question expects you to The the cause and the fix.
# N+1: 1 query for articles, then 1 per author
Article.all.each { |a| puts a.author.name }
# Fixed: 2 queries total
Article.includes(:author).each { |a| puts a.author.name }Finding and fixing an N+1
includes for a simple preload, references or eager_load when you also filter on the joined table.
Key point: This is the most-asked intermediate Rails question. Say the cause, the fix (includes), and how you'd catch it (log or bullet gem).
Watch a deeper explanation
Video: Learn Ruby on Rails - Full Course (freeCodeCamp.org, YouTube)
joins performs a SQL INNER JOIN and exists for filtering by a related table without loading its objects into memory. It doesn't preload the association, so touching that association afterward still triggers extra queries. It's for narrowing results, not for reading the related records themselves.
includes eager-loads the association so you can use its objects without extra queries. Use joins to filter, includes to read. Add references when you filter on an included table so Rails uses one join instead of two queries.
# joins: filter by association, don't load it
Article.joins(:author).where(authors: { active: true })
# includes: load the association for use
Article.includes(:comments).each { |a| a.comments.size }| Method | SQL | Loads association? | Use for |
|---|---|---|---|
| joins | INNER JOIN | No | Filtering by a related table |
| includes | Two queries or LEFT JOIN | Yes | Reading the association without N+1 |
| eager_load | LEFT OUTER JOIN | Yes | Load and filter in one query |
Key point: The clean split: joins to filter, includes to load. Interviewers ask this specifically to see if you conflate them.
A scope is a named, reusable query fragment defined on a model. Declaring scope :published, -> { where(published: true) } lets you write Article.published anywhere instead of repeating that where clause, so query logic stays in the model and reads like the business language rather than raw conditions.
Scopes chain, so Article.published.recent composes cleanly, and they always return a relation (never nil), which is safer than class methods for chaining. They keep query logic in the model where it belongs.
class Article < ApplicationRecord
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
end
Article.published.recent.limit(10)Key point: Mention that scopes return a relation even when empty, so they chain safely. That's the reason to prefer them over class methods for queries.
Callbacks are hooks that run at set points in a record's life: before_save, after_create, before_destroy, and more. They let you set a default value, normalize a field, or trigger something tied to persistence, all without the caller having to remember to do it. Rails fires them automatically around save and destroy.
The risk is hidden side effects. A callback that sends email or touches other records makes behavior hard to follow and tests slow. Many teams limit callbacks to data massaging and move side effects into explicit service objects.
class User < ApplicationRecord
before_save :downcase_email
private
def downcase_email
self.email = email.to_s.downcase
end
endKey point: Show you know callbacks AND their downside. 'Fine for normalizing data, risky for side effects' is the balanced answer.
Use has_many :through with an explicit join model. To connect students and courses, create an Enrollment model that belongs_to both sides, then declare has_many :courses, through: :enrollments on Student. That gives you student.courses in Ruby while the enrollments table holds the actual pairings between the two.
The join model is the reason to prefer :through over has_and_belongs_to_many: it can carry its own columns (enrolled_at, grade) and its own validations. Reach for :through by default.
class Student < ApplicationRecord
has_many :enrollments
has_many :courses, through: :enrollments
end
class Enrollment < ApplicationRecord
belongs_to :student
belongs_to :course
end
class Course < ApplicationRecord
has_many :enrollments
has_many :students, through: :enrollments
endKey point: Recommend :through over has_and_belongs_to_many and say why (the join model holds attributes). That's the modern default.
A polymorphic association lets one model belong to more than one other model through a single relationship. A Comment can attach to an Article or a Photo using two columns, commentable_type and commentable_id, which together record both what kind of parent it belongs to and which specific record it is.
You declare belongs_to :commentable, polymorphic: true on the child, and has_many :comments, as: :commentable on each parent. It avoids duplicating the comment table per parent type.
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
end
class Article < ApplicationRecord
has_many :comments, as: :commentable
end
class Photo < ApplicationRecord
has_many :comments, as: :commentable
endbefore_action registers a method to run before controller actions, in the order you declare them. It's the standard place for authentication, loading a shared record, or authorization checks. If the filter renders or redirects, the action itself never runs, which is how a login gate short-circuits a protected page.
You scope it with only: or except:, and skip_before_action removes an inherited one. A set_article filter that loads @article for show, edit, update, and destroy is the textbook use.
class ArticlesController < ApplicationController
before_action :set_article, only: [:show, :edit, :update, :destroy]
private
def set_article
@article = Article.find(params[:id])
end
endRails embeds an authenticity token in every form it renders and checks that token on every non-GET request. A request forged from another site won't carry the right token, so Rails rejects it before it reaches your action. The protection is on by default through protect_from_forgery in the base controller.
form_with adds the token automatically. For a JSON API using token or header auth instead of cookies, you configure the protection differently, since CSRF specifically targets cookie-based sessions.
Key point: CSRF protection maps to cookie-based sessions. Noting that token-auth APIs handle it differently shows you understand why the token exists.
A transaction wraps several writes so they all succeed together or all roll back together, leaving no half-finished state. You wrap the work in an ActiveRecord::Base.transaction block, and if any statement inside raises an exception, every change made in that block is undone as a unit.
Transferring money between accounts is the classic case: debit one, credit the other, and if the second fails the first must reverse. Note that raising is what triggers the rollback, so a caught exception won't undo anything.
ActiveRecord::Base.transaction do
sender.update!(balance: sender.balance - amount)
receiver.update!(balance: receiver.balance + amount)
end
# any raise inside rolls back both updatesKey point: Use the bang methods (update!) inside a transaction so failures raise and trigger the rollback. Silent update returning false won't.
The change method auto-reverses common operations, so add_column, create_table, and add_index all roll back cleanly without extra code. When an operation isn't automatically reversible, like raw SQL or a data change, you write explicit up and down methods so a rollback still knows how to undo the step.
On large production tables, add indexes with algorithm: :concurrently to avoid locking, and split data backfills out of schema migrations so a slow backfill doesn't hold a lock.
class RenameBodyToContent < ActiveRecord::Migration[7.1]
def up
rename_column :articles, :body, :content
end
def down
rename_column :articles, :content, :body
end
endRails caches at several levels. Fragment caching stores rendered view pieces keyed by a record, so a changed record busts its own cache. Low-level caching (Rails.cache.fetch) stores any computed value. Russian doll caching nests fragments so outer caches reuse inner ones.
The store is pluggable: memory for one process, or Redis and Memcached shared across servers. Cache keys built from the record and its updated_at expire automatically when data changes.
# low-level caching
value = Rails.cache.fetch("stats/#{user.id}", expires_in: 5.minutes) do
expensive_stats(user)
end
# fragment caching in a view
<% cache @article do %>
<%= render @article %>
<% end %>Active Job is the abstraction: you write a job class with a perform method and enqueue it, and a backend (Sidekiq is the common one) runs it outside the request. Use it for anything slow or external: sending email, generating a report, calling an API.
Keeping slow work off the request thread keeps responses fast. Jobs should be idempotent because a queue may retry them, and you pass IDs rather than whole objects so the job loads fresh data.
class WelcomeEmailJob < ApplicationJob
queue_as :default
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome(user).deliver_now
end
end
WelcomeEmailJob.perform_later(user.id)Key point: Say 'pass the ID, not the object' and 'jobs should be idempotent'. Those two lines signal you've run jobs in production.
A mailer is a class much like a controller: each method builds an email, and a matching view renders the body. You call the method and then deliver_later to send it through a background job or deliver_now to send inline.
Mailers use the same ERB views and layouts as the rest of the app. In development, the letter_opener gem or a preview lets you see the mail without sending real messages.
class UserMailer < ApplicationMailer
def welcome(user)
@user = user
mail(to: user.email, subject: "Welcome")
end
end
UserMailer.welcome(user).deliver_laterRails ships with Minitest built in, and many teams choose RSpec instead. You test at levels: model specs for validations and methods, request or controller specs for endpoints, and system specs that drive a real browser for end-to-end flows. Each level trades speed for coverage, so most tests sit at the fast model level.
Fixtures or factories (FactoryBot) build test data, and the test database resets between runs. A useful coverage names what to test at each level rather than listing gems.
# spec/models/user_spec.rb (RSpec)
RSpec.describe User do
it "requires an email" do
user = User.new(email: nil)
expect(user).not_to be_valid
end
endRunning rails new app --api creates a slimmer app with no view layer, no cookie or session middleware by default, and controllers that inherit from ActionController::API instead of the full base. It's for building a JSON backend that a separate frontend or a mobile client consumes over HTTP.
You render JSON directly, often with a serializer for shaping output. The trade-off is you give up the built-in HTML rendering, which is the point when the frontend lives elsewhere.
class Api::ArticlesController < ApplicationController
def index
render json: Article.published.select(:id, :title)
end
endfind looks up by primary key and raises RecordNotFound if there's no match, which is why it pairs with a 404. find_by takes conditions and returns the first match or nil, so you check for nil yourself. where returns a relation (possibly empty), not a single record.
The distinction matters for control flow: find for 'must exist', find_by for 'might exist', where when you want a collection or plan to chain more query methods.
User.find(1) # raises if missing
User.find_by(email: "a@b.com") # nil if missing
User.where(active: true) # relation, can be empty| Method | Returns | On no match |
|---|---|---|
| find | One record | Raises RecordNotFound |
| find_by | One record | Returns nil |
| where | A relation | Empty relation |
Key point: each maps to intent: find for must-exist, find_by for might-exist, where for many. The raise-vs-nil difference is the point.
pluck runs a query that returns raw column values as a plain Ruby array and skips model instantiation entirely. Calling User.pluck(:email) hands back an array of strings straight from the database, which is fast and light because Rails never builds a single ActiveRecord object out of the rows.
select returns model objects with only the chosen columns loaded. Reach for pluck when you just need values (an array of ids), select when you still need model methods on the results.
User.pluck(:email) # ["a@b.com", "c@d.com"] (no models)
User.where(active: true).pluck(:id)
User.select(:id, :email) # User objects, other attrs unsetA counter cache stores an association's count in a column so you don't run COUNT(*) on every page. Add counter_cache: true to the belongs_to and a comments_count column to the parent, and Rails keeps it updated on create and destroy.
It trades a little write cost for fast reads, which is worth it when you display counts often, like a comment total next to each article in a list.
class Comment < ApplicationRecord
belongs_to :article, counter_cache: true
end
# needs a comments_count integer column on articles
article.comments_count # read, no COUNT queryA concern is a Ruby module built with ActiveSupport::Concern that packages shared behavior for models or controllers. Code that repeats across several models, like a publishable state or soft-delete logic, moves into one concern and gets included where it's needed, so the behavior lives in a single place instead of being copied around.
They cut duplication, but the caution is the same as any mixin: a concern that reaches into the including class's internals can hide behavior. Keep them focused on one clear responsibility.
module Archivable
extend ActiveSupport::Concern
included do
scope :archived, -> { where.not(archived_at: nil) }
end
def archive!
update(archived_at: Time.current)
end
end
class Article < ApplicationRecord
include Archivable
endadvanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
fat model, skinny controller: business rules live in models, not controllers, and controllers only orchestrate comes first. As models themselves bloat, extract service objects (a plain Ruby class with one call method) for multi-step operations, and query objects for the gnarly queries, so no single class turns into a dumping ground.
The judgment is knowing when convention stops scaling. Concerns share behavior, service objects hold workflows, form objects wrap multi-model forms, and value objects capture domain concepts. The goal is code that reads like the business, not a pile of callbacks.
class RegisterUser
def initialize(params)
@params = params
end
def call
user = User.create!(@params)
WelcomeEmailJob.perform_later(user.id)
user
end
end
RegisterUser.new(params).callKey point: This is a design-judgment question. The escalation: models, then concerns, then service and query objects. Don't over-abstract early.
Since Rails 6, Zeitwerk is the code loader Rails uses. It maps file paths to constant names by strict convention, so app/models/user.rb must define User and app/services/reports/summary.rb must define Reports::Summary nested correctly. Get the file name or the module nesting wrong and Zeitwerk raises a load error instead of guessing.
In development it loads lazily and reloads changed files; in production it eager-loads everything at boot so there's no per-request loading. Understanding the path-to-constant mapping is what makes 'NameError: uninitialized constant' quick to fix.
Key point: The tell of experience: knowing Zeitwerk maps file path to constant name, so most autoload errors are a naming or nesting mismatch.
Optimistic locking assumes conflicts are rare. You add a lock_version column and Rails checks it on save; if another update landed first, it raises StaleObjectError and your code retries. No database lock is held for the duration, so it scales well under low contention and never blocks other requests waiting on a row.
Pessimistic locking takes a real database row lock with lock or with_lock, blocking others until the transaction ends. Use it when conflicts are likely or a write must not be lost, and accept the reduced concurrency.
# pessimistic: lock the row for the transaction
account.with_lock do
account.update!(balance: account.balance - amount)
end
# optimistic: needs a lock_version column, raises on conflict
# StaleObjectError on save if another update won| Approach | Holds a DB lock? | Best when |
|---|---|---|
| Optimistic | No, checks a version column | Conflicts are rare |
| Pessimistic | Yes, locks the row | Conflicts are likely or writes must not be lost |
Key point: Pick based on contention: optimistic for low, pessimistic for high. Naming lock_version and StaleObjectError shows real usage.
Rack is the interface between Ruby web servers and frameworks: a Rack app is anything that responds to call(env) and returns a status, headers, and a body. Rails itself is a Rack app, and a server like Puma speaks Rack to it, which is what lets the same framework run under different servers.
Middleware are layers that wrap the request on the way in and the response on the way out: logging, sessions, and parameter parsing are all middleware. rails middleware prints the stack. You add your own middleware to handle cross-cutting concerns before a request ever reaches a controller.
# a minimal Rack middleware
class RequestTimer
def initialize(app) = @app = app
def call(env)
start = Time.now
status, headers, body = @app.call(env)
headers["X-Runtime"] = (Time.now - start).to_s
[status, headers, body]
end
endIndex foreign keys and any column you filter, sort, or join on frequently. A missing index on a foreign key turns association lookups into full table scans, which is one of the most common causes of a Rails page that felt fast in development crawling once real data volume shows up in production.
Add unique indexes to back uniqueness validations so they're race-safe, use composite indexes when queries filter on several columns together (order matters), and watch that every index slows writes. Read the query plan with EXPLAIN rather than guessing.
add_index :comments, :article_id
add_index :users, :email, unique: true
add_index :orders, [:user_id, :created_at] # composite
# add_index :big_table, :col, algorithm: :concurrently # no lockKey point: Two senior points: unique index behind a uniqueness validation (race safety), and 'every index costs writes'. Both signal production experience.
Rails handles a lot by default: parameterized Active Record queries prevent SQL injection, ERB escapes output to block XSS, strong parameters stop unwanted mass assignment, and the CSRF token guards forms against forged requests. Encrypted credentials keep secrets out of the repo. So the framework covers the common web attacks out of the box.
The gaps are usually yours: SQL injection sneaks back in through raw string interpolation in where, XSS returns through html_safe or raw on user input, and authorization (who can do what) is your job with a tool like Pundit or CanCanCan. Run brakeman to scan for the common holes.
Key point: Split it: Rails defends against injection, XSS, and CSRF; authorization is on you. Naming brakeman as your scanner lands well.
STI stores several related model types in one table with a type column, so Admin and Member both live in users. It fits when the subtypes share almost all columns and differ mainly in behavior. The cost is a wide table with many nullable columns as subtypes diverge.
A polymorphic association is different: it lets one model belong to many parent types, not model a type hierarchy. Reach for STI for a true is-a hierarchy with shared columns, and separate tables (or delegated types) when the subtypes carry different data.
# STI: needs a `type` string column on users
class User < ApplicationRecord; end
class Admin < User; end # stored in users, type = "Admin"
class Member < User; endKey point: Draw the line clearly: STI is a type hierarchy, polymorphic is one child with many parents. Conflating them is the trap here.
Rails keeps a pool of database connections per process, sized by the pool setting in database.yml. Each thread checks out a connection while it works and returns it after. If threads exceed the pool, they wait and can time out.
This shapes scaling: your total connections are roughly processes times threads times pool, and the database has a connection ceiling. Size the pool to match your web server threads, and watch for background job workers and the database's own max_connections when you scale out.
Key point: The math is the point: connections roughly equal processes x threads. Tying pool size to Puma threads shows you've tuned a real deployment.
Hotwire is the default frontend approach in recent Rails: send HTML over the wire instead of JSON plus a heavy JavaScript app. Turbo Drive speeds page navigation, Turbo Frames update a region of the page, and Turbo Streams push targeted DOM changes, often over a WebSocket.
The point is interactive apps with mostly server-rendered HTML and little custom JavaScript, with Stimulus handling the sprinkles. It suits Rails' strengths, though very rich client interactions can still justify a separate frontend framework.
Key point: The three Turbo pieces (Drive, Frames, Streams) and Stimulus. That's the current Rails frontend story the key point is.
preload always runs a separate query per association (no join), so you can't filter on the association's columns in the same query. eager_load always uses a single LEFT OUTER JOIN, so you can filter on the joined table. includes picks between them: separate queries normally, but a join when it detects you reference the association in a condition.
In practice you use includes and add references when you filter on the included table, which nudges includes toward the eager_load strategy. Knowing the three exist explains surprising query counts in logs.
| Method | Strategy | Can filter on association? |
|---|---|---|
| preload | Separate queries | No |
| eager_load | Single LEFT OUTER JOIN | Yes |
| includes | Chooses one of the above | Yes, with references |
Key point: Most candidates only know includes. Explaining preload vs eager_load underneath it is a clear production signal.
Layer it. Cache expensive computed values with Rails.cache.fetch and a sensible TTL, cache view fragments keyed by the record so updates self-expire, and nest them Russian-doll style so an outer cache reuses unchanged inner fragments. Put the cache store in Redis or Memcached so all servers share it.
Then handle the hard parts: key by record plus updated_at so stale data can't linger, guard against a thundering herd when a hot key expires, and decide what must never be cached (personalized or sensitive data). Measure hit rate rather than assuming the cache helps.
# key includes updated_at, so an edit busts the cache
Rails.cache.fetch(["article", article.id, article.updated_at]) do
render_to_string(partial: "articles/full", locals: { article: article })
endThe rule is to keep the old and new schema both working during the deploy. To rename a column safely: add the new column, backfill it in batches, write to both from the app, switch reads to the new column, then drop the old one in a later deploy. A single rename would break the running old code.
Add indexes concurrently to avoid locking, split long data backfills out of schema migrations into background jobs, and avoid changes that rewrite a whole large table in one statement. Each step ships and is safe on its own.
Key point: Say 'expand then contract' and 'both schemas valid during the deploy'. That framing is exactly what senior migration questions test.
ActiveSupport::Notifications is Rails' built-in instrumentation bus. Rails already publishes events for SQL queries, view rendering, controller actions, and cache reads; you subscribe to those events to measure timing or emit metrics without patching framework internals. It's the clean hook for feeding performance data to a monitoring system.
Use it to feed custom performance metrics to a monitoring system, or to instrument your own code by wrapping it in an instrument block. It's how tools like the request log and APM integrations hook in cleanly.
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
StatsD.timing("db.query", event.duration)
endAnything that takes a long exclusive lock. Adding a non-concurrent index locks writes, adding a column with a volatile default can rewrite the whole table on older Postgres, and changing a column type can rewrite and lock. Backfilling data inside a schema migration holds a transaction open too long.
The safe patterns: add indexes concurrently, add columns without a heavy default then backfill in batches, and separate data changes from schema changes. Teams often add the strong_migrations gem to catch these in code review.
Key point: The strong_migrations gem and the concurrently pattern. Interviewers ask this after someone locked a table in production once.
Test behavior at the cheapest level that covers it: most logic in fast model and service specs, a smaller set of request specs for endpoints, and a thin layer of system specs for the few flows that must work end to end. Inverting that (mostly slow browser tests) makes the suite crawl and flake.
Speed comes from real data discipline: build only what a test needs, stub external services, and avoid hitting the network or the clock. Parallelize the suite and watch for order-dependent tests, which signal shared state leaking between examples.
First separate a real leak from Ruby's memory model: the heap fragments and rarely shrinks back down, so steady growth up to a plateau is often normal, and many teams restart workers periodically to reclaim that memory anyway. A true leak keeps growing without ever settling, which is the pattern you're actually hunting for.
To find it: track RSS over time, use derailed_benchmarks or memory_profiler to find allocation hot spots, and check the usual causes: loading huge result sets instead of find_each, unbounded caches, and per-request global state. The fix is usually batching queries and bounding caches.
# loads everything into memory
User.all.each { |u| process(u) }
# batches: constant memory
User.find_each(batch_size: 1000) { |u| process(u) }Key point: Distinguish fragmentation from a leak first. Then reach for find_each. That two-part answer is what senior reviewers listen for.
Delegated types (Rails 6.1+) split a shared parent table from per-subtype tables. An Entry record holds common columns and points to an entryable (a Message or Comment) that holds type-specific columns. You get polymorphic behavior without STI's wide, nullable table.
Compared to STI, delegated types keep subtype-specific data in its own table, so the schema stays clean as subtypes diverge. Reach for them when subtypes share some behavior but genuinely differ in their columns.
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[ Message Comment ]
end
class Message < ApplicationRecord
has_one :entry, as: :entryable
endThe common approach is namespaced routes: /api/v1/... maps to Api::V1 controllers, and a new version gets its own namespace so old clients keep working. Some teams version through an Accept header instead, which keeps URLs clean but is harder to test by hand.
Whichever you pick, isolate versions so a change in v2 can't break v1: separate controllers and serializers, shared logic in service objects, and a clear deprecation policy for retiring old versions.
namespace :api do
namespace :v1 do
resources :articles, only: [:index, :show]
end
namespace :v2 do
resources :articles
end
endMake jobs idempotent so a retry can't double-charge or double-send, because the queue will retry on failure. Pass record IDs, not serialized objects, so the job loads current data. Set sensible retry limits and route exhausted jobs to a dead set you monitor.
Beyond that: keep jobs small and single-purpose, use separate queues with priorities so a flood of low-priority work can't starve urgent jobs, and add a unique-job guard when the same work must not enqueue twice. Watch queue latency, not just error counts.
Key point: Lead with idempotency and 'pass the ID'. Then queues and dead sets. This question separates people who ran a queue from people who read about one.
Measure where the limit is first: CPU-bound app, slow database, or saturated external calls each point elsewhere. Then work in order: fix N+1s and add indexes, cache hot reads, move slow work to background jobs, add read replicas for read-heavy load, and run more app processes behind a load balancer.
Structural moves come later: a database connection pooler like PgBouncer, sharding or extracting a hot service only when a single database genuinely can't keep up. The production-ready answer is the ordering, cheap wins before architecture, and measuring at each step.
Key point: The value is the ordering: cheap query and cache wins before infrastructure. Jumping straight to microservices is the wrong-sounding answer.
Rails wins when a small team wants to ship a database-backed web app fast with a batteries-included stack and strong conventions. It trades some raw runtime speed and flexibility for productivity: the defaults make decisions for you, so there's less to wire up and less to argue about. Where it loses is CPU-bound or extreme-throughput work, and teams that want to pick every layer themselves. Django is the closest peer in philosophy, Laravel brings the same feel to PHP, and Node with Express is minimal by design, so you assemble the stack yourself. Saying these trade-offs out loud is itself an interview signal.
| Framework | Language | Style | Best at |
|---|---|---|---|
| Ruby on Rails | Ruby | Full-stack, convention-heavy | Fast CRUD apps, small teams, rapid prototyping |
| Django | Python | Full-stack, batteries included | Data-heavy apps, admin-driven tools, Python shops |
| Laravel | PHP | Full-stack, expressive | PHP hosting, similar convention feel |
| Node + Express | JavaScript | Minimal, assemble-your-own | Real-time, JSON APIs, one-language stacks |
Prepare in layers, and practice out loud. Most Rails rounds move from concept questions to live coding in a real Rails app to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Ruby on Rails interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Ruby on Rails questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works