Ruby on Rails is a full-stack web framework built on Ruby that follows Convention over Configuration and Don't Repeat Yourself (DRY). This cheat sheet covers the Rails CLI, routing, Active Record, controllers, views, migrations, and real-world patterns — with copy-ready examples.
Quick reference
| Task | Command / Code |
|---|---|
| New app | rails new myapp --database=postgresql |
| Start server | rails server (or rails s) |
| Rails console | rails console (or rails c) |
| Generate scaffold | rails generate scaffold Post title:string body:text |
| Run migrations | rails db:migrate |
| Rollback migration | rails db:rollback |
| List routes | rails routes |
| Run tests | rails test |
| Find record | Post.find(1) |
| Find by field | Post.find_by(title: "Hello") |
| All records | Post.all |
| Create record | Post.create!(title: "Hello") |
| Update record | post.update(title: "New") |
| Delete record | post.destroy |
| Scope | Post.where(published: true).order(:created_at) |
| Associations | has_many :comments, belongs_to :user |
| Before action | before_action :authenticate_user! |
| Render JSON | render json: @post |
| Redirect | redirect_to posts_path |
| Flash notice | flash[:notice] = "Saved" |
| Partial | <%= render "shared/header" %> |
| Link helper | <%= link_to "Show", post_path(@post) %> |
| Form helper | `<%= form_with model: @post do |
Rails CLI
New application
# New app with default SQLite
rails new myapp
# PostgreSQL database
rails new myapp --database=postgresql
# API-only mode (no views)
rails new myapp --api
# Skip test framework (for RSpec)
rails new myapp --skip-test
# Combine options
rails new myapp --database=postgresql --skip-test --css=tailwind
Generate commands
# Scaffold (model + controller + views + migration + routes)
rails generate scaffold Post title:string body:text published:boolean
# Model only
rails generate model Comment body:text post:references user:references
# Controller only
rails generate controller Posts index show new create edit update destroy
# Migration only
rails generate migration AddStatusToUsers status:string
# Mailer
rails generate mailer UserMailer welcome_email
# Job
rails generate job SendEmail
# Shorthand: g = generate, s = scaffold, m = model, c = controller
rails g scaffold Article title:string content:text
rails g model Tag name:string
rails g c Dashboard index
Database commands
rails db:create # Create database
rails db:migrate # Run pending migrations
rails db:rollback # Rollback last migration
rails db:rollback STEP=3 # Rollback 3 migrations
rails db:seed # Run db/seeds.rb
rails db:reset # Drop + create + migrate + seed
rails db:schema:load # Load schema.rb (faster than migrate)
rails db:migrate:status # Show migration status
# Run in specific environment
RAILS_ENV=production rails db:migrate
Other useful commands
rails routes # List all routes
rails routes | grep post # Filter routes
rails routes --expanded # Verbose format
rails console # IRB with app loaded
rails console --sandbox # Changes rolled back on exit
rails test # Run all tests
rails test test/models/post_test.rb # Run specific test file
rails credentials:edit # Edit encrypted credentials
rails assets:precompile # Compile assets (production)
rails middleware # List Rack middleware
Routing
Resource routes
# config/routes.rb
# RESTful resource (7 routes)
resources :posts
# Nested resources
resources :posts do
resources :comments
end
# Shallow nesting (collection on parent, member on child)
resources :posts, shallow: true do
resources :comments
end
# Only specific actions
resources :photos, only: [:index, :show]
resources :sessions, except: [:edit, :update]
# Single resource (no :id — current user, settings, etc.)
resource :profile
resource :session
Generated REST routes (resources :posts)
| HTTP Verb | Path | Controller#Action | Route Helper |
|---|---|---|---|
| GET | /posts | posts#index | posts_path |
| GET | /posts/new | posts#new | new_post_path |
| POST | /posts | posts#create | posts_path |
| GET | /posts/:id | posts#show | post_path(@post) |
| GET | /posts/:id/edit | posts#edit | edit_post_path(@post) |
| PATCH/PUT | /posts/:id | posts#update | post_path(@post) |
| DELETE | /posts/:id | posts#destroy | post_path(@post) |
Custom routes
# Named route
get "about", to: "pages#about", as: :about
# Route with param
get "posts/:year/:month", to: "posts#archive", as: :archive
# Namespace (admin routes + AdminController)
namespace :admin do
resources :users
resources :posts
end
# Scope (URL prefix without module)
scope "/api/v1" do
resources :articles
end
# Member routes (action on specific record)
resources :posts do
member do
patch :publish
patch :unpublish
end
collection do
get :drafts
end
end
# Root
root "home#index"
Active Record
Querying
# Find
Post.find(1) # Raises RecordNotFound if missing
Post.find_by(slug: "hello-world") # Returns nil if missing
Post.find_by!(slug: "hello-world") # Raises RecordNotFound if missing
# All / filtering
Post.all
Post.where(published: true)
Post.where("created_at > ?", 1.week.ago)
Post.where(status: ["draft", "review"])
Post.where.not(archived: true)
# Ordering / limiting
Post.order(created_at: :desc)
Post.order("title ASC, created_at DESC")
Post.limit(10).offset(20)
Post.first # ORDER BY id ASC LIMIT 1
Post.last # ORDER BY id DESC LIMIT 1
# Counting / existence
Post.count
Post.where(published: true).count
Post.exists?(id: 5)
Post.exists?(title: "Hello")
# Selecting specific columns
Post.select(:id, :title)
Post.pluck(:title) # Array of values (no AR objects)
Post.pluck(:id, :title) # Array of arrays
# Distinct
Post.distinct
Post.select(:category).distinct
# Grouping / aggregation
Post.group(:category).count # { "tech" => 12, "news" => 5 }
Post.average(:rating)
Post.maximum(:views)
Post.minimum(:price)
Post.sum(:amount)
# Eager loading (avoid N+1)
Post.includes(:author, :tags)
Post.includes(comments: :author)
Post.eager_load(:author) # Forces LEFT OUTER JOIN
Post.preload(:tags) # Separate query
Creating records
# new + save
post = Post.new(title: "Hello", body: "World")
post.save # returns true/false
post.save! # raises ActiveRecord::RecordInvalid if invalid
# create (new + save combined)
Post.create(title: "Hello", body: "World") # returns record (may be invalid)
Post.create!(title: "Hello", body: "World") # raises on invalid
# first_or_create / find_or_create
user = User.find_or_create_by(email: "a@b.com")
tag = Tag.first_or_create(name: "ruby")
Updating records
post = Post.find(1)
post.title = "New Title"
post.save
# update (find + assign + save)
post.update(title: "New", published: true) # returns true/false
post.update!(title: "New") # raises on invalid
# update_all (skip callbacks & validations)
Post.where(draft: true).update_all(published: false)
# increment / decrement
post.increment!(:views)
post.decrement!(:stock)
Deleting records
post = Post.find(1)
post.destroy # Runs callbacks (before_destroy, dependent: :destroy)
post.delete # Skips callbacks — direct SQL DELETE
Post.where(archived: true).destroy_all # Loads records, runs callbacks
Post.where(archived: true).delete_all # Direct SQL, no callbacks
Migrations
# db/migrate/20260714000000_create_posts.rb
class CreatePosts < ActiveRecord::Migration[7.1]
def change
create_table :posts do |t|
t.string :title, null: false
t.text :body
t.string :status, default: "draft"
t.boolean :published, default: false, null: false
t.integer :views_count, default: 0
t.references :user, null: false, foreign_key: true
t.timestamps # adds created_at and updated_at
end
add_index :posts, :status
add_index :posts, [:user_id, :created_at]
end
end
Common migration methods
# Column operations
add_column :posts, :subtitle, :string
remove_column :posts, :subtitle, :string
rename_column :posts, :body, :content
change_column :posts, :views, :bigint
# Null / default
change_column_null :posts, :title, false
change_column_default :posts, :status, "draft"
# Index
add_index :posts, :slug, unique: true
remove_index :posts, :slug
# Foreign key
add_foreign_key :posts, :users
remove_foreign_key :posts, :users
# Table operations
create_table :tags, id: false do |t| ... end
drop_table :legacy_records
rename_table :old_name, :new_name
# Column types
:string, :text, :integer, :bigint, :float, :decimal,
:boolean, :date, :datetime, :time, :binary, :json, :jsonb (PostgreSQL)
Models
Validations
class Post < ApplicationRecord
# Presence
validates :title, presence: true
validates :body, presence: true, length: { minimum: 10 }
# Format
validates :slug, format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, dashes" }
# Uniqueness
validates :email, uniqueness: { case_sensitive: false }
validates :slug, uniqueness: { scope: :user_id }
# Inclusion / exclusion
validates :status, inclusion: { in: %w[draft published archived] }
# Numericality
validates :price, numericality: { greater_than: 0 }
validates :age, numericality: { only_integer: true, in: 18..120 }
# Length
validates :title, length: { maximum: 255 }
validates :password, length: { minimum: 8, maximum: 72 }
# Confirmation
validates :password, confirmation: true
validates :password_confirmation, presence: true
# Custom validator
validate :publication_date_cannot_be_in_past
private
def publication_date_cannot_be_in_past
if publish_at.present? && publish_at < Time.current
errors.add(:publish_at, "can't be in the past")
end
end
end
Associations
class User < ApplicationRecord
has_many :posts, dependent: :destroy
has_many :comments, through: :posts
has_one :profile, dependent: :destroy
has_many :followers, class_name: "Follow", foreign_key: :followed_id
end
class Post < ApplicationRecord
belongs_to :user
belongs_to :category, optional: true
has_many :comments, dependent: :destroy
has_many :taggings
has_many :tags, through: :taggings
# Polymorphic
has_many :images, as: :imageable
end
class Comment < ApplicationRecord
belongs_to :post
belongs_to :user
# Polymorphic
belongs_to :commentable, polymorphic: true
end
Scopes and callbacks
class Post < ApplicationRecord
# Scopes
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_user, ->(user) { where(user: user) }
# Usage: Post.published.recent.by_user(current_user)
# Callbacks
before_validation :normalize_slug
before_create :set_default_status
after_create :send_notification
after_commit :invalidate_cache, on: [:create, :update]
before_destroy :check_if_deletable
private
def normalize_slug
self.slug = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "") if title
end
def send_notification
NotificationMailer.new_post(self).deliver_later
end
end
Controllers
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :authenticate_user!
before_action :set_post, only: [:show, :edit, :update, :destroy]
before_action :authorize_post!, only: [:edit, :update, :destroy]
def index
@posts = Post.published.includes(:user).order(created_at: :desc).page(params[:page])
end
def show
# @post set by before_action
end
def new
@post = Post.new
end
def create
@post = current_user.posts.build(post_params)
if @post.save
redirect_to @post, notice: "Post created."
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @post.update(post_params)
redirect_to @post, notice: "Post updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@post.destroy
redirect_to posts_url, notice: "Post deleted."
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :body, :published, tag_ids: [])
end
def authorize_post!
redirect_to root_path, alert: "Unauthorized" unless @post.user == current_user
end
end
Rendering and redirecting
# HTML (default — renders app/views/posts/show.html.erb)
render :show
# With status
render :new, status: :unprocessable_entity
# JSON API
render json: @post
render json: { error: "Not found" }, status: :not_found
render json: PostSerializer.new(@post).serializable_hash
# Redirect
redirect_to @post # post_path(@post) via polymorphic_url
redirect_to posts_path
redirect_to root_path, notice: "Done"
redirect_back fallback_location: root_path
# Head only (no body)
head :no_content
head :created, location: @post
Views
ERB templates
<%# app/views/posts/index.html.erb %>
<% @posts.each do |post| %>
<%= post.title %>
<%# Comment — not rendered %>
<% end %>
<%# Link helpers %>
<%= link_to "Show", post_path(post) %>
<%= link_to "Edit", edit_post_path(post) %>
<%= link_to "Delete", post_path(post), data: { turbo_method: :delete, turbo_confirm: "Sure?" } %>
<%# Image %>
<%= image_tag post.cover_url, alt: post.title, class: "hero" %>
<%# Render partial %>
<%= render "post", post: post %>
<%= render @posts %> # Renders _post.html.erb for each
<%# Content for layout %>
<% content_for :title, post.title %>
<% content_for :head do %>
<meta name="description" content="<%= post.excerpt %>">
<% end %>
Form helpers
<%# form_with (Rails 5.1+) %>
<%= form_with model: @post do |f| %>
<% if @post.errors.any? %>
<div class="errors">
<% @post.errors.full_messages.each do |msg| %>
<p><%= msg %></p>
<% end %>
</div>
<% end %>
<%= f.label :title %>
<%= f.text_field :title, placeholder: "Post title", class: "input" %>
<%= f.label :body %>
<%= f.text_area :body, rows: 10 %>
<%= f.label :category_id %>
<%= f.select :category_id, Category.all.collect { |c| [c.name, c.id] }, include_blank: "Select…" %>
<%= f.label :published %>
<%= f.check_box :published %>
<%# File upload %>
<%= f.label :cover %>
<%= f.file_field :cover, accept: "image/*" %>
<%# Collection of checkboxes %>
<%= f.collection_check_boxes :tag_ids, Tag.all, :id, :name %>
<%= f.submit "Save Post", class: "btn-primary" %>
<% end %>
Authentication with Devise
# Gemfile
gem "devise"
rails generate devise:install
rails generate devise User
rails db:migrate
# Generate views to customize
rails generate devise:views
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate_user! # Require login everywhere
def after_sign_in_path_for(resource)
dashboard_path
end
end
# Skip auth for public pages
class HomeController < ApplicationController
skip_before_action :authenticate_user!, only: [:index]
end
<%# Navigation %>
<% if user_signed_in? %>
<%= current_user.email %>
<%= link_to "Sign out", destroy_user_session_path, data: { turbo_method: :delete } %>
<% else %>
<%= link_to "Sign in", new_user_session_path %>
<%= link_to "Sign up", new_user_registration_path %>
<% end %>
# Add fields to Devise User (migration)
class AddNameToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :name, :string
add_column :users, :role, :string, default: "member"
end
end
# Permit extra Devise params (application_controller.rb)
before_action :configure_permitted_parameters, if: :devise_controller?
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
devise_parameter_sanitizer.permit(:account_update, keys: [:name])
end
Background Jobs
Active Job (built-in)
# app/jobs/send_welcome_email_job.rb
class SendWelcomeEmailJob < ApplicationJob
queue_as :default
retry_on Net::OpenTimeout, wait: 5.seconds, attempts: 3
discard_on ActiveJob::DeserializationError
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome(user).deliver_now
end
end
# Enqueue
SendWelcomeEmailJob.perform_later(user.id)
SendWelcomeEmailJob.set(wait: 1.hour).perform_later(user.id)
SendWelcomeEmailJob.set(wait_until: Date.tomorrow.noon).perform_later(user.id)
# config/application.rb — choose adapter
config.active_job.queue_adapter = :sidekiq # or :solid_queue, :good_job
Action Mailer
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
default from: "noreply@example.com"
def welcome(user)
@user = user
@login_url = new_user_session_url
mail(to: @user.email, subject: "Welcome to MyApp!")
end
def password_reset(user, token)
@user = user
@token = token
mail(to: @user.email, subject: "Reset your password")
end
end
# app/views/user_mailer/welcome.html.erb
# app/views/user_mailer/welcome.text.erb
# Send
UserMailer.welcome(@user).deliver_now
UserMailer.welcome(@user).deliver_later
Caching
# Fragment caching in views
<% cache @post do %>
<%= render @post %>
<% end %>
# Cache with dependencies
<% cache [@post, current_user] do %>
<%= render "post_with_actions", post: @post %>
<% end %>
# Low-level caching
Rails.cache.fetch("post_#{id}", expires_in: 1.hour) do
Post.find(id)
end
Rails.cache.write("key", value, expires_in: 30.minutes)
Rails.cache.read("key")
Rails.cache.delete("key")
# Counter cache
belongs_to :post, counter_cache: true
# Adds comments_count column to posts — auto-maintained
# config/environments/production.rb
config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }
Common patterns
API controller
# app/controllers/api/v1/posts_controller.rb
module Api
module V1
class PostsController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :authenticate_api_token!
def index
@posts = Post.published.order(created_at: :desc)
render json: @posts.as_json(only: [:id, :title, :slug, :created_at])
end
def show
@post = Post.find(params[:id])
render json: @post
rescue ActiveRecord::RecordNotFound
render json: { error: "Not found" }, status: :not_found
end
private
def authenticate_api_token!
token = request.headers["Authorization"]&.split(" ")&.last
@current_api_user = User.find_by(api_token: token)
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_api_user
end
end
end
end
Pagination with Pagy
# Gemfile
gem "pagy"
# app/controllers/application_controller.rb
include Pagy::Backend
# Controller
def index
@pagy, @posts = pagy(Post.published.order(created_at: :desc), items: 20)
end
# View helper
include Pagy::Frontend
# app/views/posts/index.html.erb
<%== pagy_nav(@pagy) %>
Service object pattern
# app/services/publish_post_service.rb
class PublishPostService
def initialize(post, publisher)
@post = post
@publisher = publisher
end
def call
return Result.failure("Already published") if @post.published?
return Result.failure("Not authorized") unless authorized?
ActiveRecord::Base.transaction do
@post.update!(published: true, published_at: Time.current)
NotificationJob.perform_later(@post.id)
AuditLog.create!(action: "publish", record: @post, user: @publisher)
end
Result.success(@post)
rescue ActiveRecord::RecordInvalid => e
Result.failure(e.message)
end
private
def authorized?
@publisher.admin? || @post.user == @publisher
end
Result = Data.define(:success, :value, :error) do
def self.success(value) = new(success: true, value: value, error: nil)
def self.failure(error) = new(success: false, value: nil, error: error)
def success? = success
end
end
# Usage in controller
result = PublishPostService.new(@post, current_user).call
if result.success?
redirect_to @post, notice: "Published"
else
redirect_to @post, alert: result.error
end
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
| N+1 queries | `@posts.each { | p |
Missing ! on save |
Silent failure — form re-renders empty | Use create! in console/seeds, save/update in controllers |
| Exposing all params | params[:post] without permit |
Always use Strong Parameters: params.require(:post).permit(...) |
| Callback hell | 10 chained callbacks — untestable | Extract to service objects for complex logic |
| Synchronous email | deliver_now blocks request for 2–5s |
deliver_later with Sidekiq/GoodJob |
update_all skips validations |
Invalid data silently saved | Use only for bulk updates where validations are irrelevant |
Forgetting dependent: |
Deleting User leaves orphan Posts | Add dependent: :destroy or :nullify to associations |
Direct find without rescue |
Unhandled RecordNotFound → 500 error |
Use find_by + nil check, or rescue in controller |
Rails vs Django vs Laravel
| Feature | Rails | Django | Laravel |
|---|---|---|---|
| Language | Ruby | Python | PHP |
| Convention | Strong (CoC) | Moderate | Strong |
| ORM | Active Record | Django ORM | Eloquent |
| Migration style | Code-first | Code-first | Code-first |
| Auth | Devise gem | Built-in | Laravel Breeze/Jetstream |
| Admin panel | ActiveAdmin gem | Built-in | No default |
| API mode | --api flag |
DRF | Built-in |
| Async/jobs | ActiveJob + Sidekiq | Celery | Laravel Queues |
| Testing | Minitest (default) | unittest | PHPUnit |
FAQ
What is "Convention over Configuration" in Rails?
Rails assumes sensible defaults — a posts table maps to the Post model, app/views/posts/index.html.erb renders for PostsController#index — so you only configure when you deviate. This reduces boilerplate dramatically.
What's the difference between find and find_by?
find(id) raises ActiveRecord::RecordNotFound if the record is missing. find_by(conditions) returns nil. Use find when you expect the record to exist; use find_by when absence is a valid case.
How do I prevent N+1 queries?
Use includes(:association) to eager-load associations. Install the bullet gem in development — it alerts you to N+1 queries and unused eager loads automatically.
What is Strong Parameters and why does Rails require it?
Strong Parameters (params.require(:post).permit(:title, :body)) prevents mass assignment vulnerabilities where attackers submit extra fields like role: "admin". Rails raises ForbiddenAttributesError if you pass unpermitted params directly to create/update.
When should I use a service object vs a model method?
Model methods are ideal for single-record business logic tightly coupled to that model. Service objects (app/services/) are better for multi-model operations, external API calls, complex workflows, or any logic that's hard to test in isolation.
How do I run Rails in production?
Use Puma (Rails default web server), Sidekiq or GoodJob for jobs, PostgreSQL for the database, and a reverse proxy (Nginx or Caddy). Set RAILS_ENV=production, run rails assets:precompile and rails db:migrate, then start with bundle exec puma -C config/puma.rb.