Rails forms and validations: building safe user input flows
Rails forms are the bridge between your users and your database. Every Rails app needs a reliable way to collect user input, and whether you are building a login form, a blog editor, or a checkout flow, you need a path from the browser to the database that is easy to follow and hard to break. The framework gives you that path through form_with, strong parameters, and validations.
Intro context
Forms are not just HTML wrappers. In a Rails app, a form is the entry point for user data, and that means it touches routing, controllers, models, and views all at once. If each piece has a clear job, the whole request cycle becomes easier to reason about.
This tutorial focuses on the three pieces that matter most in that cycle: building the form, filtering the incoming data, and validating the model before anything reaches the database. Those are the parts that keep the app both usable and safe.
TL;DR
- Use
form_withfor model-backed and non-model forms. - Use strong parameters to whitelist the fields your controller accepts.
- Use validations to keep invalid data out of the database.
- Re-render the form on failure so the object and errors stay in scope.
- Use
flash.nowwhen you render instead of redirecting.
Building forms with form_with
Rails 5.1 introduced form_with as the unified replacement for the older form_tag and form_for helpers. It handles both model-bound forms and standalone forms, so you can keep one mental model for most form work.
Model-bound forms
When you have a model instance, pass it with model: and Rails figures out the URL and HTTP method:
# app/views/articles/new.html.erb
<%= form_with model: @article do |form| %>
<div>
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :body %>
<%= form.text_area :body %>
</div>
<%= form.submit %>
<% end %>
If @article is a new record, Rails generates a POST to /articles. If it has an id, it generates a PATCH to /articles/:id. You do not have to specify either one, which keeps the view simple and lets the model drive the form behavior.
By default form_with generates remote forms. For a regular full-page POST, add local: true:
form_with model: @article, local: true do |form|
That setting is often the right choice in beginner tutorials because it behaves like the old synchronous Rails forms people expect from a standard CRUD app. When local: true is set, the form submits as a normal HTTP request and the browser navigates to the response page, which matches the mental model most developers bring to their first Rails project. For apps that use Turbo or Stimulus, the default remote behavior (local: false) is often preferred because it submits without a full page reload.
Non-model forms
For things like search boxes that do not correspond to a model, use url: instead:
form_with url: "/search", method: :get do |form|
form.text_field :query
form.submit "Search"
end
This pattern is useful for lookups, filters, and one-off screens where there is no database-backed object behind the form. It still gives you the same Rails helper API, just without the model binding.
Scoped forms
When you want field names prefixed under a namespace but do not have a model, use scope::
form_with scope: :subscription, url: subscriptions_path do |form|
form.email_field :email
form.password_field :password
end
This prefixes every input with subscription[...], so params arrive as params[:subscription][:email]. That makes it easier to keep related inputs together in the controller. The scoped form pattern is especially useful for signup flows, multi-step wizards, or any page where several models contribute to a single form submission. The namespace prefix keeps the parameters organized in the controller without coupling the view to a specific model class.
Strong parameters
Form data hits your controller as part of the params hash. Strong parameters are the gatekeepers that decide which fields your application will accept. Without them, a malicious user could send extra fields and overwrite things like admin: true or user_id: another_user.
# app/controllers/articles_controller.rb
def article_params
params.require(:article).permit(:title, :body, :author_id)
end
require(:article) raises ActionController::ParameterMissing if the :article key is missing entirely. permit(:title, :body, :author_id) returns a hash containing only those fields, and everything else is silently dropped. The require call ensures the expected top-level key exists, while permit filters which nested fields get through; together they form a whitelist that is simple to audit and hard to bypass.
Use this in your create and update actions:
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article, notice: "Article created!"
else
render :new
end
end
That controller shape matters because it keeps the form, the params, and the model aligned. When the same set of fields flows through each layer, the code becomes much easier to audit. Every time you add a new form field, you should update the strong parameters in the same commit, so the two never drift apart. Keeping the controller’s permit list in sync with the form’s fields is a small discipline that prevents a large class of bugs.
Model validations
Validations run before Rails saves anything to the database. If validation fails, save returns false, which gives your controller a chance to show the form again with error messages.
Common validation types
class Article < ApplicationRecord
validates :title, presence: true
validates :body, length: { minimum: 10 }
validates :slug, format: { with: /\A[a-z0-9-]+\z/ }
validates :price, numericality: { greater_than: 0 }
validates :email, uniqueness: true
end
Each validation type covers a different risk: is the field filled in, is it the right length, does it match a pattern, is it a valid number, and is it already in the database? That combination handles most everyday input checks.
Conditional validation
Run a validation only under certain conditions:
validates :card_number, presence: true, if: :paid_with_card?
validates :terms, acceptance: true, unless: :guest?
Rails evaluates the symbol or lambda you pass to if: or unless: and skips the validation when the condition is false. That keeps the validation logic focused on the cases where the rule actually matters.
Custom validations
When the built-in helpers are not enough, write your own:
class Order < ApplicationRecord
validate :delivery_date_not_in_past
private
def delivery_date_not_in_past
if delivery_date.present? && delivery_date < Date.today
errors.add(:delivery_date, "cannot be in the past")
end
end
end
The errors.add method takes the attribute name and a message. You can also use errors.add(:base, ...) to attach an error to the model as a whole rather than a specific field.
The uniqueness gotcha
The uniqueness validation has a race condition problem. Two requests can both pass validation before either one saves, and both end up inserting the same value.
validates :slug, uniqueness: true # good first line of defence
The application-level check catches duplicates early with a clear error message, but it cannot prevent a race condition where two requests check uniqueness simultaneously before either one writes to the database.
The real safety net is a database-level unique index:
add_index :articles, :slug, unique: true
The validation catches the common case quickly, and the index prevents duplicates from ever entering the database. You want both layers because they solve different parts of the same problem.
Displaying errors in views
When validation fails, Rails populates model.errors with the problems. You can check for errors in your view and use them to guide the user back to a valid submission.
@article.errors.any?
# => true
@article.errors[:title]
# => ["can't be blank"]
@article.errors.full_messages
# => ["Title can't be blank", "Body is too short (minimum is 10 characters)"]
The errors object gives you both per-field access and a combined list, which gives your view templates flexibility in how they present validation feedback to the user. You can choose to show all errors at the top of the form or attach each message to its corresponding field.
Render them in the form:
<% if @article.errors.any? %>
<div class="errors">
<ul>
<% @article.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= form_with model: @article do |form| %>
<div>
<%= form.label :title %>
<%= form.text_field :title %>
<% @article.errors[:title].each do |msg| %>
<span class="error"><%= msg %></span>
<% end %>
</div>
<!-- rest of form -->
<% end %>
Showing errors per field gives users clear, actionable feedback without making them hunt for what went wrong. That is usually the difference between a form that feels helpful and one that feels broken.
render :new vs redirect_to after failed validation
This catches a lot of beginners. When validation fails, you must use render :new, not redirect_to.
# WRONG - loses all form data on error
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article
else
redirect_to new_article_path # form will be empty!
end
end
# RIGHT - preserves @article and its errors
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article
else
render :new # re-renders the form with @article intact
end
end
render :new re-displays the current view with the @article object still in scope, including the values the user typed and the errors that validation produced. redirect_to sends a new request, which creates a fresh controller instance and loses that state. Keeping this straight is one of the fastest ways to avoid the empty-form-on-error bug that frustrates new Rails developers. The difference boils down to whether you want a fresh request cycle or the same request cycle.
flash.now vs flash
When you redirect after a successful action, you can pass notice: or alert: directly and Rails puts it in the flash:
redirect_to @article, notice: "Article saved!"
Rails puts the notice into flash automatically when you use the notice: option with redirect_to. The flash hash survives exactly one additional request, which is why it works after a redirect but not after a render.
But after render, there is no redirect. Use flash.now for that case:
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article, notice: "Article saved!"
else
flash.now[:alert] = "Please fix the errors below."
render :new
end
end
flash survives one redirect, while flash.now only lasts for the current response. Mixing them up means your message shows up on the wrong page, so it is worth keeping the distinction clear.
CSRF protection
Rails embeds a signed authenticity token in every form automatically via form_with. This token is verified on the server side and helps prevent cross-site request forgery attacks.
In a typical web app, leave this alone. If you are building a purely API-only Rails app that uses token-based authentication and does not use cookies or sessions, you might skip verification for those specific endpoints:
skip_before_action :verify_authenticity_token, if: :api_request?
Disabling CSRF protection in a normal browser-based app is a security vulnerability, not a shortcut.
Frequently asked questions
When should I use render :new instead of redirect_to?
Use render :new when validation fails and you want the unsaved object, its field values, and its errors to stay in memory. Use redirect_to only after a successful save or when you truly want a new request.
Why do I need strong parameters if I already have validations?
Validations check whether data is acceptable, but strong parameters decide which keys are allowed into the model in the first place. They protect different parts of the request flow, so you want both.
How do I keep the form code readable?
Keep the template focused on labels, fields, and error messages. Push data rules into the model, and keep parameter filtering in the controller. That split keeps each layer simple.
Forward link
Once you are comfortable with Rails forms and validations, the next step is usually to connect that flow to the rest of the Rails request cycle, including controllers, routing, and the underlying model layer.
Conclusion
Forms are how your users talk to your application. form_with gives you a clean, flexible way to build them. Strong parameters keep bad data from ever reaching your models. Validations catch problems early with clear messages. And understanding the difference between render and redirect after a failed save is the difference between a smooth user experience and a frustrating one with empty forms.
The patterns here - check validation, re-render on failure - apply everywhere in Rails. Once you internalize them, building any data-entry flow feels familiar.
See Also
- Rails Routing — how URLs map to controller actions, so you know where your form POSTs go.
- ActiveRecord Basics — the model layer that your validations protect.
- Views and Partials — how ERB templates render your forms and error messages.