We often need validations that adapt dynamically to the state of a model. At the same time, we want to avoid duplication and keep our code DRY. Rails 8.1 introduced the ability to pass a callable to allow_nil and allow_blank, giving us exactly that: validations that are conditional and easy to maintain.
In this post, we’ll explore this feature through a book publishing workflow. Imagine a Book model with attributes like isbn and status. Draft books are treated differently from published ones, and we want a validation setup that is clear, concise, and maintained in a single place.
Before
In older Rails versions, allow_blank could not be conditional. We duplicated validations to express different behavior. We ended up repeating the same format rule.
class Book < ApplicationRecord
# attribute :isbn, :string
enum :status, { draft: 0, submitted: 1, published: 2 }
validates :isbn,
format: { with: /\A\d{13}\z/, message: "must be a 13-digit number" },
unless: :draft?
validates :isbn,
format: { with: /\A\d{13}\z/, message: "must be a 13-digit number" },
allow_blank: true,
if: :draft?
endWhat this means:
When not draft, the isbn must exist and match the pattern. When draft, blank is allowed, but if present it must match. We repeat the format hash in two places. We maintain the pattern twice.
After
After this PR, Rails supports callable allow_nil and allow_blank. We can make the “skip blank” decision depend on model state and keep the format rule in one place.
class Book < ApplicationRecord
# attribute :isbn, :string
enum :status, { draft: 0, submitted: 1, published: 2 }
validates :isbn,
format: { with: /\A\d{13}\z/, message: "must be a 13-digit number" },
allow_blank: :draft?
endWhat happens:
If draft? returns true, we allow blank. If draft? returns false, we do not allow blank. If isbn is present in either state, the format rule runs. We stay DRY. We keep intent close to the rule.
Alternative with a lambda
We can use a proc or lambda for complex logic.
validates :isbn,
format: { with: /\A\d{13}\z/, message: "must be a 13-digit number" },
allow_blank: ->(book) { book.draft? }Why this matters
- Cleaner code.
- Less duplication.
- Expressive intent.
- Easier maintenance.
- One validation to read.
- One place to change the pattern.
Takeaways
We conditionally allow blank values without duplicating rules. We keep validations aligned with workflow states. We improve readability and long‑term maintenance.
